diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2d65583..08a6992 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -113,6 +113,17 @@ jobs: run: PYTHONPATH=src python -m unittest discover -v -s src -t src - name: Audit published map inventory and hashes run: PYTHONPATH=src python -m pokesim.maps.runtime_map_pack + - name: Replay the bound Parcel offline diagnostic + if: always() && matrix.python-version == '3.12' + run: >- + PYTHONPATH=src python -m pokesim.story.checkpoint_replay + data/story/parcel-pilot/cases.json --case parcel-all-events --summary + --out "${{ runner.temp }}/parcel-trace.json" + - uses: actions/upload-artifact@v4 + if: always() && matrix.python-version == '3.12' + with: + name: parcel-python-model-trace + path: ${{ runner.temp }}/parcel-trace.json - name: Upload actual golden routes on failure if: failure() uses: actions/upload-artifact@v4 diff --git a/data/story/native/README.md b/data/story/native/README.md index 1f91a0e..b2688d5 100644 --- a/data/story/native/README.md +++ b/data/story/native/README.md @@ -10,7 +10,10 @@ Artifact: `frlg-checkpoint-ubuntu-26.04-default/frlg-parcel.native.json`. Original artifact-file SHA256: `6c4a5ece80eba8c9aa2930ebf8a2992cecb02c92c07e236a5e3ea976e66b3b6e`. The importer checked the build revision and schema, then compacted the file; -all parsed values and ordering are preserved. Formatting changes its byte hash. +all parsed values are preserved. The authored copy orders checkpoint fields as +`id`, `title`, `start`, `events`, and expands every event into its own block. +Object-key order and formatting differ from the original artifact; array order +and values remain unchanged. Formatting changes its byte hash. The three authored events are route to below the Mart door, step north through the door, and the clerk's Parcel scene. The scene declares the arrival pose; diff --git a/data/story/native/frlg-parcel-00.05.json b/data/story/native/frlg-parcel-00.05.json index 7dbce41..2090ba2 100644 --- a/data/story/native/frlg-parcel-00.05.json +++ b/data/story/native/frlg-parcel-00.05.json @@ -1,5 +1,14 @@ { + "format": "pokesim-native-checkpoint-export/1", + "game": "frlg", "checkpoint": { + "id": "00.05", + "title": "Route 1 to Viridian Mart: Oak's Parcel", + "start": { + "certainty": "tile", + "map": "PalletTown_ProfessorOaksLab", + "where": {"facing": "south", "kind": "tiles", "tiles": [[7, 8]]} + }, "events": [ { "id": "below_mart_door", @@ -11,28 +20,23 @@ }, "why": "Route 1, running from wild battles" }, - {"id": "mart_door", "kind": "step", "why": "the clerk's scene starts on entering"}, { + "id": "mart_door", + "kind": "step", + "why": "the clerk's scene starts on entering" + }, + { + "id": "parcel_from_clerk", + "kind": "scene", "declared_after": { "certainty": "tile", "map": "ViridianCity_Mart", "where": {"facing": "west", "kind": "tiles", "tiles": [[4, 3]]} }, - "id": "parcel_from_clerk", - "kind": "scene", "why": "the clerk gives OAK'S PARCEL" } - ], - "id": "00.05", - "start": { - "certainty": "tile", - "map": "PalletTown_ProfessorOaksLab", - "where": {"facing": "south", "kind": "tiles", "tiles": [[7, 8]]} - }, - "title": "Route 1 to Viridian Mart: Oak's Parcel" + ] }, - "format": "pokesim-native-checkpoint-export/1", - "game": "frlg", "native_fields": { "menu": "before_pokedex", "supplies": { @@ -61,7 +65,7 @@ } }, "source_revision": "b170b7d2103e887ded825c37fdbdc19fd654fca8", - "state_basis": "native-table-bookkeeping", "status": "native-table-export", + "state_basis": "native-table-bookkeeping", "unbound": ["runtime_configuration", "map_pins", "executor_bindings", "entry_evidence", "effective_save_retry_policy"] } diff --git a/data/story/parcel-pilot/CASES.md b/data/story/parcel-pilot/CASES.md new file mode 100644 index 0000000..6bf5138 --- /dev/null +++ b/data/story/parcel-pilot/CASES.md @@ -0,0 +1,201 @@ +# Replay scenarios + +Generated from [cases.json](cases.json); edit that file and regenerate this view. + +All observations and interruptions are synthetic. Actions are modeled requests, +not controller timing or verified console behavior. Walk distances are requested, not confirmed displacement. +An interrupted walk keeps its requested distance; uncertainty/progress is checked separately. +Scene contents are supplied observations, not simulated button-by-button dialogue. +A signal is consumed only if its event is reached; listing a receipt does not grant the item at entry. + +## parcel-all-events + +Walk from Oak’s Lab to Viridian, enter the Mart, and receive Oak’s Parcel. + +**Start:** PalletTown_ProfessorOaksLab [7, 8] facing south (elevation 3); screen: overworld. + +**Injected observations / interruptions:** + +- `parcel_from_clerk`: overworld scene result at ViridianCity_Mart [4, 3] facing west (elevation 3); received: 1 × oak-parcel. + +**Expected result:** `model_complete`. + +- Completed events: `below_mart_door`, `mart_door`, `parcel_from_clerk`. +- Pose: ViridianCity_Mart [4, 3] facing west (elevation 3). +- Received: 1 × oak-parcel. + +**Exact action checking:** enabled; the entire ordered list follows. + +| # | Event | Expected request | +|---|---|---| +| 1 | `below_mart_door` | Walk west for 1 tile on PalletTown_ProfessorOaksLab | +| 2 | `below_mart_door` | Walk south for 4 tiles on PalletTown_ProfessorOaksLab | +| 3 | `below_mart_door` | Take warp south: PalletTown_ProfessorOaksLab → PalletTown | +| 4 | `below_mart_door` | Walk west for 4 tiles on PalletTown | +| 5 | `below_mart_door` | Walk north for 14 tiles on PalletTown | +| 6 | `below_mart_door` | Take edge north: PalletTown → Route1 | +| 7 | `below_mart_door` | Walk north for 7 tiles on Route1 | +| 8 | `below_mart_door` | Walk west for 4 tiles on Route1 | +| 9 | `below_mart_door` | Walk north for 5 tiles on Route1 | +| 10 | `below_mart_door` | Walk east for 4 tiles on Route1 | +| 11 | `below_mart_door` | Walk north for 6 tiles on Route1 | +| 12 | `below_mart_door` | Walk west for 2 tiles on Route1 | +| 13 | `below_mart_door` | Walk north for 4 tiles on Route1 | +| 14 | `below_mart_door` | Walk east for 6 tiles on Route1 | +| 15 | `below_mart_door` | Walk north for 15 tiles on Route1 | +| 16 | `below_mart_door` | Walk west for 6 tiles on Route1 | +| 17 | `below_mart_door` | Walk north for 2 tiles on Route1 | +| 18 | `below_mart_door` | Take edge north: Route1 → ViridianCity | +| 19 | `below_mart_door` | Walk north for 19 tiles on ViridianCity | +| 20 | `below_mart_door` | Walk east for 14 tiles on ViridianCity | +| 21 | `below_mart_door` | Face north on ViridianCity | +| 22 | `mart_door` | Take warp north: ViridianCity → ViridianCity_Mart | +| 23 | `parcel_from_clerk` | Request scene via scripted-scene/1 | + +## entry-not-overworld + +A dialogue screen is supplied at entry: request no actions. + +**Start:** PalletTown_ProfessorOaksLab [7, 8] facing south (elevation 3); screen: dialogue. + +**Injected observations / interruptions:** + +- `parcel_from_clerk`: overworld scene result at ViridianCity_Mart [4, 3] facing west (elevation 3); received: 1 × oak-parcel. + +**Expected result:** `stopped` / entry_requirement_failed. + +- Completed events: none. +- Pose: PalletTown_ProfessorOaksLab [7, 8] facing south (elevation 3). +- Received: nothing. + +**Exact action checking:** enabled; the entire ordered list follows. + +No actions may be requested. + +## missing-scene-evidence + +Reach the Mart, but the clerk scene has no supplied observation. + +**Start:** PalletTown_ProfessorOaksLab [7, 8] facing south (elevation 3); screen: overworld. + +**Injected observations / interruptions:** + +- None. + +**Expected result:** `stopped` / scene_evidence_missing_or_mismatched. + +- Completed events: `below_mart_door`, `mart_door`. +- Pose: unknown. +- Received: nothing. + +**Exact action checking:** disabled; this case checks outcomes only. + +## missing-parcel-receipt + +The clerk scene reports the right pose but no Parcel receipt. + +**Start:** PalletTown_ProfessorOaksLab [7, 8] facing south (elevation 3); screen: overworld. + +**Injected observations / interruptions:** + +- `parcel_from_clerk`: overworld scene result at ViridianCity_Mart [4, 3] facing west (elevation 3); received: nothing. + +**Expected result:** `stopped` / scene_evidence_missing_or_mismatched. + +- Completed events: `below_mart_door`, `mart_door`. +- Pose: unknown. +- Received: nothing. + +**Exact action checking:** disabled; this case checks outcomes only. + +## wrong-scene-arrival + +The clerk scene reports a Parcel but the wrong facing. + +**Start:** PalletTown_ProfessorOaksLab [7, 8] facing south (elevation 3); screen: overworld. + +**Injected observations / interruptions:** + +- `parcel_from_clerk`: overworld scene result at ViridianCity_Mart [4, 3] facing east (elevation 3); received: 1 × oak-parcel. + +**Expected result:** `stopped` / scene_evidence_missing_or_mismatched. + +- Completed events: `below_mart_door`, `mart_door`. +- Pose: unknown. +- Received: nothing. + +**Exact action checking:** disabled; this case checks outcomes only. + +## unconfirmed-mart-transition + +Request the Mart entry step, but its transition cannot be confirmed. + +**Start:** PalletTown_ProfessorOaksLab [7, 8] facing south (elevation 3); screen: overworld. + +**Injected observations / interruptions:** + +- `parcel_from_clerk`: overworld scene result at ViridianCity_Mart [4, 3] facing west (elevation 3); received: 1 × oak-parcel. +- `mart_door`: transition cannot be confirmed. + +**Expected result:** `stopped` / transition_unconfirmed. + +- Completed events: `below_mart_door`. +- Pose: unknown. +- Received: nothing. + +**Exact action checking:** enabled; the entire ordered list follows. + +| # | Event | Expected request | +|---|---|---| +| 1 | `below_mart_door` | Walk west for 1 tile on PalletTown_ProfessorOaksLab | +| 2 | `below_mart_door` | Walk south for 4 tiles on PalletTown_ProfessorOaksLab | +| 3 | `below_mart_door` | Take warp south: PalletTown_ProfessorOaksLab → PalletTown | +| 4 | `below_mart_door` | Walk west for 4 tiles on PalletTown | +| 5 | `below_mart_door` | Walk north for 14 tiles on PalletTown | +| 6 | `below_mart_door` | Take edge north: PalletTown → Route1 | +| 7 | `below_mart_door` | Walk north for 7 tiles on Route1 | +| 8 | `below_mart_door` | Walk west for 4 tiles on Route1 | +| 9 | `below_mart_door` | Walk north for 5 tiles on Route1 | +| 10 | `below_mart_door` | Walk east for 4 tiles on Route1 | +| 11 | `below_mart_door` | Walk north for 6 tiles on Route1 | +| 12 | `below_mart_door` | Walk west for 2 tiles on Route1 | +| 13 | `below_mart_door` | Walk north for 4 tiles on Route1 | +| 14 | `below_mart_door` | Walk east for 6 tiles on Route1 | +| 15 | `below_mart_door` | Walk north for 15 tiles on Route1 | +| 16 | `below_mart_door` | Walk west for 6 tiles on Route1 | +| 17 | `below_mart_door` | Walk north for 2 tiles on Route1 | +| 18 | `below_mart_door` | Take edge north: Route1 → ViridianCity | +| 19 | `below_mart_door` | Walk north for 19 tiles on ViridianCity | +| 20 | `below_mart_door` | Walk east for 14 tiles on ViridianCity | +| 21 | `below_mart_door` | Face north on ViridianCity | +| 22 | `mart_door` | Take warp north: ViridianCity → ViridianCity_Mart | + +## interrupted-route1-grass + +A Route 1 encounter interrupts movement; retain both tiles and stop before the Mart. + +**Start:** PalletTown_ProfessorOaksLab [7, 8] facing south (elevation 3); screen: overworld. + +**Injected observations / interruptions:** + +- `parcel_from_clerk`: overworld scene result at ViridianCity_Mart [4, 3] facing west (elevation 3); received: 1 × oak-parcel. +- `below_mart_door`: encounter interrupts a northward walk from Route1 [12, 39] facing north (elevation 3). + +**Expected result:** `stopped` / encounter_requires_recovery. + +- Completed events: none. +- Pose: unknown. +- Received: nothing. +- Candidates: Route1 [12, 39] facing unknown (elevation 3); Route1 [12, 38] facing unknown (elevation 3). + +**Exact action checking:** enabled; the entire ordered list follows. + +| # | Event | Expected request | +|---|---|---| +| 1 | `below_mart_door` | Walk west for 1 tile on PalletTown_ProfessorOaksLab | +| 2 | `below_mart_door` | Walk south for 4 tiles on PalletTown_ProfessorOaksLab | +| 3 | `below_mart_door` | Take warp south: PalletTown_ProfessorOaksLab → PalletTown | +| 4 | `below_mart_door` | Walk west for 4 tiles on PalletTown | +| 5 | `below_mart_door` | Walk north for 14 tiles on PalletTown | +| 6 | `below_mart_door` | Take edge north: PalletTown → Route1 | +| 7 | `below_mart_door` | Walk north for 7 tiles on Route1 | diff --git a/data/story/parcel-pilot/README.md b/data/story/parcel-pilot/README.md new file mode 100644 index 0000000..26b6a05 --- /dev/null +++ b/data/story/parcel-pilot/README.md @@ -0,0 +1,125 @@ +# FRLG Parcel load-and-replay example + +**Start with [the readable scenarios](CASES.md).** Each shows starting state, +supplied observations/interruptions, expected outcome and (where enabled) the +complete expected action table. It is generated from `cases.json`, not replay +output, and checked for drift in tests. + +The reusable checkpoint runner loads captured story events and dispatches +registered handlers. FRLG routing/stepping lives in the game adapter; the +Parcel-specific receipt requirement lives in `binding.json`. There is no +checkpoint-ID guard or hardcoded Parcel item in the runner. A synthetic second +checkpoint exercises the same loader/runner with a different ID, scene-first +ordering, repeated steps and another receipt. Currently supported capabilities +are FRLG route/step and scripted scene observation; other kinds/games stop at +binding until their adapters exist. + +```sh +PYTHONPATH=src python3 -m pokesim.story.checkpoint_replay \ + data/story/parcel-pilot/cases.json --case parcel-all-events --summary \ + --out /tmp/parcel-trace.json +PYTHONPATH=src python3 -m unittest pokesim.story.test_checkpoint_replay \ + pokesim.games.frlg.test_story_replay +PYTHONPATH=src python3 -m pokesim.story.replay_cases \ + data/story/parcel-pilot/cases.json --check-doc data/story/parcel-pilot/CASES.md +``` + +## What check_actions verifies + +`input.check_actions: true` requires `expectation.actions`: the **entire ordered +sequence** of actual modeled requests. Missing/extra/reordered actions, different +directions or different tile distances fail even if the final status says complete. +`false` (or omission) checks outcomes only and must omit `expectation.actions`. +The switch never changes execution. The CLI and tests use the same checker; +the CLI records `case_check.actions` as `matched` or `not-checked`. + +The selected success case is called `parcel-all-events`, rather than `complete`. +It checks 23 semantic actions: straight-line walks totaling 117 tiles, three +route map transitions, the final facing change, the Mart entrance transition, +and the scene request. For example, exiting the lab is a southward walk for four +tiles followed by a separate door warp. Hops remain separate from walking. +No expected action counts button presses or depends on input timings. + +Actions describe **requested intent**, not confirmed displacement. In the grass +interruption case the requested leg remains `walk north for 7 tiles`; the +result instead retains two candidate tiles and unknown facing. It must not +credit all seven tiles, or rewrite the original request as the one interrupted +movement. The per-move diagnostic state trace remains available separately. + +The entry failure checks an empty action list. The failed Mart transition checks +that entry was requested but the scene never was. The grass interruption checks +the exact prefix through the interrupted walk request; later actions must be absent. +Other cases deliberately check outcomes without locking down a route. + +**This checks all currently modeled requests for the three authored events.** +It does not simulate the clerk's dialogue/animation or independently implement +the game's Parcel-grant script. The scene result is still supplied by a fake +observation, visibly labeled synthetic. Exact action checking does not establish +native execution parity or turn this into a complete console simulation. + +`binding.json` pins the actual evaluated `00.05` capture/source revision, five +approved runtime maps, capabilities, receipt requirement and Python route costs. +It does not use the unreviewed full-world candidate maps. Hash drift, unknown +bindings, unsupported policy and malformed inputs fail before execution. + +The grass interruption is anchored to a north input from Route 1 `(12,39)` into +`(12,38)` (model move 26 under this profile). A changed route that misses this physical +point fails rather than silently omitting the interruption. It is not recorded +console evidence or a claim that every encounter has exactly these outcomes. +Recovery/rejoin remains unbound: this diagnostic safely stops with two candidates +and unknown facing. Existing lower-level [recovery tests](../../../docs/navigation/ENCOUNTER_RECOVERY.md) +remain separate. A stopped run exits 1; `--out` retains its report, including +actual actions if an expectation check fails. + +## Explicit limits and native parity work + +The offline policy is one attempt, no saving and stop on failure. These are +**chosen diagnostic settings**, not exported native run options. Native runtime +configuration and effective save/retry policy remain unbound in every report. +Native FRLG attaches an end-of-checkpoint save; this diagnostic does not model +or claim that save. No HP, party, bag or other bookkeeping defaults become +observed state. `received` contains only the scripted scene receipt, not a +reconstructed full inventory. + +At capture revision `b170b7d2103e887ded825c37fdbdc19fd654fca8`: + +- Native `build_story_checkpoint()` inserts a `from_save` initializer before + the three authored events. Python instead checks the supplied scripted entry + pose/screen; it does not execute that initializer or prove a save was loaded. +- Native `StoryExecutor::step()` uses `take_step()` to report a nominal same-map + tile. Python's runtime map model instead resolves the door warp to the Mart + entry mat `(4,7)`. These intermediate results are deliberately not claimed + equivalent. +- Native `StoryExecutor::mash()` waits for overworld and returns no pose. The + shared engine subsequently installs the authored scene pose. This pilot + requires a separate scripted observation and receipt; it does not upgrade + native fallback declarations to evidence. +- Native uses `default_route_costs()` and its production walker. Python uses + explicitly pinned Dijkstra weights and an abstract in-place final turn. + Its movement trace is not a controller-timing or native planner-leg oracle. + +These behaviors were inspected in the pinned +[`PokemonFRLG_AutoStoryEvents.cpp`](https://github.com/dayfine/Arduino-Source/blob/b170b7d2103e887ded825c37fdbdc19fd654fca8/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.cpp) +and shared +[`Pokemon_StoryEvents.cpp`](https://github.com/dayfine/Arduino-Source/blob/b170b7d2103e887ded825c37fdbdc19fd654fca8/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_StoryEvents.cpp), +not established by running native tests in this PR. + +Next, add a native consumer of the same scenario inputs using the existing +engine and production planner/walker seams. Export actual intermediate outcomes +and resolved runtime/save/retry settings; compare like-for-like fields, keeping +native declarations, modeled predictions and independent observations distinct. +Keep initializer/save operations visible in native traces rather than deleting +them to force matching event counts. +Any change to native fallback/step behavior needs its own regression and review. +Then add supported encounter recovery/rejoin, end-save verification and the +BDSP `01.03` capture. The native authoring table remains in place until parity +passes. This pilot does not implement a generic executable story-plan envelope, +handoff/resume, battles or full PokeGuy story execution. + +## Authored JSON pilot + +`frlg-parcel-00.05.plan.json` is the data-authored checkpoint consumed by the native story table in Arduino-Source #260. `pokesim-authored-checkpoint/1` contains common events/start plus the FRLG save-menu setting in `execution`; it does not contain export provenance, native supplies, or claimed observations. Its schema is `schemas/authored-checkpoint.schema.json`. + +`authored-binding.json` pins that file and the same maps/action capabilities used by the existing replay scenarios. `test_authored_checkpoint.py` runs all seven cases with the same expected semantic actions and outcomes. The report has no native source revision: it identifies the authored file by SHA256. The old capture/binding remain migration fixtures. + +Arduino CI checks exact equality of its embedded authored file and this file, then runs these Python scenarios; native tests separately exercise the actual JSON-backed story table through the existing engine/fake-game simulation. Neither gate proves live detector/controller behavior. Scenes still have declared fallback poses; observations and recovery are separate executor concerns. diff --git a/data/story/parcel-pilot/authored-binding.json b/data/story/parcel-pilot/authored-binding.json new file mode 100644 index 0000000..45a2bfc --- /dev/null +++ b/data/story/parcel-pilot/authored-binding.json @@ -0,0 +1,29 @@ +{ + "format": "pokesim-story-replay-binding/1", + "plan": { + "file": "data/story/parcel-pilot/frlg-parcel-00.05.plan.json", + "sha256": "c6ebd128a7c1472671db78aa0bb0bd88317ee84109f5da458fee60b193ec5a3d" + }, + "map_revision": "158b007ecd14efb5776ebdadc530c1a807ef3541", + "maps": { + "PalletTown_ProfessorOaksLab": "1dedda88f1238eccd6610cbff79c1e5d3cd21b516637b792654746e02b2cdbc8", + "PalletTown": "0dc7446f568097b36a9479954f01afe546cb64c82bb0efa91da0cb66ad950f9f", + "Route1": "78bf43c8bdf7a0c5dcb22880261626abc36d068c0dfbbc242056e12401c44033", + "ViridianCity": "6a671a0a0b06f78f566b89646f6b0f00c91c240a36300dff861f970455f99909", + "ViridianCity_Mart": "1b2b23f8f9a1b1fbe266f12bdac96cdf649d974b057e84bf9cba6befbaa15b3b" + }, + "capabilities": { + "below_mart_door": {"capability": "frlg-runtime-route/1", "parameters": {}}, + "mart_door": {"capability": "frlg-runtime-step/1", "parameters": {}}, + "parcel_from_clerk": {"capability": "scripted-scene/1", "parameters": {"require_received": {"oak-parcel": 1}}} + }, + "costs": {"tile": 1, "turn": 2, "grass": 5, "warp": 1}, + "max_route_moves": 256, + "policy": { + "basis": "synthetic-offline-policy", + "entry": "scripted-observation", + "attempts": 1, + "failure": "stop", + "save": "never" + } +} diff --git a/data/story/parcel-pilot/binding.json b/data/story/parcel-pilot/binding.json new file mode 100644 index 0000000..b53d5e1 --- /dev/null +++ b/data/story/parcel-pilot/binding.json @@ -0,0 +1,30 @@ +{ + "format": "pokesim-story-replay-binding/1", + "capture": { + "file": "data/story/native/frlg-parcel-00.05.json", + "sha256": "8b0c04f2e1507aee0f0927a8d022b8b431036431e95f8893aa7a2383360f8504", + "source_revision": "b170b7d2103e887ded825c37fdbdc19fd654fca8" + }, + "map_revision": "158b007ecd14efb5776ebdadc530c1a807ef3541", + "maps": { + "PalletTown_ProfessorOaksLab": "1dedda88f1238eccd6610cbff79c1e5d3cd21b516637b792654746e02b2cdbc8", + "PalletTown": "0dc7446f568097b36a9479954f01afe546cb64c82bb0efa91da0cb66ad950f9f", + "Route1": "78bf43c8bdf7a0c5dcb22880261626abc36d068c0dfbbc242056e12401c44033", + "ViridianCity": "6a671a0a0b06f78f566b89646f6b0f00c91c240a36300dff861f970455f99909", + "ViridianCity_Mart": "1b2b23f8f9a1b1fbe266f12bdac96cdf649d974b057e84bf9cba6befbaa15b3b" + }, + "capabilities": { + "below_mart_door": {"capability": "frlg-runtime-route/1", "parameters": {}}, + "mart_door": {"capability": "frlg-runtime-step/1", "parameters": {}}, + "parcel_from_clerk": {"capability": "scripted-scene/1", "parameters": {"require_received": {"oak-parcel": 1}}} + }, + "costs": {"tile": 1, "turn": 2, "grass": 5, "warp": 1}, + "max_route_moves": 256, + "policy": { + "basis": "synthetic-offline-policy", + "entry": "scripted-observation", + "attempts": 1, + "failure": "stop", + "save": "never" + } +} diff --git a/data/story/parcel-pilot/cases.json b/data/story/parcel-pilot/cases.json new file mode 100644 index 0000000..75a44bd --- /dev/null +++ b/data/story/parcel-pilot/cases.json @@ -0,0 +1,397 @@ +{ + "format": "pokesim-story-replay-cases/1", + "evidence_status": "synthetic-scripted-observations", + "coordinate_frame": "runtime-local", + "cases": [ + { + "id": "parcel-all-events", + "description": "Walk from Oak’s Lab to Viridian, enter the Mart, and receive Oak’s Parcel.", + "input": { + "entry": { + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "PalletTown_ProfessorOaksLab", "tile": [7, 8], "elevation": 3, "facing": "south"} + }, + "signals": { + "parcel_from_clerk": { + "kind": "scene-observation", + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "ViridianCity_Mart", "tile": [4, 3], "elevation": 3, "facing": "west"}, + "received": {"oak-parcel": 1} + } + }, + "check_actions": true + }, + "expectation": { + "status": "model_complete", + "failure": null, + "active_event": null, + "completed_events": ["below_mart_door", "mart_door", "parcel_from_clerk"], + "pose": {"map": "ViridianCity_Mart", "tile": [4, 3], "elevation": 3, "facing": "west"}, + "pose_basis": "scripted-observation", + "candidates": [], + "received": {"oak-parcel": 1}, + "save_performed": false, + "actions": [ + { + "event": "below_mart_door", + "kind": "walk", + "map": "PalletTown_ProfessorOaksLab", + "direction": "west", + "tiles": 1 + }, + { + "event": "below_mart_door", + "kind": "walk", + "map": "PalletTown_ProfessorOaksLab", + "direction": "south", + "tiles": 4 + }, + { + "event": "below_mart_door", + "kind": "transition", + "map": "PalletTown_ProfessorOaksLab", + "direction": "south", + "to_map": "PalletTown", + "via": "warp" + }, + {"event": "below_mart_door", "kind": "walk", "map": "PalletTown", "direction": "west", "tiles": 4}, + {"event": "below_mart_door", "kind": "walk", "map": "PalletTown", "direction": "north", "tiles": 14}, + { + "event": "below_mart_door", + "kind": "transition", + "map": "PalletTown", + "direction": "north", + "to_map": "Route1", + "via": "edge" + }, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "north", "tiles": 7}, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "west", "tiles": 4}, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "north", "tiles": 5}, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "east", "tiles": 4}, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "north", "tiles": 6}, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "west", "tiles": 2}, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "north", "tiles": 4}, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "east", "tiles": 6}, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "north", "tiles": 15}, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "west", "tiles": 6}, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "north", "tiles": 2}, + { + "event": "below_mart_door", + "kind": "transition", + "map": "Route1", + "direction": "north", + "to_map": "ViridianCity", + "via": "edge" + }, + {"event": "below_mart_door", "kind": "walk", "map": "ViridianCity", "direction": "north", "tiles": 19}, + {"event": "below_mart_door", "kind": "walk", "map": "ViridianCity", "direction": "east", "tiles": 14}, + {"event": "below_mart_door", "kind": "turn", "map": "ViridianCity", "direction": "north"}, + { + "event": "mart_door", + "kind": "transition", + "map": "ViridianCity", + "direction": "north", + "to_map": "ViridianCity_Mart", + "via": "warp" + }, + {"event": "parcel_from_clerk", "kind": "scene", "capability": "scripted-scene/1"} + ] + } + }, + { + "id": "entry-not-overworld", + "description": "A dialogue screen is supplied at entry: request no actions.", + "input": { + "entry": { + "basis": "scripted", + "screen": "dialogue", + "pose": {"map": "PalletTown_ProfessorOaksLab", "tile": [7, 8], "elevation": 3, "facing": "south"} + }, + "signals": { + "parcel_from_clerk": { + "kind": "scene-observation", + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "ViridianCity_Mart", "tile": [4, 3], "elevation": 3, "facing": "west"}, + "received": {"oak-parcel": 1} + } + }, + "check_actions": true + }, + "expectation": { + "status": "stopped", + "failure": "entry_requirement_failed", + "active_event": null, + "completed_events": [], + "pose": {"map": "PalletTown_ProfessorOaksLab", "tile": [7, 8], "elevation": 3, "facing": "south"}, + "pose_basis": "scripted-observation", + "candidates": [], + "received": {}, + "save_performed": false, + "actions": [] + } + }, + { + "id": "missing-scene-evidence", + "description": "Reach the Mart, but the clerk scene has no supplied observation.", + "input": { + "entry": { + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "PalletTown_ProfessorOaksLab", "tile": [7, 8], "elevation": 3, "facing": "south"} + }, + "signals": {}, + "check_actions": false + }, + "expectation": { + "status": "stopped", + "failure": "scene_evidence_missing_or_mismatched", + "active_event": "parcel_from_clerk", + "completed_events": ["below_mart_door", "mart_door"], + "pose": null, + "pose_basis": "unknown-after-scene", + "candidates": [], + "received": {}, + "save_performed": false + } + }, + { + "id": "missing-parcel-receipt", + "description": "The clerk scene reports the right pose but no Parcel receipt.", + "input": { + "entry": { + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "PalletTown_ProfessorOaksLab", "tile": [7, 8], "elevation": 3, "facing": "south"} + }, + "signals": { + "parcel_from_clerk": { + "kind": "scene-observation", + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "ViridianCity_Mart", "tile": [4, 3], "elevation": 3, "facing": "west"}, + "received": {} + } + }, + "check_actions": false + }, + "expectation": { + "status": "stopped", + "failure": "scene_evidence_missing_or_mismatched", + "active_event": "parcel_from_clerk", + "completed_events": ["below_mart_door", "mart_door"], + "pose": null, + "pose_basis": "unknown-after-scene", + "candidates": [], + "received": {}, + "save_performed": false + } + }, + { + "id": "wrong-scene-arrival", + "description": "The clerk scene reports a Parcel but the wrong facing.", + "input": { + "entry": { + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "PalletTown_ProfessorOaksLab", "tile": [7, 8], "elevation": 3, "facing": "south"} + }, + "signals": { + "parcel_from_clerk": { + "kind": "scene-observation", + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "ViridianCity_Mart", "tile": [4, 3], "elevation": 3, "facing": "east"}, + "received": {"oak-parcel": 1} + } + }, + "check_actions": false + }, + "expectation": { + "status": "stopped", + "failure": "scene_evidence_missing_or_mismatched", + "active_event": "parcel_from_clerk", + "completed_events": ["below_mart_door", "mart_door"], + "pose": null, + "pose_basis": "unknown-after-scene", + "candidates": [], + "received": {}, + "save_performed": false + } + }, + { + "id": "unconfirmed-mart-transition", + "description": "Request the Mart entry step, but its transition cannot be confirmed.", + "input": { + "entry": { + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "PalletTown_ProfessorOaksLab", "tile": [7, 8], "elevation": 3, "facing": "south"} + }, + "signals": { + "parcel_from_clerk": { + "kind": "scene-observation", + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "ViridianCity_Mart", "tile": [4, 3], "elevation": 3, "facing": "west"}, + "received": {"oak-parcel": 1} + }, + "mart_door": {"kind": "unconfirmed-transition"} + }, + "check_actions": true + }, + "expectation": { + "status": "stopped", + "failure": "transition_unconfirmed", + "active_event": "mart_door", + "completed_events": ["below_mart_door"], + "pose": null, + "pose_basis": "unknown-after-transition", + "candidates": [], + "received": {}, + "save_performed": false, + "actions": [ + { + "event": "below_mart_door", + "kind": "walk", + "map": "PalletTown_ProfessorOaksLab", + "direction": "west", + "tiles": 1 + }, + { + "event": "below_mart_door", + "kind": "walk", + "map": "PalletTown_ProfessorOaksLab", + "direction": "south", + "tiles": 4 + }, + { + "event": "below_mart_door", + "kind": "transition", + "map": "PalletTown_ProfessorOaksLab", + "direction": "south", + "to_map": "PalletTown", + "via": "warp" + }, + {"event": "below_mart_door", "kind": "walk", "map": "PalletTown", "direction": "west", "tiles": 4}, + {"event": "below_mart_door", "kind": "walk", "map": "PalletTown", "direction": "north", "tiles": 14}, + { + "event": "below_mart_door", + "kind": "transition", + "map": "PalletTown", + "direction": "north", + "to_map": "Route1", + "via": "edge" + }, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "north", "tiles": 7}, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "west", "tiles": 4}, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "north", "tiles": 5}, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "east", "tiles": 4}, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "north", "tiles": 6}, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "west", "tiles": 2}, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "north", "tiles": 4}, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "east", "tiles": 6}, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "north", "tiles": 15}, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "west", "tiles": 6}, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "north", "tiles": 2}, + { + "event": "below_mart_door", + "kind": "transition", + "map": "Route1", + "direction": "north", + "to_map": "ViridianCity", + "via": "edge" + }, + {"event": "below_mart_door", "kind": "walk", "map": "ViridianCity", "direction": "north", "tiles": 19}, + {"event": "below_mart_door", "kind": "walk", "map": "ViridianCity", "direction": "east", "tiles": 14}, + {"event": "below_mart_door", "kind": "turn", "map": "ViridianCity", "direction": "north"}, + { + "event": "mart_door", + "kind": "transition", + "map": "ViridianCity", + "direction": "north", + "to_map": "ViridianCity_Mart", + "via": "warp" + } + ] + } + }, + { + "id": "interrupted-route1-grass", + "description": "A Route 1 encounter interrupts movement; retain both tiles and stop before the Mart.", + "input": { + "entry": { + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "PalletTown_ProfessorOaksLab", "tile": [7, 8], "elevation": 3, "facing": "south"} + }, + "signals": { + "parcel_from_clerk": { + "kind": "scene-observation", + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "ViridianCity_Mart", "tile": [4, 3], "elevation": 3, "facing": "west"}, + "received": {"oak-parcel": 1} + }, + "below_mart_door": { + "kind": "wild-encounter", + "from": {"map": "Route1", "tile": [12, 39], "elevation": 3, "facing": "north"}, + "direction": "north" + } + }, + "check_actions": true + }, + "expectation": { + "status": "stopped", + "failure": "encounter_requires_recovery", + "active_event": "below_mart_door", + "completed_events": [], + "pose": null, + "pose_basis": "unknown-after-interruption", + "candidates": [ + {"map": "Route1", "tile": [12, 39], "elevation": 3, "facing": null}, + {"map": "Route1", "tile": [12, 38], "elevation": 3, "facing": null} + ], + "received": {}, + "save_performed": false, + "actions": [ + { + "event": "below_mart_door", + "kind": "walk", + "map": "PalletTown_ProfessorOaksLab", + "direction": "west", + "tiles": 1 + }, + { + "event": "below_mart_door", + "kind": "walk", + "map": "PalletTown_ProfessorOaksLab", + "direction": "south", + "tiles": 4 + }, + { + "event": "below_mart_door", + "kind": "transition", + "map": "PalletTown_ProfessorOaksLab", + "direction": "south", + "to_map": "PalletTown", + "via": "warp" + }, + {"event": "below_mart_door", "kind": "walk", "map": "PalletTown", "direction": "west", "tiles": 4}, + {"event": "below_mart_door", "kind": "walk", "map": "PalletTown", "direction": "north", "tiles": 14}, + { + "event": "below_mart_door", + "kind": "transition", + "map": "PalletTown", + "direction": "north", + "to_map": "Route1", + "via": "edge" + }, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "north", "tiles": 7} + ] + } + } + ] +} diff --git a/data/story/parcel-pilot/frlg-parcel-00.05.plan.json b/data/story/parcel-pilot/frlg-parcel-00.05.plan.json new file mode 100644 index 0000000..6df2d7a --- /dev/null +++ b/data/story/parcel-pilot/frlg-parcel-00.05.plan.json @@ -0,0 +1,42 @@ +{ + "format": "pokesim-authored-checkpoint/1", + "game": "frlg", + "checkpoint": { + "id": "00.05", + "title": "Route 1 to Viridian Mart: Oak's Parcel", + "start": { + "certainty": "tile", + "map": "PalletTown_ProfessorOaksLab", + "where": {"facing": "south", "kind": "tiles", "tiles": [[7, 8]]} + }, + "events": [ + { + "id": "below_mart_door", + "kind": "route", + "target": { + "certainty": "tile", + "map": "ViridianCity", + "where": {"facing": "north", "kind": "tiles", "tiles": [[36, 20]]} + }, + "why": "Route 1, running from wild battles" + }, + { + "id": "mart_door", + "kind": "step", + "why": "the clerk's scene starts on entering", + "direction": "north" + }, + { + "id": "parcel_from_clerk", + "kind": "scene", + "declared_after": { + "certainty": "tile", + "map": "ViridianCity_Mart", + "where": {"facing": "west", "kind": "tiles", "tiles": [[4, 3]]} + }, + "why": "the clerk gives OAK'S PARCEL" + } + ] + }, + "execution": {"menu": "before_pokedex"} +} diff --git a/docs/story/EXECUTION_PLAN.md b/docs/story/EXECUTION_PLAN.md index e00245d..79ac5c0 100644 --- a/docs/story/EXECUTION_PLAN.md +++ b/docs/story/EXECUTION_PLAN.md @@ -13,6 +13,11 @@ export, JSON binding and handoff/resume are not implemented by this document. ## Ordered milestones +Implementation checkpoint: the [Parcel Python diagnostic](../../data/story/parcel-pilot/README.md) +binds the captured events to approved maps and synthetic observations. Native +trace comparison, actual runtime/save/retry policy export and BDSP capture remain +open; this is not completion of milestone 1. + 1. **Prove the event boundary.** Export evaluated FRLG Parcel `00.05` and BDSP mine/Roark `01.03` checkpoints, preserving their complete actions, conditions, game extensions and save/retry behavior. Bind the same serialized data to diff --git a/docs/story/NATIVE_EXPORTS.md b/docs/story/NATIVE_EXPORTS.md index 0c47061..937bcb0 100644 --- a/docs/story/NATIVE_EXPORTS.md +++ b/docs/story/NATIVE_EXPORTS.md @@ -47,6 +47,15 @@ listed as `unbound`. No executable plan or successful replay is inferred. ## Next parity gate +The [Parcel Python pilot](../../data/story/parcel-pilot/README.md) now loads the +capture into pinned spatial models with synthetic scene evidence and explicit +stop/no-save diagnostic policy. It is not native execution parity: actual +native STEP/scene fallback semantics and effective runtime/save/retry options +still need a native trace consumer and comparison. +The [readable cases](../../data/story/parcel-pilot/CASES.md) show inputs, outcomes +and selected exact action assertions. The runner is reusable across supported +checkpoints; Parcel's receipt requirement is binding data, not runner code. + - Parcel capture/import validation is implemented; keep its native revision and provenance pinned. - Add BDSP mine/Roark `01.03` through its own evaluated native table adapter. - Resolve shared requirements/state/evidence and effective save/failure policy @@ -58,3 +67,9 @@ listed as `unbound`. No executable plan or successful replay is inferred. Synthetic boundary tests remain labeled synthetic; a captured authored row does not prove console execution or timing. Native tests stay next to native sources, Python tests next to their reader. Shared format tests are not controller tests. + +## Read the Parcel fields + +The checkpoint is displayed as `id`, `title`, `start`, `events`. Every event has its own block. `declared_after` is the author-declared post-event pose, not an observation or independently confirmed success. `status: native-table-export` identifies the source of this document; it does not mean the game completed the checkpoint. `format` selects [the export schema](../../schemas/native-checkpoint-export.schema.json), which references [the shared checkpoint schema](../../schemas/story-checkpoint.schema.json). + +`native_fields` preserves FRLG extensions for migration comparison. It is not an exhaustive entry requirement: defaults, unread party values, transient foe bookkeeping and expected inventory must not be promoted to observed facts. A future data-authored checkpoint should require only the state it depends on, separately describe effects/exit expectations, and obtain actual inventory/party/pose from runtime evidence or explicit assumptions. This is a relevant-state summary of earlier progress, not a checksum proving all preceding checkpoints ran. Export provenance belongs outside the authored gameplay requirements. diff --git a/schemas/README.md b/schemas/README.md index abd92a2..fe9df1d 100644 --- a/schemas/README.md +++ b/schemas/README.md @@ -14,6 +14,7 @@ PYTHONPATH=src python3 -m pokesim.formats.json_schema frlg-battle-report report. |---|---| | `native-checkpoint-export` | Evaluated FRLG stage-1 checkpoint capture, including raw native extensions and explicit unbound runtime/evidence/policy inputs. This is not an executable plan. | | `story-checkpoint` | Common native checkpoint, step and tile-pose records from #66. Policy/state envelope is still proposed, not covered here. | +| `story-replay-binding`, `story-replay-cases` | Offline capability bindings and synthetic input/expectation scenarios, including opt-in exact action assertions. The current game adapter supports FRLG route/step and scripted scenes. These do not resolve native runtime options or establish execution parity. | | `runtime-map-v2` | Current published FRLG/BDSP runtime maps with coordinate pairs and typed NPC/item/sign/interactable arrays. Historical v1 and older grid formats remain handled by their existing readers. | | `frlg-battle-cases` | Existing Gen 3 solo-battle experiment inputs. This does not imply support for another generation or arbitrary teams. | | `frlg-battle-report` | Existing full benchmark output (`pokesim-frlg-report/1`), including probability/count shapes. Run summaries and other result formats are not covered yet. | @@ -42,7 +43,7 @@ PYTHONPATH=src python3 -m pokesim.formats.contract_format --check PYTHONPATH=src python3 -m pokesim.formats.contract_format path/to/authored.json ``` -With no paths, this command touches schemas, shared story cases and checked-in native captures; explicit paths format other authored JSON. It preserves all values and key order. +With no paths, this command touches schemas, shared story cases, checked-in native captures and the Parcel pilot; explicit paths format other authored JSON. It preserves all values and key order. CI checks the formatting. ## Structural validation is one layer @@ -70,3 +71,5 @@ files. Pin/adopt this revision in native authoring validation after review. The native capture schema names C++ widths in `$defs` (`int32`, `uint8`, `uint16`, `count64`) and references them at each field. Stats use `uint16`; PP/levels use `uint8`. These encode storage bounds, not valid gameplay ranges. + +`authored-checkpoint.schema.json` defines the first executable native authoring pilot: common checkpoint data plus FRLG `execution.menu`. It is separate from the full native export snapshot. Replay bindings accept exactly one pinned `capture` or `plan` source. diff --git a/schemas/authored-checkpoint.schema.json b/schemas/authored-checkpoint.schema.json new file mode 100644 index 0000000..eefe35a --- /dev/null +++ b/schemas/authored-checkpoint.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/dayfine/PokemonSimulation/main/schemas/authored-checkpoint.schema.json", + "title": "Authored checkpoint with FRLG executor settings", + "type": "object", + "properties": { + "format": {"const": "pokesim-authored-checkpoint/1"}, + "game": {"const": "frlg"}, + "checkpoint": {"$ref": "story-checkpoint.schema.json"}, + "execution": { + "type": "object", + "properties": {"menu": {"enum": ["before_pokemon", "before_pokedex", "full"]}}, + "required": ["menu"], + "additionalProperties": false + } + }, + "required": ["format", "game", "checkpoint", "execution"], + "additionalProperties": false +} diff --git a/schemas/story-replay-binding.schema.json b/schemas/story-replay-binding.schema.json new file mode 100644 index 0000000..5ac5cea --- /dev/null +++ b/schemas/story-replay-binding.schema.json @@ -0,0 +1,99 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/dayfine/PokemonSimulation/schemas/story-replay-binding.schema.json", + "type": "object", + "required": ["format", "map_revision", "maps", "capabilities", "costs", "max_route_moves", "policy"], + "additionalProperties": false, + "properties": { + "format": {"const": "pokesim-story-replay-binding/1"}, + "capture": { + "type": "object", + "required": ["file", "sha256", "source_revision"], + "additionalProperties": false, + "properties": { + "file": {"type": "string", "minLength": 1}, + "sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "source_revision": {"type": "string", "pattern": "^[0-9a-f]{40}$"} + } + }, + "map_revision": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, + "maps": { + "type": "object", + "minProperties": 1, + "propertyNames": {"pattern": "^[A-Za-z0-9_]+$"}, + "additionalProperties": {"type": "string", "pattern": "^[0-9a-f]{64}$"} + }, + "capabilities": { + "type": "object", + "minProperties": 1, + "propertyNames": {"minLength": 1}, + "additionalProperties": { + "oneOf": [ + { + "type": "object", + "required": ["capability", "parameters"], + "additionalProperties": false, + "properties": { + "capability": {"enum": ["frlg-runtime-route/1", "frlg-runtime-step/1"]}, + "parameters": {"type": "object", "required": [], "additionalProperties": false, "properties": {}} + } + }, + { + "type": "object", + "required": ["capability", "parameters"], + "additionalProperties": false, + "properties": { + "capability": {"const": "scripted-scene/1"}, + "parameters": { + "type": "object", + "required": ["require_received"], + "additionalProperties": false, + "properties": { + "require_received": { + "type": "object", + "propertyNames": {"minLength": 1}, + "additionalProperties": {"type": "integer", "minimum": 1} + } + } + } + } + } + ] + } + }, + "costs": { + "type": "object", + "required": ["tile", "turn", "grass", "warp"], + "additionalProperties": false, + "properties": { + "tile": {"type": "number", "exclusiveMinimum": 0}, + "turn": {"type": "number", "minimum": 0}, + "grass": {"type": "number", "minimum": 0}, + "warp": {"type": "number", "minimum": 0} + } + }, + "max_route_moves": {"type": "integer", "minimum": 1, "maximum": 4096}, + "policy": { + "type": "object", + "required": ["basis", "entry", "attempts", "failure", "save"], + "additionalProperties": false, + "properties": { + "basis": {"const": "synthetic-offline-policy"}, + "entry": {"const": "scripted-observation"}, + "attempts": {"const": 1}, + "failure": {"const": "stop"}, + "save": {"const": "never"} + } + }, + "plan": { + "type": "object", + "properties": { + "file": {"type": "string", "minLength": 1}, + "sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"} + }, + "required": ["file", "sha256"], + "additionalProperties": false + } + }, + "oneOf": [{"required": ["capture"]}, {"required": ["plan"]}] +} diff --git a/schemas/story-replay-cases.schema.json b/schemas/story-replay-cases.schema.json new file mode 100644 index 0000000..2fe635a --- /dev/null +++ b/schemas/story-replay-cases.schema.json @@ -0,0 +1,223 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/dayfine/PokemonSimulation/schemas/story-replay-cases.schema.json", + "type": "object", + "required": ["format", "evidence_status", "coordinate_frame", "cases"], + "additionalProperties": false, + "properties": { + "format": {"const": "pokesim-story-replay-cases/1"}, + "evidence_status": {"const": "synthetic-scripted-observations"}, + "coordinate_frame": {"const": "runtime-local"}, + "cases": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["id", "description", "input", "expectation"], + "additionalProperties": false, + "properties": { + "id": {"type": "string", "minLength": 1}, + "description": {"type": "string", "minLength": 1}, + "input": { + "type": "object", + "required": ["entry", "signals"], + "additionalProperties": false, + "properties": { + "entry": { + "type": "object", + "required": ["basis", "screen", "pose"], + "additionalProperties": false, + "properties": { + "basis": {"const": "scripted"}, + "screen": {"type": "string", "minLength": 1}, + "pose": {"$ref": "#/$defs/pose"} + } + }, + "signals": { + "type": "object", + "propertyNames": {"minLength": 1}, + "additionalProperties": { + "oneOf": [ + { + "type": "object", + "required": ["kind", "from", "direction"], + "additionalProperties": false, + "properties": { + "kind": {"const": "wild-encounter"}, + "from": {"$ref": "#/$defs/pose"}, + "direction": {"enum": ["north", "east", "south", "west"]} + } + }, + { + "type": "object", + "required": ["kind"], + "additionalProperties": false, + "properties": {"kind": {"const": "unconfirmed-transition"}} + }, + { + "type": "object", + "required": ["kind", "basis", "screen", "pose", "received"], + "additionalProperties": false, + "properties": { + "kind": {"const": "scene-observation"}, + "basis": {"const": "scripted"}, + "screen": {"type": "string", "minLength": 1}, + "pose": {"$ref": "#/$defs/pose"}, + "received": { + "type": "object", + "propertyNames": {"minLength": 1}, + "additionalProperties": {"type": "integer", "minimum": 1} + } + } + } + ] + } + }, + "check_actions": { + "type": "boolean", + "description": "Assert the complete ordered expectation.actions list; never used to generate replay actions." + } + } + }, + "expectation": { + "type": "object", + "required": [ + "status", + "failure", + "active_event", + "completed_events", + "pose", + "pose_basis", + "candidates", + "received", + "save_performed" + ], + "additionalProperties": false, + "properties": { + "status": {"enum": ["model_complete", "stopped"]}, + "failure": {"type": ["string", "null"]}, + "active_event": {"type": ["string", "null"]}, + "completed_events": {"type": "array", "items": {"type": "string", "minLength": 1}, "uniqueItems": true}, + "pose": {"anyOf": [{"$ref": "#/$defs/pose"}, {"type": "null"}]}, + "pose_basis": { + "enum": [ + "scripted-observation", + "model-prediction", + "unknown-after-interruption", + "unknown-after-transition", + "unknown-after-scene", + "unknown-after-route-mismatch" + ] + }, + "candidates": { + "type": "array", + "items": { + "type": "object", + "required": ["map", "tile", "elevation", "facing"], + "additionalProperties": false, + "properties": { + "map": {"type": "string", "minLength": 1}, + "tile": { + "type": "array", + "prefixItems": [{"type": "integer"}, {"type": "integer"}], + "items": false, + "minItems": 2 + }, + "elevation": {"type": "integer", "minimum": 0, "maximum": 15}, + "facing": {"anyOf": [{"enum": ["north", "east", "south", "west"]}, {"type": "null"}]} + } + } + }, + "received": { + "type": "object", + "propertyNames": {"minLength": 1}, + "additionalProperties": {"type": "integer", "minimum": 1} + }, + "save_performed": {"const": false}, + "actions": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "required": ["event", "kind", "map", "direction", "tiles"], + "additionalProperties": false, + "properties": { + "event": {"type": "string", "minLength": 1}, + "kind": {"enum": ["walk", "hop"]}, + "map": {"type": "string", "minLength": 1}, + "direction": {"enum": ["north", "east", "south", "west"]}, + "tiles": {"type": "integer", "minimum": 1} + } + }, + { + "type": "object", + "required": ["event", "kind", "map", "direction", "to_map", "via"], + "additionalProperties": false, + "properties": { + "event": {"type": "string", "minLength": 1}, + "kind": {"const": "transition"}, + "map": {"type": "string", "minLength": 1}, + "direction": {"enum": ["north", "east", "south", "west"]}, + "to_map": {"anyOf": [{"type": "string", "minLength": 1}, {"type": "null"}]}, + "via": {"enum": ["warp", "edge"]} + } + }, + { + "type": "object", + "required": ["event", "kind", "map", "direction"], + "additionalProperties": false, + "properties": { + "event": {"type": "string", "minLength": 1}, + "kind": {"const": "turn"}, + "map": {"type": "string", "minLength": 1}, + "direction": {"enum": ["north", "east", "south", "west"]} + } + }, + { + "type": "object", + "required": ["event", "kind", "capability"], + "additionalProperties": false, + "properties": { + "event": {"type": "string", "minLength": 1}, + "kind": {"const": "scene"}, + "capability": {"const": "scripted-scene/1"} + } + } + ] + } + } + } + } + }, + "allOf": [ + { + "if": { + "properties": {"input": {"required": ["check_actions"], "properties": {"check_actions": {"const": true}}}} + }, + "then": {"properties": {"expectation": {"required": ["actions"]}}}, + "else": {"properties": {"expectation": {"not": {"required": ["actions"]}}}} + } + ] + } + } + }, + "$defs": { + "pose": { + "type": "object", + "required": ["map", "tile", "elevation", "facing"], + "additionalProperties": false, + "properties": { + "map": {"type": "string", "minLength": 1}, + "tile": { + "type": "array", + "prefixItems": [{"type": "integer"}, {"type": "integer"}], + "items": false, + "minItems": 2 + }, + "elevation": {"type": "integer", "minimum": 0, "maximum": 15}, + "facing": {"enum": ["north", "east", "south", "west"]} + } + } + } +} diff --git a/src/pokesim/formats/contract_format.py b/src/pokesim/formats/contract_format.py index a69f67c..ddf56ed 100644 --- a/src/pokesim/formats/contract_format.py +++ b/src/pokesim/formats/contract_format.py @@ -4,7 +4,7 @@ PYTHONPATH=src python3 -m pokesim.formats.contract_format --check PYTHONPATH=src python3 -m pokesim.formats.contract_format path/to/data.json -With no paths, formats schemas, shared story cases and native story captures. +With no paths, formats schemas, shared story cases, native captures and the Parcel pilot. Explicit paths opt other authored JSON files in. Every field and its order are retained. Republish hash-pinned data before adopting changed bytes in consumers. """ @@ -19,9 +19,15 @@ def json_text(data, width=120): """Expand long containers using two-space indentation and a soft width.""" + native_capture = isinstance(data, dict) and data.get("format") in ("pokesim-native-checkpoint-export/1", "pokesim-authored-checkpoint/1") + + def event(value): + return native_capture and isinstance(value, dict) and "id" in value and "kind" in value + def render(value, depth=0, prefix=0): inline = json.dumps(value, ensure_ascii=False) - if not isinstance(value, (dict, list)) or not value or depth * 2 + prefix + len(inline) <= width: + expand = event(value) or (isinstance(value, list) and any(event(item) for item in value)) + if not isinstance(value, (dict, list)) or not value or (not expand and depth * 2 + prefix + len(inline) <= width): return inline indent = ' ' * ((depth + 1) * 2) if isinstance(value, dict): @@ -39,12 +45,13 @@ def render(value, depth=0, prefix=0): def main(): parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) - parser.add_argument('paths', nargs='*', type=Path, help='JSON files; defaults to schemas, story cases and native captures') + parser.add_argument('paths', nargs='*', type=Path, help='JSON files; defaults to schemas and shared story fixtures') parser.add_argument('--check', action='store_true', help='report formatting drift without writing') args = parser.parse_args() changed = [] paths = args.paths or (sorted(SCHEMAS.glob('*.schema.json')) - + [ROOT / 'data/story/wire-conformance.json'] + sorted(ROOT.glob('data/story/native/*.json'))) + + [ROOT / 'data/story/wire-conformance.json'] + sorted(ROOT.glob('data/story/native/*.json')) + + sorted(ROOT.glob('data/story/parcel-pilot/*.json'))) for path in paths: original = path.read_text(encoding='utf-8') formatted = json_text(json.loads(original)) diff --git a/src/pokesim/formats/json_schema.py b/src/pokesim/formats/json_schema.py index 88f6183..85a60a7 100644 --- a/src/pokesim/formats/json_schema.py +++ b/src/pokesim/formats/json_schema.py @@ -39,6 +39,10 @@ def published(): yield 'frlg-battle-cases', ROOT / 'data/games/frlg/battles/cases.json' for path in sorted(ROOT.glob('data/story/native/*.json')): yield 'native-checkpoint-export', path + yield 'authored-checkpoint', ROOT / 'data/story/parcel-pilot/frlg-parcel-00.05.plan.json' + yield 'story-replay-binding', ROOT / 'data/story/parcel-pilot/binding.json' + yield 'story-replay-binding', ROOT / 'data/story/parcel-pilot/authored-binding.json' + yield 'story-replay-cases', ROOT / 'data/story/parcel-pilot/cases.json' def main(): diff --git a/src/pokesim/formats/test_contract_format.py b/src/pokesim/formats/test_contract_format.py new file mode 100644 index 0000000..f03c929 --- /dev/null +++ b/src/pokesim/formats/test_contract_format.py @@ -0,0 +1,24 @@ +"""Native capture formatting keeps events readable without changing their data.""" + +import json +import unittest + +from .contract_format import json_text + + +class NativeCaptureFormatTests(unittest.TestCase): + def test_short_native_events_remain_separate_blocks(self): + value = {'format': 'pokesim-native-checkpoint-export/1', 'checkpoint': { + 'id': '00.05', 'title': 'Parcel', 'start': {'tiles': [[7, 8]]}, + 'events': [{'id': 'door', 'kind': 'step'}, {'id': 'answer', 'kind': 'answer', 'yes': False}]}} + text = json_text(value) + self.assertNotIn('{"id": "door"', text) + self.assertNotIn('{"id": "answer"', text) + self.assertIn('"tiles": [[7, 8]]', text) + self.assertEqual(json.loads(text), value) + self.assertEqual(list(json.loads(text)['checkpoint']), ['id', 'title', 'start', 'events']) + self.assertEqual(json_text(json.loads(text)), text) + + def test_other_short_objects_stay_compact(self): + self.assertEqual(json_text({'id': 'tile', 'kind': 'marker', 'coord': [0, 0]}), + '{"id": "tile", "kind": "marker", "coord": [0, 0]}\n') diff --git a/src/pokesim/formats/test_json_schema.py b/src/pokesim/formats/test_json_schema.py index 3f57580..e63c5a8 100644 --- a/src/pokesim/formats/test_json_schema.py +++ b/src/pokesim/formats/test_json_schema.py @@ -16,6 +16,23 @@ @unittest.skipUnless(jsonschema, 'install schemas/requirements.txt for schema tests') class JsonSchemaTests(unittest.TestCase): + def test_action_assertions_require_opt_in_and_exact_typed_lists(self): + original = json.loads((ROOT / 'data/story/parcel-pilot/cases.json').read_text()) + check = validator('story-replay-cases') + changes = [ + lambda c: c['expectation'].pop('actions'), + lambda c: c['input'].update(check_actions=False), + lambda c: c['input'].update(check_actions=1), + lambda c: c['expectation']['actions'][0].update(tiles=True), + lambda c: c['expectation']['actions'][0].update(extra='ignored'), + lambda c: c['expectation']['actions'][0].update(kind='press'), + ] + for change in changes: + with self.subTest(change=changes.index(change)): + value = copy.deepcopy(original) + change(value['cases'][0]) + self.assertFalse(check.is_valid(value)) + def test_schemas_are_valid_and_published_data_passes(self): for path in SCHEMAS.glob('*.schema.json'): validator(path.name[:-12]) diff --git a/src/pokesim/games/frlg/story_replay.py b/src/pokesim/games/frlg/story_replay.py new file mode 100644 index 0000000..e9d18f2 --- /dev/null +++ b/src/pokesim/games/frlg/story_replay.py @@ -0,0 +1,159 @@ +"""FRLG route/step replay using the existing runtime world and movement rules. + +Predictions are not console confirmations. Unknown transitions and encounters +stop with invalidated pose; no recovery or controller timing is invented. +""" + +from pokesim.grid.geometry import Direction, Tile +from pokesim.grid.maps.world import Place +from pokesim.grid.movement import Edge, Warp, WorldState, world_step +from pokesim.grid.routing.shortest import MoveKind, as_move, shortest_path +from pokesim.story.replay_handlers import exact + + +def pose(state, facing): + return {'map': state.place.map, 'tile': list(state.place.tile), + 'elevation': state.elevation, 'facing': facing.name.lower()} + + +def movement_action(current, move, direction, unresolved=None): + """Describe a straight walk, hop or map transition, without input timings.""" + result = dict(map=current.place.map, direction=direction.name.lower()) + if move and (move.kind is MoveKind.WARP or move.to.place.map != current.place.map): + return dict(result, kind='transition', to_map=move.to.place.map, + via='warp' if move.kind is MoveKind.WARP else 'edge') + if isinstance(unresolved, (Warp, Edge)): + return dict(result, kind='transition', to_map=None, via='warp' if isinstance(unresolved, Warp) else 'edge') + if move and move.kind is MoveKind.HOP: + start, end = current.place.tile, move.to.place.tile + return dict(result, kind='hop', tiles=abs(end.x - start.x) + abs(end.y - start.y)) + return dict(result, kind='walk', tiles=1) + + +def route_legs(path): + """Group planned same-map walking into requested straight legs. + + Transitions/hops stay separate. A leg is logged before any of its model + steps execute, so an interruption cannot shrink its requested distance. + """ + legs, current = [], path.start + for number, move in enumerate(path.moves, 1): + action = movement_action(current, move, move.direction) + if (action['kind'] == 'walk' and legs + and {k: v for k, v in legs[-1][0].items() if k != 'tiles'} + == {k: v for k, v in action.items() if k != 'tiles'}): + legs[-1][0]['tiles'] += 1 + legs[-1][1].append((number, move)) + else: + legs.append((action, [(number, move)])) + current = move.to + return legs + + +class FRLGReplay: + """Game-owned spatial handlers, reusable across checkpoints and event IDs.""" + def __init__(self, world, costs, max_route_moves): + self.world, self.costs, self.max_route_moves = world, costs, max_route_moves + + def handlers(self): + return {'frlg-runtime-route/1': ('route', self.route_signal, self.route), + 'frlg-runtime-step/1': ('step', self.step_signal, self.step)} + + def validate_pose(self, value): + exact(value, 'map tile elevation facing') + if (not isinstance(value['map'], str) or not isinstance(value['tile'], list) + or len(value['tile']) != 2 or any(type(n) is not int for n in value['tile']) + or value['facing'] not in ('north', 'east', 'south', 'west')): + raise ValueError('expected a known runtime-local pose') + state = WorldState(Place(value['map'], Tile(*value['tile'])), value['elevation']) + self.world.validate_state(state) + return state, Direction[value['facing'].upper()] + + def authored_pose(self, wire): + where = wire['where'] + if wire['certainty'] != 'tile' or where['kind'] != 'tiles' or len(where['tiles']) != 1 or 'facing' not in where: + raise ValueError('spatial replay requires one authored tile and known facing') + place = Place(wire['map'], Tile(*where['tiles'][0])) + cell = self.world.at(place) + if cell is None: + raise ValueError('authored pose outside bound maps') + result = pose(WorldState(place, cell.elevation), Direction[where['facing'].upper()]) + self.validate_pose(result) + return result + + def route_signal(self, event, signal): + if signal is not None: + exact(signal, 'kind from direction') + if signal['kind'] != 'wild-encounter' or signal['direction'] not in ('north', 'east', 'south', 'west'): + raise ValueError('unsupported route signal') + self.validate_pose(signal['from']) + + def step_signal(self, event, signal): + if signal is not None: + exact(signal, 'kind') + if signal['kind'] != 'unconfirmed-transition': + raise ValueError('unsupported step signal') + + def interruption_move(self, path, facing, signal): + if not signal: + return None + current, matches = path.start, [] + for number, move in enumerate(path.moves, 1): + if pose(current, facing) == signal['from'] and move.direction.name.lower() == signal['direction']: + matches.append(number) + result = world_step(self.world, current, move.direction, self.world.rules) + current, facing = move.to, getattr(result, 'facing', move.direction) + if len(matches) != 1: + raise ValueError('encounter injection must match exactly one physical route step') + return matches[0] + + def route(self, state, event, signal): + current, facing = self.validate_pose(state.pose) + target = self.authored_pose(event['target']) + path = shortest_path(self.world, current, Place(target['map'], Tile(*target['tile'])), self.costs, self.world.rules) + if path is None: + return 'no_route' + if len(path.moves) > self.max_route_moves: + return 'route_move_budget_exceeded' + interrupted_at = self.interruption_move(path, facing, signal) + for action, moves in route_legs(path): + state.action(event['id'], **action) + for number, move in moves: + result = world_step(self.world, current, move.direction, self.world.rules) + actual = as_move(move.direction, result) + if actual is None or actual.to != move.to: + state.pose, state.basis = None, 'unknown-after-route-mismatch' + return 'route_model_mismatch' + end_facing = getattr(result, 'facing', move.direction) + predicted = pose(actual.to, end_facing) + if interrupted_at == number: + state.candidates = [dict(state.pose, facing=None), dict(predicted, facing=None)] + state.pose, state.basis = None, 'unknown-after-interruption' + state.record(event['id'], 'interrupted', before_move=number, candidates=state.candidates) + return 'encounter_requires_recovery' + current, facing = actual.to, end_facing + state.pose, state.basis = predicted, 'model-prediction' + state.record(event['id'], 'move', number=number, direction=move.direction.name.lower()) + if facing.name.lower() != target['facing']: + state.action(event['id'], 'turn', map=current.place.map, direction=target['facing']) + state.pose = pose(current, Direction[target['facing'].upper()]) + state.basis = 'model-prediction' + state.record(event['id'], 'turn') + return None + + def step(self, state, event, signal): + current, facing = self.validate_pose(state.pose) + direction = Direction[event.get('direction', 'north').upper()] + result = world_step(self.world, current, direction, self.world.rules) + move = as_move(direction, result) + state.action(event['id'], **movement_action(current, move, direction, result)) + if signal: + state.pose, state.basis = None, 'unknown-after-transition' + return 'transition_unconfirmed' + if move is None: + if isinstance(result, (Warp, Edge)): + state.pose, state.basis = None, 'unknown-after-transition' + return 'step_unresolved' + state.pose, state.basis = pose(move.to, getattr(result, 'facing', direction)), 'model-prediction' + state.record(event['id'], 'step', direction=direction.name.lower()) + return None diff --git a/src/pokesim/games/frlg/test_story_replay.py b/src/pokesim/games/frlg/test_story_replay.py new file mode 100644 index 0000000..dee5a94 --- /dev/null +++ b/src/pokesim/games/frlg/test_story_replay.py @@ -0,0 +1,111 @@ +"""FRLG spatial fidelity under the shared checkpoint runner; no console input.""" + +import copy +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +from pokesim.maps.runtime_map_pack import ROOT +from pokesim.story.replay_binding import PROFILE, load_binding +from pokesim.story.checkpoint_replay import run +from pokesim.story.replay_cases import read_cases + +CASES = ROOT / 'data/story/parcel-pilot/cases.json' + + +class FRLGStoryReplayTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.binding = load_binding() + cls.cases = read_cases(CASES) + + def test_semantic_legs_split_hops_and_warps_from_straight_walking(self): + from pokesim.grid.geometry import Direction, Tile + from pokesim.grid.maps.world import Place + from pokesim.grid.movement import WorldState + from pokesim.grid.routing.shortest import Move, MoveKind, Path as RoutePath + from .story_replay import route_legs + # Synthetic geometry diagnostic, not a measured/source map route. + start = WorldState(Place('A', Tile(0, 0)), 3) + endpoints = [('A', 1, MoveKind.STEP), ('A', 2, MoveKind.STEP), + ('A', 4, MoveKind.HOP), ('A', 5, MoveKind.STEP), + ('B', 1, MoveKind.WARP), ('B', 2, MoveKind.STEP)] + moves = [Move(Direction.SOUTH, kind, WorldState(Place(name, Tile(0, y)), 3)) + for name, y, kind in endpoints] + actions = [a for a, _ in route_legs(RoutePath(start, moves, 0))] + self.assertEqual([ + {'map': 'A', 'direction': 'south', 'kind': 'walk', 'tiles': 2}, + {'map': 'A', 'direction': 'south', 'kind': 'hop', 'tiles': 2}, + {'map': 'A', 'direction': 'south', 'kind': 'walk', 'tiles': 1}, + {'map': 'A', 'direction': 'south', 'kind': 'transition', 'to_map': 'B', 'via': 'warp'}, + {'map': 'B', 'direction': 'south', 'kind': 'walk', 'tiles': 1}], actions) + + def test_execution_rejects_a_planner_endpoint_that_the_move_did_not_reach(self): + from pokesim.grid.geometry import Direction, Tile + from pokesim.grid.maps.world import Place + from pokesim.grid.movement import WorldState + from pokesim.grid.routing.shortest import Move, MoveKind, Path as RoutePath + initial, _ = self.binding.adapter.validate_pose(self.cases[0]['input']['entry']['pose']) + wrong = WorldState(Place('ViridianCity', Tile(36, 20)), 3) + # Synthetic broken planner: one west input cannot teleport from the lab + # to Viridian. Execution must check movement, not copy the planned goal. + planned = RoutePath(initial, [Move(Direction.WEST, MoveKind.STEP, wrong)], 1) + with patch('pokesim.games.frlg.story_replay.shortest_path', return_value=planned): + report = run(self.binding, self.cases[0]['input']) + self.assertEqual('route_model_mismatch', report['failure']) + self.assertEqual([], report['completed_events']) + self.assertIsNone(report['pose']) + self.assertEqual(1, len(report['actions'])) + self.assertEqual('west', report['actions'][0]['direction']) + + def test_real_cross_map_geometry_and_final_turn(self): + result = run(self.binding, self.cases[0]['input']) + completed = [r for r in result['trace'] if r['phase'] == 'completed'] + self.assertEqual(['below_mart_door', 'mart_door', 'parcel_from_clerk'], + [r['event'] for r in completed]) + self.assertEqual({'map': 'ViridianCity', 'tile': [36, 20], 'facing': 'north', 'elevation': 3}, + completed[0]['pose']) + self.assertEqual({'map': 'ViridianCity_Mart', 'tile': [4, 7], 'facing': 'north', 'elevation': 3}, + completed[1]['pose']) + self.assertEqual('model-prediction', completed[1]['basis']) + self.assertEqual('scripted-observation', completed[2]['basis']) + self.assertTrue(any(r['phase'] == 'turn' for r in result['trace'])) + moves = [r for r in result['trace'] if r['phase'] == 'move'] + self.assertEqual(120, len(moves)) # Python costs, not native leg-cost parity. + self.assertEqual({'PalletTown_ProfessorOaksLab', 'PalletTown', 'Route1', 'ViridianCity'}, + {r['pose']['map'] for r in moves}) + self.assertEqual(117, sum(a['tiles'] for a in result['actions'] + if a['kind'] == 'walk' and a['event'] == 'below_mart_door')) + self.assertEqual(4, sum(a['kind'] == 'transition' for a in result['actions'])) + + + def test_interruption_preserves_both_tiles_and_stops_before_later_events(self): + case = next(c for c in self.cases if c['id'] == 'interrupted-route1-grass') + result = run(self.binding, case['input']) + self.assertIsNone(result['pose']) + self.assertEqual([None, None], [p['facing'] for p in result['candidates']]) + self.assertEqual(25, sum(r['phase'] == 'move' for r in result['trace'])) + self.assertEqual({'below_mart_door'}, {r['event'] for r in result['trace']}) + self.assertEqual({'event': 'below_mart_door', 'kind': 'walk', 'map': 'Route1', + 'direction': 'north', 'tiles': 7}, result['actions'][-1]) + # Seven tiles were requested; no tile after the interrupted move is + # confirmed, and the full leg's endpoint must not become our pose. + self.assertEqual([[12, 39], [12, 38]], [p['tile'] for p in result['candidates']]) + # Actual source grass at the injected destination, not a synthetic board. + from pokesim.grid.geometry import Tile + self.assertEqual('~', self.binding.adapter.world.views['Route1'].glyph(Tile(12, 38))) + + + def test_route_budget_stops_without_claiming_the_target(self): + with tempfile.TemporaryDirectory() as directory: + config = json.loads(PROFILE.read_text()) + config['max_route_moves'] = 1 + path = Path(directory, 'binding.json') + path.write_text(json.dumps(config)) + result = run(load_binding(path), self.cases[0]['input']) + self.assertEqual('route_move_budget_exceeded', result['failure']) + self.assertEqual([], result['completed_events']) + self.assertEqual(self.cases[0]['input']['entry']['pose'], result['pose']) + self.assertEqual(0, sum(r['phase'] == 'move' for r in result['trace'])) diff --git a/src/pokesim/story/authored_checkpoint.py b/src/pokesim/story/authored_checkpoint.py new file mode 100644 index 0000000..7d4cef5 --- /dev/null +++ b/src/pokesim/story/authored_checkpoint.py @@ -0,0 +1,17 @@ +"""Read a small authored checkpoint; execution settings are not observed state.""" + +import copy +from .checkpoint_wire import read_checkpoint +from .replay_handlers import exact + + +def read_authored_checkpoint(value): + """Reject unsupported fields/game/menu before binding an authored checkpoint.""" + exact(value, 'format game checkpoint execution') + if value['format'] != 'pokesim-authored-checkpoint/1' or value['game'] != 'frlg': + raise ValueError('unsupported authored checkpoint format/game') + execution = exact(value['execution'], 'menu') + if execution['menu'] not in ('before_pokemon', 'before_pokedex', 'full'): + raise ValueError('unsupported authored checkpoint menu') + read_checkpoint(value['checkpoint']) + return copy.deepcopy(value) diff --git a/src/pokesim/story/checkpoint_replay.py b/src/pokesim/story/checkpoint_replay.py new file mode 100644 index 0000000..d5f1263 --- /dev/null +++ b/src/pokesim/story/checkpoint_replay.py @@ -0,0 +1,119 @@ +"""Run captured checkpoints through registered offline game/action handlers. + + PYTHONPATH=src python3 -m pokesim.story.checkpoint_replay \ + data/story/parcel-pilot/cases.json --case parcel-all-events --summary --out /tmp/parcel-trace.json + +Only registered capabilities execute. Expectations never drive execution; +check_actions changes assertions, not the commands requested by replay. +""" + +import argparse +import copy +from pathlib import Path + +from pokesim.formats.contract_format import json_text +from .replay_binding import PROFILE, load_binding +from .replay_handlers import exact +from .replay_state import ReplayState + + +def preflight(binding, scenario): + """Validate entry and every signal before asking any handler to execute.""" + if not isinstance(scenario, dict) or set(scenario) - {'entry', 'signals', 'check_actions'}: + raise ValueError('unsupported scenario fields') + if type(scenario.get('check_actions', False)) is not bool: + raise ValueError('check_actions must be boolean') + entry = exact(scenario['entry'], 'basis screen pose') + if entry['basis'] != 'scripted': + raise ValueError('replay currently accepts only scripted observations') + binding.adapter.validate_pose(entry['pose']) + if not isinstance(scenario['signals'], dict): + raise ValueError('signals must be keyed by event ID') + events = {e['id']: e for e in binding.checkpoint['events']} + for name, signal in scenario['signals'].items(): + if name not in events or not isinstance(signal, dict): + raise ValueError('signal references an unknown event') + binding.actions[name].preflight(events[name], signal) + + +def run(binding, scenario): + """Apply shared event ordering/failure policy; action details belong to handlers.""" + scenario = copy.deepcopy(scenario) + preflight(binding, scenario) + state = ReplayState(copy.deepcopy(scenario['entry']['pose'])) + completed, active, failure = [], None, None + if (scenario['entry']['screen'] != 'overworld' + or state.pose != binding.adapter.authored_pose(binding.checkpoint['start'])): + failure = 'entry_requirement_failed' + else: + for event in binding.checkpoint['events']: + active = event['id'] + state.record(active, 'started') + failure = binding.actions[active].execute(state, event, scenario['signals'].get(active)) + if failure: + state.record(active, 'failed', failure=failure) + break + completed.append(active) + state.record(active, 'completed') + return {'format': 'pokesim-checkpoint-replay-report/1', 'status': 'stopped' if failure else 'model_complete', + 'checkpoint': binding.checkpoint['id'], 'profile_sha256': binding.profile_sha256, + 'native_source_revision': binding.capture['source_revision'], + 'authored_plan_sha256': binding.capture.get('authored_plan_sha256'), + 'native_parity': 'not-established', 'native_unbound': list(binding.capture['unbound']), + 'policy_basis': 'synthetic-offline-policy', 'failure': failure, + 'active_event': active if failure else None, 'completed_events': completed, + 'pose': state.pose, 'pose_basis': state.basis, 'candidates': state.candidates, + 'received': state.received, 'save_performed': False, 'actions': state.actions, 'trace': state.trace} + + +def summary(report): + """Small event-level view; full actions and movement remain in the report.""" + lines = ['{}: {} (offline diagnostic; native parity not established)'.format( + report['checkpoint'], report['status'])] + for row in report['trace']: + if row['phase'] == 'completed': + p = row['pose'] + lines.append('{} -> {} {} {}, elevation {} [{}]'.format( + row['event'], p['map'], p['tile'], p['facing'], p['elevation'], row['basis'])) + if report['failure']: + lines.append('Stopped: {} at {}'.format(report['failure'], report['active_event'] or 'entry')) + if report['candidates']: + lines.append('Candidates: ' + '; '.join('{} {} (facing unknown)'.format(p['map'], p['tile']) + for p in report['candidates'])) + lines.append('Save: never; retry: none (synthetic policy).') + return '\n'.join(lines) + '\n' + + +def main(): + from .replay_cases import check_case, read_cases + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument('cases', type=Path) + parser.add_argument('--case', required=True) + parser.add_argument('--binding', type=Path, default=PROFILE) + parser.add_argument('--out', type=Path) + parser.add_argument('--summary', action='store_true') + args = parser.parse_args() + try: + matches = [c for c in read_cases(args.cases) if c['id'] == args.case] + if len(matches) != 1: + raise ValueError('case ID must select exactly one scenario') + report = run(load_binding(args.binding), matches[0]['input']) + # Retain the actual trace even if an expectation/action assertion fails. + if args.out: + args.out.write_text(json_text(report), encoding='utf-8') + report['case_check'] = check_case(matches[0], report) + if args.out: + args.out.write_text(json_text(report), encoding='utf-8') + if args.summary: + print(summary(report), end='') + print('Expected action sequence: {}.'.format(report['case_check']['actions'])) + elif not args.out: + print(json_text(report), end='') + except (OSError, ValueError, KeyError, TypeError, AssertionError) as error: + parser.exit(1, str(error) + '\n') + if report['status'] != 'model_complete': + parser.exit(1) + + +if __name__ == '__main__': + main() diff --git a/src/pokesim/story/replay_binding.py b/src/pokesim/story/replay_binding.py new file mode 100644 index 0000000..dc6b28e --- /dev/null +++ b/src/pokesim/story/replay_binding.py @@ -0,0 +1,103 @@ +"""Bind captured checkpoints to versioned offline game/action capabilities. + +The profile is a simulation diagnostic, not the native runtime configuration. +It never supplies missing native save/retry options or observed party state. +""" + +from dataclasses import dataclass +import hashlib +import math +from pathlib import Path + +from pokesim.maps.consumer_source import read_json +from pokesim.maps.local_routing import LocalMap +from pokesim.maps.pinned_runtime_map import read_pinned_map +from pokesim.maps.runtime_map_pack import ROOT +from pokesim.maps.runtime_world import RuntimeWorld +from pokesim.grid.routing.shortest import Costs +from .checkpoint_wire import read_checkpoint +from .replay_handlers import exact, resolve_action +from pokesim.games.frlg.story_replay import FRLGReplay + +PROFILE = ROOT / 'data/story/parcel-pilot/binding.json' +OFFLINE_POLICY = {'basis': 'synthetic-offline-policy', 'entry': 'scripted-observation', + 'attempts': 1, 'failure': 'stop', 'save': 'never'} + + +def positive(value): + """A finite positive route move count, not a boolean or unbounded budget.""" + if type(value) is not int or not 1 <= value <= 4096: + raise ValueError('route move budget must be an integer in 1..4096') + return value + + +@dataclass +class ReplayBinding: + """Validated capture, model and explicit simulation-only execution options.""" + capture: dict + checkpoint: dict + adapter: object + actions: dict + profile_sha256: str + + +def load_binding(path=PROFILE, root=ROOT): + """Preflight every binding/hash before any replay action; never fetch data.""" + raw = Path(path).read_bytes() + config = read_json(raw) + authored = 'plan' in config + source_key = 'plan' if authored else 'capture' + exact(config, 'format {} map_revision maps capabilities costs max_route_moves policy'.format(source_key)) + if (config['format'] != 'pokesim-story-replay-binding/1' or config['policy'] != OFFLINE_POLICY + or type(config['policy'].get('attempts')) is not int): + raise ValueError('unsupported offline profile/policy; native options remain unbound') + pin = exact(config[source_key], 'file sha256' if authored else 'file sha256 source_revision') + source = Path(root).resolve() + capture_path = (source / pin['file']).resolve() + if source not in capture_path.parents: + raise ValueError('capture must be inside the source repository') + capture_raw = capture_path.read_bytes() + if hashlib.sha256(capture_raw).hexdigest() != pin['sha256']: + raise ValueError('capture hash mismatch') + capture = read_json(capture_raw) + if authored: + from .authored_checkpoint import read_authored_checkpoint + plan = read_authored_checkpoint(capture) + # Internal report provenance, not an invented native export or observation. + capture = dict(plan, source_revision=None, authored_plan_sha256=pin['sha256'], + unbound=['entry_evidence', 'effective_save_retry_policy']) + elif (capture['format'] != 'pokesim-native-checkpoint-export/1' + or capture['game'] != 'frlg' or capture['source_revision'] != pin['source_revision'] + or capture['state_basis'] != 'native-table-bookkeeping'): + raise ValueError('unsupported capture identity/state basis') + checkpoint = read_checkpoint(capture['checkpoint']) + if not checkpoint['events'] or 'start' not in checkpoint: + raise ValueError('replay currently requires a nonempty checkpoint with a starting pose') + allowed = {'route': {'id', 'kind', 'why', 'target'}, + 'step': {'id', 'kind', 'why', 'direction'}, + 'scene': {'id', 'kind', 'why', 'declared_after'}} + for event in checkpoint['events']: + if event['kind'] not in allowed or set(event) - allowed[event['kind']]: + raise ValueError('unsupported event kind/options in spatial replay') + required = {'route': 'target', 'scene': 'declared_after'}.get(event['kind']) + if required and required not in event: + raise ValueError('event is missing ' + required) + if not isinstance(config['capabilities'], dict) or set(config['capabilities']) != {e['id'] for e in checkpoint['events']}: + raise ValueError('missing or extra event capability') + costs = exact(config['costs'], 'tile turn grass warp') + if any(type(v) not in (int, float) or not math.isfinite(v) or v < 0 for v in costs.values()) or costs['tile'] <= 0: + raise ValueError('route costs must be finite nonnegative numbers with positive tile cost') + budget = positive(config['max_route_moves']) + if not isinstance(config['maps'], dict) or not config['maps']: + raise ValueError('map pins required') + maps = [LocalMap(read_json(read_pinned_map(config['map_revision'], 'frlg', name, digest, root)), 'frlg') + for name, digest in config['maps'].items()] + world = RuntimeWorld(maps) + adapter = FRLGReplay(world, Costs(**costs), budget) + poses = [checkpoint['start']] + [e[k] for e in checkpoint['events'] for k in ('target', 'declared_after') if k in e] + for pose in poses: + if pose['map'] not in world.views: + raise ValueError('checkpoint references an unbound map') + adapter.authored_pose(pose) + actions = {e['id']: resolve_action(e['kind'], config['capabilities'][e['id']], adapter) for e in checkpoint['events']} + return ReplayBinding(capture, checkpoint, adapter, actions, hashlib.sha256(raw).hexdigest()) diff --git a/src/pokesim/story/replay_cases.py b/src/pokesim/story/replay_cases.py new file mode 100644 index 0000000..984425f --- /dev/null +++ b/src/pokesim/story/replay_cases.py @@ -0,0 +1,187 @@ +"""Read/assert replay cases and render their inputs and expected actions. + + PYTHONPATH=src python3 -m pokesim.story.replay_cases \ + data/story/parcel-pilot/cases.json --out data/story/parcel-pilot/CASES.md + PYTHONPATH=src python3 -m pokesim.story.replay_cases \ + data/story/parcel-pilot/cases.json --check-doc data/story/parcel-pilot/CASES.md + +Rendering never runs replay or updates expectations from actual output. +""" + +import argparse +from itertools import zip_longest +from pathlib import Path + +from pokesim.maps.consumer_source import read_json +from .replay_handlers import exact + + +def validate_action(action): + """Reject ambiguous assertions such as boolean tile distances or unknown fields.""" + if not isinstance(action, dict): + raise ValueError('expected an action record') + kind = action.get('kind') + if kind in ('walk', 'hop'): + exact(action, 'event kind map direction tiles') + if type(action['tiles']) is not int or action['tiles'] <= 0: + raise ValueError('walk/hop tiles must be a positive integer') + elif kind == 'transition': + exact(action, 'event kind map direction to_map via') + if (action['via'] not in ('warp', 'edge') or action['to_map'] is not None + and (not isinstance(action['to_map'], str) or not action['to_map'])): + raise ValueError('transition needs a destination or explicit unknown, and warp/edge mode') + elif kind == 'turn': + exact(action, 'event kind map direction') + elif kind == 'scene': + exact(action, 'event kind capability') + if not isinstance(action['capability'], str) or not action['capability']: + raise ValueError('scene capability required') + else: + raise ValueError('unsupported action kind') + if not isinstance(action['event'], str) or not action['event']: + raise ValueError('action event required') + if kind != 'scene' and (not isinstance(action['map'], str) or not action['map'] + or action['direction'] not in ('north', 'east', 'south', 'west')): + raise ValueError('action map and direction required') + + +def action_contract(case): + """An enabled check requires an explicit full list; disabled checks omit it.""" + enabled = case['input'].get('check_actions', False) + if type(enabled) is not bool: + raise ValueError('check_actions must be boolean') + supplied = 'actions' in case['expectation'] + if enabled != supplied or supplied and not isinstance(case['expectation']['actions'], list): + raise ValueError('expectation.actions is required exactly when input.check_actions is true') + if supplied: + for action in case['expectation']['actions']: + validate_action(action) + return enabled + + +def read_cases(path): + """Reject unknown versions, duplicate IDs and ambiguous action assertions.""" + data = exact(read_json(Path(path).read_bytes()), 'format evidence_status coordinate_frame cases') + if (data['format'] != 'pokesim-story-replay-cases/1' + or data['evidence_status'] != 'synthetic-scripted-observations' + or data['coordinate_frame'] != 'runtime-local' + or not isinstance(data['cases'], list) or not data['cases']): + raise ValueError('unsupported or empty case inventory') + names = set() + for case in data['cases']: + exact(case, 'id description input expectation') + if not isinstance(case['id'], str) or not case['id'] or case['id'] in names: + raise ValueError('empty/duplicate case ID') + if not isinstance(case['description'], str) or not case['description']: + raise ValueError('case description required') + names.add(case['id']) + action_contract(case) + return data['cases'] + + +def check_case(case, report): + """Assert outcomes and optionally every ordered action, independently of run().""" + enabled = action_contract(case) + for key, expected in case['expectation'].items(): + if key != 'actions' and (key not in report or report[key] != expected): + raise AssertionError('{}: {} expected {!r}, got {!r}'.format(case['id'], key, expected, report.get(key))) + if enabled: + if not isinstance(report.get('actions'), list): + raise AssertionError(case['id'] + ': actual report has no action list') + for index, actual in enumerate(report['actions']): + try: + validate_action(actual) + except ValueError as error: + raise AssertionError('{}: invalid actions[{}]: {}'.format(case['id'], index, error)) from error + for index, (expected, actual) in enumerate(zip_longest(case['expectation']['actions'], report['actions'])): + if expected != actual: + raise AssertionError('{}: actions[{}] expected {!r}, got {!r}'.format(case['id'], index, expected, actual)) + return {'id': case['id'], 'outcomes': 'matched', 'actions': 'matched' if enabled else 'not-checked'} + + +def place(value): + """Readable state spelling shared by the case document's input/output views.""" + if value is None: + return 'unknown' + return '{} {} facing {} (elevation {})'.format(value['map'], value['tile'], value['facing'] or 'unknown', value['elevation']) + + +def receipts(value): + return ', '.join('{} × {}'.format(count, item) for item, count in value.items()) or 'nothing' + + +def action_text(action): + """Describe semantic movement intents, separate from confirmed displacement.""" + if action['kind'] in ('walk', 'hop'): + return '{} {} for {} {} on {}'.format(action['kind'].capitalize(), action['direction'], action['tiles'], + 'tile' if action['tiles'] == 1 else 'tiles', action['map']) + if action['kind'] == 'transition': + return 'Take {} {}: {} → {}'.format(action['via'], action['direction'], action['map'], action['to_map'] or 'unknown destination') + if action['kind'] == 'turn': + return 'Face {} on {}'.format(action['direction'], action['map']) + return 'Request scene via ' + action['capability'] + + +def render_cases(cases): + """Generate a review view from authored inputs/expectations, without execution.""" + lines = ['# Replay scenarios', '', + 'Generated from [cases.json](cases.json); edit that file and regenerate this view.', '', + 'All observations and interruptions are synthetic. Actions are modeled requests,', + 'not controller timing or verified console behavior. Walk distances are requested, not confirmed displacement.', + 'An interrupted walk keeps its requested distance; uncertainty/progress is checked separately.', + 'Scene contents are supplied observations, not simulated button-by-button dialogue.', + 'A signal is consumed only if its event is reached; listing a receipt does not grant the item at entry.', ''] + for case in cases: + inp, expected = case['input'], case['expectation'] + lines += ['## ' + case['id'], '', case['description'], '', + '**Start:** ' + place(inp['entry']['pose']) + '; screen: ' + inp['entry']['screen'] + '.', '', + '**Injected observations / interruptions:**', ''] + if not inp['signals']: + lines += ['- None.'] + for event, signal in inp['signals'].items(): + if signal['kind'] == 'scene-observation': + lines += ['- `{}`: {} scene result at {}; received: {}.'.format( + event, signal['screen'], place(signal['pose']), receipts(signal['received']))] + elif signal['kind'] == 'wild-encounter': + lines += ['- `{}`: encounter interrupts a {}ward walk from {}.'.format(event, signal['direction'], place(signal['from']))] + else: + lines += ['- `{}`: transition cannot be confirmed.'.format(event)] + lines += ['', '**Expected result:** `{}`{}.'.format(expected['status'], + ' / ' + expected['failure'] if expected['failure'] else ''), '', + '- Completed events: ' + (', '.join('`' + e + '`' for e in expected['completed_events']) or 'none') + '.', + '- Pose: ' + place(expected['pose']) + '.', + '- Received: ' + receipts(expected['received']) + '.'] + if expected['candidates']: + lines += ['- Candidates: ' + '; '.join(place(p) for p in expected['candidates']) + '.'] + lines += ['', '**Exact action checking:** ' + ('enabled; the entire ordered list follows.' if action_contract(case) + else 'disabled; this case checks outcomes only.'), ''] + if 'actions' in expected: + if not expected['actions']: + lines += ['No actions may be requested.', ''] + else: + lines += ['| # | Event | Expected request |', '|---|---|---|'] + lines += ['| {} | `{}` | {} |'.format(i, a['event'], action_text(a)) + for i, a in enumerate(expected['actions'], 1)] + lines += [''] + return '\n'.join(lines) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument('cases', type=Path) + output = parser.add_mutually_exclusive_group() + output.add_argument('--out', type=Path) + output.add_argument('--check-doc', type=Path) + args = parser.parse_args() + text = render_cases(read_cases(args.cases)) + if args.check_doc: + if args.check_doc.read_text() != text: + parser.exit(1, 'Case documentation differs; regenerate with replay_cases --out.\n') + elif args.out: + args.out.write_text(text, encoding='utf-8') + else: + print(text, end='') + + +if __name__ == '__main__': + main() diff --git a/src/pokesim/story/replay_handlers.py b/src/pokesim/story/replay_handlers.py new file mode 100644 index 0000000..ba7da9b --- /dev/null +++ b/src/pokesim/story/replay_handlers.py @@ -0,0 +1,67 @@ +"""Resolve versioned action capabilities; scripted scene effects are bound data.""" + +from dataclasses import dataclass +from typing import Callable + + +def exact(value, keys): + """Reject unknown or missing fields at replay boundaries.""" + if not isinstance(value, dict) or set(value) != set(keys.split()): + raise ValueError('expected exactly: ' + keys) + return value + + +@dataclass +class BoundAction: + """One validated game action with its own signal checker and executor.""" + capability: str + parameters: dict + preflight: Callable + execute: Callable + + +def resolve_action(kind, spec, adapter): + """Reject unknown capabilities/kind combinations before executing any event.""" + exact(spec, 'capability parameters') + capability, parameters = spec['capability'], spec['parameters'] + methods = adapter.handlers() + if capability in methods: + supported_kind, preflight, execute = methods[capability] + if kind != supported_kind or parameters != {}: + raise ValueError('mismatched kind or unsupported movement parameters') + return BoundAction(capability, parameters, preflight, execute) + if capability != 'scripted-scene/1' or kind != 'scene': + raise ValueError('unknown or mismatched event capability') + exact(parameters, 'require_received') + required = parameters['require_received'] + if (not isinstance(required, dict) or any(not isinstance(k, str) or not k + or type(v) is not int or v <= 0 for k, v in required.items())): + raise ValueError('scene receipt requirements must have named positive quantities') + + def preflight(event, signal): + if signal is None: + return + exact(signal, 'kind basis screen pose received') + if signal['kind'] != 'scene-observation' or signal['basis'] != 'scripted': + raise ValueError('scene observation must be explicitly scripted') + received = signal['received'] + if (not isinstance(received, dict) or set(received) - set(required) + or any(type(v) is not int or v <= 0 for v in received.values())): + raise ValueError('unsupported receipt item/quantity') + adapter.validate_pose(signal['pose']) + + def execute(state, event, signal): + state.action(event['id'], 'scene', capability=capability) + # Authored arrival is checked against separately supplied evidence. + if (not signal or signal['screen'] != 'overworld' + or signal['pose'] != adapter.authored_pose(event['declared_after']) + or signal['received'] != required): + state.pose, state.basis = None, 'unknown-after-scene' + return 'scene_evidence_missing_or_mismatched' + state.pose, state.basis = dict(signal['pose']), 'scripted-observation' + for item, count in signal['received'].items(): + state.received[item] = state.received.get(item, 0) + count + state.record(event['id'], 'observation', received=dict(signal['received'])) + return None + + return BoundAction(capability, parameters, preflight, execute) diff --git a/src/pokesim/story/replay_state.py b/src/pokesim/story/replay_state.py new file mode 100644 index 0000000..a028f26 --- /dev/null +++ b/src/pokesim/story/replay_state.py @@ -0,0 +1,26 @@ +"""Shared execution state and requested actions for offline checkpoint replay.""" + +from dataclasses import dataclass, field + + +@dataclass +class ReplayState: + """Keep model/observation basis alongside pose, effects and ordered actions.""" + pose: object + basis: str = 'scripted-observation' + received: dict = field(default_factory=dict) + candidates: list = field(default_factory=list) + trace: list = field(default_factory=list) + actions: list = field(default_factory=list) + + def record(self, event, phase, **details): + """Record state independently of case expectations.""" + self.trace.append(dict(event=event, phase=phase, pose=self.pose, basis=self.basis, **details)) + + def action(self, event, kind, **details): + """Record a requested semantic leg before execution, not its completion. + + Distance is intent: an interrupted walk retains its requested tiles. + Confirmed/model displacement and uncertainty belong to state/trace. + """ + self.actions.append(dict(event=event, kind=kind, **details)) diff --git a/src/pokesim/story/test_authored_checkpoint.py b/src/pokesim/story/test_authored_checkpoint.py new file mode 100644 index 0000000..b6a0e5d --- /dev/null +++ b/src/pokesim/story/test_authored_checkpoint.py @@ -0,0 +1,39 @@ +"""Run the data-authored native Parcel checkpoint through the Python simulator.""" + +import copy +import hashlib +import json +import unittest + +from pokesim.maps.runtime_map_pack import ROOT +from .authored_checkpoint import read_authored_checkpoint +from .replay_binding import load_binding +from .checkpoint_replay import run +from .replay_cases import read_cases, check_case + +FOLDER = ROOT / 'data/story/parcel-pilot' + + +class AuthoredCheckpointTests(unittest.TestCase): + def test_same_authored_plan_passes_all_shared_scenarios(self): + binding = load_binding(FOLDER / 'authored-binding.json') + for case in read_cases(FOLDER / 'cases.json'): + with self.subTest(case=case['id']): + report = run(binding, case['input']) + check_case(case, report) + self.assertIsNone(report['native_source_revision']) + self.assertEqual(report['authored_plan_sha256'], hashlib.sha256( + (FOLDER / 'frlg-parcel-00.05.plan.json').read_bytes()).hexdigest()) + + def test_rejects_snapshot_fields_and_unsupported_executor_configuration(self): + original = json.loads((FOLDER / 'frlg-parcel-00.05.plan.json').read_text()) + mutations = [lambda p: p.update(native_fields={}), + lambda p: p.update(game='bdsp'), + lambda p: p['execution'].update(menu='guess'), + lambda p: p['execution'].update(supplies={}), + lambda p: p['checkpoint']['events'].append(p['checkpoint']['events'][0])] + for mutate in mutations: + plan = copy.deepcopy(original) + mutate(plan) + with self.assertRaises(ValueError): + read_authored_checkpoint(plan) diff --git a/src/pokesim/story/test_checkpoint_replay.py b/src/pokesim/story/test_checkpoint_replay.py new file mode 100644 index 0000000..a03f70d --- /dev/null +++ b/src/pokesim/story/test_checkpoint_replay.py @@ -0,0 +1,215 @@ +"""Real captured events/maps; explicitly synthetic observations and interruptions.""" + +import copy +import hashlib +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + +from pokesim.maps.runtime_map_pack import ROOT +from .replay_binding import PROFILE, load_binding +from .checkpoint_replay import run, summary +from .replay_cases import read_cases, check_case, render_cases + +CASES = ROOT / 'data/story/parcel-pilot/cases.json' + + +class CheckpointReplayTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.binding = load_binding() + cls.cases = read_cases(CASES) + + def test_shared_input_expectation_table(self): + for case in self.cases: + with self.subTest(case=case['id']): + result = run(self.binding, case['input']) + check_case(case, result) + self.assertEqual('not-established', result['native_parity']) + self.assertEqual(self.binding.capture['unbound'], result['native_unbound']) + if result['status'] == 'stopped': + self.assertFalse(result['save_performed']) + self.assertNotIn('parcel_from_clerk', result['completed_events']) + + + def test_wrong_entry_does_not_use_capture_start_as_observation(self): + scenario = copy.deepcopy(self.cases[0]['input']) + scenario['entry']['pose']['tile'] = [6, 8] + result = run(self.binding, scenario) + self.assertEqual('entry_requirement_failed', result['failure']) + self.assertEqual([], result['trace']) + self.assertEqual([6, 8], result['pose']['tile']) + + def test_inputs_and_capture_bookkeeping_are_not_mutated_or_promoted(self): + scenario = copy.deepcopy(self.cases[0]['input']) + capture_before = copy.deepcopy(self.binding.capture) + scenario_before = copy.deepcopy(scenario) + report = run(self.binding, scenario) + self.assertEqual(scenario_before, scenario) + report['native_unbound'].clear() + self.assertEqual(capture_before, self.binding.capture) + self.assertNotIn('hp', report) + self.assertNotIn('potions', report) + + def test_unsupported_observations_and_injections_are_rejected(self): + changes = [ + lambda s: s['entry'].update(extra='ignored'), + lambda s: s['entry'].update(basis='console'), + lambda s: s['signals'].update(unknown={'kind': 'scene-observation'}), + lambda s: s['signals']['parcel_from_clerk'].update(kind='assume-declared'), + lambda s: s['signals']['parcel_from_clerk'].update(received={'oak-parcel': True}), + lambda s: s['signals']['parcel_from_clerk'].update(received={'master-ball': 1}), + lambda s: s['signals'].update(below_mart_door={'kind': 'wild-encounter', + 'from': dict(s['entry']['pose'], tile=[6, 8]), 'direction': 'north'}), + ] + for change in changes: + with self.subTest(change=changes.index(change)): + scenario = copy.deepcopy(self.cases[0]['input']) + change(scenario) + with self.assertRaises(ValueError): + run(self.binding, scenario) + + def test_binding_failures_precede_execution(self): + changes = [ + lambda b: b['capture'].update(sha256='0' * 64), + lambda b: b['maps'].update(Route1='0' * 64), + lambda b: b['capabilities'].update(parcel_from_clerk='assume-success/1'), + lambda b: b['capabilities'].pop('mart_door'), + lambda b: b['policy'].update(save='after'), + lambda b: b['policy'].update(attempts=True), + lambda b: b.update(max_route_moves=True), + lambda b: b['costs'].update(tile=float('nan')), + lambda b: b.update(extra='ignored'), + ] + with tempfile.TemporaryDirectory() as directory: + path = Path(directory, 'binding.json') + for change in changes: + with self.subTest(change=changes.index(change)): + binding = json.loads(PROFILE.read_text()) + change(binding) + path.write_text(json.dumps(binding)) + with self.assertRaises(ValueError): + load_binding(path) + + def test_cli_writes_an_explicit_failure_report_and_nonzero_status(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory, 'trace.json') + result = subprocess.run([sys.executable, '-m', 'pokesim.story.checkpoint_replay', + str(CASES), '--case', 'missing-scene-evidence', '--out', str(path)], + capture_output=True, text=True) + self.assertEqual(1, result.returncode, result.stderr) + self.assertEqual('stopped', json.loads(path.read_text())['status']) + + def test_readable_summary_keeps_evidence_and_policy_limits(self): + rendered = summary(run(self.binding, self.cases[0]['input'])) + self.assertIn('native parity not established', rendered) + self.assertIn('mart_door -> ViridianCity_Mart [4, 7] north, elevation 3 [model-prediction]', rendered) + self.assertIn('parcel_from_clerk -> ViridianCity_Mart [4, 3] west, elevation 3 [scripted-observation]', rendered) + self.assertIn('synthetic policy', rendered) + + def test_invalid_inventory_version_or_duplicate_ids_are_rejected(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory, 'cases.json') + for invalid in ('version', 'duplicate'): + data = json.loads(CASES.read_text()) + if invalid == 'version': + data['format'] = 'future/2' + else: + data['cases'].append(copy.deepcopy(data['cases'][0])) + path.write_text(json.dumps(data)) + with self.assertRaises(ValueError): + read_cases(path) + + def test_exact_action_check_catches_missing_extra_reordered_and_wrong_requests(self): + case = self.cases[0] + self.assertTrue(case['input']['check_actions']) + report = run(self.binding, case['input']) + changes = [ + lambda a: a.pop(), + lambda a: a.append(copy.deepcopy(a[-1])), + lambda a: a.reverse(), + lambda a: a[0].update(direction='east'), + lambda a: a[0].update(tiles=2), + ] + for change in changes: + with self.subTest(change=changes.index(change)): + wrong = copy.deepcopy(report) + change(wrong['actions']) + self.assertEqual('model_complete', wrong['status']) + with self.assertRaisesRegex(AssertionError, r'actions\['): + check_case(case, wrong) + + def test_action_check_option_does_not_drive_replay(self): + scenario = copy.deepcopy(self.cases[0]['input']) + checked = run(self.binding, scenario) + scenario['check_actions'] = False + self.assertEqual(checked, run(self.binding, scenario)) + case = copy.deepcopy(self.cases[0]) + case['input']['check_actions'] = False + case['expectation'].pop('actions') + check_case(case, checked) + + def test_action_contract_requires_list_only_when_enabled(self): + from .replay_cases import action_contract + changes = [lambda c: c['expectation'].pop('actions'), + lambda c: c['input'].update(check_actions=False), + lambda c: c['input'].update(check_actions=1), + lambda c: c['expectation']['actions'][0].update(tiles=True), + lambda c: c['expectation']['actions'][0].update(kind='press')] + for change in changes: + with self.subTest(change=changes.index(change)): + case = copy.deepcopy(self.cases[0]) + change(case) + with self.assertRaises(ValueError): + action_contract(case) + + def test_failed_transition_requests_entry_but_never_the_scene(self): + case = next(c for c in self.cases if c['id'] == 'unconfirmed-mart-transition') + result = run(self.binding, case['input']) + check_case(case, result) + self.assertEqual('mart_door', result['actions'][-1]['event']) + self.assertFalse(any(a['kind'] == 'scene' for a in result['actions'])) + + def test_case_document_matches_authored_expectations(self): + path = CASES.with_name('CASES.md') + self.assertEqual(path.read_text(), render_cases(self.cases)) + + def test_second_checkpoint_uses_same_loader_and_runner_without_parcel_names(self): + # Synthetic capture, never published as native run evidence. Different + # ID/order, repeated STEP and another receipt prove this is not 00.05 code. + capture = copy.deepcopy(self.binding.capture) + start = capture['checkpoint']['start'] + capture['checkpoint'] = {'id': 'synthetic-second', 'title': 'Scene then two steps', 'start': start, + 'events': [{'id': 'get-pass', 'kind': 'scene', 'declared_after': start}, + {'id': 'down', 'kind': 'step', 'direction': 'south'}, + {'id': 'left', 'kind': 'step', 'direction': 'west'}]} + profile = json.loads(PROFILE.read_text()) + profile['capabilities'] = { + 'get-pass': {'capability': 'scripted-scene/1', 'parameters': {'require_received': {'test-pass': 2}}}, + 'down': {'capability': 'frlg-runtime-step/1', 'parameters': {}}, + 'left': {'capability': 'frlg-runtime-step/1', 'parameters': {}}} + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + raw = json.dumps(capture).encode() + (root / 'capture.json').write_bytes(raw) + profile['capture']['file'] = 'capture.json' + profile['capture']['sha256'] = hashlib.sha256(raw).hexdigest() + for name in profile['maps']: + relative = Path('data/games/frlg/maps/runtime', name + '.json') + (root / relative).parent.mkdir(parents=True, exist_ok=True) + (root / relative).write_bytes((ROOT / relative).read_bytes()) + path = root / 'binding.json' + path.write_text(json.dumps(profile)) + binding = load_binding(path, root) + scenario = copy.deepcopy(self.cases[0]['input']) + scenario['signals'] = {'get-pass': {'kind': 'scene-observation', 'basis': 'scripted', + 'screen': 'overworld', 'pose': copy.deepcopy(scenario['entry']['pose']), 'received': {'test-pass': 2}}} + report = run(binding, scenario) + self.assertEqual('model_complete', report['status']) + self.assertEqual(['get-pass', 'down', 'left'], report['completed_events']) + self.assertEqual({'test-pass': 2}, report['received']) + self.assertEqual([6, 9], report['pose']['tile']) + self.assertEqual('west', report['pose']['facing'])