Add revisioned retained Box3D queries
This commit is contained in:
parent
014c95c454
commit
d854d9376f
6 changed files with 199 additions and 12 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -112,6 +112,50 @@ public sealed record YmirSessionStepResult(
|
|||
long StepIndex,
|
||||
IReadOnlyList<YmirContactFact> ContactFacts);
|
||||
|
||||
public sealed record YmirSessionCircleCastQuery(
|
||||
string QueryId,
|
||||
long ExpectedRevision,
|
||||
Vec2 Origin,
|
||||
Vec2 Direction,
|
||||
float Distance,
|
||||
float Radius,
|
||||
IReadOnlyList<string> CandidateBodyIds);
|
||||
|
||||
public sealed record YmirSessionCircleOverlapQuery(
|
||||
string QueryId,
|
||||
long ExpectedRevision,
|
||||
Vec2 Center,
|
||||
float Radius,
|
||||
IReadOnlyList<string> 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<CircleCastHit> Hits);
|
||||
|
||||
public sealed record YmirSessionCircleOverlapResult(
|
||||
string QueryId,
|
||||
string SessionId,
|
||||
string SessionGeneration,
|
||||
long ObservedRevision,
|
||||
long ObservedStepIndex,
|
||||
YmirQueryError Error,
|
||||
IReadOnlyList<CircleOverlapHit> Hits);
|
||||
|
||||
public sealed record YmirSessionInfo(
|
||||
string SessionId,
|
||||
string SessionGeneration,
|
||||
|
|
|
|||
|
|
@ -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<string>? 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<string>(StringComparer.Ordinal);
|
||||
var selected = new List<PhysicsBody>(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<YmirContactFact> ProjectFacts(IReadOnlyList<Box3DContactFact> nativeFacts)
|
||||
{
|
||||
var projected = new List<ProjectedFact>(nativeFacts.Count);
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
Loading…
Reference in a new issue