Initial Ymir physics substrate

This commit is contained in:
Meta Vens 2026-06-17 02:27:59 +01:00
commit 131b6019bb
18 changed files with 1053 additions and 0 deletions

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
bin/
obj/
**/bin/
**/obj/
.ymir/
*.user
TestResults/

17
.voidbot/state/README.md Normal file
View 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

Binary file not shown.

View 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
View 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
View 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
View 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
View 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
View 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.

View 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);

View 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>

View 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
View 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]
""");
}
}

View 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>

View 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>

View 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
View 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
}

View 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
}