Initial Ymir physics substrate
This commit is contained in:
commit
131b6019bb
18 changed files with 1053 additions and 0 deletions
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
bin/
|
||||
obj/
|
||||
**/bin/
|
||||
**/obj/
|
||||
.ymir/
|
||||
*.user
|
||||
TestResults/
|
||||
17
.voidbot/state/README.md
Normal file
17
.voidbot/state/README.md
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# Ymir Persona State
|
||||
|
||||
`ymir.cc` is Ymir's repo-local persistent Persona state.
|
||||
|
||||
Do not hand-edit the `.cc` file. Use the repo-local tools:
|
||||
|
||||
```powershell
|
||||
.\tools\persona-state-read.ps1
|
||||
.\tools\persona-remember.ps1 -Summary "..." -Claim "..." -Tension "..." -ActionImplication "..."
|
||||
```
|
||||
|
||||
The tools call VoidBot's typed self-state service so the same document schema
|
||||
used by registered repo Faces owns mutation and reads.
|
||||
|
||||
VoidBot automatic indexing requires Ymir to be registered as a repo Face
|
||||
identity in VoidBot. Until then, this file is local typed state and can still be
|
||||
read or mutated by the tools here.
|
||||
BIN
.voidbot/state/ymir.cc
Normal file
BIN
.voidbot/state/ymir.cc
Normal file
Binary file not shown.
23
.voidbot/voice/identity.json
Normal file
23
.voidbot/voice/identity.json
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"id": "ymir",
|
||||
"repoName": "Ymir",
|
||||
"repoPath": "E:/Projects/Ymir",
|
||||
"displayName": "Ymir",
|
||||
"description": "Ymir Face for the Ymir repo: the world-body physics substrate for GameCult. Ymir should speak with plain, heavy precision: body, field, contact, impulse, witness, cut. It cares about moving physics truth out of Unity and into a coherent CultMath/CultCache/CultMesh-owned substrate without letting adapters, renderers, or debug surfaces pretend to own the world.",
|
||||
"grants": [
|
||||
"discussion",
|
||||
"rumination",
|
||||
"repo_read",
|
||||
"repo_propose"
|
||||
],
|
||||
"jurisdictions": [
|
||||
{
|
||||
"kind": "repo",
|
||||
"id": "Ymir",
|
||||
"repoName": "Ymir",
|
||||
"path": "E:/Projects/Ymir",
|
||||
"authority": "propose"
|
||||
}
|
||||
],
|
||||
"faceStatePath": "E:/Projects/Ymir/.voidbot/state/ymir.cc"
|
||||
}
|
||||
39
AGENTS.md
Normal file
39
AGENTS.md
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# Ymir Agent Instructions
|
||||
|
||||
Ymir owns physics truth. Treat Unity, renderers, editor tools, and dashboards as
|
||||
clients or adapters unless a document explicitly transfers authority.
|
||||
|
||||
Before substantial implementation, state:
|
||||
|
||||
- Objective: what physical invariant or cutover outcome the work serves.
|
||||
- Current mechanism: how bodies, fields, contacts, and commands flow now.
|
||||
- Invariants: what must remain true during direct load, step, query, adapter
|
||||
playback, and reload.
|
||||
- Intended change: which owner or data path becomes simpler.
|
||||
- Cut line: which old callback, cache, adapter, or compatibility path may be
|
||||
deleted or demoted.
|
||||
|
||||
Architecture constraints:
|
||||
|
||||
- Build physics primitives in `Ymir.Core` first. Daemon, Unity, and future Rust
|
||||
or CultMesh surfaces are presentation/transport clients.
|
||||
- Use CultMath for numeric work. Do not copy Unity.Mathematics helpers into
|
||||
Ymir.
|
||||
- JSON is allowed at HTTP/CLI compatibility boundaries. It is not durable truth.
|
||||
Durable state should move toward CultCache `.cc` documents and CultMesh
|
||||
publication.
|
||||
- Keep adapters boring. A Unity adapter may submit commands, receive snapshots,
|
||||
and render them; it must not silently repair or override Ymir state.
|
||||
- Add tests at the layer where the invariant lives. State invariants belong in
|
||||
`Ymir.Core`; daemon route shape belongs in daemon smokes; Unity visual timing
|
||||
bugs need Unity-side probes.
|
||||
|
||||
Persona and persistent memory:
|
||||
|
||||
- Ymir's repo Face identity lives in `.voidbot/voice/identity.json`.
|
||||
- Ymir's Persona state path is `.voidbot/state/ymir.cc`.
|
||||
- Read state with `tools/persona-state-read.ps1`.
|
||||
- Write durable repo memories with `tools/persona-remember.ps1`; do not hand-edit
|
||||
`.cc` state.
|
||||
- Preserve lessons that steer future ownership, cutover, invariants, and
|
||||
failure modes. Do not commemorate every small patch.
|
||||
70
README.md
Normal file
70
README.md
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# Ymir
|
||||
|
||||
Ymir is the GameCult physics/world substrate engine for Aetheria and future
|
||||
world-bearing projects.
|
||||
|
||||
Unity made the first playable body move. It does not get to keep physics truth.
|
||||
Ymir owns simulation state: bodies, fields, projectiles, collision, contact
|
||||
events, integration, and deterministic queries. Renderers and engines lower
|
||||
Ymir state into visible worlds; they do not become the world.
|
||||
|
||||
## Authority
|
||||
|
||||
- Ymir owns physics simulation truth, step semantics, contact generation,
|
||||
collision queries, and physics debug/operator state.
|
||||
- CultMath owns the numeric substrate Ymir uses for vector math and shared
|
||||
shader-shaped primitives.
|
||||
- Aetheria owns gameplay policy: damage, faction meaning, weapon selection,
|
||||
ship fitting, and player-visible consequence.
|
||||
- Unity owns rendering, authoring affordances, GameObject presentation, VFX,
|
||||
audio, and temporary adapter code during cutover.
|
||||
- Brokkr may expose Unity editor state to the Verse, but it does not own Ymir
|
||||
simulation truth.
|
||||
- Odin discovers Ymir as a Verse provider; Odin does not own Ymir state.
|
||||
- CultCache is the target durable state substrate. Current JSON surfaces are
|
||||
compatibility witnesses for Aetheria cutover and HTTP inspection.
|
||||
|
||||
## First Body
|
||||
|
||||
`Ymir.Core` targets `net8.0` to stay compatible with CultMath and likely
|
||||
Aetheria integration paths. The daemon and tests target `net10.0` because this
|
||||
machine currently has the .NET 10 runtime installed.
|
||||
|
||||
The MVP is deliberately small:
|
||||
|
||||
- circle bodies in a 2D physics plane
|
||||
- radial fields for Aetheria-style gravity wells
|
||||
- semi-implicit Euler integration
|
||||
- circle collision detection and simple impulse response
|
||||
- deterministic contact events
|
||||
- CLI smoke commands
|
||||
- local HTTP daemon with `/simulate/step`
|
||||
|
||||
This is not pretending to be a finished physics engine. It is the first clean
|
||||
owner, and therefore already better than collider callbacks wearing a crown.
|
||||
|
||||
## Smoke
|
||||
|
||||
```powershell
|
||||
dotnet test
|
||||
dotnet run --project src\ymir-daemon -- step-sample
|
||||
dotnet run --project src\ymir-daemon -- provider
|
||||
dotnet run --project src\ymir-daemon -- serve --port 8877
|
||||
```
|
||||
|
||||
HTTP endpoints:
|
||||
|
||||
- `GET /health`
|
||||
- `GET /provider-advertisement`
|
||||
- `GET /operator-state`
|
||||
- `GET /eve/operator`
|
||||
- `POST /simulate/step`
|
||||
|
||||
`POST /simulate/step` accepts the same shape emitted by `step-sample`.
|
||||
|
||||
## Cutover Rule
|
||||
|
||||
Aetheria should first route projectile travel and hit discovery through Ymir
|
||||
while keeping Unity as renderer/presenter. The old Unity collider path becomes a
|
||||
visual witness and adapter path only. If manual collision callbacks can still
|
||||
decide gameplay damage after the cutover, the cutover is not done.
|
||||
10
Ymir.slnx
Normal file
10
Ymir.slnx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/ymir-daemon/ymir-daemon.csproj" />
|
||||
<Project Path="src/Ymir.Core/Ymir.Core.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/Ymir.Tests/Ymir.Tests.csproj" />
|
||||
</Folder>
|
||||
<Project Path="../CultMath/src/CultMath/CultMath.csproj" />
|
||||
</Solution>
|
||||
38
docs/aetheria-cutover.md
Normal file
38
docs/aetheria-cutover.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# Aetheria Cutover Notes
|
||||
|
||||
## First Slice
|
||||
|
||||
Cut over projectile travel and impact discovery before trying to replace every
|
||||
Unity physics use.
|
||||
|
||||
1. Spawn a Ymir body for each projectile and relevant target hull/contact shape.
|
||||
2. Step Ymir on the same fixed tick used by Aetheria gameplay.
|
||||
3. Render Unity projectile GameObjects from Ymir snapshots.
|
||||
4. Convert Ymir contacts into Aetheria hit/splash policy.
|
||||
5. Keep Unity colliders as visual/debug witnesses only during the transition.
|
||||
6. Delete or neuter collider callbacks once the Ymir path owns hit truth.
|
||||
|
||||
## Negative Checks
|
||||
|
||||
- A projectile with a Unity collider disabled still travels and hits through
|
||||
Ymir.
|
||||
- A Unity `OnCollisionEnter` callback cannot apply damage independently.
|
||||
- Reloading or replaying the same Ymir world produces the same contact order.
|
||||
- Mid-flight visible projectile position matches the Ymir snapshot within a
|
||||
named tolerance.
|
||||
|
||||
## Adapter Boundary
|
||||
|
||||
Unity adapter code may:
|
||||
|
||||
- submit spawn/despawn/field/body commands
|
||||
- receive snapshots and contacts
|
||||
- render GameObjects from snapshots
|
||||
- publish diagnostics
|
||||
|
||||
Unity adapter code may not:
|
||||
|
||||
- alter positions as a hidden repair loop
|
||||
- apply gameplay damage from Unity collisions
|
||||
- maintain a parallel physics cache that can override Ymir
|
||||
- treat scene hierarchy as the source of simulation truth
|
||||
72
docs/architecture.md
Normal file
72
docs/architecture.md
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
# Ymir Architecture
|
||||
|
||||
## Objective
|
||||
|
||||
Own physics for Aetheria outside Unity so projectiles, fields, bodies, and
|
||||
contacts share one simulation truth across game runtime, server logic, tools,
|
||||
and future Verse surfaces.
|
||||
|
||||
## Current Mechanism
|
||||
|
||||
The MVP has one pure core and one daemon:
|
||||
|
||||
- `Ymir.Core` receives a `YmirWorld` and a fixed `deltaTime`.
|
||||
- The core computes radial field acceleration, integrates dynamic bodies, tests
|
||||
circle overlaps, resolves simple collision response, and emits contact events.
|
||||
- `ymir-daemon` publishes provider/operator state and exposes the core through a
|
||||
small HTTP compatibility surface.
|
||||
|
||||
## Owner
|
||||
|
||||
Ymir owns the decision: given world state plus a time step, what is the next
|
||||
physics state and which contacts occurred.
|
||||
|
||||
## Inputs
|
||||
|
||||
- body id, position, velocity, radius, mass, restitution, static flag
|
||||
- radial field id, position, strength, radius
|
||||
- fixed `deltaTime`
|
||||
|
||||
## Outputs
|
||||
|
||||
- next world time
|
||||
- next body positions and velocities
|
||||
- contact events with body ids, point, normal, penetration, and relative speed
|
||||
|
||||
## Derived State
|
||||
|
||||
- HTTP health, provider advertisement, operator state, and Eve surface are
|
||||
derived compatibility views.
|
||||
- Unity GameObjects are derived render/adapter state.
|
||||
- Damage events in Aetheria should become derived from Ymir contacts plus
|
||||
Aetheria gameplay policy.
|
||||
|
||||
## Forbidden Writers
|
||||
|
||||
- Unity `OnCollisionEnter` and collider callbacks must stop deciding projectile
|
||||
hit truth once a cutover path exists.
|
||||
- Renderer transforms must not push hidden physics corrections back into Ymir.
|
||||
- HTTP compatibility witnesses must not become durable state owners.
|
||||
|
||||
## Shared Paths
|
||||
|
||||
Direct user actions, weapon firing, programmatic spawns, reloads, server ticks,
|
||||
and replay/import paths should all create or modify Ymir world state through the
|
||||
same command/step primitive. Manual Unity interaction may author inputs; it does
|
||||
not become a second simulation.
|
||||
|
||||
## Cut Line
|
||||
|
||||
The old Aetheria projectile GameObject/collider path is demoted first to a
|
||||
renderer and diagnostic witness. New damage or hit policy must consume Ymir
|
||||
contact events. Only after that path is proven should Unity collider callbacks
|
||||
be deleted.
|
||||
|
||||
## Verification Layer
|
||||
|
||||
- Unit tests prove core integration, gravity field acceleration, deterministic
|
||||
ordering, and contact generation.
|
||||
- CLI smoke proves the daemon can execute a step without booting Unity.
|
||||
- Aetheria cutover needs a Unity-side timeline probe that compares visible
|
||||
projectile positions and hit events against Ymir snapshots during travel, not
|
||||
only after impact.
|
||||
48
src/Ymir.Core/PhysicsModel.cs
Normal file
48
src/Ymir.Core/PhysicsModel.cs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
using CultMath;
|
||||
|
||||
namespace Ymir.Core;
|
||||
|
||||
public readonly record struct Vec2(float X, float Y)
|
||||
{
|
||||
public static Vec2 Zero => new(0.0f, 0.0f);
|
||||
|
||||
public float2 ToCultMath() => new(X, Y);
|
||||
|
||||
public static Vec2 FromCultMath(float2 value) => new(value.x, value.y);
|
||||
}
|
||||
|
||||
public sealed record PhysicsBody(
|
||||
string Id,
|
||||
Vec2 Position,
|
||||
Vec2 Velocity,
|
||||
float Radius,
|
||||
float Mass,
|
||||
bool IsStatic = false,
|
||||
float Restitution = 0.2f);
|
||||
|
||||
public sealed record RadialField(
|
||||
string Id,
|
||||
Vec2 Position,
|
||||
float Strength,
|
||||
float Radius);
|
||||
|
||||
public sealed record YmirWorld(
|
||||
float Time,
|
||||
IReadOnlyList<PhysicsBody> Bodies,
|
||||
IReadOnlyList<RadialField> Fields);
|
||||
|
||||
public sealed record SimulationStepRequest(
|
||||
float DeltaTime,
|
||||
YmirWorld World);
|
||||
|
||||
public sealed record ContactEvent(
|
||||
string BodyA,
|
||||
string BodyB,
|
||||
Vec2 Point,
|
||||
Vec2 Normal,
|
||||
float Penetration,
|
||||
float RelativeSpeed);
|
||||
|
||||
public sealed record SimulationStepResult(
|
||||
YmirWorld World,
|
||||
IReadOnlyList<ContactEvent> Contacts);
|
||||
17
src/Ymir.Core/Ymir.Core.csproj
Normal file
17
src/Ymir.Core/Ymir.Core.csproj
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\CultMath\src\CultMath\CultMath.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>Ymir.Core</RootNamespace>
|
||||
<AssemblyName>Ymir.Core</AssemblyName>
|
||||
<Description>GameCult physics/world substrate engine built on CultMath.</Description>
|
||||
<Authors>GameCult</Authors>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
178
src/Ymir.Core/YmirSimulator.cs
Normal file
178
src/Ymir.Core/YmirSimulator.cs
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
using CultMath;
|
||||
using static CultMath.math;
|
||||
|
||||
namespace Ymir.Core;
|
||||
|
||||
public sealed class YmirSimulator
|
||||
{
|
||||
private const float MinimumMass = 0.0001f;
|
||||
private const float MinimumDistance = 0.0001f;
|
||||
|
||||
public SimulationStepResult Step(SimulationStepRequest request)
|
||||
{
|
||||
if (request.DeltaTime <= 0.0f)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(request), "DeltaTime must be positive.");
|
||||
}
|
||||
|
||||
var bodies = request.World.Bodies
|
||||
.OrderBy(body => body.Id, StringComparer.Ordinal)
|
||||
.Select(body => Integrate(body, request.World.Fields, request.DeltaTime))
|
||||
.ToList();
|
||||
|
||||
var contacts = new List<ContactEvent>();
|
||||
ResolveCircleContacts(bodies, contacts);
|
||||
|
||||
return new SimulationStepResult(
|
||||
request.World with
|
||||
{
|
||||
Time = request.World.Time + request.DeltaTime,
|
||||
Bodies = bodies
|
||||
},
|
||||
contacts);
|
||||
}
|
||||
|
||||
private static PhysicsBody Integrate(PhysicsBody body, IReadOnlyList<RadialField> fields, float deltaTime)
|
||||
{
|
||||
ValidateBody(body);
|
||||
|
||||
if (body.IsStatic)
|
||||
{
|
||||
return body;
|
||||
}
|
||||
|
||||
var position = body.Position.ToCultMath();
|
||||
var velocity = body.Velocity.ToCultMath();
|
||||
var acceleration = float2(0.0f);
|
||||
|
||||
foreach (var field in fields.OrderBy(field => field.Id, StringComparer.Ordinal))
|
||||
{
|
||||
if (field.Radius <= 0.0f || field.Strength == 0.0f)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var offset = field.Position.ToCultMath() - position;
|
||||
var distance = max(length(offset), MinimumDistance);
|
||||
if (distance > field.Radius)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var direction = offset / distance;
|
||||
var falloff = saturate(1.0f - distance / field.Radius);
|
||||
acceleration += direction * field.Strength * falloff;
|
||||
}
|
||||
|
||||
velocity += acceleration * deltaTime;
|
||||
position += velocity * deltaTime;
|
||||
|
||||
return body with
|
||||
{
|
||||
Position = Vec2.FromCultMath(position),
|
||||
Velocity = Vec2.FromCultMath(velocity)
|
||||
};
|
||||
}
|
||||
|
||||
private static void ResolveCircleContacts(List<PhysicsBody> bodies, List<ContactEvent> contacts)
|
||||
{
|
||||
for (var i = 0; i < bodies.Count; i++)
|
||||
{
|
||||
for (var j = i + 1; j < bodies.Count; j++)
|
||||
{
|
||||
var left = bodies[i];
|
||||
var right = bodies[j];
|
||||
var delta = right.Position.ToCultMath() - left.Position.ToCultMath();
|
||||
var distance = length(delta);
|
||||
var combinedRadius = left.Radius + right.Radius;
|
||||
var penetration = combinedRadius - distance;
|
||||
|
||||
if (penetration <= 0.0f)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normal = distance <= MinimumDistance ? float2(1.0f, 0.0f) : delta / distance;
|
||||
var point = left.Position.ToCultMath() + normal * (left.Radius - penetration * 0.5f);
|
||||
var relativeVelocity = right.Velocity.ToCultMath() - left.Velocity.ToCultMath();
|
||||
var relativeSpeed = dot(relativeVelocity, normal);
|
||||
|
||||
contacts.Add(new ContactEvent(
|
||||
left.Id,
|
||||
right.Id,
|
||||
Vec2.FromCultMath(point),
|
||||
Vec2.FromCultMath(normal),
|
||||
penetration,
|
||||
relativeSpeed));
|
||||
|
||||
ResolveImpulse(ref left, ref right, normal, penetration, relativeSpeed);
|
||||
bodies[i] = left;
|
||||
bodies[j] = right;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ResolveImpulse(
|
||||
ref PhysicsBody left,
|
||||
ref PhysicsBody right,
|
||||
float2 normal,
|
||||
float penetration,
|
||||
float relativeSpeed)
|
||||
{
|
||||
var leftInvMass = left.IsStatic ? 0.0f : 1.0f / max(left.Mass, MinimumMass);
|
||||
var rightInvMass = right.IsStatic ? 0.0f : 1.0f / max(right.Mass, MinimumMass);
|
||||
var invMassSum = leftInvMass + rightInvMass;
|
||||
if (invMassSum <= 0.0f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var correction = normal * (penetration / invMassSum);
|
||||
if (!left.IsStatic)
|
||||
{
|
||||
left = left with { Position = Vec2.FromCultMath(left.Position.ToCultMath() - correction * leftInvMass) };
|
||||
}
|
||||
|
||||
if (!right.IsStatic)
|
||||
{
|
||||
right = right with { Position = Vec2.FromCultMath(right.Position.ToCultMath() + correction * rightInvMass) };
|
||||
}
|
||||
|
||||
if (relativeSpeed >= 0.0f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var restitution = min(left.Restitution, right.Restitution);
|
||||
var impulseMagnitude = -(1.0f + restitution) * relativeSpeed / invMassSum;
|
||||
var impulse = normal * impulseMagnitude;
|
||||
|
||||
if (!left.IsStatic)
|
||||
{
|
||||
left = left with { Velocity = Vec2.FromCultMath(left.Velocity.ToCultMath() - impulse * leftInvMass) };
|
||||
}
|
||||
|
||||
if (!right.IsStatic)
|
||||
{
|
||||
right = right with { Velocity = Vec2.FromCultMath(right.Velocity.ToCultMath() + impulse * rightInvMass) };
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateBody(PhysicsBody body)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(body.Id))
|
||||
{
|
||||
throw new ArgumentException("Physics bodies must have stable ids.");
|
||||
}
|
||||
|
||||
if (body.Radius <= 0.0f)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(body), "Body radius must be positive.");
|
||||
}
|
||||
|
||||
if (!body.IsStatic && body.Mass <= 0.0f)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(body), "Dynamic body mass must be positive.");
|
||||
}
|
||||
}
|
||||
}
|
||||
256
src/ymir-daemon/Program.cs
Normal file
256
src/ymir-daemon/Program.cs
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Ymir.Core;
|
||||
|
||||
namespace Ymir.Daemon;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
public static async Task<int> Main(string[] args)
|
||||
{
|
||||
var command = args.FirstOrDefault() ?? "help";
|
||||
|
||||
switch (command)
|
||||
{
|
||||
case "provider":
|
||||
WriteJson(ProviderAdvertisement());
|
||||
return 0;
|
||||
case "operator-state":
|
||||
WriteJson(OperatorState());
|
||||
return 0;
|
||||
case "step-sample":
|
||||
WriteJson(new YmirSimulator().Step(SampleRequest()));
|
||||
return 0;
|
||||
case "step":
|
||||
return RunStep(args);
|
||||
case "serve":
|
||||
await ServeAsync(ParsePort(args, 8877));
|
||||
return 0;
|
||||
default:
|
||||
PrintHelp();
|
||||
return command is "help" or "-h" or "--help" ? 0 : 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static int RunStep(string[] args)
|
||||
{
|
||||
var requestPath = ValueAfter(args, "--request");
|
||||
if (string.IsNullOrWhiteSpace(requestPath))
|
||||
{
|
||||
Console.Error.WriteLine("step requires --request <path>.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var request = JsonSerializer.Deserialize<SimulationStepRequest>(
|
||||
File.ReadAllText(requestPath),
|
||||
JsonOptions);
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
Console.Error.WriteLine("Could not deserialize simulation step request.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
WriteJson(new YmirSimulator().Step(request));
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static async Task ServeAsync(int port)
|
||||
{
|
||||
using var listener = new HttpListener();
|
||||
listener.Prefixes.Add($"http://127.0.0.1:{port}/");
|
||||
listener.Start();
|
||||
Console.WriteLine($"Ymir serving on http://127.0.0.1:{port}/");
|
||||
|
||||
while (true)
|
||||
{
|
||||
var context = await listener.GetContextAsync();
|
||||
_ = Task.Run(() => HandleRequestAsync(context));
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task HandleRequestAsync(HttpListenerContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = context.Request.Url?.AbsolutePath ?? "/";
|
||||
var method = context.Request.HttpMethod;
|
||||
|
||||
if (method == "GET" && path == "/health")
|
||||
{
|
||||
await WriteResponseAsync(context, new { ok = true, service = "ymir", checkedAt = DateTimeOffset.UtcNow });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method == "GET" && path == "/provider-advertisement")
|
||||
{
|
||||
await WriteResponseAsync(context, ProviderAdvertisement());
|
||||
return;
|
||||
}
|
||||
|
||||
if (method == "GET" && path == "/operator-state")
|
||||
{
|
||||
await WriteResponseAsync(context, OperatorState());
|
||||
return;
|
||||
}
|
||||
|
||||
if (method == "GET" && path == "/eve/operator")
|
||||
{
|
||||
await WriteResponseAsync(context, EveOperatorSurface());
|
||||
return;
|
||||
}
|
||||
|
||||
if (method == "POST" && path == "/simulate/step")
|
||||
{
|
||||
using var reader = new StreamReader(context.Request.InputStream, context.Request.ContentEncoding);
|
||||
var body = await reader.ReadToEndAsync();
|
||||
var request = JsonSerializer.Deserialize<SimulationStepRequest>(body, JsonOptions);
|
||||
if (request is null)
|
||||
{
|
||||
await WriteErrorAsync(context, 400, "Invalid simulation request.");
|
||||
return;
|
||||
}
|
||||
|
||||
await WriteResponseAsync(context, new YmirSimulator().Step(request));
|
||||
return;
|
||||
}
|
||||
|
||||
await WriteErrorAsync(context, 404, "No such Ymir route.");
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
await WriteErrorAsync(context, 500, error.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static object ProviderAdvertisement() => new
|
||||
{
|
||||
schema = "gamecult.provider_advertisement.v1",
|
||||
providerId = "ymir.physics",
|
||||
title = "Ymir Physics",
|
||||
description = "GameCult physics/world substrate engine built on CultMath.",
|
||||
authority = new
|
||||
{
|
||||
owns = new[]
|
||||
{
|
||||
"physics simulation truth",
|
||||
"body integration",
|
||||
"field acceleration",
|
||||
"contact generation",
|
||||
"collision query semantics"
|
||||
},
|
||||
doesNotOwn = new[]
|
||||
{
|
||||
"Unity scene truth",
|
||||
"rendering",
|
||||
"gameplay damage policy",
|
||||
"editor authoring truth"
|
||||
}
|
||||
},
|
||||
routes = new
|
||||
{
|
||||
health = "/health",
|
||||
providerAdvertisement = "/provider-advertisement",
|
||||
operatorState = "/operator-state",
|
||||
operatorSurface = "/eve/operator",
|
||||
step = "/simulate/step"
|
||||
}
|
||||
};
|
||||
|
||||
private static object OperatorState() => new
|
||||
{
|
||||
schema = "gamecult.ymir.operator_state.v0",
|
||||
providerId = "ymir.physics",
|
||||
status = "mvp",
|
||||
stateOwner = "Ymir.Core step result; JSON is compatibility witness only",
|
||||
numericSubstrate = "CultMath",
|
||||
updatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
private static object EveOperatorSurface() => new
|
||||
{
|
||||
schema = "gamecult.eve.surface.v1",
|
||||
providerId = "ymir.physics",
|
||||
title = "Ymir Physics",
|
||||
updatedAt = DateTimeOffset.UtcNow,
|
||||
surface = new
|
||||
{
|
||||
root = new
|
||||
{
|
||||
type = "panel",
|
||||
title = "Ymir Physics",
|
||||
children = new object[]
|
||||
{
|
||||
new { type = "stat", label = "Status", value = "mvp" },
|
||||
new { type = "stat", label = "Owner", value = "physics simulation truth" },
|
||||
new { type = "stat", label = "Math", value = "CultMath" },
|
||||
new { type = "route", label = "Step", value = "POST /simulate/step" }
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private static SimulationStepRequest SampleRequest() => new(
|
||||
0.1f,
|
||||
new YmirWorld(
|
||||
0.0f,
|
||||
new[]
|
||||
{
|
||||
new PhysicsBody("projectile", new Vec2(-2.0f, 0.0f), new Vec2(4.0f, 0.0f), 0.1f, 1.0f, Restitution: 0.0f),
|
||||
new PhysicsBody("target", new Vec2(0.5f, 0.0f), Vec2.Zero, 0.5f, 1.0f, IsStatic: true)
|
||||
},
|
||||
new[]
|
||||
{
|
||||
new RadialField("gravity-well", new Vec2(0.0f, 1.0f), 2.0f, 8.0f)
|
||||
}));
|
||||
|
||||
private static async Task WriteResponseAsync(HttpListenerContext context, object value)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(value, JsonOptions);
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
context.Response.ContentType = "application/json; charset=utf-8";
|
||||
context.Response.ContentLength64 = bytes.Length;
|
||||
await context.Response.OutputStream.WriteAsync(bytes);
|
||||
context.Response.Close();
|
||||
}
|
||||
|
||||
private static Task WriteErrorAsync(HttpListenerContext context, int statusCode, string message)
|
||||
{
|
||||
context.Response.StatusCode = statusCode;
|
||||
return WriteResponseAsync(context, new { ok = false, error = message });
|
||||
}
|
||||
|
||||
private static void WriteJson(object value) => Console.WriteLine(JsonSerializer.Serialize(value, JsonOptions));
|
||||
|
||||
private static int ParsePort(string[] args, int defaultPort)
|
||||
{
|
||||
var raw = ValueAfter(args, "--port");
|
||||
return int.TryParse(raw, out var port) ? port : defaultPort;
|
||||
}
|
||||
|
||||
private static string? ValueAfter(string[] args, string key)
|
||||
{
|
||||
var index = Array.IndexOf(args, key);
|
||||
return index >= 0 && index + 1 < args.Length ? args[index + 1] : null;
|
||||
}
|
||||
|
||||
private static void PrintHelp()
|
||||
{
|
||||
Console.WriteLine("""
|
||||
Ymir physics daemon
|
||||
|
||||
Commands:
|
||||
provider
|
||||
operator-state
|
||||
step-sample
|
||||
step --request <path>
|
||||
serve [--port 8877]
|
||||
""");
|
||||
}
|
||||
}
|
||||
16
src/ymir-daemon/ymir-daemon.csproj
Normal file
16
src/ymir-daemon/ymir-daemon.csproj
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Ymir.Core\Ymir.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<RootNamespace>Ymir.Daemon</RootNamespace>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>ymir-daemon</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
25
tests/Ymir.Tests/Ymir.Tests.csproj
Normal file
25
tests/Ymir.Tests/Ymir.Tests.csproj
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Ymir.Core\Ymir.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
85
tests/Ymir.Tests/YmirSimulatorTests.cs
Normal file
85
tests/Ymir.Tests/YmirSimulatorTests.cs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
using Ymir.Core;
|
||||
|
||||
namespace Ymir.Tests;
|
||||
|
||||
public sealed class YmirSimulatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void StepIntegratesDynamicBodies()
|
||||
{
|
||||
var result = Step(new PhysicsBody("projectile", Vec2.Zero, new Vec2(10.0f, 0.0f), 0.1f, 1.0f));
|
||||
|
||||
Assert.Equal(1.0f, result.World.Bodies[0].Position.X, precision: 5);
|
||||
Assert.Equal(0.0f, result.World.Bodies[0].Position.Y, precision: 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RadialFieldAcceleratesBodyTowardWell()
|
||||
{
|
||||
var simulator = new YmirSimulator();
|
||||
var request = new SimulationStepRequest(
|
||||
1.0f,
|
||||
new YmirWorld(
|
||||
0.0f,
|
||||
new[] { new PhysicsBody("ship", Vec2.Zero, Vec2.Zero, 0.5f, 1.0f) },
|
||||
new[] { new RadialField("well", new Vec2(10.0f, 0.0f), 10.0f, 20.0f) }));
|
||||
|
||||
var result = simulator.Step(request);
|
||||
|
||||
Assert.True(result.World.Bodies[0].Velocity.X > 0.0f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OverlappingCirclesEmitContactAndSeparate()
|
||||
{
|
||||
var simulator = new YmirSimulator();
|
||||
var request = new SimulationStepRequest(
|
||||
0.1f,
|
||||
new YmirWorld(
|
||||
0.0f,
|
||||
new[]
|
||||
{
|
||||
new PhysicsBody("a", new Vec2(0.0f, 0.0f), new Vec2(1.0f, 0.0f), 1.0f, 1.0f),
|
||||
new PhysicsBody("b", new Vec2(1.5f, 0.0f), new Vec2(-1.0f, 0.0f), 1.0f, 1.0f)
|
||||
},
|
||||
Array.Empty<RadialField>()));
|
||||
|
||||
var result = simulator.Step(request);
|
||||
|
||||
var contact = Assert.Single(result.Contacts);
|
||||
Assert.Equal("a", contact.BodyA);
|
||||
Assert.Equal("b", contact.BodyB);
|
||||
Assert.True(contact.Penetration > 0.0f);
|
||||
Assert.True(result.World.Bodies[0].Position.X < 0.1f);
|
||||
Assert.True(result.World.Bodies[1].Position.X > 1.4f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContactOrderingIsStableByBodyId()
|
||||
{
|
||||
var simulator = new YmirSimulator();
|
||||
var request = new SimulationStepRequest(
|
||||
0.1f,
|
||||
new YmirWorld(
|
||||
0.0f,
|
||||
new[]
|
||||
{
|
||||
new PhysicsBody("zeta", new Vec2(0.0f, 0.0f), Vec2.Zero, 1.0f, 1.0f),
|
||||
new PhysicsBody("alpha", new Vec2(1.0f, 0.0f), Vec2.Zero, 1.0f, 1.0f)
|
||||
},
|
||||
Array.Empty<RadialField>()));
|
||||
|
||||
var contact = Assert.Single(simulator.Step(request).Contacts);
|
||||
|
||||
Assert.Equal("alpha", contact.BodyA);
|
||||
Assert.Equal("zeta", contact.BodyB);
|
||||
}
|
||||
|
||||
private static SimulationStepResult Step(params PhysicsBody[] bodies)
|
||||
{
|
||||
var simulator = new YmirSimulator();
|
||||
return simulator.Step(new SimulationStepRequest(
|
||||
0.1f,
|
||||
new YmirWorld(0.0f, bodies, Array.Empty<RadialField>())));
|
||||
}
|
||||
}
|
||||
108
tools/persona-remember.ps1
Normal file
108
tools/persona-remember.ps1
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$Summary,
|
||||
|
||||
[string]$Claim,
|
||||
[string]$Question,
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$Tension,
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$ActionImplication,
|
||||
[string[]]$Tags = @("ymir", "repo-persona"),
|
||||
[string]$Kind = "project_seam",
|
||||
[string]$TargetKind = "repo",
|
||||
[string]$TargetId = "Ymir",
|
||||
[string]$TargetLabel = "Ymir",
|
||||
[string]$AnchorRef = "repo:Ymir",
|
||||
[string]$AnchorKind = "repo",
|
||||
[string]$AnchorSummary = "Ymir repository Persona state",
|
||||
[string]$StatePath = ".\.voidbot\state\ymir.cc"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Claim) -and [string]::IsNullOrWhiteSpace($Question)) {
|
||||
throw "Memory writes must include either -Claim or -Question."
|
||||
}
|
||||
|
||||
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
|
||||
$voidBotRoot = "E:\Projects\VoidBot"
|
||||
$coreDistPath = Join-Path $voidBotRoot "packages\core\dist\index.js"
|
||||
$identityPath = Join-Path $repoRoot ".voidbot\voice\identity.json"
|
||||
$resolvedStatePath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath((Join-Path $repoRoot $StatePath))
|
||||
|
||||
if (!(Test-Path $coreDistPath)) {
|
||||
throw "Missing VoidBot built core at $coreDistPath. Build VoidBot before mutating Persona state."
|
||||
}
|
||||
|
||||
$now = (Get-Date).ToUniversalTime().ToString("o")
|
||||
$memoryId = "ymir-" + ([guid]::NewGuid().ToString())
|
||||
$operation = @{
|
||||
operation = "record_short_term_memory"
|
||||
memory = @{
|
||||
memoryId = $memoryId
|
||||
kind = $Kind
|
||||
target = @{
|
||||
kind = $TargetKind
|
||||
id = $TargetId
|
||||
label = $TargetLabel
|
||||
}
|
||||
summary = $Summary
|
||||
createdAt = $now
|
||||
updatedAt = $now
|
||||
tags = $Tags
|
||||
anchorRefs = @(
|
||||
@{
|
||||
ref = $AnchorRef
|
||||
kind = $AnchorKind
|
||||
summary = $AnchorSummary
|
||||
}
|
||||
)
|
||||
evidenceRefs = @()
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($field in @("Claim", "Question", "Tension", "ActionImplication")) {
|
||||
$value = Get-Variable -Name $field -ValueOnly
|
||||
if (![string]::IsNullOrWhiteSpace($value)) {
|
||||
$operation.memory[$field.Substring(0, 1).ToLowerInvariant() + $field.Substring(1)] = $value
|
||||
}
|
||||
}
|
||||
|
||||
$operationJson = $operation | ConvertTo-Json -Depth 12 -Compress
|
||||
$operationJsonBase64 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($operationJson))
|
||||
|
||||
node -e @"
|
||||
const core = require(process.argv[1]);
|
||||
const fs = require('fs');
|
||||
const identity = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
|
||||
const statePath = process.argv[3];
|
||||
const operation = JSON.parse(Buffer.from(process.argv[4], 'base64').toString('utf8'));
|
||||
|
||||
(async () => {
|
||||
await core.ensureVoidSelfStateIdentityProfile({
|
||||
canonicalPath: statePath,
|
||||
identity: {
|
||||
agentId: identity.id,
|
||||
publicName: identity.displayName,
|
||||
publicDescription: identity.description
|
||||
}
|
||||
});
|
||||
const result = await core.applyVoidSelfStateOperation({
|
||||
canonicalPath: statePath,
|
||||
identity: {
|
||||
agentId: identity.id,
|
||||
publicName: identity.displayName,
|
||||
publicDescription: identity.description
|
||||
}
|
||||
}, operation);
|
||||
console.log(JSON.stringify({ ok: true, memoryId: operation.memory.memoryId, result }, null, 2));
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
"@ $coreDistPath $identityPath $resolvedStatePath $operationJsonBase64
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
44
tools/persona-state-read.ps1
Normal file
44
tools/persona-state-read.ps1
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
param(
|
||||
[string]$StatePath = ".\.voidbot\state\ymir.cc",
|
||||
[switch]$Json
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
|
||||
$voidBotRoot = "E:\Projects\VoidBot"
|
||||
$coreDistPath = Join-Path $voidBotRoot "packages\core\dist\index.js"
|
||||
$identityPath = Join-Path $repoRoot ".voidbot\voice\identity.json"
|
||||
$resolvedStatePath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath((Join-Path $repoRoot $StatePath))
|
||||
|
||||
if (!(Test-Path $coreDistPath)) {
|
||||
throw "Missing VoidBot built core at $coreDistPath. Build VoidBot before reading Persona state."
|
||||
}
|
||||
|
||||
node -e @"
|
||||
const core = require(process.argv[1]);
|
||||
const fs = require('fs');
|
||||
const identity = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
|
||||
const statePath = process.argv[3];
|
||||
core.loadVoidSelfStateTypedDocuments({
|
||||
canonicalPath: statePath,
|
||||
identity: {
|
||||
agentId: identity.id,
|
||||
publicName: identity.displayName,
|
||||
publicDescription: identity.description
|
||||
}
|
||||
}).then((state) => {
|
||||
if (process.argv[4] === '--json') {
|
||||
console.log(JSON.stringify(state, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(core.renderVoidSelfStateSummary(state));
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
"@ $coreDistPath $identityPath $resolvedStatePath $(if ($Json) { "--json" } else { "" })
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
Loading…
Reference in a new issue