diff --git a/README.md b/README.md index 7452b06..5a7caaa 100644 --- a/README.md +++ b/README.md @@ -61,9 +61,10 @@ mutations, force and torque commands, revisioned receipts, retained stepping, and typed Begin/Hit/End facts. It never treats omission as removal. The daemon still lacks a named session registry and CultMesh command lowering. Aetheria will name one session per run and zone through that daemon contract; its old -process-wide simulator objects are not session identifiers. Retained-world -queries, checkpoint reconstruction, and non-Windows RID artifacts remain -cutover work. +process-wide simulator objects are not session identifiers. Revision-checked +retained-session circle overlap and cast queries observe the current stable-id +body projection without mutating it. Checkpoint reconstruction and non-Windows +RID artifacts remain cutover work. See [the architecture map](docs/architecture.md) and [the executable parity contract](docs/box3d-parity.md). The diff --git a/docs/architecture.md b/docs/architecture.md index c377e64..08a0184 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -43,7 +43,7 @@ Begin/Hit/End facts with stable Ymir identities. Ordinary retained steps do not accept complete body snapshots. The current CLI still has no named session registry, CultMesh command -lowerings, retained-world queries, durable receipt ledger, or reconstruction +lowerings, durable receipt ledger, or reconstruction checkpoint. Its CultCache and Eve output is a regenerated diagnostic projection. The sections marked as target contract describe that daemon cut; they are not advertisements of current CLI capability. @@ -119,10 +119,11 @@ infer retained identity from a body list. Session identity must be explicit. A process-wide simulator object, zone index, or repeated stable body id is not enough to join two calls to the same world. Aetheria owns run and zone lifetime and will create, retain, and dispose the -corresponding Ymir session after daemon registry integration. Current projectile -sweep and stationary mine proximity integration can use Ymir's static -Box3D-backed circle-cast and overlap queries, but those calls do not join a -retained world or own contact lifecycle. +corresponding Ymir session after daemon registry integration. Projectile sweep +and stationary mine proximity integration can use Ymir's revision-checked +retained-session circle-cast and overlap queries. They observe the current +stable-id body projection; they do not invent a contact episode or mutate the +Box3D world. Radial gravity and tractor attraction are typed force-generation inputs. They are applied to Box3D before stepping. Pickup collection remains an Aetheria diff --git a/docs/retained-session-contract.md b/docs/retained-session-contract.md index cc770bb..c5b3897 100644 --- a/docs/retained-session-contract.md +++ b/docs/retained-session-contract.md @@ -43,9 +43,11 @@ The retained port accepts narrow operations with explicit meaning: - step with a fixed delta, substep count, and typed fields; - dispose the session. -The current public slice implements those mutation and step operations. -Retained-world overlap and cast queries at a named revision are the next -library cut; the static compatibility queries do not satisfy that contract. +The current public slice implements those mutation and step operations plus +revision-checked circle overlap and cast queries. A query observes the current +retained body projection under the session lock and accepts an explicit set of +stable candidate body ids. The candidate set is product-owned eligibility; +Box3D still owns the overlap/cast geometry and numeric result. Bullet, collision-filter, and field-participation profiles are currently spawn/restore definitions. Retained `Configure` changes radius, mass, static/kinematic/dynamic type, and restitution only. Changing the profile @@ -130,7 +132,7 @@ is derived process state: it never appears in a public fact or checkpoint. 1. Typed native mutation operations and step-owned lifecycle buffers: done. 2. Public managed retained session with no ordinary full synchronization: done. -3. Add retained-world queries and revision checks. +3. Revision-checked retained-session overlap and cast queries: done. 4. Add the daemon session registry, generation-bearing routed commands, idempotent Create, bounded live dedupe backed by durable receipts, and CultMesh command lowering. diff --git a/src/Ymir.Core/RetainedSessionModel.cs b/src/Ymir.Core/RetainedSessionModel.cs index 7f51f20..fb7999a 100644 --- a/src/Ymir.Core/RetainedSessionModel.cs +++ b/src/Ymir.Core/RetainedSessionModel.cs @@ -112,6 +112,50 @@ public sealed record YmirSessionStepResult( long StepIndex, IReadOnlyList ContactFacts); +public sealed record YmirSessionCircleCastQuery( + string QueryId, + long ExpectedRevision, + Vec2 Origin, + Vec2 Direction, + float Distance, + float Radius, + IReadOnlyList CandidateBodyIds); + +public sealed record YmirSessionCircleOverlapQuery( + string QueryId, + long ExpectedRevision, + Vec2 Center, + float Radius, + IReadOnlyList CandidateBodyIds); + +public enum YmirQueryError +{ + None, + StaleRevision, + UnknownBody, + InvalidQuery, + SessionDisposed, + SessionFaulted +} + +public sealed record YmirSessionCircleCastResult( + string QueryId, + string SessionId, + string SessionGeneration, + long ObservedRevision, + long ObservedStepIndex, + YmirQueryError Error, + IReadOnlyList Hits); + +public sealed record YmirSessionCircleOverlapResult( + string QueryId, + string SessionId, + string SessionGeneration, + long ObservedRevision, + long ObservedStepIndex, + YmirQueryError Error, + IReadOnlyList Hits); + public sealed record YmirSessionInfo( string SessionId, string SessionGeneration, diff --git a/src/Ymir.Core/YmirSession.cs b/src/Ymir.Core/YmirSession.cs index 71f5dd3..23a5976 100644 --- a/src/Ymir.Core/YmirSession.cs +++ b/src/Ymir.Core/YmirSession.cs @@ -409,6 +409,59 @@ public sealed class YmirSession : IDisposable } } + public YmirSessionCircleCastResult CastCircle(YmirSessionCircleCastQuery query) + { + ArgumentNullException.ThrowIfNull(query); + lock (_gate) + { + var error = QueryPreflight(query.QueryId, query.ExpectedRevision, query.CandidateBodyIds, out var bodies); + if (error != YmirQueryError.None) + { + return new YmirSessionCircleCastResult( + query.QueryId ?? "", _sessionId, _sessionGeneration, _revision, _stepIndex, error, []); + } + + try + { + var result = YmirQueries.CastCircle(new CircleCastQueryRequest( + query.Origin, query.Direction, query.Distance, query.Radius, bodies)); + return new YmirSessionCircleCastResult( + query.QueryId, _sessionId, _sessionGeneration, _revision, _stepIndex, YmirQueryError.None, result.Hits); + } + catch (ArgumentException) + { + return new YmirSessionCircleCastResult( + query.QueryId ?? "", _sessionId, _sessionGeneration, _revision, _stepIndex, YmirQueryError.InvalidQuery, []); + } + } + } + + public YmirSessionCircleOverlapResult OverlapCircle(YmirSessionCircleOverlapQuery query) + { + ArgumentNullException.ThrowIfNull(query); + lock (_gate) + { + var error = QueryPreflight(query.QueryId, query.ExpectedRevision, query.CandidateBodyIds, out var bodies); + if (error != YmirQueryError.None) + { + return new YmirSessionCircleOverlapResult( + query.QueryId ?? "", _sessionId, _sessionGeneration, _revision, _stepIndex, error, []); + } + + try + { + var result = YmirQueries.OverlapCircle(new CircleOverlapQueryRequest(query.Center, query.Radius, bodies)); + return new YmirSessionCircleOverlapResult( + query.QueryId, _sessionId, _sessionGeneration, _revision, _stepIndex, YmirQueryError.None, result.Hits); + } + catch (ArgumentException) + { + return new YmirSessionCircleOverlapResult( + query.QueryId ?? "", _sessionId, _sessionGeneration, _revision, _stepIndex, YmirQueryError.InvalidQuery, []); + } + } + } + public YmirCommandReceipt Dispose(YmirDisposeSessionCommand command) { ArgumentNullException.ThrowIfNull(command); @@ -458,6 +511,48 @@ public sealed class YmirSession : IDisposable }); } + private YmirQueryError QueryPreflight( + string? queryId, + long expectedRevision, + IReadOnlyList? candidateBodyIds, + out PhysicsBody[] bodies) + { + bodies = []; + if (_faulted) + { + return YmirQueryError.SessionFaulted; + } + if (_disposed) + { + return YmirQueryError.SessionDisposed; + } + if (string.IsNullOrWhiteSpace(queryId) || expectedRevision < 0 || candidateBodyIds == null) + { + return YmirQueryError.InvalidQuery; + } + if (expectedRevision != _revision) + { + return YmirQueryError.StaleRevision; + } + + var seen = new HashSet(StringComparer.Ordinal); + var selected = new List(candidateBodyIds.Count); + foreach (var bodyId in candidateBodyIds) + { + if (string.IsNullOrWhiteSpace(bodyId) || !seen.Add(bodyId)) + { + return YmirQueryError.InvalidQuery; + } + if (!_bodies.TryGetValue(bodyId, out var body)) + { + return YmirQueryError.UnknownBody; + } + selected.Add(body); + } + bodies = selected.ToArray(); + return YmirQueryError.None; + } + private IReadOnlyList ProjectFacts(IReadOnlyList nativeFacts) { var projected = new List(nativeFacts.Count); diff --git a/tests/Ymir.Tests/YmirSessionTests.cs b/tests/Ymir.Tests/YmirSessionTests.cs index ce7ad41..4160a92 100644 --- a/tests/Ymir.Tests/YmirSessionTests.cs +++ b/tests/Ymir.Tests/YmirSessionTests.cs @@ -258,6 +258,50 @@ public sealed class YmirSessionTests Assert.Equal(Vec2.Zero, target.Position); } + [Fact] + public void RetainedCastObservesNamedRevisionAndExplicitCandidates() + { + using var session = YmirSession.Create(new YmirSessionCreateRequest( + "retained-cast", + [Body("source", positionX: -2.0f), Body("target", positionX: 2.0f), Body("ignored", positionX: 0.0f)])); + + var result = session.CastCircle(new YmirSessionCircleCastQuery( + "payload-sweep", + 0, + new Vec2(-5.0f, 0.0f), + new Vec2(1.0f, 0.0f), + 10.0f, + 0.25f, + ["target"])); + + Assert.Equal(YmirQueryError.None, result.Error); + Assert.Equal(0, result.ObservedRevision); + Assert.Equal(0, result.ObservedStepIndex); + Assert.Equal("target", Assert.Single(result.Hits).BodyId); + Assert.Equal(0, session.Info.Revision); + } + + [Fact] + public void RetainedQueriesFailClosedForStaleUnknownAndDisposedSessions() + { + var session = YmirSession.Create(new YmirSessionCreateRequest("query-failures", [Body("target")])); + + var stale = session.OverlapCircle(new YmirSessionCircleOverlapQuery( + "stale", 1, Vec2.Zero, 1.0f, ["target"])); + var unknown = session.OverlapCircle(new YmirSessionCircleOverlapQuery( + "unknown", 0, Vec2.Zero, 1.0f, ["missing"])); + session.Dispose(); + var disposed = session.OverlapCircle(new YmirSessionCircleOverlapQuery( + "disposed", 0, Vec2.Zero, 1.0f, ["target"])); + + Assert.Equal(YmirQueryError.StaleRevision, stale.Error); + Assert.Equal(YmirQueryError.UnknownBody, unknown.Error); + Assert.Equal(YmirQueryError.SessionDisposed, disposed.Error); + Assert.Empty(stale.Hits); + Assert.Empty(unknown.Hits); + Assert.Empty(disposed.Hits); + } + private static YmirCommandHeader Header(string id, long revision) => new(id, revision); private static long BodyGeneration(YmirContactFact fact, string bodyId) =>