commit 131b6019bb36f465b58c61859ccb7b8054aa16ab Author: Meta Vens Date: Wed Jun 17 02:27:59 2026 +0100 Initial Ymir physics substrate diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b8ce64a --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +bin/ +obj/ +**/bin/ +**/obj/ +.ymir/ +*.user +TestResults/ diff --git a/.voidbot/state/README.md b/.voidbot/state/README.md new file mode 100644 index 0000000..30776d7 --- /dev/null +++ b/.voidbot/state/README.md @@ -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. diff --git a/.voidbot/state/ymir.cc b/.voidbot/state/ymir.cc new file mode 100644 index 0000000..8eaa940 Binary files /dev/null and b/.voidbot/state/ymir.cc differ diff --git a/.voidbot/voice/identity.json b/.voidbot/voice/identity.json new file mode 100644 index 0000000..9dc8809 --- /dev/null +++ b/.voidbot/voice/identity.json @@ -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" +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b31406b --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e004f70 --- /dev/null +++ b/README.md @@ -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. diff --git a/Ymir.slnx b/Ymir.slnx new file mode 100644 index 0000000..2db5400 --- /dev/null +++ b/Ymir.slnx @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/docs/aetheria-cutover.md b/docs/aetheria-cutover.md new file mode 100644 index 0000000..66512f5 --- /dev/null +++ b/docs/aetheria-cutover.md @@ -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 diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..db6c40d --- /dev/null +++ b/docs/architecture.md @@ -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. diff --git a/src/Ymir.Core/PhysicsModel.cs b/src/Ymir.Core/PhysicsModel.cs new file mode 100644 index 0000000..00ecd1b --- /dev/null +++ b/src/Ymir.Core/PhysicsModel.cs @@ -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 Bodies, + IReadOnlyList 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 Contacts); diff --git a/src/Ymir.Core/Ymir.Core.csproj b/src/Ymir.Core/Ymir.Core.csproj new file mode 100644 index 0000000..4ebbc69 --- /dev/null +++ b/src/Ymir.Core/Ymir.Core.csproj @@ -0,0 +1,17 @@ + + + + + + + + net8.0 + enable + enable + Ymir.Core + Ymir.Core + GameCult physics/world substrate engine built on CultMath. + GameCult + + + diff --git a/src/Ymir.Core/YmirSimulator.cs b/src/Ymir.Core/YmirSimulator.cs new file mode 100644 index 0000000..9dbbd79 --- /dev/null +++ b/src/Ymir.Core/YmirSimulator.cs @@ -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(); + 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 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 bodies, List 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."); + } + } +} diff --git a/src/ymir-daemon/Program.cs b/src/ymir-daemon/Program.cs new file mode 100644 index 0000000..053512d --- /dev/null +++ b/src/ymir-daemon/Program.cs @@ -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 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 ."); + return 1; + } + + var request = JsonSerializer.Deserialize( + 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(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 + serve [--port 8877] + """); + } +} diff --git a/src/ymir-daemon/ymir-daemon.csproj b/src/ymir-daemon/ymir-daemon.csproj new file mode 100644 index 0000000..7cd479b --- /dev/null +++ b/src/ymir-daemon/ymir-daemon.csproj @@ -0,0 +1,16 @@ + + + + + + + + Exe + net10.0 + Ymir.Daemon + enable + enable + ymir-daemon + + + diff --git a/tests/Ymir.Tests/Ymir.Tests.csproj b/tests/Ymir.Tests/Ymir.Tests.csproj new file mode 100644 index 0000000..1c5b2f7 --- /dev/null +++ b/tests/Ymir.Tests/Ymir.Tests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + diff --git a/tests/Ymir.Tests/YmirSimulatorTests.cs b/tests/Ymir.Tests/YmirSimulatorTests.cs new file mode 100644 index 0000000..1e16272 --- /dev/null +++ b/tests/Ymir.Tests/YmirSimulatorTests.cs @@ -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())); + + 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())); + + 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()))); + } +} diff --git a/tools/persona-remember.ps1 b/tools/persona-remember.ps1 new file mode 100644 index 0000000..e32af0a --- /dev/null +++ b/tools/persona-remember.ps1 @@ -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 +} diff --git a/tools/persona-state-read.ps1 b/tools/persona-state-read.ps1 new file mode 100644 index 0000000..50e7f0f --- /dev/null +++ b/tools/persona-state-read.ps1 @@ -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 +}