Recursive AI

Start tests from fixtures and snapshots

All onegame recipes

Give tests a ready-made world with named fixtures, save a running game as a snapshot, and start a scenario from either, instead of playing up to the interesting moment every time.

Fixtures: worlds built in code

A fixture is a named function that fills a new world. Add one to the apple tree from Getting started, with a sim method on Tree to build it:

C#
    // Sim code a fixture calls to build a world; not a command, so no player can send it.
    public void Ripen(int apples) => Apples = apples;

And in OrchardGame:

C#
    // Ready-made worlds that scenarios, tests and live control load by name.
    public override void DefineFixtures(FixtureSet fixtures) =>
        fixtures
            .Add("heavy-tree", world => world.Add(new Tree()).Ripen(40))
            .Add("bare-tree", world => world.Add(new Tree()));

A scenario starts from it with fixture::

YAML
name: heavy-tree
description: Start from a tree with 40 apples and pick ten.
fixture: heavy-tree
steps:
  - command: { entity: Tree, name: Pick, args: [10] }
  - step: 1
expect:
  - state: { entity: Tree, property: Apples, equals: 30 }
  - ui: { id: apples, text: "30 on the tree, 10 picked" }
Text
PASS heavy-tree (32 ms, tick 1, hash 78e6a5eeffa093c0)
1 passed, 0 failed

fixture: also works as a step, to switch worlds mid-scenario, and live control loads one with fixture_load.

Snapshots: a saved game

A snapshot is the whole world at one tick: every entity, the random streams and the tick, with its state hash. Save one from a running game over live control. This session uses the small MCP client from Drive a running game over MCP, which prints each tool call and its result:

Text
fixture_load {"name": "heavy-tree"} -> {"frame":0,"tick":0,"hash":"5a1f6ada5d6e7e3e"}
command_send {"entity": "Tree", "command": "Pick", "args": ["5"]} -> {"entity":"Tree#1","command":"Pick","status":"Queued"}
step {"frames": 120} -> {"frame":120,"tick":120,"hash":"8984cf483a6e339b"}
state_query {"entity": "Tree"} -> {"id":1,"type":"Tree","Apples":43,"Picked":5,"Growth":0}
snapshot_save {"path": "snapshots/after-pick.ogs"} -> {"path":"/tmp/games/orchard/artifacts/snapshots/after-pick.ogs","bytes":103,"hash":"8984cf483a6e339b","tick":120}
reset {} -> {"frame":120,"tick":0,"hash":"6554bfe7b94da5ca"}
state_query {"entity": "Tree"} -> {"id":1,"type":"Tree","Apples":0,"Picked":0,"Growth":0}
snapshot_load {"path": "snapshots/after-pick.ogs"} -> {"frame":120,"tick":120,"hash":"8984cf483a6e339b"}
state_query {"entity": "Tree"} -> {"id":1,"type":"Tree","Apples":43,"Picked":5,"Growth":0}
quit {} -> {"frame":120,"tick":120,"hash":"8984cf483a6e339b"}

The loaded world has the hash it was saved with. File paths are relative to the game’s output folder (artifacts/ unless the game was started with --artifacts), and a path that leaves it is refused.

Copy the file next to the scenarios and start from it with snapshot::

YAML
name: from-snapshot
description: Carry on from a saved game with 43 apples and 5 picked, at tick 120.
snapshot: snapshots/after-pick.ogs
steps:
  - command: { entity: Tree, name: Pick, args: [3] }
  - step: 1
expect:
  - state: { entity: Tree, property: Picked, equals: 8 }
  - tick: 121
Text
PASS from-snapshot (28 ms, tick 121, hash 4ad2aa9eebde135b)
1 passed, 0 failed

The scenario carries on from tick 120, so after one step it is at tick 121.

Good to know

  • A snapshot loads only into a game with the same seed. After a reset with another seed, snapshot_load fails with bad_data: “The save could not be loaded: SeedMismatch (None).”
  • In a single-player game a save is a snapshot: GameInstance.SaveSnapshot() returns the bytes and LoadSnapshot(bytes) restores them. Snapshots are versioned. A [Migrate] method on an entity loads state saved by an older schema.
  • Determinism.SnapshotRestore proves a game survives a save and load mid-run: see Test a game from C#.
  • Loading a fixture or a snapshot replaces the world, so a running recording starts a new segment there and replays still match.