diff --git a/data/story/bdsp-mine-pilot/CASES.md b/data/story/bdsp-mine-pilot/CASES.md new file mode 100644 index 0000000..f121543 --- /dev/null +++ b/data/story/bdsp-mine-pilot/CASES.md @@ -0,0 +1,279 @@ +# 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. + +## mine-all-events + +All 15 events from current #226, with explicitly scripted door, dialogue, scene, Hiker-clearance and healing evidence. + +**Start:** OreburghCity [47, 21] facing south (elevation 0); screen: overworld. + +**Injected observations / interruptions:** + +- `above_the_mine`: synthetic clearance window for hiker_0; encounter: `null`. +- `into_the_mine`: scripted overworld at OreburghMineB1F [12, 1] facing south (elevation 0); result: `{"transition": true}`. +- `into_the_coal_room`: scripted overworld at OreburghMine [15, 2] facing south (elevation 0); result: `{"transition": true}`. +- `roark`: scripted dialogue at OreburghMine [18, 29] facing north (elevation 0); result: `{"npc": "roark"}`. +- `roark_smashes_the_rock`: scripted overworld at OreburghMine [18, 29] facing north (elevation 0); result: `{"departed": "roark"}`. +- `up_to_the_hall`: scripted overworld at OreburghMineB1F [12, 21] facing north (elevation 0); result: `{"transition": true}`. +- `out_of_the_mine`: scripted overworld at OreburghCity [46, 59] facing north (elevation 0); result: `{"transition": true}`. +- `north_to_the_centers_wall`: synthetic clearance window for hiker_0; encounter: `null`. +- `heal_oreburgh`: scripted overworld at OreburghCity [47, 21] facing south (elevation 0); result: `{"party_healed": true}`. + +**Expected result:** `model_complete`. + +- Completed events: `above_the_mine`, `into_the_mine`, `into_the_coal_room`, `south_to_the_rock`, `to_roarks_corner`, `beside_roarks_rock`, `below_roark`, `roark`, `roark_smashes_the_rock`, `below_the_coal_rooms_warp`, `up_to_the_hall`, `out_of_the_mine`, `north_to_the_centers_wall`, `east_below_the_centers_door`, `heal_oreburgh`. +- Pose: OreburghCity [47, 21] facing south (elevation 0). +- Received: nothing. + +**Exact action checking:** enabled; the entire ordered list follows. + +| # | Event | Expected request | +|---|---|---| +| 1 | `above_the_mine` | Walk south for 37 tiles on OreburghCity | +| 2 | `above_the_mine` | Walk west for 1 tile on OreburghCity | +| 3 | `above_the_mine` | Face south on OreburghCity | +| 4 | `into_the_mine` | Follow the OreburghCity corridor south through the door to OreburghMineB1F. | +| 5 | `into_the_coal_room` | Follow the OreburghMineB1F corridor south through the door to OreburghMine. | +| 6 | `south_to_the_rock` | Walk south for 8 tiles on OreburghMine | +| 7 | `to_roarks_corner` | Walk east for 1 tile on OreburghMine | +| 8 | `to_roarks_corner` | Walk south for 2 tiles on OreburghMine | +| 9 | `to_roarks_corner` | Walk west for 1 tile on OreburghMine | +| 10 | `to_roarks_corner` | Walk south for 5 tiles on OreburghMine | +| 11 | `to_roarks_corner` | Walk east for 14 tiles on OreburghMine | +| 12 | `to_roarks_corner` | Walk south for 10 tiles on OreburghMine | +| 13 | `to_roarks_corner` | Walk west for 2 tiles on OreburghMine | +| 14 | `to_roarks_corner` | Walk south for 2 tiles on OreburghMine | +| 15 | `to_roarks_corner` | Walk west for 7 tiles on OreburghMine | +| 16 | `beside_roarks_rock` | Walk south for 1 tile on OreburghMine | +| 17 | `below_roark` | Walk west for 2 tiles on OreburghMine | +| 18 | `roark` | Face north on OreburghMine | +| 19 | `roark` | Talk to roark. | +| 20 | `roark_smashes_the_rock` | Request scene via bdsp-scene-observation/1 | +| 21 | `below_the_coal_rooms_warp` | Walk east for 9 tiles on OreburghMine | +| 22 | `below_the_coal_rooms_warp` | Walk north for 3 tiles on OreburghMine | +| 23 | `below_the_coal_rooms_warp` | Walk east for 2 tiles on OreburghMine | +| 24 | `below_the_coal_rooms_warp` | Walk north for 10 tiles on OreburghMine | +| 25 | `below_the_coal_rooms_warp` | Walk west for 14 tiles on OreburghMine | +| 26 | `below_the_coal_rooms_warp` | Walk north for 5 tiles on OreburghMine | +| 27 | `below_the_coal_rooms_warp` | Walk east for 1 tile on OreburghMine | +| 28 | `below_the_coal_rooms_warp` | Walk north for 2 tiles on OreburghMine | +| 29 | `below_the_coal_rooms_warp` | Walk west for 1 tile on OreburghMine | +| 30 | `below_the_coal_rooms_warp` | Walk north for 7 tiles on OreburghMine | +| 31 | `up_to_the_hall` | Follow the OreburghMine corridor north through the door to OreburghMineB1F. | +| 32 | `out_of_the_mine` | Follow the OreburghMineB1F corridor north through the door to OreburghCity. | +| 33 | `north_to_the_centers_wall` | Walk north for 38 tiles on OreburghCity | +| 34 | `east_below_the_centers_door` | Walk east for 1 tile on OreburghCity | +| 35 | `heal_oreburgh` | Heal at the Pokemon Center and return outside. | + +## entry-not-overworld + +Reject a wrong entry screen before any story action. + +**Start:** OreburghCity [47, 21] facing south (elevation 0); screen: dialogue. + +**Injected observations / interruptions:** + +- None. + +**Expected result:** `stopped` / entry_requirement_failed. + +- Completed events: none. +- Pose: OreburghCity [47, 21] facing south (elevation 0). +- Received: nothing. + +**Exact action checking:** enabled; the entire ordered list follows. + +No actions may be requested. + +## mine-arrival-unconfirmed + +Reach the city mine doorway, but do not assume B1F arrival without evidence. + +**Start:** OreburghCity [47, 21] facing south (elevation 0); screen: overworld. + +**Injected observations / interruptions:** + +- `above_the_mine`: synthetic clearance window for hiker_0; encounter: `null`. + +**Expected result:** `stopped` / observation_missing_or_mismatched. + +- Completed events: `above_the_mine`. +- Pose: unknown. +- Received: nothing. + +**Exact action checking:** enabled; the entire ordered list follows. + +| # | Event | Expected request | +|---|---|---| +| 1 | `above_the_mine` | Walk south for 37 tiles on OreburghCity | +| 2 | `above_the_mine` | Walk west for 1 tile on OreburghCity | +| 3 | `above_the_mine` | Face south on OreburghCity | +| 4 | `into_the_mine` | Follow the OreburghCity corridor south through the door to OreburghMineB1F. | + +## interrupted-before-roark + +A battle during the south-five leg leaves two candidate tiles and unknown facing; stop before Roark instead of crediting requested distance. + +**Start:** OreburghCity [47, 21] facing south (elevation 0); screen: overworld. + +**Injected observations / interruptions:** + +- `above_the_mine`: synthetic clearance window for hiker_0; encounter: `null`. +- `into_the_mine`: scripted overworld at OreburghMineB1F [12, 1] facing south (elevation 0); result: `{"transition": true}`. +- `into_the_coal_room`: scripted overworld at OreburghMine [15, 2] facing south (elevation 0); result: `{"transition": true}`. +- `to_roarks_corner`: synthetic clearance window for no wandering NPCs; encounter: `{"kind": "wild-encounter", "from": {"map": "OreburghMine", "tile": [15, 12], "elevation": 0, "facing": "south"}, "direction": "south"}`. + +**Expected result:** `stopped` / encounter_requires_recovery. + +- Completed events: `above_the_mine`, `into_the_mine`, `into_the_coal_room`, `south_to_the_rock`. +- Pose: unknown. +- Received: nothing. +- Candidates: OreburghMine [15, 12] facing unknown (elevation 0); OreburghMine [15, 13] facing unknown (elevation 0). + +**Exact action checking:** enabled; the entire ordered list follows. + +| # | Event | Expected request | +|---|---|---| +| 1 | `above_the_mine` | Walk south for 37 tiles on OreburghCity | +| 2 | `above_the_mine` | Walk west for 1 tile on OreburghCity | +| 3 | `above_the_mine` | Face south on OreburghCity | +| 4 | `into_the_mine` | Follow the OreburghCity corridor south through the door to OreburghMineB1F. | +| 5 | `into_the_coal_room` | Follow the OreburghMineB1F corridor south through the door to OreburghMine. | +| 6 | `south_to_the_rock` | Walk south for 8 tiles on OreburghMine | +| 7 | `to_roarks_corner` | Walk east for 1 tile on OreburghMine | +| 8 | `to_roarks_corner` | Walk south for 2 tiles on OreburghMine | +| 9 | `to_roarks_corner` | Walk west for 1 tile on OreburghMine | +| 10 | `to_roarks_corner` | Walk south for 5 tiles on OreburghMine | + +## roark-dialogue-unconfirmed + +Standing below Roark and requesting talk does not prove a conversation opened. + +**Start:** OreburghCity [47, 21] facing south (elevation 0); screen: overworld. + +**Injected observations / interruptions:** + +- `above_the_mine`: synthetic clearance window for hiker_0; encounter: `null`. +- `into_the_mine`: scripted overworld at OreburghMineB1F [12, 1] facing south (elevation 0); result: `{"transition": true}`. +- `into_the_coal_room`: scripted overworld at OreburghMine [15, 2] facing south (elevation 0); result: `{"transition": true}`. +- `roark_smashes_the_rock`: scripted overworld at OreburghMine [18, 29] facing north (elevation 0); result: `{"departed": "roark"}`. +- `up_to_the_hall`: scripted overworld at OreburghMineB1F [12, 21] facing north (elevation 0); result: `{"transition": true}`. +- `out_of_the_mine`: scripted overworld at OreburghCity [46, 59] facing north (elevation 0); result: `{"transition": true}`. +- `north_to_the_centers_wall`: synthetic clearance window for hiker_0; encounter: `null`. +- `heal_oreburgh`: scripted overworld at OreburghCity [47, 21] facing south (elevation 0); result: `{"party_healed": true}`. + +**Expected result:** `stopped` / dialogue_unconfirmed. + +- Completed events: `above_the_mine`, `into_the_mine`, `into_the_coal_room`, `south_to_the_rock`, `to_roarks_corner`, `beside_roarks_rock`, `below_roark`. +- Pose: OreburghMine [18, 29] facing north (elevation 0). +- Received: nothing. + +**Exact action checking:** disabled; this case checks outcomes only. + +## roark-departure-unconfirmed + +Do not accept the authored scene pose without the scripted Roark-departure result. + +**Start:** OreburghCity [47, 21] facing south (elevation 0); screen: overworld. + +**Injected observations / interruptions:** + +- `above_the_mine`: synthetic clearance window for hiker_0; encounter: `null`. +- `into_the_mine`: scripted overworld at OreburghMineB1F [12, 1] facing south (elevation 0); result: `{"transition": true}`. +- `into_the_coal_room`: scripted overworld at OreburghMine [15, 2] facing south (elevation 0); result: `{"transition": true}`. +- `roark`: scripted dialogue at OreburghMine [18, 29] facing north (elevation 0); result: `{"npc": "roark"}`. +- `roark_smashes_the_rock`: scripted overworld at OreburghMine [18, 29] facing north (elevation 0); result: `{"departed": "somebody_else"}`. +- `up_to_the_hall`: scripted overworld at OreburghMineB1F [12, 21] facing north (elevation 0); result: `{"transition": true}`. +- `out_of_the_mine`: scripted overworld at OreburghCity [46, 59] facing north (elevation 0); result: `{"transition": true}`. +- `north_to_the_centers_wall`: synthetic clearance window for hiker_0; encounter: `null`. +- `heal_oreburgh`: scripted overworld at OreburghCity [47, 21] facing south (elevation 0); result: `{"party_healed": true}`. + +**Expected result:** `stopped` / observation_missing_or_mismatched. + +- Completed events: `above_the_mine`, `into_the_mine`, `into_the_coal_room`, `south_to_the_rock`, `to_roarks_corner`, `beside_roarks_rock`, `below_roark`, `roark`. +- Pose: unknown. +- Received: nothing. + +**Exact action checking:** disabled; this case checks outcomes only. + +## mine-exit-unconfirmed + +Stop at the B1F north doorway before the city-wall leg when the exit observation is missing. + +**Start:** OreburghCity [47, 21] facing south (elevation 0); screen: overworld. + +**Injected observations / interruptions:** + +- `above_the_mine`: synthetic clearance window for hiker_0; encounter: `null`. +- `into_the_mine`: scripted overworld at OreburghMineB1F [12, 1] facing south (elevation 0); result: `{"transition": true}`. +- `into_the_coal_room`: scripted overworld at OreburghMine [15, 2] facing south (elevation 0); result: `{"transition": true}`. +- `roark`: scripted dialogue at OreburghMine [18, 29] facing north (elevation 0); result: `{"npc": "roark"}`. +- `roark_smashes_the_rock`: scripted overworld at OreburghMine [18, 29] facing north (elevation 0); result: `{"departed": "roark"}`. +- `up_to_the_hall`: scripted overworld at OreburghMineB1F [12, 21] facing north (elevation 0); result: `{"transition": true}`. +- `north_to_the_centers_wall`: synthetic clearance window for hiker_0; encounter: `null`. +- `heal_oreburgh`: scripted overworld at OreburghCity [47, 21] facing south (elevation 0); result: `{"party_healed": true}`. + +**Expected result:** `stopped` / observation_missing_or_mismatched. + +- Completed events: `above_the_mine`, `into_the_mine`, `into_the_coal_room`, `south_to_the_rock`, `to_roarks_corner`, `beside_roarks_rock`, `below_roark`, `roark`, `roark_smashes_the_rock`, `below_the_coal_rooms_warp`, `up_to_the_hall`. +- Pose: unknown. +- Received: nothing. + +**Exact action checking:** disabled; this case checks outcomes only. + +## hiker-clearance-unconfirmed + +The 37-38 tile north walk encounters the Hiker wandering region; no clearance window means stop before entering it. + +**Start:** OreburghCity [47, 21] facing south (elevation 0); screen: overworld. + +**Injected observations / interruptions:** + +- `above_the_mine`: synthetic clearance window for hiker_0; encounter: `null`. +- `into_the_mine`: scripted overworld at OreburghMineB1F [12, 1] facing south (elevation 0); result: `{"transition": true}`. +- `into_the_coal_room`: scripted overworld at OreburghMine [15, 2] facing south (elevation 0); result: `{"transition": true}`. +- `roark`: scripted dialogue at OreburghMine [18, 29] facing north (elevation 0); result: `{"npc": "roark"}`. +- `roark_smashes_the_rock`: scripted overworld at OreburghMine [18, 29] facing north (elevation 0); result: `{"departed": "roark"}`. +- `up_to_the_hall`: scripted overworld at OreburghMineB1F [12, 21] facing north (elevation 0); result: `{"transition": true}`. +- `out_of_the_mine`: scripted overworld at OreburghCity [46, 59] facing north (elevation 0); result: `{"transition": true}`. +- `heal_oreburgh`: scripted overworld at OreburghCity [47, 21] facing south (elevation 0); result: `{"party_healed": true}`. + +**Expected result:** `stopped` / dynamic_npc_clearance_unconfirmed. + +- Completed events: `above_the_mine`, `into_the_mine`, `into_the_coal_room`, `south_to_the_rock`, `to_roarks_corner`, `beside_roarks_rock`, `below_roark`, `roark`, `roark_smashes_the_rock`, `below_the_coal_rooms_warp`, `up_to_the_hall`, `out_of_the_mine`. +- Pose: OreburghCity [46, 28] facing north (elevation 0). +- Received: nothing. + +**Exact action checking:** disabled; this case checks outcomes only. + +## heal-unconfirmed + +A heal request does not establish healed party or final position without its receipt. + +**Start:** OreburghCity [47, 21] facing south (elevation 0); screen: overworld. + +**Injected observations / interruptions:** + +- `above_the_mine`: synthetic clearance window for hiker_0; encounter: `null`. +- `into_the_mine`: scripted overworld at OreburghMineB1F [12, 1] facing south (elevation 0); result: `{"transition": true}`. +- `into_the_coal_room`: scripted overworld at OreburghMine [15, 2] facing south (elevation 0); result: `{"transition": true}`. +- `roark`: scripted dialogue at OreburghMine [18, 29] facing north (elevation 0); result: `{"npc": "roark"}`. +- `roark_smashes_the_rock`: scripted overworld at OreburghMine [18, 29] facing north (elevation 0); result: `{"departed": "roark"}`. +- `up_to_the_hall`: scripted overworld at OreburghMineB1F [12, 21] facing north (elevation 0); result: `{"transition": true}`. +- `out_of_the_mine`: scripted overworld at OreburghCity [46, 59] facing north (elevation 0); result: `{"transition": true}`. +- `north_to_the_centers_wall`: synthetic clearance window for hiker_0; encounter: `null`. + +**Expected result:** `stopped` / observation_missing_or_mismatched. + +- Completed events: `above_the_mine`, `into_the_mine`, `into_the_coal_room`, `south_to_the_rock`, `to_roarks_corner`, `beside_roarks_rock`, `below_roark`, `roark`, `roark_smashes_the_rock`, `below_the_coal_rooms_warp`, `up_to_the_hall`, `out_of_the_mine`, `north_to_the_centers_wall`, `east_below_the_centers_door`. +- Pose: unknown. +- Received: nothing. + +**Exact action checking:** disabled; this case checks outcomes only. diff --git a/data/story/bdsp-mine-pilot/README.md b/data/story/bdsp-mine-pilot/README.md new file mode 100644 index 0000000..0a834c8 --- /dev/null +++ b/data/story/bdsp-mine-pilot/README.md @@ -0,0 +1,45 @@ +# BDSP mine/Roark authored checkpoint + +Start with [the 15 authored events](bdsp-mine-01.03.plan.json) and [the readable cases](CASES.md). The native story table loads the same JSON. The old hand-transcribed BDSP draft had 16 events; it is not the source of this pilot. + +Source: [Arduino-Source #226](https://github.com/dayfine/Arduino-Source/pull/226), exact revision `0738cd55b562d1dd685cdea289663a015a1cbe59`, `SerialPrograms/Source/PokemonBDSP/Programs/AutoStory/PokemonBDSP_AutoStory_StoryTable.cpp`, `oreburgh_mine_to_roark()`. Barry and the first heal now belong to `01.02b`. This file preserves the current inbound/outbound legs, two-tile Center/exit beliefs, `counted_wall` and `to_wall` variants, failure policy, and save-card guard. + +The native companion is [Arduino #263](https://github.com/dayfine/Arduino-Source/pull/263), branch `bdsp/checkpoint-json`, based on that exact #226 head. Python implementation: [PokemonSimulation #74](https://github.com/dayfine/PokemonSimulation/pull/74). Game executors remain unchanged. Its adjacent tests load the actual table, execute `01.03` through the existing engine/fake-game simulator, and stop before healing when the exit fails through all three attempts. This is integration coverage; that fake is not a detector/timing oracle. + +## Run the Python check + +```sh +PYTHONPATH=src python3 -m pokesim.story.checkpoint_replay \ + data/story/bdsp-mine-pilot/cases.json --case mine-all-events \ + --binding data/story/bdsp-mine-pilot/binding.json --summary +PYTHONPATH=src python3 -m unittest pokesim.games.test_bdsp_story_replay +``` + +Nine scenarios cover all 15 events, wrong entry, unconfirmed mine arrival/exit, wild interruption before Roark, missing conversation/departure evidence, the wandering Hiker, and an unconfirmed heal. The full success, wrong-entry, missing-arrival and interrupted cases assert every ordered semantic action. Expectations do not drive execution. Shortened legs cannot be repaired by assigning `declared_after`. + +## What the model proves + +- Local routes and legs use the existing runtime world/movement/planner and the approved map bytes pinned at `4de7742a764d27293b4525d6ad2a6fc8a95f3e3a`. No terrain, corrections or resource pins are changed. +- BDSP `STEP` traverses its source corridor to a doorway; it is not treated as one tile. B1F arrivals can stand on a cave mat, then move away from it toward the interior. +- Complete mine destination links are absent from the published maps. Arrival is therefore a separate **scripted observation**, checked against the authored alternatives, rather than a predicted or confirmed warp. Without it the run stops. +- `to_wall` must actually reach a terrain wall within its bound; an NPC/unknown/transition is not a wall. `counted_wall` computes both native declared starts: 37 and 38 tiles north converge at city `(46,21)`. The requested count 37 must lie within those bounds. The observed/model physical pose and native fallback belief remain separate. +- Roark's conversation needs adjacency, a facing change where necessary, and a scripted dialogue observation. The scene needs a separate departure result. Healing needs a receipt; neither action becomes successful merely because it was requested. +- Wandering NPC regions remain hazards. A test may explicitly script a collision-free movement window for a named NPC; absent that window the model stops before entering its range. It does not treat the Hiker as a permanent wall or invent live waiting behavior. +- A battle during a counted leg retains the requested distance, invalidates the pose, and exposes the before/after candidate tiles with unknown facing. Recovery/rejoin is not silently assumed. + +## What remains unproved + +The Python path uses its pinned cost profile; it does not reproduce native robust planner costs or select identical legs. Observations, clearance windows and scene/heal results are synthetic. Controller queues, analog movement, actual NPC trajectories, banner/fade/dialogue detectors and scene timing are not simulated here. The native executor's declared-arrival fallback remains unchanged. + +The authored native policy is save-at-start with `reload_and_retry` guarded by the `Oreburgh City` save card. Python uses the existing explicit one-attempt stop/no-save diagnostic binding; a Python pass is not native retry/save parity. Battle outcomes and post-heal stats are not fabricated. + +## Existing run evidence behind this route + +These are references preserved from #226's comments at `0738cd55b`, not new console runs performed for this PR: + +- Lab-B, 2026-11-07: a counted south-three approach below Roark moved only two tiles. The current route clips the room’s south wall before moving west to talk. +- Lab-B, 2026-11-08 11:55: wild-battle recovery replanned only to open tile `(15,16)`; a later south-five leg overshot a row and the remaining taps ran under Roark’s rocks. The current source groups the inbound legs through the corner. This pilot’s interruption case stops with uncertainty; it does not claim recovery/rejoin is proved. +- Lab-B, 2026-11-08 09:46, 10:39 and 12:03: the B1F exit came out on city column 46, east of the sign at `(45,56)`, so walking north did not stop at that sign. The current route instead clips the wall beside the Center. +- Lab-B, 2026-11-08 13:50: a north tap/east wall hold matched the map only from column 46; east from `(48,57)` was blocked although the map showed `(49,57)` open. The current route avoids that street. The pilot does not change terrain to explain this unresolved discrepancy. + +Entry assumes the native preceding `01.02b` Barry/heal setup. The pilot does not invent party levels, HP, item counts, or quest-flag observations to turn that assumption into a verified precondition. diff --git a/data/story/bdsp-mine-pilot/bdsp-mine-01.03.plan.json b/data/story/bdsp-mine-pilot/bdsp-mine-01.03.plan.json new file mode 100644 index 0000000..7a82616 --- /dev/null +++ b/data/story/bdsp-mine-pilot/bdsp-mine-01.03.plan.json @@ -0,0 +1,197 @@ +{ + "format": "pokesim-authored-checkpoint/1", + "game": "bdsp", + "checkpoint": { + "id": "01.03", + "title": "Oreburgh Mine to Roark", + "start": { + "map": "OreburghCity", + "certainty": "set", + "where": {"kind": "tiles", "tiles": [[47, 21], [46, 21]], "facing": "south"} + }, + "events": [ + { + "id": "above_the_mine", + "kind": "route", + "target": { + "map": "OreburghCity", + "certainty": "tile", + "where": {"kind": "tiles", "tiles": [[46, 58]], "facing": "south"} + }, + "variant": "town", + "why": "down the door's column to above the mine's warp tiles" + }, + { + "id": "into_the_mine", + "kind": "step", + "direction": "south", + "declared_after": { + "map": "OreburghMineB1F", + "certainty": "tile", + "where": {"kind": "tiles", "tiles": [[12, 1]], "facing": "south"} + }, + "why": "the warp tile (46, 59)" + }, + { + "id": "into_the_coal_room", + "kind": "step", + "direction": "south", + "declared_after": { + "map": "OreburghMine", + "certainty": "tile", + "where": {"kind": "tiles", "tiles": [[15, 2]], "facing": "south"} + }, + "why": "down the entrance hall through the cave mouth" + }, + { + "id": "south_to_the_rock", + "kind": "legs", + "legs": [{"direction": "south", "tiles": 8}], + "declared_after": { + "map": "OreburghMine", + "certainty": "tile", + "where": {"kind": "tiles", "tiles": [[15, 9]], "facing": "south"} + }, + "why": "south against the corridor rock (15, 11)", + "variant": "to_wall" + }, + { + "id": "to_roarks_corner", + "kind": "legs", + "legs": [ + {"direction": "east", "tiles": 1}, + {"direction": "south", "tiles": 2}, + {"direction": "west", "tiles": 1}, + {"direction": "south", "tiles": 5}, + {"direction": "east", "tiles": 14}, + {"direction": "south", "tiles": 10}, + {"direction": "west", "tiles": 2}, + {"direction": "south", "tiles": 2}, + {"direction": "west", "tiles": 7} + ], + "declared_after": { + "map": "OreburghMine", + "certainty": "tile", + "where": {"kind": "tiles", "tiles": [[20, 28]], "facing": "west"} + }, + "why": "east around the rock, across the room, down column 29, west to Roark's rock" + }, + { + "id": "beside_roarks_rock", + "kind": "legs", + "legs": [{"direction": "south", "tiles": 1}], + "declared_after": { + "map": "OreburghMine", + "certainty": "tile", + "where": {"kind": "tiles", "tiles": [[20, 29]], "facing": "south"} + }, + "why": "south against the room's wall", + "variant": "to_wall" + }, + { + "id": "below_roark", + "kind": "legs", + "legs": [{"direction": "west", "tiles": 2}], + "declared_after": { + "map": "OreburghMine", + "certainty": "tile", + "where": {"kind": "tiles", "tiles": [[18, 29]], "facing": "north"} + }, + "why": "west 2 to below Roark" + }, + { + "id": "roark", + "kind": "talk", + "why": "Roark, north of us between his rocks" + }, + { + "id": "roark_smashes_the_rock", + "kind": "scene", + "declared_after": { + "map": "OreburghMine", + "certainty": "tile", + "where": {"kind": "tiles", "tiles": [[18, 29]], "facing": "north"} + }, + "why": "his lines, Rock Smash on the rock east of him, and he walks off" + }, + { + "id": "below_the_coal_rooms_warp", + "kind": "legs", + "legs": [ + {"direction": "east", "tiles": 9}, + {"direction": "north", "tiles": 3}, + {"direction": "east", "tiles": 2}, + {"direction": "north", "tiles": 10}, + {"direction": "west", "tiles": 14}, + {"direction": "north", "tiles": 5}, + {"direction": "east", "tiles": 1}, + {"direction": "north", "tiles": 2}, + {"direction": "west", "tiles": 1}, + {"direction": "north", "tiles": 7} + ], + "declared_after": { + "map": "OreburghMine", + "certainty": "tile", + "where": {"kind": "tiles", "tiles": [[15, 2]], "facing": "north"} + }, + "why": "east around crate, up column 29, west across row 17, and up corridor to warp doorstep" + }, + { + "id": "up_to_the_hall", + "kind": "step", + "direction": "north", + "declared_after": { + "map": "OreburghMineB1F", + "certainty": "tile", + "where": {"kind": "tiles", "tiles": [[12, 21]], "facing": "north"} + }, + "why": "the warp tile (15, 1)" + }, + { + "id": "out_of_the_mine", + "kind": "step", + "direction": "north", + "declared_after": { + "map": "OreburghCity", + "certainty": "set", + "where": {"kind": "tiles", "tiles": [[46, 59], [46, 58]], "facing": "north"} + }, + "why": "up the entrance hall and out to the city" + }, + { + "id": "north_to_the_centers_wall", + "kind": "legs", + "legs": [{"direction": "north", "tiles": 37}], + "declared_after": { + "map": "OreburghCity", + "certainty": "tile", + "where": {"kind": "tiles", "tiles": [[46, 21]], "facing": "north"} + }, + "why": "north up column 46 to the wall west of the Center's door", + "variant": "counted_wall" + }, + { + "id": "east_below_the_centers_door", + "kind": "legs", + "legs": [{"direction": "east", "tiles": 1}], + "declared_after": { + "map": "OreburghCity", + "certainty": "tile", + "where": {"kind": "tiles", "tiles": [[47, 21]], "facing": "north"} + }, + "why": "a tap east below the door" + }, + { + "id": "heal_oreburgh", + "kind": "heal", + "declared_after": { + "map": "OreburghCity", + "certainty": "set", + "where": {"kind": "tiles", "tiles": [[47, 21], [46, 21]], "facing": "south"} + }, + "why": "the nurse; out onto the doorstep, where 01.04 starts" + } + ] + }, + "execution": {"on_failure": "reload_and_retry", "saves_in": "Oreburgh City"} +} diff --git a/data/story/bdsp-mine-pilot/binding.json b/data/story/bdsp-mine-pilot/binding.json new file mode 100644 index 0000000..37134da --- /dev/null +++ b/data/story/bdsp-mine-pilot/binding.json @@ -0,0 +1,39 @@ +{ + "format": "pokesim-story-replay-binding/1", + "plan": { + "file": "data/story/bdsp-mine-pilot/bdsp-mine-01.03.plan.json", + "sha256": "c51c7ad19c6f0c55bdb0d112cf794c3af987fbbdec7a80ca26257b9bdf5761d4" + }, + "map_revision": "4de7742a764d27293b4525d6ad2a6fc8a95f3e3a", + "maps": { + "OreburghCity": "97e3b3f03785268a3ef355f35ba2a77b811e8c010c4859d50eca58bbade707c4", + "OreburghMineB1F": "f136c91897bc100e3cfc2098f53ab1c44544a4cdd93114f1d1a08832fe21060b", + "OreburghMine": "21319ba1d8507ae7a91fe2ad2d59f99df162dfb19f5ca9bcd4c30afc27f08560" + }, + "capabilities": { + "above_the_mine": {"capability": "bdsp-runtime-route/1", "parameters": {}}, + "into_the_mine": {"capability": "bdsp-door-observation/1", "parameters": {}}, + "into_the_coal_room": {"capability": "bdsp-door-observation/1", "parameters": {}}, + "south_to_the_rock": {"capability": "bdsp-runtime-legs/1", "parameters": {}}, + "to_roarks_corner": {"capability": "bdsp-runtime-legs/1", "parameters": {}}, + "beside_roarks_rock": {"capability": "bdsp-runtime-legs/1", "parameters": {}}, + "below_roark": {"capability": "bdsp-runtime-legs/1", "parameters": {}}, + "roark": {"capability": "bdsp-dialogue-observation/1", "parameters": {"npc": "roark"}}, + "roark_smashes_the_rock": {"capability": "bdsp-scene-observation/1", "parameters": {"npc": "roark"}}, + "below_the_coal_rooms_warp": {"capability": "bdsp-runtime-legs/1", "parameters": {}}, + "up_to_the_hall": {"capability": "bdsp-door-observation/1", "parameters": {}}, + "out_of_the_mine": {"capability": "bdsp-door-observation/1", "parameters": {}}, + "north_to_the_centers_wall": {"capability": "bdsp-runtime-legs/1", "parameters": {}}, + "east_below_the_centers_door": {"capability": "bdsp-runtime-legs/1", "parameters": {}}, + "heal_oreburgh": {"capability": "bdsp-heal-observation/1", "parameters": {}} + }, + "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/bdsp-mine-pilot/cases.json b/data/story/bdsp-mine-pilot/cases.json new file mode 100644 index 0000000..74c71c7 --- /dev/null +++ b/data/story/bdsp-mine-pilot/cases.json @@ -0,0 +1,760 @@ +{ + "format": "pokesim-story-replay-cases/1", + "evidence_status": "synthetic-scripted-observations", + "coordinate_frame": "runtime-local", + "cases": [ + { + "id": "mine-all-events", + "description": "All 15 events from current #226, with explicitly scripted door, dialogue, scene, Hiker-clearance and healing evidence.", + "input": { + "entry": { + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "OreburghCity", "tile": [47, 21], "elevation": 0, "facing": "south"} + }, + "signals": { + "above_the_mine": {"kind": "movement-script", "clear_npcs": ["hiker_0"], "encounter": null}, + "into_the_mine": { + "kind": "bdsp-observation", + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "OreburghMineB1F", "tile": [12, 1], "elevation": 0, "facing": "south"}, + "result": {"transition": true} + }, + "into_the_coal_room": { + "kind": "bdsp-observation", + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "OreburghMine", "tile": [15, 2], "elevation": 0, "facing": "south"}, + "result": {"transition": true} + }, + "roark": { + "kind": "bdsp-observation", + "basis": "scripted", + "screen": "dialogue", + "pose": {"map": "OreburghMine", "tile": [18, 29], "elevation": 0, "facing": "north"}, + "result": {"npc": "roark"} + }, + "roark_smashes_the_rock": { + "kind": "bdsp-observation", + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "OreburghMine", "tile": [18, 29], "elevation": 0, "facing": "north"}, + "result": {"departed": "roark"} + }, + "up_to_the_hall": { + "kind": "bdsp-observation", + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "OreburghMineB1F", "tile": [12, 21], "elevation": 0, "facing": "north"}, + "result": {"transition": true} + }, + "out_of_the_mine": { + "kind": "bdsp-observation", + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "OreburghCity", "tile": [46, 59], "elevation": 0, "facing": "north"}, + "result": {"transition": true} + }, + "north_to_the_centers_wall": {"kind": "movement-script", "clear_npcs": ["hiker_0"], "encounter": null}, + "heal_oreburgh": { + "kind": "bdsp-observation", + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "OreburghCity", "tile": [47, 21], "elevation": 0, "facing": "south"}, + "result": {"party_healed": true} + } + }, + "check_actions": true + }, + "expectation": { + "status": "model_complete", + "failure": null, + "active_event": null, + "completed_events": [ + "above_the_mine", + "into_the_mine", + "into_the_coal_room", + "south_to_the_rock", + "to_roarks_corner", + "beside_roarks_rock", + "below_roark", + "roark", + "roark_smashes_the_rock", + "below_the_coal_rooms_warp", + "up_to_the_hall", + "out_of_the_mine", + "north_to_the_centers_wall", + "east_below_the_centers_door", + "heal_oreburgh" + ], + "pose": {"map": "OreburghCity", "tile": [47, 21], "elevation": 0, "facing": "south"}, + "pose_basis": "scripted-observation", + "candidates": [], + "received": {}, + "save_performed": false, + "actions": [ + {"event": "above_the_mine", "kind": "walk", "map": "OreburghCity", "direction": "south", "tiles": 37}, + {"event": "above_the_mine", "kind": "walk", "map": "OreburghCity", "direction": "west", "tiles": 1}, + {"event": "above_the_mine", "kind": "turn", "map": "OreburghCity", "direction": "south"}, + { + "event": "into_the_mine", + "kind": "door", + "map": "OreburghCity", + "direction": "south", + "to_map": "OreburghMineB1F" + }, + { + "event": "into_the_coal_room", + "kind": "door", + "map": "OreburghMineB1F", + "direction": "south", + "to_map": "OreburghMine" + }, + {"event": "south_to_the_rock", "kind": "walk", "map": "OreburghMine", "direction": "south", "tiles": 8}, + {"event": "to_roarks_corner", "kind": "walk", "map": "OreburghMine", "direction": "east", "tiles": 1}, + {"event": "to_roarks_corner", "kind": "walk", "map": "OreburghMine", "direction": "south", "tiles": 2}, + {"event": "to_roarks_corner", "kind": "walk", "map": "OreburghMine", "direction": "west", "tiles": 1}, + {"event": "to_roarks_corner", "kind": "walk", "map": "OreburghMine", "direction": "south", "tiles": 5}, + {"event": "to_roarks_corner", "kind": "walk", "map": "OreburghMine", "direction": "east", "tiles": 14}, + {"event": "to_roarks_corner", "kind": "walk", "map": "OreburghMine", "direction": "south", "tiles": 10}, + {"event": "to_roarks_corner", "kind": "walk", "map": "OreburghMine", "direction": "west", "tiles": 2}, + {"event": "to_roarks_corner", "kind": "walk", "map": "OreburghMine", "direction": "south", "tiles": 2}, + {"event": "to_roarks_corner", "kind": "walk", "map": "OreburghMine", "direction": "west", "tiles": 7}, + {"event": "beside_roarks_rock", "kind": "walk", "map": "OreburghMine", "direction": "south", "tiles": 1}, + {"event": "below_roark", "kind": "walk", "map": "OreburghMine", "direction": "west", "tiles": 2}, + {"event": "roark", "kind": "turn", "map": "OreburghMine", "direction": "north"}, + {"event": "roark", "kind": "talk", "npc": "roark"}, + {"event": "roark_smashes_the_rock", "kind": "scene", "capability": "bdsp-scene-observation/1"}, + {"event": "below_the_coal_rooms_warp", "kind": "walk", "map": "OreburghMine", "direction": "east", "tiles": 9}, + { + "event": "below_the_coal_rooms_warp", + "kind": "walk", + "map": "OreburghMine", + "direction": "north", + "tiles": 3 + }, + {"event": "below_the_coal_rooms_warp", "kind": "walk", "map": "OreburghMine", "direction": "east", "tiles": 2}, + { + "event": "below_the_coal_rooms_warp", + "kind": "walk", + "map": "OreburghMine", + "direction": "north", + "tiles": 10 + }, + { + "event": "below_the_coal_rooms_warp", + "kind": "walk", + "map": "OreburghMine", + "direction": "west", + "tiles": 14 + }, + { + "event": "below_the_coal_rooms_warp", + "kind": "walk", + "map": "OreburghMine", + "direction": "north", + "tiles": 5 + }, + {"event": "below_the_coal_rooms_warp", "kind": "walk", "map": "OreburghMine", "direction": "east", "tiles": 1}, + { + "event": "below_the_coal_rooms_warp", + "kind": "walk", + "map": "OreburghMine", + "direction": "north", + "tiles": 2 + }, + {"event": "below_the_coal_rooms_warp", "kind": "walk", "map": "OreburghMine", "direction": "west", "tiles": 1}, + { + "event": "below_the_coal_rooms_warp", + "kind": "walk", + "map": "OreburghMine", + "direction": "north", + "tiles": 7 + }, + { + "event": "up_to_the_hall", + "kind": "door", + "map": "OreburghMine", + "direction": "north", + "to_map": "OreburghMineB1F" + }, + { + "event": "out_of_the_mine", + "kind": "door", + "map": "OreburghMineB1F", + "direction": "north", + "to_map": "OreburghCity" + }, + { + "event": "north_to_the_centers_wall", + "kind": "walk", + "map": "OreburghCity", + "direction": "north", + "tiles": 38 + }, + { + "event": "east_below_the_centers_door", + "kind": "walk", + "map": "OreburghCity", + "direction": "east", + "tiles": 1 + }, + {"event": "heal_oreburgh", "kind": "heal"} + ] + } + }, + { + "id": "entry-not-overworld", + "description": "Reject a wrong entry screen before any story action.", + "input": { + "entry": { + "basis": "scripted", + "screen": "dialogue", + "pose": {"map": "OreburghCity", "tile": [47, 21], "elevation": 0, "facing": "south"} + }, + "signals": {}, + "check_actions": true + }, + "expectation": { + "status": "stopped", + "failure": "entry_requirement_failed", + "active_event": null, + "completed_events": [], + "pose": {"map": "OreburghCity", "tile": [47, 21], "elevation": 0, "facing": "south"}, + "pose_basis": "scripted-observation", + "candidates": [], + "received": {}, + "save_performed": false, + "actions": [] + } + }, + { + "id": "mine-arrival-unconfirmed", + "description": "Reach the city mine doorway, but do not assume B1F arrival without evidence.", + "input": { + "entry": { + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "OreburghCity", "tile": [47, 21], "elevation": 0, "facing": "south"} + }, + "signals": {"above_the_mine": {"kind": "movement-script", "clear_npcs": ["hiker_0"], "encounter": null}}, + "check_actions": true + }, + "expectation": { + "status": "stopped", + "failure": "observation_missing_or_mismatched", + "active_event": "into_the_mine", + "completed_events": ["above_the_mine"], + "pose": null, + "pose_basis": "unknown-after-observation", + "candidates": [], + "received": {}, + "save_performed": false, + "actions": [ + {"event": "above_the_mine", "kind": "walk", "map": "OreburghCity", "direction": "south", "tiles": 37}, + {"event": "above_the_mine", "kind": "walk", "map": "OreburghCity", "direction": "west", "tiles": 1}, + {"event": "above_the_mine", "kind": "turn", "map": "OreburghCity", "direction": "south"}, + { + "event": "into_the_mine", + "kind": "door", + "map": "OreburghCity", + "direction": "south", + "to_map": "OreburghMineB1F" + } + ] + } + }, + { + "id": "interrupted-before-roark", + "description": "A battle during the south-five leg leaves two candidate tiles and unknown facing; stop before Roark instead of crediting requested distance.", + "input": { + "entry": { + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "OreburghCity", "tile": [47, 21], "elevation": 0, "facing": "south"} + }, + "signals": { + "above_the_mine": {"kind": "movement-script", "clear_npcs": ["hiker_0"], "encounter": null}, + "into_the_mine": { + "kind": "bdsp-observation", + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "OreburghMineB1F", "tile": [12, 1], "elevation": 0, "facing": "south"}, + "result": {"transition": true} + }, + "into_the_coal_room": { + "kind": "bdsp-observation", + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "OreburghMine", "tile": [15, 2], "elevation": 0, "facing": "south"}, + "result": {"transition": true} + }, + "to_roarks_corner": { + "kind": "movement-script", + "clear_npcs": [], + "encounter": { + "kind": "wild-encounter", + "from": {"map": "OreburghMine", "tile": [15, 12], "elevation": 0, "facing": "south"}, + "direction": "south" + } + } + }, + "check_actions": true + }, + "expectation": { + "status": "stopped", + "failure": "encounter_requires_recovery", + "active_event": "to_roarks_corner", + "completed_events": ["above_the_mine", "into_the_mine", "into_the_coal_room", "south_to_the_rock"], + "pose": null, + "pose_basis": "unknown-after-interruption", + "candidates": [ + {"map": "OreburghMine", "tile": [15, 12], "elevation": 0, "facing": null}, + {"map": "OreburghMine", "tile": [15, 13], "elevation": 0, "facing": null} + ], + "received": {}, + "save_performed": false, + "actions": [ + {"event": "above_the_mine", "kind": "walk", "map": "OreburghCity", "direction": "south", "tiles": 37}, + {"event": "above_the_mine", "kind": "walk", "map": "OreburghCity", "direction": "west", "tiles": 1}, + {"event": "above_the_mine", "kind": "turn", "map": "OreburghCity", "direction": "south"}, + { + "event": "into_the_mine", + "kind": "door", + "map": "OreburghCity", + "direction": "south", + "to_map": "OreburghMineB1F" + }, + { + "event": "into_the_coal_room", + "kind": "door", + "map": "OreburghMineB1F", + "direction": "south", + "to_map": "OreburghMine" + }, + {"event": "south_to_the_rock", "kind": "walk", "map": "OreburghMine", "direction": "south", "tiles": 8}, + {"event": "to_roarks_corner", "kind": "walk", "map": "OreburghMine", "direction": "east", "tiles": 1}, + {"event": "to_roarks_corner", "kind": "walk", "map": "OreburghMine", "direction": "south", "tiles": 2}, + {"event": "to_roarks_corner", "kind": "walk", "map": "OreburghMine", "direction": "west", "tiles": 1}, + {"event": "to_roarks_corner", "kind": "walk", "map": "OreburghMine", "direction": "south", "tiles": 5} + ] + } + }, + { + "id": "roark-dialogue-unconfirmed", + "description": "Standing below Roark and requesting talk does not prove a conversation opened.", + "input": { + "entry": { + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "OreburghCity", "tile": [47, 21], "elevation": 0, "facing": "south"} + }, + "signals": { + "above_the_mine": {"kind": "movement-script", "clear_npcs": ["hiker_0"], "encounter": null}, + "into_the_mine": { + "kind": "bdsp-observation", + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "OreburghMineB1F", "tile": [12, 1], "elevation": 0, "facing": "south"}, + "result": {"transition": true} + }, + "into_the_coal_room": { + "kind": "bdsp-observation", + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "OreburghMine", "tile": [15, 2], "elevation": 0, "facing": "south"}, + "result": {"transition": true} + }, + "roark_smashes_the_rock": { + "kind": "bdsp-observation", + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "OreburghMine", "tile": [18, 29], "elevation": 0, "facing": "north"}, + "result": {"departed": "roark"} + }, + "up_to_the_hall": { + "kind": "bdsp-observation", + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "OreburghMineB1F", "tile": [12, 21], "elevation": 0, "facing": "north"}, + "result": {"transition": true} + }, + "out_of_the_mine": { + "kind": "bdsp-observation", + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "OreburghCity", "tile": [46, 59], "elevation": 0, "facing": "north"}, + "result": {"transition": true} + }, + "north_to_the_centers_wall": {"kind": "movement-script", "clear_npcs": ["hiker_0"], "encounter": null}, + "heal_oreburgh": { + "kind": "bdsp-observation", + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "OreburghCity", "tile": [47, 21], "elevation": 0, "facing": "south"}, + "result": {"party_healed": true} + } + }, + "check_actions": false + }, + "expectation": { + "status": "stopped", + "failure": "dialogue_unconfirmed", + "active_event": "roark", + "completed_events": [ + "above_the_mine", + "into_the_mine", + "into_the_coal_room", + "south_to_the_rock", + "to_roarks_corner", + "beside_roarks_rock", + "below_roark" + ], + "pose": {"map": "OreburghMine", "tile": [18, 29], "elevation": 0, "facing": "north"}, + "pose_basis": "model-prediction", + "candidates": [], + "received": {}, + "save_performed": false + } + }, + { + "id": "roark-departure-unconfirmed", + "description": "Do not accept the authored scene pose without the scripted Roark-departure result.", + "input": { + "entry": { + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "OreburghCity", "tile": [47, 21], "elevation": 0, "facing": "south"} + }, + "signals": { + "above_the_mine": {"kind": "movement-script", "clear_npcs": ["hiker_0"], "encounter": null}, + "into_the_mine": { + "kind": "bdsp-observation", + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "OreburghMineB1F", "tile": [12, 1], "elevation": 0, "facing": "south"}, + "result": {"transition": true} + }, + "into_the_coal_room": { + "kind": "bdsp-observation", + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "OreburghMine", "tile": [15, 2], "elevation": 0, "facing": "south"}, + "result": {"transition": true} + }, + "roark": { + "kind": "bdsp-observation", + "basis": "scripted", + "screen": "dialogue", + "pose": {"map": "OreburghMine", "tile": [18, 29], "elevation": 0, "facing": "north"}, + "result": {"npc": "roark"} + }, + "roark_smashes_the_rock": { + "kind": "bdsp-observation", + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "OreburghMine", "tile": [18, 29], "elevation": 0, "facing": "north"}, + "result": {"departed": "somebody_else"} + }, + "up_to_the_hall": { + "kind": "bdsp-observation", + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "OreburghMineB1F", "tile": [12, 21], "elevation": 0, "facing": "north"}, + "result": {"transition": true} + }, + "out_of_the_mine": { + "kind": "bdsp-observation", + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "OreburghCity", "tile": [46, 59], "elevation": 0, "facing": "north"}, + "result": {"transition": true} + }, + "north_to_the_centers_wall": {"kind": "movement-script", "clear_npcs": ["hiker_0"], "encounter": null}, + "heal_oreburgh": { + "kind": "bdsp-observation", + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "OreburghCity", "tile": [47, 21], "elevation": 0, "facing": "south"}, + "result": {"party_healed": true} + } + }, + "check_actions": false + }, + "expectation": { + "status": "stopped", + "failure": "observation_missing_or_mismatched", + "active_event": "roark_smashes_the_rock", + "completed_events": [ + "above_the_mine", + "into_the_mine", + "into_the_coal_room", + "south_to_the_rock", + "to_roarks_corner", + "beside_roarks_rock", + "below_roark", + "roark" + ], + "pose": null, + "pose_basis": "unknown-after-observation", + "candidates": [], + "received": {}, + "save_performed": false + } + }, + { + "id": "mine-exit-unconfirmed", + "description": "Stop at the B1F north doorway before the city-wall leg when the exit observation is missing.", + "input": { + "entry": { + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "OreburghCity", "tile": [47, 21], "elevation": 0, "facing": "south"} + }, + "signals": { + "above_the_mine": {"kind": "movement-script", "clear_npcs": ["hiker_0"], "encounter": null}, + "into_the_mine": { + "kind": "bdsp-observation", + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "OreburghMineB1F", "tile": [12, 1], "elevation": 0, "facing": "south"}, + "result": {"transition": true} + }, + "into_the_coal_room": { + "kind": "bdsp-observation", + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "OreburghMine", "tile": [15, 2], "elevation": 0, "facing": "south"}, + "result": {"transition": true} + }, + "roark": { + "kind": "bdsp-observation", + "basis": "scripted", + "screen": "dialogue", + "pose": {"map": "OreburghMine", "tile": [18, 29], "elevation": 0, "facing": "north"}, + "result": {"npc": "roark"} + }, + "roark_smashes_the_rock": { + "kind": "bdsp-observation", + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "OreburghMine", "tile": [18, 29], "elevation": 0, "facing": "north"}, + "result": {"departed": "roark"} + }, + "up_to_the_hall": { + "kind": "bdsp-observation", + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "OreburghMineB1F", "tile": [12, 21], "elevation": 0, "facing": "north"}, + "result": {"transition": true} + }, + "north_to_the_centers_wall": {"kind": "movement-script", "clear_npcs": ["hiker_0"], "encounter": null}, + "heal_oreburgh": { + "kind": "bdsp-observation", + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "OreburghCity", "tile": [47, 21], "elevation": 0, "facing": "south"}, + "result": {"party_healed": true} + } + }, + "check_actions": false + }, + "expectation": { + "status": "stopped", + "failure": "observation_missing_or_mismatched", + "active_event": "out_of_the_mine", + "completed_events": [ + "above_the_mine", + "into_the_mine", + "into_the_coal_room", + "south_to_the_rock", + "to_roarks_corner", + "beside_roarks_rock", + "below_roark", + "roark", + "roark_smashes_the_rock", + "below_the_coal_rooms_warp", + "up_to_the_hall" + ], + "pose": null, + "pose_basis": "unknown-after-observation", + "candidates": [], + "received": {}, + "save_performed": false + } + }, + { + "id": "hiker-clearance-unconfirmed", + "description": "The 37-38 tile north walk encounters the Hiker wandering region; no clearance window means stop before entering it.", + "input": { + "entry": { + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "OreburghCity", "tile": [47, 21], "elevation": 0, "facing": "south"} + }, + "signals": { + "above_the_mine": {"kind": "movement-script", "clear_npcs": ["hiker_0"], "encounter": null}, + "into_the_mine": { + "kind": "bdsp-observation", + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "OreburghMineB1F", "tile": [12, 1], "elevation": 0, "facing": "south"}, + "result": {"transition": true} + }, + "into_the_coal_room": { + "kind": "bdsp-observation", + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "OreburghMine", "tile": [15, 2], "elevation": 0, "facing": "south"}, + "result": {"transition": true} + }, + "roark": { + "kind": "bdsp-observation", + "basis": "scripted", + "screen": "dialogue", + "pose": {"map": "OreburghMine", "tile": [18, 29], "elevation": 0, "facing": "north"}, + "result": {"npc": "roark"} + }, + "roark_smashes_the_rock": { + "kind": "bdsp-observation", + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "OreburghMine", "tile": [18, 29], "elevation": 0, "facing": "north"}, + "result": {"departed": "roark"} + }, + "up_to_the_hall": { + "kind": "bdsp-observation", + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "OreburghMineB1F", "tile": [12, 21], "elevation": 0, "facing": "north"}, + "result": {"transition": true} + }, + "out_of_the_mine": { + "kind": "bdsp-observation", + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "OreburghCity", "tile": [46, 59], "elevation": 0, "facing": "north"}, + "result": {"transition": true} + }, + "heal_oreburgh": { + "kind": "bdsp-observation", + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "OreburghCity", "tile": [47, 21], "elevation": 0, "facing": "south"}, + "result": {"party_healed": true} + } + }, + "check_actions": false + }, + "expectation": { + "status": "stopped", + "failure": "dynamic_npc_clearance_unconfirmed", + "active_event": "north_to_the_centers_wall", + "completed_events": [ + "above_the_mine", + "into_the_mine", + "into_the_coal_room", + "south_to_the_rock", + "to_roarks_corner", + "beside_roarks_rock", + "below_roark", + "roark", + "roark_smashes_the_rock", + "below_the_coal_rooms_warp", + "up_to_the_hall", + "out_of_the_mine" + ], + "pose": {"map": "OreburghCity", "tile": [46, 28], "elevation": 0, "facing": "north"}, + "pose_basis": "model-prediction", + "candidates": [], + "received": {}, + "save_performed": false + } + }, + { + "id": "heal-unconfirmed", + "description": "A heal request does not establish healed party or final position without its receipt.", + "input": { + "entry": { + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "OreburghCity", "tile": [47, 21], "elevation": 0, "facing": "south"} + }, + "signals": { + "above_the_mine": {"kind": "movement-script", "clear_npcs": ["hiker_0"], "encounter": null}, + "into_the_mine": { + "kind": "bdsp-observation", + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "OreburghMineB1F", "tile": [12, 1], "elevation": 0, "facing": "south"}, + "result": {"transition": true} + }, + "into_the_coal_room": { + "kind": "bdsp-observation", + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "OreburghMine", "tile": [15, 2], "elevation": 0, "facing": "south"}, + "result": {"transition": true} + }, + "roark": { + "kind": "bdsp-observation", + "basis": "scripted", + "screen": "dialogue", + "pose": {"map": "OreburghMine", "tile": [18, 29], "elevation": 0, "facing": "north"}, + "result": {"npc": "roark"} + }, + "roark_smashes_the_rock": { + "kind": "bdsp-observation", + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "OreburghMine", "tile": [18, 29], "elevation": 0, "facing": "north"}, + "result": {"departed": "roark"} + }, + "up_to_the_hall": { + "kind": "bdsp-observation", + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "OreburghMineB1F", "tile": [12, 21], "elevation": 0, "facing": "north"}, + "result": {"transition": true} + }, + "out_of_the_mine": { + "kind": "bdsp-observation", + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "OreburghCity", "tile": [46, 59], "elevation": 0, "facing": "north"}, + "result": {"transition": true} + }, + "north_to_the_centers_wall": {"kind": "movement-script", "clear_npcs": ["hiker_0"], "encounter": null} + }, + "check_actions": false + }, + "expectation": { + "status": "stopped", + "failure": "observation_missing_or_mismatched", + "active_event": "heal_oreburgh", + "completed_events": [ + "above_the_mine", + "into_the_mine", + "into_the_coal_room", + "south_to_the_rock", + "to_roarks_corner", + "beside_roarks_rock", + "below_roark", + "roark", + "roark_smashes_the_rock", + "below_the_coal_rooms_warp", + "up_to_the_hall", + "out_of_the_mine", + "north_to_the_centers_wall", + "east_below_the_centers_door" + ], + "pose": null, + "pose_basis": "unknown-after-observation", + "candidates": [], + "received": {}, + "save_performed": false + } + } + ] +} diff --git a/docs/autostory-plan.md b/docs/autostory-plan.md index d1d7db4..92092e4 100644 --- a/docs/autostory-plan.md +++ b/docs/autostory-plan.md @@ -259,3 +259,9 @@ Exchange-format JSON Schemas now cover common story records, current v2 runtime reusing existing battle/progression evidence and adding source-backed overworld actions. Track authored, bound and replay-validated coverage separately; see [execution plan](story/EXECUTION_PLAN.md#full-frlg-world-and-pokeguy-plan-expansion). + +### Authored checkpoint pilot update (2026-11-08) + +- FRLG Parcel `00.05` is data-authored in Arduino #260 / Simulation #73. Native Linux, Windows and macOS tests and same-file Python replay pass. The lean authored contract replaces the export snapshot as the production definition; snapshots remain migration fixtures only. +- The next slice is [BDSP mine/Roark `01.03`](../data/story/bdsp-mine-pilot/README.md), preserving current #226 revision `0738cd55b` (15 events), with explicit save-card/failure policy, multi-tile arrivals and wall/door variants. Nine Python scenarios and Python CI pass in Simulation #74; Arduino #263 is published with full native execution/paired CI running. No new map edits are included. +- Then add the starter RNG handoff/resume example, before full FRLG expansion. The [execution plan](story/EXECUTION_PLAN.md) records the revised two-gate migration sequence and remaining parity limits. diff --git a/docs/story/EXECUTION_PLAN.md b/docs/story/EXECUTION_PLAN.md index 8fb8e4b..fe37d63 100644 --- a/docs/story/EXECUTION_PLAN.md +++ b/docs/story/EXECUTION_PLAN.md @@ -18,31 +18,14 @@ Keep implementation-specific tests with their owning repository. ## 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 - native scripted execution and Python replay. Compare ordered actions, state - evidence, interruptions and failures, not just whether both runs finish. -2. **Make shared events executable.** Add strict parsing and binding around the - existing shared native engine and per-game executors. Author these two - checkpoints as data only after parity passes; remove their duplicate C++ - authoring. Include a handoff/resume example before scaling up the format. -3. **Write the complete FRLG plan.** Use the readable, symbolic style of the - [PLZA draft](PLZA_OPENING_DRAFT.md), with checkpoint requirements, actions, - expected effects and unresolved bindings. Cover the selected main story from - new game through the first Champion victory; track postgame separately. - Migrate existing native checkpoints first, then draft uncovered sections. - Full authored coverage can precede full executor coverage, but each section - must state which it has. No invented routes, party state or battle win rates. -4. **Bind and evaluate incrementally.** Enable supported FRLG checkpoints in - order; replay navigation, transitions, recovery and battle histories against - pinned data. Report missing maps, executors or battle inputs as blockers. - Repeat the proven shared contract for BDSP without assuming FRLG semantics. +Current checkpoint (2026-11-08): FRLG Parcel `00.05` is authored in lean JSON and loaded by the native story table in Arduino #260. Its native Linux/Windows/macOS tests and the paired byte-equality/seven-scenario Python gate pass. PokemonSimulation #73 holds the schema, strict reader and reusable replay engine. The broad native/Python trace-comparison diagnostic (#258) remains separate; live timing, effective runtime policy and full execution parity are not established. + +The user chose two practical migration gates: **load the authored data through the existing native story table/engine**, and **run the same JSON through PokemonSimulation**. Keep export snapshots as regression fixtures, not as the authoring format. `declared_after` remains an explicit native fallback, not observed state. This replaces the earlier mandatory export-first sequencing. + +1. **Land the FRLG pilot.** Merge PokemonSimulation #73 and Arduino #260 after review. The C++ Parcel event list is removed; save-menu configuration remains game-owned. +2. **Prove the shape on BDSP.** [The mine/Roark pilot](../../data/story/bdsp-mine-pilot/README.md) migrates current #226 revision `0738cd55b` and its 15 events. Preserve BDSP's save-card/failure policy, walking/door variants and multi-tile beliefs. Native integration and Python scenario checks are independent gates; scripted observations cannot prove live transitions or native cost/retry parity. The paired drafts are PokemonSimulation #74 and Arduino #263. Python CI, local native syntax and code health pass; full native CI is running. +3. **Add one handoff/resume example.** Use the actual FRLG starter RNG boundary. Suspend before transfer, verify a linked return receipt/state, and resume once. Reject stale/duplicate/wrong-plan returns and invalidate state affected by reset/load. Do this before scaling the full format. +4. **Write and enable FRLG incrementally.** Migrate existing native checkpoints first, then draft the remaining Pokeguy story through Champion. Track authored, bound and replay-validated coverage separately. Include navigation transitions/recovery and battle histories against pinned data; expose missing map/executor/battle inputs rather than successful placeholders. The FRLG coverage index should list checkpoint ID, story objective, source revision/evidence, entering requirements, authored events, execution bindings, @@ -125,6 +108,12 @@ requirements must come from its native implementation; they are not inferred from this sketch. Python replay uses scripted receipts and evidence, not RNG hardware execution or an assumed successful outcome. +### Starter RNG return: save before the rival + +The first handoff pilot must introduce a dedicated checkpoint immediately after a verified successful Starter RNG return. Confirm the returned starter and an overworld state in which saving is available, save it, and verify save completion **before** moving toward the rival trigger. Only then can a failure reload the new save. A failed/unconfirmed save stops without resetting to the pre-RNG save. + +The current native `rows_after_starter_rng()` resumes at the rival portion of `00.04b`, and `after_shiny_starter()` sets `has_save = false` until that row saves after the battle. That is the current implementation, not a verified game restriction requiring delayed saving. Replace that grouping in the handoff slice; do not encode the delay as a universal rule. Native/Python tests must cover saving before the rival, failed/unconfirmed save without reload, and retry from the newly confirmed save. No console validation or production change is claimed by this plan note. + ## Acceptance tests Keep native tests beside their owning sources and Python tests beside their diff --git a/schemas/authored-checkpoint.schema.json b/schemas/authored-checkpoint.schema.json index eefe35a..8b45444 100644 --- a/schemas/authored-checkpoint.schema.json +++ b/schemas/authored-checkpoint.schema.json @@ -1,19 +1,36 @@ { "$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", + "title": "Authored tile-game checkpoint with game-specific executor settings", "type": "object", "properties": { "format": {"const": "pokesim-authored-checkpoint/1"}, - "game": {"const": "frlg"}, + "game": {"enum": ["frlg", "bdsp"]}, "checkpoint": {"$ref": "story-checkpoint.schema.json"}, - "execution": { + "execution": {"type": "object"} + }, + "required": ["format", "game", "checkpoint", "execution"], + "additionalProperties": false, + "$defs": { + "frlg_execution": { "type": "object", "properties": {"menu": {"enum": ["before_pokemon", "before_pokedex", "full"]}}, "required": ["menu"], "additionalProperties": false + }, + "bdsp_execution": { + "type": "object", + "properties": {"on_failure": {"enum": ["reload_and_retry", "stop"]}, "saves_in": {"type": "string"}}, + "required": ["on_failure", "saves_in"], + "additionalProperties": false, + "oneOf": [ + {"properties": {"on_failure": {"const": "reload_and_retry"}, "saves_in": {"minLength": 1}}}, + {"properties": {"on_failure": {"const": "stop"}, "saves_in": {"const": ""}}} + ] } }, - "required": ["format", "game", "checkpoint", "execution"], - "additionalProperties": false + "oneOf": [ + {"properties": {"game": {"const": "frlg"}, "execution": {"$ref": "#/$defs/frlg_execution"}}}, + {"properties": {"game": {"const": "bdsp"}, "execution": {"$ref": "#/$defs/bdsp_execution"}}} + ] } diff --git a/schemas/story-replay-binding.schema.json b/schemas/story-replay-binding.schema.json index 5ac5cea..0188c79 100644 --- a/schemas/story-replay-binding.schema.json +++ b/schemas/story-replay-binding.schema.json @@ -57,6 +57,36 @@ } } } + }, + { + "type": "object", + "properties": { + "capability": { + "enum": [ + "bdsp-runtime-route/1", + "bdsp-runtime-legs/1", + "bdsp-door-observation/1", + "bdsp-heal-observation/1" + ] + }, + "parameters": {"type": "object", "additionalProperties": false} + }, + "required": ["capability", "parameters"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "capability": {"enum": ["bdsp-dialogue-observation/1", "bdsp-scene-observation/1"]}, + "parameters": { + "type": "object", + "properties": {"npc": {"type": "string", "minLength": 1}}, + "required": ["npc"], + "additionalProperties": false + } + }, + "required": ["capability", "parameters"], + "additionalProperties": false } ] } diff --git a/schemas/story-replay-cases.schema.json b/schemas/story-replay-cases.schema.json index 2fe635a..588d7df 100644 --- a/schemas/story-replay-cases.schema.json +++ b/schemas/story-replay-cases.schema.json @@ -69,6 +69,52 @@ "additionalProperties": {"type": "integer", "minimum": 1} } } + }, + { + "type": "object", + "properties": { + "kind": {"const": "movement-script"}, + "clear_npcs": {"type": "array", "items": {"type": "string", "minLength": 1}}, + "encounter": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/encounter"}]} + }, + "required": ["kind", "clear_npcs", "encounter"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": {"const": "bdsp-observation"}, + "basis": {"const": "scripted"}, + "screen": {"enum": ["overworld", "dialogue"]}, + "pose": {"$ref": "#/$defs/pose"}, + "result": { + "type": "object", + "oneOf": [ + { + "properties": {"transition": {"type": "boolean"}}, + "required": ["transition"], + "additionalProperties": false + }, + { + "properties": {"npc": {"type": "string"}}, + "required": ["npc"], + "additionalProperties": false + }, + { + "properties": {"departed": {"type": "string"}}, + "required": ["departed"], + "additionalProperties": false + }, + { + "properties": {"party_healed": {"type": "boolean"}}, + "required": ["party_healed"], + "additionalProperties": false + } + ] + } + }, + "required": ["kind", "basis", "screen", "pose", "result"], + "additionalProperties": false } ] } @@ -106,7 +152,8 @@ "unknown-after-interruption", "unknown-after-transition", "unknown-after-scene", - "unknown-after-route-mismatch" + "unknown-after-route-mismatch", + "unknown-after-observation" ] }, "candidates": { @@ -181,8 +228,36 @@ "properties": { "event": {"type": "string", "minLength": 1}, "kind": {"const": "scene"}, - "capability": {"const": "scripted-scene/1"} + "capability": {"enum": ["scripted-scene/1", "bdsp-scene-observation/1"]} } + }, + { + "type": "object", + "properties": { + "event": {"type": "string", "minLength": 1}, + "kind": {"const": "door"}, + "map": {"type": "string", "minLength": 1}, + "direction": {"enum": ["north", "east", "south", "west"]}, + "to_map": {"type": "string", "minLength": 1} + }, + "required": ["event", "kind", "map", "direction", "to_map"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "event": {"type": "string", "minLength": 1}, + "kind": {"const": "talk"}, + "npc": {"type": "string", "minLength": 1} + }, + "required": ["event", "kind", "npc"], + "additionalProperties": false + }, + { + "type": "object", + "properties": {"event": {"type": "string", "minLength": 1}, "kind": {"const": "heal"}}, + "required": ["event", "kind"], + "additionalProperties": false } ] } @@ -218,6 +293,16 @@ "elevation": {"type": "integer", "minimum": 0, "maximum": 15}, "facing": {"enum": ["north", "east", "south", "west"]} } + }, + "encounter": { + "type": "object", + "properties": { + "kind": {"const": "wild-encounter"}, + "from": {"$ref": "#/$defs/pose"}, + "direction": {"enum": ["north", "east", "south", "west"]} + }, + "required": ["kind", "from", "direction"], + "additionalProperties": false } } } diff --git a/src/pokesim/formats/contract_format.py b/src/pokesim/formats/contract_format.py index ddf56ed..b02ad2b 100644 --- a/src/pokesim/formats/contract_format.py +++ b/src/pokesim/formats/contract_format.py @@ -51,7 +51,7 @@ def main(): changed = [] paths = args.paths or (sorted(SCHEMAS.glob('*.schema.json')) + [ROOT / 'data/story/wire-conformance.json'] + sorted(ROOT.glob('data/story/native/*.json')) - + sorted(ROOT.glob('data/story/parcel-pilot/*.json'))) + + sorted(ROOT.glob('data/story/parcel-pilot/*.json')) + sorted(ROOT.glob('data/story/bdsp-mine-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 85a60a7..9512171 100644 --- a/src/pokesim/formats/json_schema.py +++ b/src/pokesim/formats/json_schema.py @@ -43,6 +43,9 @@ def published(): 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' + yield 'authored-checkpoint', ROOT / 'data/story/bdsp-mine-pilot/bdsp-mine-01.03.plan.json' + yield 'story-replay-binding', ROOT / 'data/story/bdsp-mine-pilot/binding.json' + yield 'story-replay-cases', ROOT / 'data/story/bdsp-mine-pilot/cases.json' def main(): diff --git a/src/pokesim/games/bdsp_story_replay.py b/src/pokesim/games/bdsp_story_replay.py new file mode 100644 index 0000000..8d380a3 --- /dev/null +++ b/src/pokesim/games/bdsp_story_replay.py @@ -0,0 +1,284 @@ +"""BDSP story actions on pinned terrain with explicitly scripted observations. + +Doors traverse their source corridor, then require arrival evidence: the pack +has no complete mine warp links. Dynamic NPC clearance and scene/heal outcomes +are scenario inputs, never inferred from authored fallback poses. +""" + +from functools import partial + +from pokesim.grid.geometry import Direction, Tile +from pokesim.grid.maps.world import Place +from pokesim.grid.movement import Blocked, BlockReason, Moved, PlayerState, Warp, WorldState, step as tile_step, world_step +from pokesim.grid.routing.shortest import as_move, shortest_path +from pokesim.story.replay_handlers import BoundAction, exact +from pokesim.story.spatial_replay import RuntimeSpatialReplay, pose + + +def dynamic_objects(world): + """Keep wandering regions separate from permanent wall/occupancy geometry.""" + regions = {} + for name, view in world.views.items(): + static = set() + for obj in view.data.get('objects', []): + box = obj.get('wander') + if box: + regions[name, obj['name']] = {Tile(x, y) for y in range(box['y0'], box['y1'] + 1) + for x in range(box['x0'], box['x1'] + 1)} + else: + static.add(Tile(obj['x'], obj['y'])) + view.occupied = static # State projection only; raw map bytes/terrain remain untouched. + return regions + + +class BDSPReplay(RuntimeSpatialReplay): + """Supported BDSP profiles; native robust cost/timing parity is unclaimed.""" + def __init__(self, world, costs, max_route_moves): + super().__init__(world, costs, max_route_moves) + self.dynamic = dynamic_objects(world) + + def validate_event(self, event): + kind, variant = event['kind'], event.get('variant', '') + if kind == 'route' and variant not in ('', 'town'): + raise ValueError('unsupported BDSP route profile') + if kind == 'legs': + if variant not in ('', 'to_wall', 'counted_wall') or not event.get('legs'): + raise ValueError('unsupported BDSP legs profile') + if (any(not 1 <= leg['tiles'] <= self.max_route_moves for leg in event['legs']) + or sum(leg['tiles'] for leg in event['legs']) > self.max_route_moves): + raise ValueError('leg distance outside bound') + if variant == 'counted_wall' and len(event['legs']) != 1: + raise ValueError('counted-wall requires exactly one leg') + if kind in ('step', 'scene', 'heal', 'legs') and 'declared_after' not in event: + raise ValueError('BDSP pilot action requires explicit arrival data') + + def handlers(self): + return {} + + def bind_action(self, kind, spec): + profiles = {'bdsp-runtime-route/1': ('route', self.movement_signal, self.route), + 'bdsp-runtime-legs/1': ('legs', self.movement_signal, self.legs), + 'bdsp-door-observation/1': ('step', self.observation, self.door), + 'bdsp-dialogue-observation/1': ('talk', self.observation, self.talk), + 'bdsp-scene-observation/1': ('scene', self.observation, self.scene), + 'bdsp-heal-observation/1': ('heal', self.observation, self.heal)} + if spec['capability'] not in profiles: + return None + supported, preflight, execute = profiles[spec['capability']] + params = spec['parameters'] + exact(params, 'npc' if kind in ('talk', 'scene') else '') + if kind != supported or ('npc' in params and (not isinstance(params['npc'], str) or not params['npc'])): + raise ValueError('unsupported BDSP kind/parameters') + return BoundAction(spec['capability'], params, preflight, partial(execute, params=params)) + + 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') + or type(value['elevation']) is not int or value['elevation'] != 0): + raise ValueError('expected a known flat BDSP pose') + state = WorldState(Place(value['map'], Tile(*value['tile'])), 0) + view = self.world.views.get(value['map']) + if view is None or self.world.at(state.place) is None: + raise ValueError('pose outside bound maps') + if view.glyph(state.place.tile) != 'D': + self.world.validate_state(state) + return state, Direction[value['facing'].upper()] + + def authored_poses(self, wire): + where = wire['where'] + if wire['certainty'] not in ('tile', 'set') or where['kind'] != 'tiles' or not where['tiles'] or 'facing' not in where: + raise ValueError('BDSP replay requires explicit tile alternatives and facing') + result = [] + for tile in where['tiles']: + location = Place(wire['map'], Tile(*tile)) + cell = self.world.at(location) + if cell is None: + raise ValueError('authored pose outside bound maps') + observed = pose(WorldState(location, cell.elevation), Direction[where['facing'].upper()]) + self.validate_pose(observed) + result.append(observed) + return result + + def authored_pose(self, wire): + alternatives = self.authored_poses(wire) + if len(alternatives) != 1: + raise ValueError('this action needs one authored target, not an arbitrary first alternative') + return alternatives[0] + + def matches_authored_pose(self, actual, wire): + return actual in self.authored_poses(wire) + + def movement_signal(self, event, signal): + if signal is None: + return + exact(signal, 'kind clear_npcs encounter') + if signal['kind'] != 'movement-script' or not isinstance(signal['clear_npcs'], list): + raise ValueError('movement scripts must declare their synthetic clearance window') + names = {name for _, name in self.dynamic} + if any(not isinstance(name, str) or name not in names for name in signal['clear_npcs']): + raise ValueError('unknown dynamic NPC clearance') + if signal['encounter'] is not None: + exact(signal['encounter'], 'kind from direction') + self.route_signal(event, signal['encounter']) + + def observation(self, event, signal): + if signal is None: + return + exact(signal, 'kind basis screen pose result') + if signal['kind'] != 'bdsp-observation' or signal['basis'] != 'scripted' or not isinstance(signal['result'], dict): + raise ValueError('BDSP observations must be explicitly scripted') + self.validate_pose(signal['pose']) + key = {'step': 'transition', 'talk': 'npc', 'scene': 'departed', 'heal': 'party_healed'}[event['kind']] + exact(signal['result'], key) + expected_type = bool if key in ('transition', 'party_healed') else str + if type(signal['result'][key]) is not expected_type: + raise ValueError('unsupported observation result type') + + def hazard(self, current, signal): + clear = signal['clear_npcs'] if signal else [] + return any(map == current.place.map and current.place.tile in tiles and name not in clear + for (map, name), tiles in self.dynamic.items()) + + def route(self, state, event, signal, params): + current, _ = 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 and any(self.hazard(move.to, signal) for move in path.moves): + return 'dynamic_npc_clearance_unconfirmed' + return super().route(state, event, signal['encounter'] if signal else None) + + def press(self, current, direction): + # A confirmed arrival can stand on a cave mat. Leaving it toward the + # interior does not reactivate the source warp merely by standing there. + view = self.world.views[current.place.map] + if view.glyph(current.place.tile) == 'D': + local = tile_step(view.map_file.grid, PlayerState(current.place.tile, 0), direction, + self.world.rules, view.occupied) + return Moved(WorldState(Place(current.place.map, local.state.tile), 0)) if isinstance(local, Moved) else local + return world_step(self.world, current, direction, self.world.rules) + + def advance(self, state, direction, count, event, signal, wall=False): + """Requested distance stays in the action log when a battle invalidates pose.""" + current, facing = self.validate_pose(state.pose) + state.action(event['id'], 'walk', map=current.place.map, direction=direction.name.lower(), tiles=count) + for number in range(count): + result = self.press(current, direction) + move = as_move(direction, result) + if move is None: + if wall and isinstance(result, Blocked) and result.reason == BlockReason.WALL: + return None + return 'movement_blocked_or_unresolved' + if self.hazard(move.to, signal): + return 'dynamic_npc_clearance_unconfirmed' + predicted = pose(move.to, getattr(result, 'facing', direction)) + encounter = signal['encounter'] if signal else None + if encounter and state.pose == encounter['from'] and direction.name.lower() == encounter['direction']: + state.candidates = [dict(state.pose, facing=None), dict(predicted, facing=None)] + state.pose, state.basis = None, 'unknown-after-interruption' + return 'encounter_requires_recovery' + current, facing = move.to, direction + state.pose, state.basis = predicted, 'model-prediction' + if wall: + result = self.press(current, direction) + if not isinstance(result, Blocked) or result.reason != BlockReason.WALL: + return 'wall_not_reached' + return None + + def wall_distance(self, observed, direction): + current, _ = self.validate_pose(observed) + for distance in range(self.max_route_moves + 1): + result = self.press(current, direction) + move = as_move(direction, result) + if move is None: + if isinstance(result, Blocked) and result.reason == BlockReason.WALL: + return distance, pose(current, direction) + raise ValueError('counted-wall corridor ends at an NPC, unknown tile or transition') + current = move.to + raise ValueError('counted-wall exceeds bounded movement budget') + + def legs(self, state, event, signal, params): + variant = event.get('variant', '') + if variant not in ('', 'to_wall', 'counted_wall'): + return 'unsupported_legs_variant' + for leg in event['legs']: + direction = Direction[leg['direction'].upper()] + count = leg['tiles'] + if variant == 'counted_wall': + # The native executor uses its declared belief, not a guessed first tile. + beliefs = state.declared_belief or [state.pose] + distances = [self.wall_distance(p, direction)[0] for p in beliefs] + if not min(distances) <= count <= max(distances): + return 'counted_wall_map_mismatch' + ends = [self.wall_distance(p, direction)[1] for p in beliefs] + if any(p != ends[0] for p in ends): + return 'wall_does_not_relocalize' + count = self.wall_distance(state.pose, direction)[0] + state.record(event['id'], 'wall_bounds', fewest=min(distances), most=max(distances)) + failure = self.advance(state, direction, count, event, signal, wall=bool(variant)) + if failure: + return failure + if signal and signal['encounter'] is not None: + raise ValueError('injected leg encounter was not reached') + target = event.get('declared_after') + if target and not any(state.pose['map'] == p['map'] and state.pose['tile'] == p['tile'] for p in self.authored_poses(target)): + return 'declared_arrival_disagrees_with_model' + state.declared_belief = [] + return None + + def accept(self, state, event, signal, result): + target = event.get('declared_after') + if (not signal or signal['screen'] != 'overworld' or signal['result'] != result + or not target or not self.matches_authored_pose(signal['pose'], target)): + state.pose, state.basis = None, 'unknown-after-observation' + return 'observation_missing_or_mismatched' + state.pose, state.basis = dict(signal['pose']), 'scripted-observation' + state.record(event['id'], 'observation', result=dict(result), declared_belief=target) + # Retain the native declared alternatives separately from this scripted physical pose. + state.declared_belief = self.authored_poses(target) + return None + + def door(self, state, event, signal, params): + direction = Direction[event.get('direction', 'north').upper()] + current, _ = self.validate_pose(state.pose) + state.action(event['id'], 'door', map=current.place.map, direction=direction.name.lower(), + to_map=event['declared_after']['map']) + for distance in range(self.max_route_moves): + result = self.press(current, direction) + if isinstance(result, Warp): + state.record(event['id'], 'doorway_reached', tile=list(result.tile), walked=distance) + return self.accept(state, event, signal, {'transition': True}) + move = as_move(direction, result) + if move is None or self.hazard(move.to, None): + return 'door_corridor_blocked_or_unresolved' + current = move.to + state.pose, state.basis = pose(current, direction), 'model-prediction' + return 'door_move_budget_exceeded' + + def talk(self, state, event, signal, params): + current, facing = self.validate_pose(state.pose) + view = self.world.views[current.place.map] + actors = [o for o in view.data.get('objects', []) if o['name'] == params['npc'] and not o.get('wander')] + if len(actors) != 1: + return 'dialogue_target_unbound' + target = Tile(actors[0]['x'], actors[0]['y']) + directions = [d for d in Direction if current.place.tile.step(d) == target] + if not directions: + return 'not_adjacent_to_dialogue_target' + if directions[0] != facing: + state.action(event['id'], 'turn', map=current.place.map, direction=directions[0].name.lower()) + state.pose = pose(current, directions[0]) + state.action(event['id'], 'talk', npc=params['npc']) + if not signal or signal['screen'] != 'dialogue' or signal['pose'] != state.pose or signal['result'] != {'npc': params['npc']}: + return 'dialogue_unconfirmed' + state.basis = 'scripted-observation' + return None + + def scene(self, state, event, signal, params): + state.action(event['id'], 'scene', capability='bdsp-scene-observation/1') + return self.accept(state, event, signal, {'departed': params['npc']}) + + def heal(self, state, event, signal, params): + state.action(event['id'], 'heal') + return self.accept(state, event, signal, {'party_healed': True}) diff --git a/src/pokesim/games/frlg/story_replay.py b/src/pokesim/games/frlg/story_replay.py index e9d18f2..92a0448 100644 --- a/src/pokesim/games/frlg/story_replay.py +++ b/src/pokesim/games/frlg/story_replay.py @@ -1,159 +1,10 @@ -"""FRLG route/step replay using the existing runtime world and movement rules. +"""FRLG capabilities for the shared runtime-world movement adapter.""" -Predictions are not console confirmations. Unknown transitions and encounters -stop with invalidated pose; no recovery or controller timing is invented. -""" +from pokesim.story.spatial_replay import RuntimeSpatialReplay -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 +class FRLGReplay(RuntimeSpatialReplay): + """Bind FRLG action names to shared movement with FRLG runtime rules.""" 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 index dee5a94..100274b 100644 --- a/src/pokesim/games/frlg/test_story_replay.py +++ b/src/pokesim/games/frlg/test_story_replay.py @@ -26,7 +26,7 @@ def test_semantic_legs_split_hops_and_warps_from_straight_walking(self): 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 + from pokesim.story.spatial_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), @@ -52,7 +52,7 @@ def test_execution_rejects_a_planner_endpoint_that_the_move_did_not_reach(self): # 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): + with patch('pokesim.story.spatial_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']) diff --git a/src/pokesim/games/test_bdsp_story_replay.py b/src/pokesim/games/test_bdsp_story_replay.py new file mode 100644 index 0000000..2d0c6ba --- /dev/null +++ b/src/pokesim/games/test_bdsp_story_replay.py @@ -0,0 +1,119 @@ +"""Current #226 mine checkpoint, pinned maps and explicit observation boundaries.""" + +import copy +import json +import unittest + +from pokesim.maps.runtime_map_pack import ROOT +from pokesim.story.authored_checkpoint import read_authored_checkpoint +from pokesim.story.checkpoint_replay import run +from pokesim.story.replay_binding import load_binding +from pokesim.story.replay_cases import read_cases, check_case, render_cases + +FOLDER = ROOT / 'data/story/bdsp-mine-pilot' + + +class BDSPStoryReplayTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.cases = read_cases(FOLDER / 'cases.json') + + def binding(self): + return load_binding(FOLDER / 'binding.json') + + def test_all_shared_scenarios(self): + binding = self.binding() + for case in self.cases: + with self.subTest(case=case['id']): + check_case(case, run(binding, case['input'])) + + def test_success_runs_every_authored_event_and_keeps_all_native_alternatives(self): + binding = self.binding() + report = run(binding, self.cases[0]['input']) + self.assertEqual(15, len(report['completed_events'])) + self.assertEqual([e['id'] for e in binding.checkpoint['events']], report['completed_events']) + bounds = next(t for t in report['trace'] if t['phase'] == 'wall_bounds') + self.assertEqual((37, 38), (bounds['fewest'], bounds['most'])) + self.assertIsNone(report['native_source_revision']) + self.assertFalse(report['save_performed']) + + def test_both_exit_tiles_relocalize_at_the_same_wall(self): + for y, tiles in ((59, 38), (58, 37)): + with self.subTest(exit_y=y): + scenario = copy.deepcopy(self.cases[0]['input']) + scenario['signals']['out_of_the_mine']['pose']['tile'] = [46, y] + report = run(self.binding(), scenario) + self.assertEqual('model_complete', report['status']) + action = next(a for a in report['actions'] if a['event'] == 'north_to_the_centers_wall') + self.assertEqual(tiles, action['tiles']) + step = next(t for t in report['trace'] if t['event'] == 'north_to_the_centers_wall' and t['phase'] == 'completed') + self.assertEqual([46, 21], step['pose']['tile']) + + def test_interruption_retains_requested_leg_and_two_uncredited_tiles(self): + case = next(c for c in self.cases if c['id'] == 'interrupted-before-roark') + report = run(self.binding(), case['input']) + self.assertEqual([5], [a['tiles'] for a in report['actions'] + if a['event'] == 'to_roarks_corner' and a.get('direction') == 'south' and a.get('tiles') == 5]) + self.assertEqual([[15, 12], [15, 13]], [p['tile'] for p in report['candidates']]) + self.assertTrue(all(p['facing'] is None for p in report['candidates'])) + self.assertNotIn('roark', report['completed_events']) + + def test_source_corridor_is_traversed_before_the_door_observation(self): + report = run(self.binding(), self.cases[0]['input']) + reached = {t['event']: t for t in report['trace'] if t['phase'] == 'doorway_reached'} + self.assertEqual([12, 21], reached['into_the_coal_room']['tile']) + self.assertGreater(reached['into_the_coal_room']['walked'], 1) + self.assertEqual([12, 1], reached['out_of_the_mine']['tile']) + + def test_shortened_leg_cannot_be_rescued_by_declared_after(self): + binding = self.binding() + event = next(e for e in binding.checkpoint['events'] if e['id'] == 'below_roark') + event['legs'][0]['tiles'] = 1 + report = run(binding, self.cases[0]['input']) + self.assertEqual('declared_arrival_disagrees_with_model', report['failure']) + self.assertNotIn('roark', report['completed_events']) + + def test_talk_requires_adjacency_not_just_a_requested_dialogue(self): + binding = self.binding() + actor = next(o for o in binding.adapter.world.views['OreburghMine'].data['objects'] if o['name'] == 'roark') + actor['x'] -= 1 # Synthetic actor diagnostic; map source/terrain remains unchanged. + report = run(binding, self.cases[0]['input']) + self.assertEqual('not_adjacent_to_dialogue_target', report['failure']) + + def test_invalid_observation_type_does_not_pass_as_true(self): + scenario = copy.deepcopy(self.cases[0]['input']) + scenario['signals']['into_the_mine']['result']['transition'] = 1 + with self.assertRaises(ValueError): + run(self.binding(), scenario) + + def test_clearance_actor_and_bound_profiles_are_strict(self): + binding = self.binding() + scenario = copy.deepcopy(self.cases[0]['input']) + scenario['signals']['above_the_mine']['clear_npcs'] = ['unknown'] + with self.assertRaises(ValueError): + run(binding, scenario) + for change in (lambda e: e.update(variant='one_short_ok'), + lambda e: e['legs'][0].update(tiles=-1)): + event = copy.deepcopy(binding.checkpoint['events'][4]) + change(event) + with self.assertRaises(ValueError): + binding.adapter.validate_event(event) + + def test_unreached_encounter_injection_cannot_silently_pass(self): + scenario = copy.deepcopy(next(c['input'] for c in self.cases if c['id'] == 'interrupted-before-roark')) + scenario['signals']['to_roarks_corner']['encounter']['direction'] = 'north' + with self.assertRaisesRegex(ValueError, 'not reached'): + run(self.binding(), scenario) + + def test_game_extensions_do_not_leak_between_games(self): + plan = json.loads((FOLDER / 'bdsp-mine-01.03.plan.json').read_text()) + for change in (lambda p: p['execution'].update(menu='full'), + lambda p: p['execution'].update(saves_in=''), + lambda p: p['execution'].update(on_failure='guess')): + altered = copy.deepcopy(plan) + change(altered) + with self.assertRaises(ValueError): + read_authored_checkpoint(altered) + + def test_review_document_is_generated_without_running_the_simulation(self): + self.assertEqual((FOLDER / 'CASES.md').read_text(), render_cases(self.cases)) diff --git a/src/pokesim/story/authored_checkpoint.py b/src/pokesim/story/authored_checkpoint.py index 7d4cef5..8003036 100644 --- a/src/pokesim/story/authored_checkpoint.py +++ b/src/pokesim/story/authored_checkpoint.py @@ -8,10 +8,17 @@ 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': + if value['format'] != 'pokesim-authored-checkpoint/1' or value['game'] not in ('frlg', 'bdsp'): 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') + if value['game'] == 'frlg': + execution = exact(value['execution'], 'menu') + if execution['menu'] not in ('before_pokemon', 'before_pokedex', 'full'): + raise ValueError('unsupported authored checkpoint menu') + else: + execution = exact(value['execution'], 'on_failure saves_in') + if (execution['on_failure'] not in ('reload_and_retry', 'stop') + or not isinstance(execution['saves_in'], str) + or (execution['on_failure'] == 'reload_and_retry') != bool(execution['saves_in'])): + raise ValueError('BDSP needs a save-card guard exactly when saving/retrying') read_checkpoint(value['checkpoint']) return copy.deepcopy(value) diff --git a/src/pokesim/story/checkpoint_replay.py b/src/pokesim/story/checkpoint_replay.py index d5f1263..ef565c3 100644 --- a/src/pokesim/story/checkpoint_replay.py +++ b/src/pokesim/story/checkpoint_replay.py @@ -43,7 +43,7 @@ def run(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'])): + or not binding.adapter.matches_authored_pose(state.pose, binding.checkpoint['start'])): failure = 'entry_requirement_failed' else: for event in binding.checkpoint['events']: diff --git a/src/pokesim/story/replay_binding.py b/src/pokesim/story/replay_binding.py index dc6b28e..231c4b3 100644 --- a/src/pokesim/story/replay_binding.py +++ b/src/pokesim/story/replay_binding.py @@ -18,6 +18,7 @@ from .checkpoint_wire import read_checkpoint from .replay_handlers import exact, resolve_action from pokesim.games.frlg.story_replay import FRLGReplay +from pokesim.games.bdsp_story_replay import BDSPReplay PROFILE = ROOT / 'data/story/parcel-pilot/binding.json' OFFLINE_POLICY = {'basis': 'synthetic-offline-policy', 'entry': 'scripted-observation', @@ -70,12 +71,20 @@ def load_binding(path=PROFILE, root=ROOT): 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') + game = capture['game'] 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'}} + if game == 'bdsp': + allowed = {'route': {'id', 'kind', 'why', 'target', 'variant'}, + 'legs': {'id', 'kind', 'why', 'legs', 'variant', 'declared_after'}, + 'step': {'id', 'kind', 'why', 'direction', 'declared_after'}, + 'talk': {'id', 'kind', 'why'}, + 'scene': {'id', 'kind', 'why', 'declared_after'}, + 'heal': {'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') @@ -90,14 +99,20 @@ def load_binding(path=PROFILE, root=ROOT): 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') + maps = [LocalMap(read_json(read_pinned_map(config['map_revision'], game, name, digest, root)), game) for name, digest in config['maps'].items()] world = RuntimeWorld(maps) - adapter = FRLGReplay(world, Costs(**costs), budget) + adapter = (BDSPReplay if game == 'bdsp' else FRLGReplay)(world, Costs(**costs), budget) + if game == 'bdsp': + for event in checkpoint['events']: + adapter.validate_event(event) 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) + if game == 'bdsp': + adapter.authored_poses(pose) + else: + 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 index 984425f..54567f3 100644 --- a/src/pokesim/story/replay_cases.py +++ b/src/pokesim/story/replay_cases.py @@ -9,6 +9,7 @@ """ import argparse +import json from itertools import zip_longest from pathlib import Path @@ -30,6 +31,16 @@ def validate_action(action): 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 == 'door': + exact(action, 'event kind map direction to_map') + if not isinstance(action['to_map'], str) or not action['to_map']: + raise ValueError('door needs a destination map') + elif kind == 'talk': + exact(action, 'event kind npc') + if not isinstance(action['npc'], str) or not action['npc']: + raise ValueError('talk needs a named NPC') + elif kind == 'heal': + exact(action, 'event kind') elif kind == 'turn': exact(action, 'event kind map direction') elif kind == 'scene': @@ -40,7 +51,7 @@ def validate_action(action): 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'] + if kind not in ('scene', 'talk', 'heal') 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') @@ -117,6 +128,12 @@ def action_text(action): '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'] == 'door': + return 'Follow the {} corridor {} through the door to {}.'.format(action['map'], action['direction'], action['to_map']) + if action['kind'] == 'talk': + return 'Talk to {}.'.format(action['npc']) + if action['kind'] == 'heal': + return 'Heal at the Pokemon Center and return outside.' if action['kind'] == 'turn': return 'Face {} on {}'.format(action['direction'], action['map']) return 'Request scene via ' + action['capability'] @@ -142,6 +159,12 @@ def render_cases(cases): if signal['kind'] == 'scene-observation': lines += ['- `{}`: {} scene result at {}; received: {}.'.format( event, signal['screen'], place(signal['pose']), receipts(signal['received']))] + elif signal['kind'] == 'bdsp-observation': + lines += ['- `{}`: scripted {} at {}; result: `{}`.'.format( + event, signal['screen'], place(signal['pose']), json.dumps(signal['result']))] + elif signal['kind'] == 'movement-script': + lines += ['- `{}`: synthetic clearance window for {}; encounter: `{}`.'.format( + event, ', '.join(signal['clear_npcs']) or 'no wandering NPCs', json.dumps(signal['encounter']))] elif signal['kind'] == 'wild-encounter': lines += ['- `{}`: encounter interrupts a {}ward walk from {}.'.format(event, signal['direction'], place(signal['from']))] else: diff --git a/src/pokesim/story/replay_handlers.py b/src/pokesim/story/replay_handlers.py index ba7da9b..5430828 100644 --- a/src/pokesim/story/replay_handlers.py +++ b/src/pokesim/story/replay_handlers.py @@ -24,6 +24,10 @@ 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'] + if hasattr(adapter, 'bind_action'): + bound = adapter.bind_action(kind, spec) + if bound is not None: + return bound methods = adapter.handlers() if capability in methods: supported_kind, preflight, execute = methods[capability] diff --git a/src/pokesim/story/replay_state.py b/src/pokesim/story/replay_state.py index a028f26..ef07300 100644 --- a/src/pokesim/story/replay_state.py +++ b/src/pokesim/story/replay_state.py @@ -10,6 +10,8 @@ class ReplayState: basis: str = 'scripted-observation' received: dict = field(default_factory=dict) candidates: list = field(default_factory=list) + # Native author-declared fallback, kept separate from physical pose uncertainty. + declared_belief: list = field(default_factory=list) trace: list = field(default_factory=list) actions: list = field(default_factory=list) diff --git a/src/pokesim/story/spatial_replay.py b/src/pokesim/story/spatial_replay.py new file mode 100644 index 0000000..ec335b6 --- /dev/null +++ b/src/pokesim/story/spatial_replay.py @@ -0,0 +1,158 @@ +"""Tile-game 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 .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 RuntimeSpatialReplay: + """Spatial handlers driven by the supplied runtime world and movement rules.""" + def __init__(self, world, costs, max_route_moves): + self.world, self.costs, self.max_route_moves = world, costs, max_route_moves + + 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 matches_authored_pose(self, actual, wire): + return actual == self.authored_pose(wire) + + 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