39 KiB
Headless Playground: Run Lifecycle Cut Map
Date: 2026-09-17
Status: Imagination pass, cut map. Nothing here has landed. Anchors are against
codex/item-provenance HEAD 1e647953. Mechanism claims marked (probe) were run
headless against the real catalog GameData/Aetheria.cc (read-only) and the authored
Assets/Resources/Settings.asset. Run and player stores went to a temp directory. The
probe was built with --artifacts-path in scratch, so this tree got no bin/obj
writes. CultLib was a temporary detached worktree at a0813c6, since removed. The probe
source is not kept; §2.8 records what it did.
0. Target
Operator, 2026-09-17: player inputs "map directly to the same entity inputs the AI uses". An agent should be able to "spin up a playground ... generate galaxies, puppeteer an entity around yourself using the existing agent routines", and "This should be possible without involving Unity".
Ends:
- An agent (Hands or Soul) runs a scripted session headless. The script can:
- start a new game (galaxy, entrance zone, player ship);
- drive the pilot by hand-set entity inputs or by an existing
Agent; - dock, undock, warp between zones, take hits, die, and save;
- reopen the stores and Continue.
- The session emits a compact trace. It exits non-zero when an expectation fails or an exception escapes.
- First consumers:
- the headless half of the CultCache-migration play smoke
(
docs/cultcache-migration-cut.md:2739-2765); - the headless half of the provenance Cut C operator checks
(
docs/item-provenance-cut.md§6, "Operator-only checks after Cut C"); - the reopen defect in §2.6.
- the headless half of the CultCache-migration play smoke
(
Invariants:
- One owner of the run lifecycle. The playground and
ActionGameManagerboth call the same ServerShared owner. Neither re-implements new game, zone entry, warp, dock, save, Continue or death. - One input surface. Manual input, agent routines and playground commands write the same entity fields and call the same behavior methods. The playground has no private input path and writes no entity state the player cannot reach. Exceptions are named commands that call the same owner the Unity console calls.
- Determinism. A scenario with a given seed produces the same trace twice. The probe's two runs matched line for line.
- The playground never touches
GameData/run.ccorGameData/player.cc. It opens the catalog read-only and keeps its run and player stores in a directory it is given.
Non-goals:
- No rendering, no Unity, no new daemon, no CultMesh surface.
- No headless ballistics. Whether a shot geometrically hits stays with Unity physics (fork D).
- No trade menu, no action-bar UI, no input devices.
- Tier colour, properties panel, input screen and CultCache Studio rendering stay operator checks.
1. Verdict on the operator's claim
Mostly true for movement and firing. False for how combat resolves, and the AI's combat state cannot run without Unity.
- True:
- Player movement and AI movement write the same
Ship.MovementDirection(Ship.cs:27). Player:ActionGameManager.cs:1201. AI:Agent.cs:67,73,77. - Both write the same
Entity.LookDirection(Entity.cs:42). Player:ActionGameManager.cs:1189-1192. AI:MoveTo.cs:26,Combat.cs:111,Agent.cs:66. - Both write the same
Entity.Target(Entity.cs:40). Player:ActionGameManager.cs:366-397. AI:Minion.cs:14. - Both fire through the same
Weapon.Activate/Deactivate. Player:ActionBarSlot.cs:190-204, via the action bar atActionGameManager.cs:411-412. AI:Combat.cs:56,106,108.
- Player movement and AI movement write the same
- Player-only inputs, all plain entity writes that an agent could make:
TractorPower(ActionGameManager.cs:1214-1216);OverrideShutdown,HeatsinksEnabled, shieldEnabled,Sensor.Ping(:339-362);- gear
IActivatedBehavior(ActionBarSlot.cs:159-168); - consumables (
ActionBarSlot.cs:119-122,Entity.TryActivateConsumable).
- Player-only lifecycle actions. These are the orchestration this map moves:
- interact, which means warp, dock or undock (
ActionGameManager.cs:298-320); - tow (
:889-913); - boarding another docked ship (
InventoryPanel.cs:117-118).
- interact, which means warp, dock or undock (
- False: combat resolves in Unity.
InstantWeapon.OnFireonly raises an event (InstantWeapon.cs:183). The projectile is a Unity effect manager (EntityInstance.cs:203-204).- Hits are physics colliders (
HullCollider.cs:18,GuidedProjectile.cs:170). - Turning a hit into armor, item and hull damage is
EntityInstance.DamageSchematic(EntityInstance.cs:276-314), with hit-shape and penetration at:330-395. - NPC death removes the entity from the zone at
:437. Loot drops in the same place (:405-435), rolled withUnityEngine.Random. - Pickup is a collision trigger (
ShieldManager.cs:29). - (probe) Weapon groups fired 6
OnFireevents at an enemy 50 units ahead. Enemy hull stayed 500.0 -> 500.0.
- False:
CombatStateneeds Unity-written data. It indexesShip.HardpointTransforms(Combat.cs:101). OnlyEntityInstance.cs:516writes that dictionary, from barrel transforms.- (probe) A
Minionwith a target throwsKeyNotFoundException: 'Energy Hardpoint 'on its first combat step. - Every NPC agent that acquires a target would throw the same way headless.
- The weapons' own direction reader already has a fallback (
Behaviors.cs:24-43).CombatStatedoes not use it.
- (probe) A
2. Body findings
2.1 Where the run lifecycle lives today
All of this is Unity code:
| Concern | Anchor | Unity dependencies |
|---|---|---|
| Store composition | ActionGameManager.cs:43-63 (static, Application.dataPath) |
path only |
| Handoff across scene load | statics CurrentGalaxy, IsTutorial (:96-97), written by MainMenu.cs:104-105,126,138,152,166, read by SectorMap.cs:114-310 |
SceneManager.LoadScene (MainMenu.cs:106,139,167) |
| New game: clear | MainMenu.cs:112 |
none |
| New game: galaxy | MainMenu.cs:125,148 (UnityEngine.Random.value background noise), :127-141,153-169 (Task.Run, then Observable.NextFrame) |
Random, UniRx main-thread scheduler |
| ItemManager | ActionGameManager.cs:264; RNG clock-seeded at ItemManager.cs:20 |
Debug.Log |
| Start / Continue | StartGame :725-770: spawn player ship :733-744; Continue resolves the docked child :751-762; action bar restore :764-767 |
renderer binding, SectorMap reveal |
| Zone entry | PopulateLevel :639-675: generate/unpack :643-654; move pilot :659-666 |
ZoneRenderer.LoadZone, Debug.Log |
| Wormhole placement (a game rule) | ZoneRenderer.cs:222-231 (dir * Pack.Radius * GameSettings.WormholeDistanceRatio) |
lives in a MonoBehaviour |
| Warp | detect ActionGameManager.cs:312-316; EnterWormhole :615-637, which saves on arrival (:635) |
ZoneRenderer.WormholeInstances as the wormhole list |
| Dock / undock / tow | :801-823, DoDock :825-843 (cameras, menus), :845-887 (dialogs), :889-913 |
cameras, dialogs |
| Board a docked ship | InventoryPanel.cs:117-118 |
UI button |
| Save | SaveRun :241-249, called at quit :236 and warp :635 |
action-bar slots (:246) |
| Death | BindToEntity subscribes Death -> Die (:1053); Die :1080-1115 clears the run (:1088) |
Time.time, Observable.EveryUpdate post-fx |
| Tick | Update :1151-1220: input :1189-1216, then Zone.Update(Time.deltaTime) :1218 |
Time.deltaTime, pause flag |
| Dead code | IntroCutscene :772-799; its only call is commented out (:760) |
coroutine, Time.time |
2.2 Already headless
Aetheria.Shared/Aetheria.Shared.csprojcompilesAssets/Scripts/ServerShared/**plus UniRx (UniRxLibrary) and Ink with no Unity defines. It is the Unity-independence check.- Galaxy generation, zone generation,
Zone, entities, behaviors, agents,RunSave,AetheriaStoresandItemManagerall run under it. The probe measured:- galaxy seed 42: 196 zones;
- the first zone with ships and stations: 7 entities and 4 agents;
- a player
LonginusXspawned throughLoadoutGenerator; - thrust by hand-set
MovementDirectionreached 142 m/s in 5 s; - a
Minionpatrol moved the player ship 642 units in 10 s; TryDockandTryUndocksucceeded (Entity.cs:721-755);Ship.EnterWormholefollowed by stepping firedOnEnteredWormhole(Ship.cs:82-90,372);Deathfired on hull damage (Entity.cs:277-280);RunSave.CaptureandRunSave.Commitwrote a 489 KBrun.cc.
- Settings:
GameSettingsis a ScriptableObject (GameSettings.cs:11) holding ServerShared classes.tools/AetherDb/AuthoredSettings.csreads them from the asset YAML. The probe placed every field (0 unplaced).- Two lifecycle inputs are Unity-only
GameSettingsfields:StartingHullName(GameSettings.cs:17, authoredLonginusX) andWormholeDistanceRatio(:23, authored 0.75).
- Randomness:
ItemManager.Randomis clock-seeded (ItemManager.cs:20).Galaxyis clock-seeded only whenseed == 0(Galaxy.cs:108,158).Zoneseeds from the zone name (Zone.cs:54).Zone.Updateruns belt updates onTask.Run(Zone.cs:144). It is still deterministic (probe: identical traces).
2.3 Agents
- The agent API is
Agent.Update(dt)(Agent.cs:47-56) over aBaseStategraph. Minion(Minion.cs:7-21) patrols (PatrolOrbits.cs), auto-targets the first visible enemy (:14), and entersCombatState.- Agents are created only for packed non-player ships at zone construction
(
Zone.cs:88-93,99-106) and are updated before entities (:147-150). - Nothing ever removes one from
Zone.Agents. - The player ship never gets an agent.
- An agent held in a zone's list would stay behind when its ship warps (
PopulateLevelmoves the entity, not agents). - There is no "fly to a point" state.
MoveToStateis abstract and its only concrete state isMoveToOrbitState(MoveTo.cs:7-37).
2.4 AetherDb
- Commands:
census,station-fit,hardpoint-fit,loadout [seed],save,factions,dangling,settings,settings-dump(Program.cs:18-35). - Stores open through
AetherDb.Open, which callsAetheriaStores.OpenwithGameData/run.cchard-wired (AetherDb.cs).save(Program.cs:349-410) is the only played-save reader. - It already owns
AuthoredSettings(YamlDotNet) and referencesAetheria.Shared, so it has everything the playground needs.
2.5 Tests at HEAD
(probe) dotnet test tests/Aetheria.Shared.Tests at 1e647953 with CultLib
a0813c6: 32 passed, 2 failed. Both failures are
MessagePackSerializationException: Failed to deserialize SavedZone value:
LoadoutTests.MaterializedLotsSurviveSaveAndReloadRunSaveTests.CommitKeepsOnlyReachableLots
This contradicts the "34 tests green" verification that docs/item-provenance-cut.md
sets for Cut B.
2.6 The reopen defect, confirmed
(probe) Reopening a run store that holds a SavedZone with any entity throws:
MessagePackSerializationException: Failed to deserialize SavedZone value.
TypeAccessException: No hash-resistant equality comparer available for type: CultMath.int2
- The failing field is
EntityPack.PersistedBehaviors, aDictionary<int2, PersistentBehaviorData[]>(EntitySerializer.cs:200), written at:35-40and read at:126-130. - The player ship's dictionary was empty (0 persisted behaviors) and it still threw. MessagePack builds the dictionary's comparer before reading any entry.
CultMessagePackSecurityrunsUntrustedDataand adds a comparer only forCultRecordRef<T>keys.- No other run-store type has a struct dictionary key.
SavedGameusesintkeys, the ledger usesint, andPlayerSettingsusesstring. - Consequence: Continue has been unable to load any run that visited a zone since entities entered the run store under this security. Migration smoke step 6 cannot pass. Unity reads through the same CultLib serializer, which is inferred, not run here.
2.7 Asides found in passing (not this cut's to fix)
TargetNearestpicks the farthest visible enemy:MaxBy(length(...))(ActionGameManager.cs:378-379).- The interact loop enters every wormhole in range, then calls
Dock()anyway (:312-317). SaveRunpassesDockedEntity ?? CurrentEntity(:245), butBindToEntitynullsDockedEntity(:946). The order is fragile, not wrong today.
2.8 The probe (discarded)
A scratch console referencing Aetheria.Shared and AuthoredSettings.cs. It:
- Loaded authored settings.
- Opened the stores (catalog read-only; run and player in temp).
- Generated a galaxy with seed 42 and set
ItemManager.Randomto seed 7. - Generated zones outward from the entrance until one held ships and stations.
- Spawned the player ship, drove it by hand, then by a
Minion. - Forced a target, fired groups by hand, then docked, undocked and entered a wormhole.
- Killed an NPC by hull damage, then saved, disposed and reopened.
Traces from two runs were identical.
3. Authority map
Owner: Run, a new ServerShared class (Assets/Scripts/ServerShared/Run.cs). It
owns one live run's lifecycle: start, zone entry, pilot, dock state, warp, save,
Continue and death. There is one instance per run. It holds no Unity type.
-
Inputs:
- the
CultCachefromAetheriaStores.Open, composed by the caller; GameplaySettings,ZoneGenerationSettings,PlanetSettings;- a
uint seedforItemManager; Action<string> log;- for a new run, a caller-built
GalaxyandisTutorial; dtper step;- commands from either lowering;
SavedActionBarBinding[]at save, owned by the Unity action bar (the playground passes empty).
- the
-
Outputs:
- state:
Galaxy,ItemManager,Zone,Pilot(the pilotedEntity),StationandBaywhen docked,TowingStation,Ended; - events:
ZoneEntered(Zone),PilotChanged(Entity),Docked(Entity, EquippedDockingBay),Undocked,WarpArrived(GalaxyZone),Saved,Died(CauseOfDeath); - store writes through
RunSave.CommitandRunSave.Clearonly.
- state:
-
Derived state:
ActionGameManager.CurrentEntity,.Zone,.ItemManager,.DockedEntity,.DockingBayand.TowingStationbecome read-only projections ofRun, for display only.ZoneRenderer.WormholeInstancesbecomes render-only: its keys areZone.Wormholes, and it is no longer the wormhole list gameplay reads.SectorMapreadsRun.Galaxyfor display.
-
Forbidden writers after the cut:
ActionGameManagermay not callRunSave.Clear,RunSave.Capture,RunSave.Commit,ZoneGenerator.GenerateZoneornew Zone, and may not add or remove the pilot inZone.Entities.- It may not construct
ItemManager, spawn the player ship, subscribeDeathto end the run, or setCurrentEntity,DockedEntity,DockingBayorTowingStation. MainMenumay not callRunSave.Clearor constructGalaxy(cache, saved).InventoryPanelmay not writeCurrentEntityorDockingBay.DockedShip.ZoneRenderermay not createWormholeobjects.- The playground may not write
Position,Velocity,Hull.Durabilityor zone membership directly. Itshitcommand goes through the damage owner (fork D).
-
Shared paths:
- Manual input and agent input write the same entity fields (§1), and both go before
Run.Step. Unity writes input inUpdate, then callsrun.Step(Time.deltaTime). The playground updates its pilot agent insideRun.Step, beforeZone.Update, so the order matches. - Direct interaction (Unity's Interact key), programmatic interaction (playground
dock/warp) and Continue's docked landing all commit throughRun.TryDock,Run.TryEnterWormholeandRun.Land. - A new run and Continue share
Run.EnterZone. - Warp arrival and quit share
Run.Save.
- Manual input and agent input write the same entity fields (§1), and both go before
-
Pilot agent:
Run.PilotAgent(nullable) is owned byRun, not byZone.Agents, so it follows the pilot across warps.- Unity leaves it null. It may never be non-null while Unity input is enabled.
Zone.Agentsstays the owner of NPC agents.
-
Deletion line:
ActionGameManager: bodies ofSaveRun,EnterWormhole,PopulateLevel's simulation half,StartGame,Dock,DoDock's state half,Undock's checks,TowShip,Die's clear, andIntroCutscene(entirely);- statics
CurrentGalaxyandIsTutorial; MainMenu.cs:104-105,112;ZoneRenderer.cs:222-231's wormhole construction;ItemManager.cs:20's clock seed.
All of these go in the same cut that adds
Run. None survives as a fallback.
Unity dependencies, each injected or cut:
| Dependency | Where | Disposition |
|---|---|---|
Time.deltaTime |
:1218 |
injected: Run.Step(float dt) |
Time.time |
IntroCutscene, Die post-fx |
cutscene deleted; post-fx stays in Unity, on Died |
UnityEngine.Random |
MainMenu.cs:125,148 (background noise), EntityInstance.cs:413-428 (loot) |
noise stays in the menu as a galaxy-generation input; loot roll moves to ServerShared on ItemManager.Random (fork D) |
| Clock RNG | ItemManager.cs:20 |
cut; the seed is a constructor argument, and Unity passes a clock-derived one |
| Coroutines | IntroCutscene |
deleted (dead) |
| UniRx | Observable.NextFrame in the menu, EveryUpdate in Die |
stay in Unity; Run uses C# events, and Entity.Death (UniRx, headless-compiled) is what Run subscribes to |
Debug.Log |
:264, :657, MainMenu |
injected Action<string> |
| Scene load | MainMenu |
stays; the handoff static becomes ActionGameManager.NewRun : (Galaxy, bool)?, command-only, consumed once in Start; null means Continue |
| MonoBehaviour state | _currentEntity, DockedEntity, DockingBay, TowingStation, Zone, ItemManager |
projections of Run |
| Barrel transforms | EntityInstance.cs:516 -> HardpointTransforms |
render-owned aim; CombatState stops depending on it (fork H) |
4. Cuts
Build budget for every cut:
- Packages:
Aetheria.Shared(netstandard2.1),tools/AetherDbandtests/Aetheria.Shared.Tests(net10.0), Debug only. - CultLib at
a0813c6, unmodified, under fork R (a). - No new project, target, package or schema store.
- Build host: this Windows workstation, which is also the target for the headless checks.
Assembly-CSharpis proven only by Unity batchmode with the editor closed, perdocs/item-provenance-cut.md§6. This is the operator's call.- Headless commands use
--artifacts-path <scratch>while another agent is reading the tree.
Cut 0: make a run with entities reopenable (fork R)
Ruled (operator, 2026-09-17): fork R is the pairs array. It lands with the provenance
Cut B fix batch; see docs/item-provenance-cut.md.
EntitySerializer.cs:200:PersistedBehaviorsbecomes(int2 position, PersistentBehaviorData[] data)[], matchingEquipmentat:197-199.- Key 5 is kept. The wire shape changes from a map to an array; saves are discarded
and no
GameData/run.ccexists. :35-40packs pairs.:126-130builds a local lookup at unpack.
- Key 5 is kept. The wire shape changes from a map to an array; saves are discarded
and no
RunSaveTests.csBarePackinitializer:Array.Empty<...>().- Verification:
- The two HEAD-red tests go green: 34 passed.
- Mutation: restore the
Dictionary<int2,…>type. Both tests go red with theint2comparer message. - Add
ReopenedPackKeepsPersistedBehaviorstoRunSaveTests. A pack with two positions, each with one persisted behavior, commits and reopens, and the positions and data are equal.- Mutation: pack drops
data, so the test goes red. - Mutation: unpack ignores the lookup, so the test goes red.
- Mutation: pack drops
- Negative:
rg -n "Dictionary<int2" Assets/Scripts/ServerSharedis empty. - Ledger: about ±6 C#, +25 test.
Cut 1: Run becomes the single lifecycle owner; Unity lowers it
This is subtraction first, then one new file.
- Deletes and moves:
ActionGameManager.cs:IntroCutscene:772-799(dead).- Statics
CurrentGalaxyandIsTutorial(:96-97). SaveRunbody:243-248becomesRun?.Save(actionBar). Quit (:236) calls it.EnterWormhole:615-637and the detect loop:312-316becomeRun.TryEnterWormhole().WarpArriveddrivesSectorMap.QueueZoneReveal.PopulateLevel:639-675: the simulation half moves intoRun.EnterZone. What stays is aZoneEnteredhandler:ZoneRenderer.LoadZone,PlayMusic, rebind.StartGame:725-770becomesRun.NeworRun.ContinueinStart. It keeps the sector reveal and action-bar restore in Unity. Restore readsmin(slots, saved.Length), so a playground-written save continues in Unity.Dock:801-823becomesRun.TryDock().DoDock:825-843keeps cameras and menus onDocked; its state writes:828,830,840-841go toRun.Undock:845-887becomesRun.Undock(), returningUndockResult. The dialog text maps from the result.TowShip:889-913becomesRun.Tow().Die:1080-1088becomes aDiedhandler: unbind, menus, post-fx,SavePlayerSettings. TheRunSave.Clearat:1088goes.:1053subscribesRun.Died.:264constructs noItemManager.Update:1218becomesRun.Step(Time.deltaTime).
MainMenu.cs:- Continue (
:102-107) setsActionGameManager.NewRun = nulland loads the scene. - New Game drops
:112and setsNewRun = (sector, tutorial)at:138,166.
- Continue (
SectorMap.cs:114-310readsActionGameManager.Instance.Run.Galaxy.InventoryPanel.cs:117-118becomesGameManager.Run.Board(ship).ZoneRenderer.cs:222-231iterateszone.Wormholes.ItemManager.cs:20: the seed becomes a constructor argument. Call sites: 11 in tests, 1 inAetherDb, andRun.
- Settings (fork S (a)):
StartingHullNameandWormholeDistanceRatiomove fromGameSettings.cs:17,23toGameplaySettings(Settings.cs:155).- The asset keys move under
GameplaySettings:inSettings.assetby text edit, with Unity closed. - Readers
ActionGameManager.cs:738andZoneRenderergo throughItemManager.GameplaySettings.
Zone.cs:public Wormhole[] Wormholes, built in the constructor fromGalaxyZone.AdjacentZoneswith the rule fromZoneRenderer.cs:226-230. Null galaxy zone gives an empty array.- New
Run.cs: types and rules, no bodies here.static Run New(CultCache, GameplaySettings, ZoneGenerationSettings, PlanetSettings, Galaxy, bool isTutorial, uint seed, Action<string> log)RunSave.Clear.ItemManageroverRunSave.Lots, seeded.EnterZone(Entrance).- Spawn the player ship per
:734-743. - Set the pilot. Returns a live run.
static Run Continue(CultCache, …, uint seed, Action<string> log)- Returns null when there is no
SavedGame. - Otherwise
Galaxy(cache, saved, log), thenEnterZone(saved zone), thenLand(saved entity). Landhandles an orbital entity per:753-756: pilot is theIsPlayerShipchild, and the run is docked there.
- Returns null when there is no
void Step(float dt)- Throws if
Ended. - Updates
PilotAgent, thenZone.Update(dt).
- Throws if
void EnterZone(GalaxyZone): the rule from:643-666. It raisesZoneEntered.bool TryEnterWormhole()- Picks the nearest
Zone.Wormholesentry withinGameplaySettings.WormholeExitRadius. Requires the pilot to be aShip, not mid-animation, and undocked. - On
OnEnteredWormhole:EnterZone(target).- Discover adjacent zones.
ExitWormholeat the back-link wormhole withWormholeExitVelocity * ItemManager.Random.NextFloat2Direction().Save(lastActionBar).- Raise
WarpArrived.
- Picks the nearest
EquippedDockingBay TryDock(): the rule from:803-821, withinDockingDistance.UndockResult Undock():Ok,NotDocked,MissingCockpit,MissingThruster,MissingReactor,BayNotEmpty. Check order as:850-885.bool Board(Ship): docked only; the ship must be the station'sIsPlayerShipchild; updatesBay.DockedShip.void Tow(): the rule from:891-911.void Save(SavedActionBarBinding[] actionBar)RunSave.Capture(cache, Galaxy, Zone, Station ?? Pilot, isTutorial, actionBar), thenCommit(…, ItemManager.Lots).- No-op when
Ended. - Remembers
actionBarfor warp saves.
- Death:
RunsubscribesPilot.Deathwhenever the pilot changes. On death:Ended = true.RunSave.Clear.- Dispose subscriptions.
- Raise
Died(cause).
Agent PilotAgent { get; set; }: setting it whileEndedthrows.
- Tests (in
RunTests, same project, fixture catalog asLoadoutTests). Each is paired with the mutation that must kill it:
| Test | Asserts | Mutation that must kill it |
|---|---|---|
NewRunSpawnsPilotInEntrance |
pilot is a Ship with IsPlayerShip, in Zone.Entities, Zone.GalaxyZone == Galaxy.Entrance; the run store is cleared of a prior SavedGame |
New skips RunSave.Clear; spawn omits Zones.Entities.Add |
WarpMovesPilotAndSaves |
stepping after TryEnterWormhole lands the pilot in the target zone, out of the old zone's entities, with SavedGame.CurrentZone equal to the target index after reopen |
arrival skips Save; EnterZone leaves the pilot in the old zone |
ContinueLandsDocked |
save while docked, reopen, Continue: Station is the saved orbital, Pilot is its player child |
Land binds to the orbital itself |
DeathEndsAndClears |
hull death clears every run record; Step throws; Save writes nothing |
Died handler omits Clear; Ended not set |
PilotAgentFollowsWarp |
a Minion set as PilotAgent still updates the pilot (position changes) after a warp; Zone.Agents of both zones exclude it |
agent added to Zone.Agents |
WormholesAreZoneOwned |
Zone.Wormholes.Length == AdjacentZones.Count and positions follow the ratio |
ratio ignored |
- Verification:
- Headless build and tests green.
- Unity batchmode has no
error CS. - Negative greps:
rg -n "RunSave\.(Clear|Commit|Capture)" Assets/Scripts --glob '!ServerShared/**'is empty.rg -n "CurrentGalaxy|IsTutorial\s*=" Assets/Scriptsis empty.rg -n "new Wormhole" Assets/Scriptsmatches onlyZone.cs.rg -n "new ItemManager\(" Assets/Scriptsmatches onlyRun.cs.rg -n "ZoneGenerator\.GenerateZone|new Zone\(" Assets/Scripts --glob '!ServerShared/**'is empty.rg -n "CurrentEntity\s*=[^=]" Assets/Scripts --glob '!ServerShared/**'is empty.rg -n "DateTime\.Now" Assets/Scripts/ServerShared/ItemManager.csis empty.
- Ledger:
ActionGameManagerabout -190 +45;MainMenu-4 +2;ZoneRenderer-9 +3;InventoryPanel-2 +1;SectorMap±12 renames;ItemManager±1;Zone+10;Run.csabout +190; tests about +200.- Statics: -2 +1.
- Net production code is about +40. That buys the invariant: the lifecycle has one owner, and every rule it holds existed before in a MonoBehaviour.
Cut 2: combat and death resolve without Unity (forks H, D)
Combat.cs:101:shouldFirereadstestWeapon.Direction, the same reader shots use (Behaviors.cs:24-43). Its fallback is hull-relative item rotation; with Unity running, the articulated barrel transform wins as today.- Damage owner:
EntityInstance.cs:276-314(DamageSchematic) and:330-395(hit-shape) move toEntity:void ApplyHit(HullHit hit)withHullHit { Damage, Penetration, Spread, DamageType, Source, HullUv, LocalDirection };void ApplySplash(float damage, DamageType type, Entity source, float2 localDirection).- The only Unity-specific step,
transform.InverseTransformDirection, becomes a rotation byShip.Rotation(Ship.cs:71) at the caller.HullCollidersubscribers call these methods.
- Death owner:
Zonesubscribes each entity'sDeathwhen it is added. On death:- remove the entity from
Entitiesand its agent fromAgents; - roll drops with
ItemManager.RandomagainstLootDropProbability. That field moves fromGameSettings.cs:19toGameplaySettings, together withLootDropVelocity. - Keep
Zone.Loot : ReactiveCollection<LootDrop { ItemInstance Item; float3 Position; float2 Velocity }>. EntityInstance.cs:405-437keeps the destroy effect only.ZoneRenderer.DropItemlowersLoot.ObserveAdd.
- remove the entity from
- Pickup owner:
bool Zone.TryPickUp(Entity, LootDrop)stores the item in the first cargo bay that accepts it and removes the drop.ShieldManager.cs:29calls it. - Tests:
| Test | Asserts | Mutation that must kill it |
|---|---|---|
CombatStateFiresHeadless |
a Minion ship with a target in range steps 10 s headless without throwing and raises at least one OnFire |
revert :101 |
HardpointHitDamagesItemThenHull |
a hit on hardpoint index k with damage greater than armor plus item durability lowers that item's durability to 0 and the hull by the remainder | armor not subtracted; hull skipped |
NpcDeathRemovesEntityAndAgent |
after hull death the entity and its agent are gone from the zone | removal omitted |
LootDropsAreSeeded |
two zones from the same seed drop identical item lists | roll uses a clock RNG |
PickUpStoresAndRemoves |
the item lands in cargo and the drop is gone; a full bay leaves the drop | drop removed on failure |
- Verification:
- Headless green; Unity batchmode has no
error CS. - Negative:
rg -n "Random\.value|onUnitSphere" Assets/Scripts/Gameplay/EntityInstance.csmatches only visual effects, not the drop decision. - Negative:
rg -n "Entities\.Remove" Assets/Scripts --glob '!ServerShared/**'is empty. - Operator: a Unity fight still damages and drops loot.
- Headless green; Unity batchmode has no
- Ledger:
EntityInstanceabout -110 +12;Entity+95;Zone+45;ShieldManager±3;Combat±1; tests +180. Net production about +40. It moves rules; it adds none.
Cut 3: AetherDb play
AetherDb.cs:OpentakesdataDir. Run and player paths come from it. It defaults toGameDatafor the existing commands and is required forplay.Program.cs:case "play": return Play(args).- Usage:
play <scenario-file|-> --data <dir> [--seed <u>]. playrefuses a--datathat resolves to<root>/GameData.
- Usage:
- New
tools/AetherDb/Playground.cs. It is a line interpreter overRunwith no simulation rules of its own. One command per line;#starts a comment.
| Command | Calls |
|---|---|
new [tutorial] [galaxy-seed=<u>] |
Galaxy(...) from authored settings, then Run.New |
continue |
Run.Continue; fails the scenario when null |
reopen |
dispose cache, AetheriaStores.Open again (quit/relaunch; the layer where §2.6 shows) |
step <seconds> |
Run.Step(1/60) repeated |
move <x> <y> / look <x> <z> / tractor <0..1> |
pilot entity fields, as ActionGameManager.cs:1189-1216 |
target nearest|none|<entity#> |
Pilot.Target.Value |
fire <group> on|off |
WeaponGroups[g].weapons Activate/Deactivate, as ActionBarSlot.cs:190-204 |
gear <equipment#> on|off |
IActivatedBehavior Activate/Deactivate |
pilot manual|minion|patrol|goto station <i>|goto wormhole <i> |
Run.PilotAgent |
dock / undock / board <entity#> / warp / tow |
Run.TryDock / Undock / Board / TryEnterWormhole / Tow |
hit <entity#> <damage> [hardpoint <k>] |
Entity.ApplyHit (Cut 2) |
pickup all |
Zone.TryPickUp for drops within DockingDistance |
save |
Run.Save(empty) |
expect <fact> <op> <value> |
facts: ended, zone, docked, pilot.hull, zone.entities, run.savedgame, lots.absent, lots.mismatched, cargo.count |
state |
trace one state line |
gotoneedsMoveToPositionState : MoveToStateinMoveTo.cs(about 6 lines), wrapped in a one-stateAgent. It is the only new agent code.- The trace goes to stdout, one line per step command and per
Runevent:t=<zone time> zone=<name> <event> k=v….stateadds pilot position, velocity, hull, target, docked, entity and agent counts, lot count andrun.ccbytes. - An exception prints its type, message and first frame, then ends the scenario.
- Exit code is failed expectations plus escaped exceptions.
lots.absentandlots.mismatchedreuse the checks inProgram.cs:399-408. They are extracted to one method thatsavealso calls, not copied.- First smoke,
tools/AetherDb/scenarios/lifecycle.play, checked in:
new galaxy-seed=42
state
move 0 1
step 5
pilot goto station 0
step 60
dock
expect docked == true
undock
pilot goto wormhole 0
step 60
warp
step 5
expect run.savedgame == true
reopen
continue
expect zone == <the warp target, printed by the trace>
expect lots.absent == 0
expect lots.mismatched == 0
target nearest
pilot minion
step 30
hit pilot 100000
expect ended == true
expect run.savedgame == false
- Hands fixes the literal zone name after the first run. The scenario checks that it reproduces, not what it is.
- Verification:
dotnet run --project tools/AetherDb -- play tools/AetherDb/scenarios/lifecycle.play --data <scratch>exits 0.- Running it twice gives byte-identical traces.
- Mutation: revert Cut 0, and the scenario exits non-zero at
continuewith theint2message. - Mutation:
Run.DiedomitsClear, andexpect run.savedgame == falsefails. - Negative:
rg -n "RunSave\.|ZoneGenerator|\.Position\s*=|Durability\s*=" tools/AetherDb/Playground.csis empty.
- Ledger:
Playground.csabout +220;AetherDb.cs±4;Program.cs+3 and ±10 (extraction);MoveTo.cs+6; scenario +26. No new target.
Cut 4: consumer scenarios
These are scenario files only; no code.
cultcache-smoke.playcovers migration smoke steps 3-8 minus the rendering and UI parts:- new, warp, warp again with the
SavedZonecount constant (expect run.savedzones == <n>, a new fact); - reopen and continue on the same zone and entity;
- reopen before any step and check every visited zone still holds contents;
- die, then the run is cleared.
- Steps 1-2 (
player.ccfrom settings UI), 3's input screen and 9 (loadout UI) stay operator-only.
- new, warp, warp again with the
provenance-c.playcovers the Cut C checks:- new; kill an NPC with
hit;pickup all; expect cargo.count > 0;- warp; reopen; continue;
expect lots.absent == 0,expect lots.mismatched == 0;- die;
expect run.savedgame == false. - Tier colour, Manufacturer row, trade purchase and Studio rendering stay operator-only.
- new; kill an NPC with
5. Forks
R. Reopen defect. Blocking: every Continue and two HEAD tests.
- (a)
PersistedBehaviorsas a pairs array. This is local, matches the sibling pack fields, and saves are discarded anyway. - (b) CultLib: a hash-resistant comparer for CultMath value types in
CultMessagePackSecurity. That is a foundation change with a new pin and a Unity package release, and it keeps a map for 0-12 entries. - (c) Key persisted behaviors by equipment index. It saves bytes but couples to
equipment order, which
Packdoes not promise. - Recommend (a).
P. Where the playground lives.
- (a)
AetherDb play. It already has settings, stores and the save checks. - (b) Scenarios only as xunit tests. They cannot use authored settings without dragging YamlDotNet into tests, and agents cannot puppeteer interactively.
- (c) A new
tools/AetherPlayexecutable, which duplicates store and settings loading for no second consumer. - Recommend (a), with lifecycle rules tested in xunit (Cut 1 and 2 tests) and
sessions in
play.
H. How CombatState decides to fire.
- (a) Read
Weapon.Direction, the same reader the shot uses. - (b) A ServerShared articulation model owning barrel aim, with Unity rendering it. That is correct under gimbals but a new subsystem with no playground need yet.
- (c) The playground writes
HardpointTransforms. Rejected: a second writer compensating for a missing owner. - Recommend (a).
D. How far combat goes headless.
Ruled (operator, 2026-09-17): none of (a)-(c). Hit detection goes away entirely, as
docs/three-gates-scope.md "Fire control" already planned: "We wanted to get rid of hit
detection anyway, remember? Just roll the dice based on weapon stats, targeting system
stats (new subsystem) and sensor state." Cut 2 is re-mapped as fire control in
ServerShared: a hit is a roll over weapon stats, the stats of a new targeting-system
subsystem, and sensor state. The playground's hit stand-in is not needed.
- (a) Move damage application, death removal, loot roll and pickup into ServerShared;
Unity keeps hit detection; the playground's
hitsupplies hits. - (b) (a) plus headless ballistics. That is a projectile simulation Unity would have to adopt, or else two truths about hits. It needs its own map.
- (c) Nothing moves; the playground's fights stop at
OnFire, and the Cut C kill-and-loot check stays operator-only. - Recommend (a). The operator asked for fight and loot. Without (a) or (b) the playground cannot kill anything by the rules the game uses.
S. The two Unity-only lifecycle settings.
- (a) Move
StartingHullName,WormholeDistanceRatioand (Cut 2)LootDrop*intoGameplaySettings, with a text edit ofSettings.assetwhile Unity is closed. - (b) Pass them as
Runconstructor arguments read fromGameSettings; the playground reads them by name from YAML. That leaves two readers of one authored value. - (c) A new
RunRulesbundle, which is a type with no owner the existing settings class lacks. - Recommend (a).
N. Is the scene handoff static acceptable? Low-blocking.
- (a)
ActionGameManager.NewRun : (Galaxy, bool)?, consumed once byStart. - (b)
MainMenubuilds the wholeRunon its generation thread. That moves zone generation's run-store upserts off the main thread, and theCultCachethreading contract is not checked here. - Recommend (a).
6. Subtraction ledger
| Cut | Removed | Added | Targets, schemas, stores |
|---|---|---|---|
| 0 | Dictionary<int2,…> field and map-building pack code, ~6 |
pairs pack/unpack ~6; test ~25 | EntityPack key 5 wire shape map -> array; no new schema |
| 1 | ActionGameManager ~190 (incl. dead IntroCutscene 28), 2 statics, MainMenu 4, ZoneRenderer 9, InventoryPanel 2, clock seed |
Run.cs ~190; Zone +10; AGM handlers ~45; 1 static; tests ~200 |
0 targets; Settings.asset two keys move |
| 2 | EntityInstance ~110 |
Entity ~95; Zone ~45; tests ~180 |
0 targets; two settings keys move |
| 3 | extracted duplicate of the save lot check |
Playground.cs ~220; MoveTo +6; scenario 26 |
0 targets; AetherDb gains one command |
| 4 | — | 2 scenario files | — |
Net production code across Cuts 0-3 is about +300. Tests are about +400. No executable,
package, daemon or store is added. Rules move from MonoBehaviours to ServerShared, and
the only genuinely new behavior is MoveToPositionState and the scenario interpreter.
In exchange, Continue works again (Cut 0), and agents can falsify lifecycle, save and
provenance claims without the editor.
7. Risks
- Unity batchmode is the only proof for Cuts 1-2's
Assembly-CSharpedits. It needs the editor closed. The operator's in-editor play remains the proof for the lowering (cameras, menus, action bar, pickup visuals). ZonesubscribingDeathchanges NPC death timing in Unity. Removal now happens inside the damage call rather than inEntityInstance's subscriber. The operator's fight check covers it.- The playground's
hitis not ballistics. A scenario that passes proves damage, death, loot and save. It does not prove that shots connect in the game. - Cut B's recorded green is contradicted by §2.5. Soul should rerun the suite before trusting any provenance verification that reopens a store.