diff --git a/.github/workflows/cpp-ci-serial-programs-base.yml b/.github/workflows/cpp-ci-serial-programs-base.yml index c27db9594d..1601ddaa9a 100644 --- a/.github/workflows/cpp-ci-serial-programs-base.yml +++ b/.github/workflows/cpp-ci-serial-programs-base.yml @@ -101,6 +101,50 @@ jobs: operation: prepare consumer-directory: Arduino-Source + - name: Checkout preview replay fixture producer (not a resource adoption) + if: startsWith(inputs.os, 'ubuntu') && inputs.run-tests + uses: actions/checkout@v7 + with: + repository: dayfine/PokemonSimulation + ref: 9cb12c52f5f055299e32c2a7cf43e7103c101e3e + path: StoryReplaySource + + - name: Verify replay preview bytes and independently regenerate its route input + if: startsWith(inputs.os, 'ubuntu') && inputs.run-tests + env: + PYTHONPATH: StoryReplaySource/src + run: | + python3 - <<'PYTHON' + import hashlib, json, subprocess + from pathlib import Path + from pokesim.story.replay_binding import load_binding + from pokesim.story.checkpoint_replay import run + source = Path('StoryReplaySource') + native = Path('Arduino-Source/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory') + folder = native / 'ReplayData' + pin = json.loads((folder / 'pin.json').read_text()) + revision = subprocess.check_output(['git', '-C', str(source), 'rev-parse', 'HEAD']).decode().strip() + assert pin['status'] == 'preview-unmerged-PR-73' and revision == pin['revision'] + for name, entry in pin['files'].items(): + published = (source / entry['path']).read_bytes() + copied = (native / 'StoryData/frlg-parcel-00.05.json' if name == 'capture' else folder / (name + '.json')).read_bytes() + assert hashlib.sha256(published).hexdigest() == entry['sha256'], name + assert copied == published, name + binding = load_binding(source / pin['files']['binding']['path'], source) + cases = json.loads((source / pin['files']['cases']['path']).read_text())['cases'] + scenario = next(c['input'] for c in cases if c['id'] == 'parcel-all-events') + actual = run(binding, scenario) # No expectations are consulted to generate the candidate. + assert actual['status'] == 'model_complete' + candidate = {'format': 'pokesim-route-candidate/1', 'binding_sha256': binding.profile_sha256, + 'checkpoint': binding.checkpoint['id'], 'routes': { + e['id']: [a for a in actual['actions'] if a['event'] == e['id']] + for e in binding.checkpoint['events'] if e['kind'] == 'route'}} + raw = (folder / 'route-candidate.json').read_bytes() + assert hashlib.sha256(raw).hexdigest() == pin['candidate']['sha256'] + assert json.loads(raw) == candidate + print('Preview source bytes and independently generated candidate match.') + PYTHON + - name: Checkout authored checkpoint simulator if: startsWith(inputs.os, 'ubuntu') && inputs.run-tests uses: actions/checkout@v7 @@ -253,7 +297,7 @@ jobs: cd Arduino-Source/SerialPrograms mkdir bin cd bin - cmake .. -DQT_MAJOR:STRING=6 -DPA_FRLG_STORY_EXPORT=ON ${{env.CMAKE_ADDITIONAL_FLAGS}} + cmake .. -DQT_MAJOR:STRING=6 -DPA_FRLG_STORY_EXPORT=ON -DPA_FRLG_STORY_REPLAY=ON ${{env.CMAKE_ADDITIONAL_FLAGS}} # By default Ninja reads each compile's .d dependency file into its own database, then deletes it. # To keep them for the downstream clang-query cache (see .github/scripts/clang-query-cached.py), add # -d keepdepfile @@ -279,6 +323,49 @@ jobs: name: frlg-checkpoint-${{inputs.os}}-${{inputs.compiler}} path: Arduino-Source/SerialPrograms/bin/frlg-parcel.native.json + - name: Run compiled native replay and compare shared semantic scenarios (Ubuntu) + if: startsWith(inputs.os, 'ubuntu') && inputs.run-tests + env: + PYTHONPATH: StoryReplaySource/src + run: | + Arduino-Source/SerialPrograms/bin/StoryCheckpointReplay > Arduino-Source/SerialPrograms/bin/frlg-parcel.replay.json + python3 - <<'PYTHON' + import json, subprocess + from pathlib import Path + from pokesim.story.replay_binding import load_binding + from pokesim.story.checkpoint_replay import run + from pokesim.story.replay_cases import read_cases, check_case + source = Path('StoryReplaySource') + data = json.loads(Path('Arduino-Source/SerialPrograms/bin/frlg-parcel.replay.json').read_text()) + revision = subprocess.check_output(['git', '-C', 'Arduino-Source', 'rev-parse', 'HEAD']).decode().strip() + assert data['native_execution_revision'] == revision + cases = read_cases(source / 'data/story/parcel-pilot/cases.json') + reports = {item['id']: item['report'] for item in data['reports']} + assert len(reports) == len(data['reports']) == len(cases) + assert set(reports) == {case['id'] for case in cases} + binding = load_binding(source / 'data/story/parcel-pilot/binding.json', source) + for case in cases: + native = reports[case['id']] + python = run(binding, case['input']) + check_case(case, native) + check_case(case, python) + for field in case['expectation']: + assert native[field] == python[field], (case['id'], field) + if case['id'] == 'parcel-all-events': + control = native['native_timing_controls']['below_mart_door'] + assert control['status'] == 'complete', control + assert any('from_save' in line for line in native['engine_log']) + print(case['id'] + ': shared diagnostic outcomes/actions match; live executor parity remains unclaimed.') + PYTHON + + - name: Upload native replay trace + if: always() && startsWith(inputs.os, 'ubuntu') && inputs.run-tests + uses: actions/upload-artifact@v7 + with: + name: frlg-native-replay-${{inputs.os}}-${{inputs.compiler}} + path: Arduino-Source/SerialPrograms/bin/frlg-parcel.replay.json + if-no-files-found: ignore + - name: Prepare upload build if: inputs.upload-build shell: bash diff --git a/Common/Cpp/Json/JsonTools.cpp b/Common/Cpp/Json/JsonTools.cpp index 24313a92c7..b1cb5beccc 100644 --- a/Common/Cpp/Json/JsonTools.cpp +++ b/Common/Cpp/Json/JsonTools.cpp @@ -84,7 +84,7 @@ nlohmann::json to_nlohmann(const JsonValue& json){ return ret; } if (json.is_object()){ - nlohmann::json ret; + nlohmann::json::object_t ret; for (const auto& item : *json.to_object()){ ret[item.first] = to_nlohmann(item.second); } diff --git a/Common/Cpp/Json/JsonTools_Tests.cpp b/Common/Cpp/Json/JsonTools_Tests.cpp new file mode 100644 index 0000000000..f548ac8a34 --- /dev/null +++ b/Common/Cpp/Json/JsonTools_Tests.cpp @@ -0,0 +1,77 @@ +/* JSON Tools Tests + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" +#include "JsonValue.h" +#include "JsonTools_Tests.h" + +namespace PokemonAutomation{ +namespace{ + + + +struct DumpInput{ + std::string_view name; + std::string_view text; +}; +struct DumpExpectation{ + std::string_view dumped; +}; +struct DumpCase{ + DumpInput input; + DumpExpectation expectation; +}; + +// Empty containers must keep their type through dump() and parse_json(). +const DumpCase CASES[] = { + {.input = {.name = "Empty object", .text = "{}"}, .expectation = {.dumped = "{}"}}, + {.input = {.name = "Nested empty object", .text = R"({"object":{}})"}, .expectation = {.dumped = R"({"object":{}})"}}, + {.input = {.name = "Nested empty array", .text = R"({"array":[]})"}, .expectation = {.dumped = R"({"array":[]})"}}, +}; + + + +class Test_JsonTools_DumpRoundTrip : public UnitTest{ +public: + explicit Test_JsonTools_DumpRoundTrip(DumpCase test) + : UnitTest(std::format("Common::JsonTools - {} round trip", test.input.name)) + , m_test(test) + {} + + virtual UnitTestResult run(Logger& logger, CancellableScope& scope) const override{ + const std::string dumped = parse_json(std::string(m_test.input.text)).dump(-1); + if (dumped != m_test.expectation.dumped){ + return UnitTestResult(std::format("dump: expected {}, got {}", m_test.expectation.dumped, dumped)); + } + const std::string again = parse_json(dumped).dump(-1); + if (again != dumped){ + return UnitTestResult(std::format("dump after reparse: expected {}, got {}", dumped, again)); + } + return true; + } + +private: + DumpCase m_test; +}; + + + +} // namespace + + + +void add_tests_JsonTools(UnitTestDatabase& database){ + for (const DumpCase& test : CASES){ + database.add(test); + } +} + + + +} // namespace PokemonAutomation diff --git a/Common/Cpp/Json/JsonTools_Tests.h b/Common/Cpp/Json/JsonTools_Tests.h new file mode 100644 index 0000000000..76d61d4b7c --- /dev/null +++ b/Common/Cpp/Json/JsonTools_Tests.h @@ -0,0 +1,22 @@ +/* JSON Tools Tests + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#ifndef PokemonAutomation_JsonTools_Tests_H +#define PokemonAutomation_JsonTools_Tests_H + +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" + +namespace PokemonAutomation{ + + + +// Register the JSON dump/parse round-trip tests. +void add_tests_JsonTools(UnitTestDatabase& database); + + + +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/CMakeLists.txt b/SerialPrograms/CMakeLists.txt index bd7c93a134..eb78e63e6a 100644 --- a/SerialPrograms/CMakeLists.txt +++ b/SerialPrograms/CMakeLists.txt @@ -235,6 +235,8 @@ pa_embed_text_files(SerialProgramsLib Source/VideoGames/PokemonFRLG/Programs/Aut VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryBattleData.h PokemonAutomation::NintendoSwitch::PokemonFRLG autostory_battle_json) pa_embed_text_files(SerialProgramsLib Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData VideoGames/PokemonFRLG/Programs/AutoStory/Data/JsonParser.h PokemonAutomation::NintendoSwitch::PokemonFRLG autostory_checkpoint_json) +pa_embed_text_files(SerialProgramsLib Source/VideoGames/PokemonFRLG/Programs/AutoStory/ReplayData + VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay.h PokemonAutomation::NintendoSwitch::PokemonFRLG story_replay_fixture_json) # Create binary target @@ -930,3 +932,5 @@ if(PA_PYTHON_BINDINGS) # Python bindings (GUI-free) from subdirectory include(Source/PythonBindings/PythonBindings.cmake) endif() + +include(../devtools/story-replay/FRLG.cmake) diff --git a/SerialPrograms/Source/ComputerPrograms/ComputerPrograms_UnitTestRunner.cpp b/SerialPrograms/Source/ComputerPrograms/ComputerPrograms_UnitTestRunner.cpp index 2f878a6551..b4f5041241 100644 --- a/SerialPrograms/Source/ComputerPrograms/ComputerPrograms_UnitTestRunner.cpp +++ b/SerialPrograms/Source/ComputerPrograms/ComputerPrograms_UnitTestRunner.cpp @@ -4,6 +4,7 @@ * */ +#include "Common/Cpp/Json/JsonTools_Tests.h" #include "Common/Cpp/ListenerSet.h" #include "Common/Cpp/MoveOnlyFunction_Tests.h" #include "Common/Cpp/ScopeExit.h" @@ -60,6 +61,7 @@ UnitTestDatabase make_UNIT_TESTS_ALL(){ add_tests_Sockets(ret, GlobalThreadPools::unlimited_normal()); add_tests_BlackBorderDetector(ret); add_tests_ListenerSet(ret); + add_tests_JsonTools(ret); add_tests_MoveOnlyFunction(ret); SysbotBase::add_tests_SysbotBasePing(ret); SysbotBase::add_tests_SysbotBaseReceiveLine(ret); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp index 47158d6cf9..8556442dd0 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp @@ -20,34 +20,6 @@ namespace NintendoSwitch{ namespace PokemonFRLG{ -std::string to_string(const MapPosition& position){ - return position.map + " " + to_string(position.point); -} - -std::string to_string(const RouteStep& step){ - std::string text; - switch (step.action){ - case RouteAction::WALK: - text = std::format("walk {} {}", direction_name(step.direction), step.tiles); - if (step.warps){ - text += " (warp)"; - } - if (step.to_wall){ - text += " (to wall)"; - } - break; - case RouteAction::SURF: - text = "surf " + std::string(direction_name(step.direction)); - break; - case RouteAction::FACE: - text = "face " + std::string(direction_name(step.direction)); - break; - } - return text + " -> " + to_string(step.end); -} - - - const RouteCosts& default_route_costs(){ static const RouteCosts COSTS{ {RouteCost::TILE, 1}, @@ -372,6 +344,29 @@ RouteStep to_route_step(const std::vector& maps, const Pokemon::Leg predict_overworld_press( + const std::vector& maps, const OverworldPose& from, Direction direction +){ + const int map = find_map(maps, from.position.map); + if (map < 0){ + return std::nullopt; + } + const Located start{map, {from.position.point, from.elevation, from.surfing}}; + const auto next = press(maps, start, direction); + if (!next){ + return std::nullopt; + } + return OverworldPress{ + .pose = { + .position = {maps[next->next.map].name, next->next.state.position}, + .elevation = next->next.state.elevation, + .surfing = next->next.state.surfing, + .facing = next->walked_out ? Direction::SOUTH : direction, + }, + .warped = next->warped, + }; +} + std::optional> plan_overworld_route( const std::vector& maps, const MapPosition& start, diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h index 7d03dedf95..82a9d65676 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h @@ -43,6 +43,23 @@ enum class RouteAction{ FACE, // turn to `direction` without moving }; +// A stationary prediction in the map's runtime-local tile/elevation frame. +struct OverworldPose{ + MapPosition position; + uint8_t elevation = ELEVATION_GROUND; + bool surfing = false; + Direction facing = Direction::NORTH; +}; +// One transition from the production planner's own collision/warp implementation. +struct OverworldPress{ + OverworldPose pose; + bool warped = false; +}; +// A model prediction only; nullopt means no known move, never confirmed localization. +std::optional predict_overworld_press( + const std::vector& maps, const OverworldPose& from, Direction direction +); + // A single step along an overworld path. struct RouteStep{ RouteAction action = RouteAction::WALK; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRouteStrings.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRouteStrings.cpp new file mode 100644 index 0000000000..4c082f2c76 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRouteStrings.cpp @@ -0,0 +1,39 @@ +/* Overworld Route Formatting + * From: https://github.com/PokemonAutomation/ + */ +#include +#include "PokemonFRLG_OverworldRoute.h" +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ +std::string to_string(const MapPosition& position){ + return position.map + " " + to_string(position.point); +} + +std::string to_string(const RouteStep& step){ + std::string text; + switch (step.action){ + case RouteAction::WALK: + text = std::format("walk {} {}", direction_name(step.direction), step.tiles); + if (step.warps){ + text += " (warp)"; + } + if (step.to_wall){ + text += " (to wall)"; + } + break; + case RouteAction::SURF: + text = "surf " + std::string(direction_name(step.direction)); + break; + case RouteAction::FACE: + text = "face " + std::string(direction_name(step.direction)); + break; + } + return text + " -> " + to_string(step.end); +} + + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/Json_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/Json_Tests.cpp index 2b4715df94..0e712607a2 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/Json_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/Json_Tests.cpp @@ -9,6 +9,7 @@ #include "JsonParser.h" #include "JsonWriter.h" #include "VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.h" +#include "VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay.h" #include "Json_Tests.h" namespace PokemonAutomation{ @@ -269,6 +270,7 @@ class LoadTest : public UnitTest{ } // namespace void add_tests_AutoStoryJson(UnitTestDatabase& database){ + add_tests_StoryReplay(database); for (const ExportCase& test : CASES){ database.add(test); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay.cpp new file mode 100644 index 0000000000..c9b9fe14d6 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay.cpp @@ -0,0 +1,43 @@ +/* Native Offline Story Replay + * From: https://github.com/PokemonAutomation/ + */ +#include +#include "Common/Cpp/Exceptions.h" +#include "Pokemon/AutoStory/Pokemon_StoryEvents.h" +#include "PokemonFRLG_StoryReplay_Executor.h" +#include "PokemonFRLG_StoryReplay.h" +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ +const std::string& story_replay_fixture(std::string_view name){ + for (const auto& [file, text] : story_replay_fixture_json()){ + if (name == file){ + return text; + } + } + if (name == "capture"){ + for (const auto& [file, text] : autostory_checkpoint_json()){ + if (file == "frlg-parcel-00.05"){ + return text; + } + } + } + throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, std::format("Unknown replay fixture {}", name)); +} +JsonValue replay_story_checkpoint(std::string_view capture, std::string_view binding, + std::string_view candidate, const JsonValue& scenario){ + const auto data = StoryReplayInternal::read_data(capture, binding, candidate); + StoryReplayInternal::validate_scenario(data, scenario); + StoryReplayInternal::ReplayExecutor executor(data, scenario); + Pokemon::Checkpoint checkpoint = executor.build(); + const auto entry = StoryReplayInternal::read_pose(scenario.to_object_throw().get_object_throw("entry").get_value_throw("pose"), data.maps); + Pokemon::RunState state; + state.pose = Pokemon::tile_pose(entry.position.map, {entry.position.point}, entry.facing); + JsonArray logs; + Pokemon::EventLogger logger = [&](const std::string& line){ logs.push_back(line); }; + const Pokemon::CheckpointReport result = Pokemon::run_checkpoint(state, checkpoint, logger); + return executor.report(result, state, logs); +} +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay.h new file mode 100644 index 0000000000..273aea3d3b --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay.h @@ -0,0 +1,29 @@ +/* Offline FRLG Story Replay + * From: https://github.com/PokemonAutomation/ + */ +#ifndef PokemonAutomation_PokemonFRLG_StoryReplay_H +#define PokemonAutomation_PokemonFRLG_StoryReplay_H +#include +#include +#include +#include +#include "Common/Cpp/Json/JsonValue.h" +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ +// Execute a captured row with the shared engine/run_route and scripted IO. +// The supplied route is a Python candidate, never the scenario's expectation. +// No console/profile/controller is constructed. Invalid bindings throw ParseException. +JsonValue replay_story_checkpoint(std::string_view capture, std::string_view binding, + std::string_view candidate, const JsonValue& scenario); +// Test-only preview resources, embedded from ReplayData/*.json with source pins. +const std::vector>& story_replay_fixture_json(); +// Find one embedded preview resource, or fail for an unknown fixture name. +const std::string& story_replay_fixture(std::string_view name); +// Register adjacent native replay/conformance and negative-control tests. +void add_tests_StoryReplay(UnitTestDatabase& database); +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_Executor.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_Executor.cpp new file mode 100644 index 0000000000..f10f353d50 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_Executor.cpp @@ -0,0 +1,372 @@ +/* Native Checkpoint Engine with Scripted Game IO + * From: https://github.com/PokemonAutomation/ + */ +#include +#include +#include "Common/Cpp/Exceptions.h" +#include "Pokemon/AutoStory/Pokemon_StoryStepJson.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h" +#include "PokemonFRLG_StoryReplay_Executor.h" +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ +namespace StoryReplayInternal{ +namespace{ +struct ReplayStopped{}; +Pokemon::Pose native_pose(const OverworldPose& pose){ + return Pokemon::tile_pose(pose.position.map, {pose.position.point}, pose.facing); +} +JsonValue action_json(const StoryStep& event, const OverworldPose& from, const OverworldPress& next){ + JsonObject action; + action["event"] = event.name; + action["map"] = from.position.map; + action["direction"] = direction_name(event.direction); + if (next.warped || next.pose.position.map != from.position.map){ + action["kind"] = "transition"; + action["to_map"] = next.pose.position.map; + action["via"] = next.warped ? "warp" : "edge"; + }else{ + action["kind"] = "walk"; + action["tiles"] = 1; + } + return action; +} +void check_candidate_move(const JsonObject& command, const OverworldPose& from, const OverworldPress& next){ + const std::string kind = command.get_string_throw("kind"); + if (kind == "walk"){ + if (next.warped || next.pose.position.map != from.position.map){ + throw ParseException("story replay: walk crossed an undeclared map transition"); + } + }else if (command.get_string_throw("to_map") != next.pose.position.map + || command.get_string_throw("via") != (next.warped ? "warp" : "edge")){ + throw ParseException("story replay: supplied transition disagrees with native rules"); + } +} +} // namespace +ReplayExecutor::ReplayExecutor(const ReplayData& data, const JsonValue& scenario) + : m_data(&data), m_scenario(&scenario.to_object_throw()) + , m_pose(read_pose(m_scenario->get_object_throw("entry").get_value_throw("pose"), data.maps)) + , m_table(make_story_action_table([this](StoryActionId id){ return replay_action(id); })) + , m_executor(m_table, m_guard) + , m_interrupts(step_interrupts([this](bool){ return look(); }, interrupt_actions())) +{ + m_executor.set_interrupts(&m_interrupts); +} +bool ReplayStepGuard::stops_on_failure(const StoryStep&) const{ + return true; +} +Pokemon::StoryAction ReplayExecutor::replay_action(StoryActionId id){ + switch (id){ + case StoryActionId::ROUTE: + return {.act = [this](Pokemon::RunState&, const StoryStep& step){ return replay_route(step); }}; + case StoryActionId::STEP: + return {.act = [this](Pokemon::RunState&, const StoryStep& step){ return replay_step(step); }}; + case StoryActionId::MASH: + return {.act = [this](Pokemon::RunState&, const StoryStep& step){ return replay_scene(step); }}; + default: + return {.act = [](Pokemon::RunState&, const StoryStep& step){ return Pokemon::unsupported_step(step); }}; + } +} +const JsonObject* ReplayExecutor::signal() const{ + return m_scenario->get_object_throw("signals").get_object(m_active); +} +const JsonObject& ReplayExecutor::parameters(const StoryStep& step) const{ + return m_data->binding.to_object_throw().get_object_throw("capabilities").get_object_throw(step.name).get_object_throw("parameters"); +} +Pokemon::Checkpoint ReplayExecutor::build(){ + Pokemon::Checkpoint checkpoint = Pokemon::build_story_checkpoint(m_data->row, *this); + checkpoint.start.push_back(Pokemon::condition("scripted_entry", [this](Pokemon::RunState&){ + const auto expected = authored_pose(*m_data->row.start, m_data->maps); + return m_scenario->get_object_throw("entry").get_string_throw("screen") == "overworld" + && pose_json(m_pose, m_pose.facing) == pose_json(expected, expected.facing); + })); + return checkpoint; +} +Pokemon::Outcome ReplayExecutor::run(Pokemon::RunState& state, const StoryStep& step){ + m_active = step.name; + Pokemon::Outcome outcome = m_executor.run(state, step); + if (outcome.succeeded()){ + m_completed.push_back(step.name); + }else if (!m_known){ + state.pose = Pokemon::Pose{}; + } + return outcome; +} +void ReplayExecutor::lose_pose(std::string basis){ + m_known = false; + m_basis = std::move(basis); +} +Pokemon::ScreenLook ReplayExecutor::look(){ + const WallClock start = WallClock() + std::chrono::hours(1); + Pokemon::ScreenLook out; + out.log.record({}, start); + ReplayScreen screen{{}, 400}; + const JsonObject* between = m_scenario->get_object("between_steps"); + const JsonObject* shown = between ? between->get_object(m_active) : nullptr; + if (shown && m_shown.insert(m_active).second){ + screen = between_steps_screen(shown->get_string_throw("kind")); + } + for (const auto& [ms, events] : screen.frames){ + out.log.record(events, start + Milliseconds(ms)); + } + out.window = {.start = start, .end = start + Milliseconds(screen.look_ms)}; + return out; +} +StepInterruptActions ReplayExecutor::interrupt_actions(){ + const auto recorded = [this](bool warp){ + return [this, warp](Pokemon::RunState&, const Pokemon::InterruptVerdict& verdict){ + JsonObject action; + action["event"] = m_active; + action["kind"] = "interrupt"; + action["interrupt"] = Pokemon::interrupt_kind_name(verdict.kind); + m_actions.push_back(std::move(action)); + if (warp){ + lose_pose("unknown-after-warp"); + } + return Pokemon::outcome_ok(); + }; + }; + return {.wild = recorded(false), .trainer = recorded(false), .dialog = recorded(false), .warp = recorded(true)}; +} +void ReplayExecutor::observe_interruption(const OverworldPose& predicted){ + m_candidates.push_back(pose_json(m_pose, std::nullopt)); + m_candidates.push_back(pose_json(predicted, std::nullopt)); + m_interrupted = true; + lose_pose("unknown-after-interruption"); +} +ReplayLeg ReplayExecutor::prepare_leg(const JsonValue& value, OverworldPose& current) const{ + const JsonObject& command = value.to_object_throw(); + const std::string kind = command.get_string_throw("kind"); + if (kind == "walk"){ + fields(value, {"event", "kind", "map", "direction", "tiles"}); + }else if (kind == "transition"){ + fields(value, {"event", "kind", "map", "direction", "to_map", "via"}); + }else if (kind == "turn"){ + fields(value, {"event", "kind", "map", "direction"}); + }else{ + throw ParseException("story replay: unsupported supplied movement (including hops)"); + } + if (command.get_string_throw("event") != m_active || command.get_string_throw("map") != current.position.map){ + throw ParseException("story replay: supplied route has wrong event/map"); + } + const Direction facing = direction(command.get_string_throw("direction")); + const int64_t tiles = kind == "walk" ? command.get_integer_throw("tiles") : kind == "turn" ? 0 : 1; + if (tiles < (kind == "turn" ? 0 : 1) || tiles > 4096){ + throw ParseException("story replay: invalid supplied leg length"); + } + RouteStep step{.action = kind == "turn" ? RouteAction::FACE : RouteAction::WALK, .direction = facing, .tiles = (int)tiles}; + for (int64_t n = 0; n < tiles; n++){ + const auto next = predict_overworld_press(m_data->maps, current, facing); + if (!next){ + throw ParseException("story replay: native model rejects supplied route"); + } + check_candidate_move(command, current, *next); + if (!next->warped && next->pose.position.map != current.position.map){ + step.connection_steps.push_back((int)n + 1); + } + step.walk_only = step.walk_only || !map_named(m_data->maps, current.position.map).allow_running; + current = next->pose; + step.warps = step.warps || next->warped; + step.wild = step.wild || is_tall_grass(map_named(m_data->maps, current.position.map), current.position.point); + } + if (kind == "turn"){ + current.facing = facing; + } + step.end = current.position; + step.facing_after = current.facing; + return ReplayLeg{.step = step, .action = value.clone()}; +} +std::vector ReplayExecutor::prepare_route(const StoryStep& step){ + const JsonArray& commands = m_data->candidate.to_object_throw().get_object_throw("routes").get_array_throw(step.name); + if (commands.size() > 4096){ + throw ParseException("story replay: supplied route exceeds action bound"); + } + OverworldPose current = m_pose; + std::vector route; + m_legs.clear(); + for (const JsonValue& command : commands){ + m_legs.emplace_back(prepare_leg(command, current)); + route.push_back(m_legs.back().step); + } + const OverworldPose goal = authored_pose(*step.target, m_data->maps); + if (!(pose_json(current, current.facing) == pose_json(goal, goal.facing))){ + throw ParseException("story replay: supplied candidate misses its authored target"); + } + return route; +} +void ReplayExecutor::default_plan(const StoryStep& step){ + const OverworldPose goal = authored_pose(*step.target, m_data->maps); + const auto planned = plan_overworld_route(m_data->maps, m_pose.position, {goal.position, goal.facing}, story_route_costs(), m_pose.facing); + JsonObject plan; + plan["policy"] = "native-story-route-costs"; + plan["exact_python_leg_comparison"] = "not-comparable-different-planner-policies"; + JsonArray legs; + if (planned){ + const MapPosition actual = follow_overworld_route(m_data->maps, m_pose.position, *planned); + if (!(actual == goal.position)){ + throw ParseException("story replay: native default plan did not reach target"); + } + for (const RouteStep& leg : *planned){ + legs.push_back(to_string(leg)); + } + } + plan["status"] = planned ? "planned-and-replayed" : "no-route"; + plan["legs"] = std::move(legs); + m_native_plans[step.name] = std::move(plan); +} +void ReplayExecutor::timed_plan(const StoryStep& step, const std::vector& route){ + // Independent control: real simulated holds, fades and connection stalls. + // Scripted uncertain encounters below are not claimed as timed observations. + if (signal()){ + return; + } + OverworldSim sim(m_data->maps, m_pose.position, m_pose.facing); + const RoutePlanning planning{.maps = &m_data->maps, .start = m_pose.position, + .label = step.name, .wall_check = WallCheck::SKIP}; + const std::string failure = OverworldSimWalker(sim).run(route, planning); + const auto goal = authored_pose(*step.target, m_data->maps); + const bool matched = failure.empty() && sim.state().position == goal.position + && sim.state().facing == goal.facing && sim.state().violations.empty(); + JsonObject result; + result["status"] = matched ? "complete" : "failed"; + result["profile"] = "native-OverworldSimWalker-no-interruption-control"; + result["failure"] = failure; + result["position"] = to_string(sim.state().position); + result["facing"] = direction_name(sim.state().facing); + JsonArray violations; + for (const std::string& violation : sim.state().violations){ + violations.push_back(violation); + } + result["violations"] = std::move(violations); + m_timed_plans[step.name] = std::move(result); +} +StepOutcome ReplayExecutor::walk_leg(const RouteStep& step){ + m_actions.push_back(m_legs[m_next_leg++].action.clone()); + const OverworldPose start = m_pose; + for (int n = 0; n < step.tiles; n++){ + const auto next = predict_overworld_press(m_data->maps, m_pose, step.direction); + if (!next){ + throw ParseException("story replay: supplied walk became blocked"); + } + const JsonObject* s = signal(); + if (s && pose_json(m_pose, m_pose.facing) == s->get_value_throw("from") + && s->get_string_throw("direction") == direction_name(step.direction)){ + observe_interruption(next->pose); + return StepOutcome{.end = StepEnd::STOPPED}; + } + m_pose = next->pose; + m_moves++; + } + m_pose.facing = step.facing_after; + const MapPosition followed = follow_overworld_route(m_data->maps, start.position, {step}); + if (!(followed == m_pose.position)){ + throw ParseException("story replay: walker and independent native route replay disagree"); + } + m_basis = "model-prediction"; + return StepOutcome{}; +} +Pokemon::Outcome ReplayExecutor::replay_route(const StoryStep& step){ + default_plan(step); + const std::vector route = prepare_route(step); + size_t moves = 0; + for (const RouteStep& leg : route){ + moves += (size_t)leg.tiles; + } + if (moves > (size_t)m_data->binding.to_object_throw().get_integer_throw("max_route_moves")){ + return Pokemon::outcome_failed("route_move_budget_exceeded"); + } + timed_plan(step, route); + m_next_leg = 0; + RouteCalls calls; + calls.walk = [this](const RouteStep& leg, std::optional){ return walk_leg(leg); }; + calls.log = [this](const std::string& line){ m_route_logs.push_back(line); }; + calls.fail = [](const std::string&){ throw ReplayStopped{}; }; + calls.hold_to_warp = [](const RouteStep&){ throw ReplayStopped{}; }; + try{ + run_route(route, m_pose.facing, nullptr, calls); + }catch (const ReplayStopped&){ + return Pokemon::outcome_failed(m_interrupted ? "encounter_requires_recovery" : "native_route_stopped"); + } + if (signal() && !m_interrupted){ + throw ParseException("story replay: physical encounter injection was not reached"); + } + return Pokemon::outcome_ok(native_pose(m_pose)); +} +Pokemon::Outcome ReplayExecutor::replay_step(const StoryStep& step){ + const auto next = predict_overworld_press(m_data->maps, m_pose, step.direction); + if (!next){ + lose_pose("unknown-after-transition"); + return Pokemon::outcome_failed("step_unresolved"); + } + m_actions.push_back(action_json(step, m_pose, *next)); + if (signal()){ + lose_pose("unknown-after-transition"); + return Pokemon::outcome_failed("transition_unconfirmed"); + } + m_pose = next->pose; + m_moves++; + m_basis = "model-prediction"; + return Pokemon::outcome_ok(native_pose(m_pose)); +} +Pokemon::Outcome ReplayExecutor::replay_scene(const StoryStep& step){ + JsonObject action; + action["event"] = step.name; + action["kind"] = "scene"; + action["capability"] = "scripted-scene/1"; + m_actions.push_back(std::move(action)); + const JsonObject* s = signal(); + const auto& required = parameters(step).get_value_throw("require_received"); + const auto expected = authored_pose(*step.pose_after, m_data->maps); + if (!s || s->get_string_throw("screen") != "overworld" || s->get_value_throw("pose") != pose_json(expected, expected.facing) + || s->get_value_throw("received") != required){ + lose_pose("unknown-after-scene"); + return Pokemon::outcome_failed("scene_evidence_missing_or_mismatched"); + } + m_pose = read_pose(s->get_value_throw("pose"), m_data->maps); + m_basis = "scripted-observation"; + for (const auto& [item, count] : s->get_object_throw("received")){ + const int64_t previous = m_received.get_integer_default(item); + const int64_t received = count.to_integer_throw(); + if (previous > std::numeric_limits::max() - received){ + throw ParseException("story replay: accumulated receipt overflows int64"); + } + m_received[item] = previous + received; + } + return Pokemon::outcome_ok(native_pose(m_pose)); +} +JsonValue ReplayExecutor::report(const Pokemon::CheckpointReport& result, const Pokemon::RunState& state, const JsonArray& logs) const{ + JsonObject root; + root["format"] = "pokesim-native-checkpoint-replay/1"; + root["status"] = result.completed ? "model_complete" : "stopped"; + root["checkpoint"] = m_data->row.id; + root["profile_sha256"] = m_data->binding_hash; + root["profile_map_revision"] = m_data->binding.to_object_throw().get_string_throw("map_revision"); + root["native_unbound"] = m_data->native_unbound.clone(); + root["native_source_revision"] = m_data->source_revision; + root["route_source"] = "supplied-python-candidate-not-native-planner"; + root["native_parity"] = "diagnostic-profile-only-not-live-executor"; + root["failure"] = result.completed ? JsonValue() : JsonValue(result.failure.failure == "start_failed" ? "entry_requirement_failed" : result.failure.failure); + root["active_event"] = result.completed || result.failed_event == 0 ? JsonValue() : JsonValue(m_active); + root["completed_events"] = m_completed.clone(); + root["pose"] = m_known ? pose_json(m_pose, m_pose.facing) : JsonValue(); + root["pose_basis"] = m_basis; + root["candidates"] = m_candidates.clone(); + root["received"] = m_received.clone(); + root["save_performed"] = false; + root["policy_basis"] = "synthetic-offline-policy"; + root["initialization"] = "existing-engine-from_save-after-scripted-entry-check"; + root["engine_pose"] = Pokemon::story_pose_json(state.pose); + root["engine_failure"] = result.failure.failure; + root["engine_log"] = logs.clone(); + root["walker_log"] = m_route_logs.clone(); + root["actions"] = m_actions.clone(); + root["native_story_plans"] = m_native_plans.clone(); + root["native_timing_controls"] = m_timed_plans.clone(); + root["confirmed_model_moves"] = m_moves; + root["production_save_retry_policy"] = "unbound-no-console-options-exported"; + return root; +} +} // namespace StoryReplayInternal +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_Executor.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_Executor.h new file mode 100644 index 0000000000..c7b869b711 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_Executor.h @@ -0,0 +1,94 @@ +/* Scripted Offline Executor for the Existing Story Engine + * From: https://github.com/PokemonAutomation/ + */ +#ifndef PokemonAutomation_PokemonFRLG_StoryReplay_Executor_H +#define PokemonAutomation_PokemonFRLG_StoryReplay_Executor_H +#include +#include +#include "Common/Cpp/Json/JsonArray.h" +#include "Pokemon/AutoStory/Pokemon_StoryActions.h" +#include "Pokemon/AutoStory/Pokemon_StorySteps.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.h" +#include "PokemonFRLG_AutoStoryStepInterrupts.h" +#include "PokemonFRLG_AutoStoryStepTable.h" +#include "PokemonFRLG_StoryReplay_IO.h" +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ +namespace StoryReplayInternal{ +// A supplied semantic command and its endpoint checked by native movement rules. +struct ReplayLeg{ + RouteStep step; + JsonValue action; +}; +// The replay lets a failed step's exception through: nothing reloads. +class ReplayStepGuard : public Pokemon::StepGuard{ +public: + virtual bool stops_on_failure(const StoryStep& step) const override; +}; +// A synchronous scripted adapter; production engine/navigation are reused. +// It deliberately binds strict observation/stop policy, not live executor defaults. +class ReplayExecutor : public Pokemon::StoryStepExecutor{ +public: + // Inputs must outlive this synchronous replay; validation occurs before actions. + ReplayExecutor(const ReplayData& data, const JsonValue& scenario); + // Its table's actions point at it. + ReplayExecutor(const ReplayExecutor&) = delete; + ReplayExecutor& operator=(const ReplayExecutor&) = delete; + // FRLG's story action table (story_action_rows()) with ordered authored + // completions and lost-pose handling, and FRLG's interrupts between + // steps (step_interrupts()) on the scenario's scripted screens. + virtual Pokemon::Outcome run(Pokemon::RunState& state, const StoryStep& step) override; + // Build the existing checkpoint, keeping its from_save initializer explicit. + Pokemon::Checkpoint build(); + // Serialize independently computed effects/actions and raw engine/walker diagnostics. + JsonValue report(const Pokemon::CheckpointReport& result, const Pokemon::RunState& state, const JsonArray& logs) const; +private: + // The replay's action for `id`: a ROUTE, STEP or MASH; every other + // action is unsupported. + Pokemon::StoryAction replay_action(StoryActionId id); + Pokemon::Outcome replay_route(const StoryStep& step); + Pokemon::Outcome replay_step(const StoryStep& step); + Pokemon::Outcome replay_scene(const StoryStep& step); + const JsonObject* signal() const; + const JsonObject& parameters(const StoryStep& step) const; + ReplayLeg prepare_leg(const JsonValue& command, OverworldPose& current) const; + std::vector prepare_route(const StoryStep& step); + StepOutcome walk_leg(const RouteStep& step); + void default_plan(const StoryStep& step); + void timed_plan(const StoryStep& step, const std::vector& route); + void lose_pose(std::string basis); + void observe_interruption(const OverworldPose& predicted); + // The screen before the active step: the scenario's interrupt before + // it (between_steps), shown once, else nothing. + Pokemon::ScreenLook look(); + // Each kind recorded as an action; a warp loses the pose. + StepInterruptActions interrupt_actions(); + const ReplayData* m_data; + const JsonObject* m_scenario; + OverworldPose m_pose; + bool m_known = true; + std::string m_basis = "scripted-observation"; + std::string m_active; + JsonArray m_actions; + JsonArray m_completed; + JsonArray m_candidates; + JsonObject m_received; + JsonArray m_route_logs; + JsonObject m_native_plans; + JsonObject m_timed_plans; + std::vector m_legs; + size_t m_next_leg = 0; + size_t m_moves = 0; + bool m_interrupted = false; + ReplayStepGuard m_guard; + Pokemon::StoryActionTable m_table; + Pokemon::TableExecutor m_executor; + Pokemon::StoryInterrupts m_interrupts; + std::set m_shown; +}; +} // namespace StoryReplayInternal +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_IO.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_IO.cpp new file mode 100644 index 0000000000..6fc8ea63c0 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_IO.cpp @@ -0,0 +1,341 @@ +/* Offline FRLG Story Replay Input + * From: https://github.com/PokemonAutomation/ + */ +#include +#include +#include +#include +#include +#include "Common/Cpp/Cryptography/SHA256.h" +#include "Common/Cpp/Exceptions.h" +#include "Common/Cpp/Json/JsonArray.h" +#include "Pokemon/AutoStory/Pokemon_TilePose.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.h" +#include "PokemonFRLG_StoryReplay_IO.h" +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ +namespace StoryReplayInternal{ +const JsonObject& fields(const JsonValue& value, std::initializer_list names){ + const JsonObject& root = value.to_object_throw("story replay"); + if (root.size() != names.size()){ + throw ParseException("story replay: missing/extra object fields"); + } + for (std::string_view name : names){ + root.get_value_throw(std::string(name)); + } + return root; +} +std::string digest(std::string_view text){ + SHA256 hash; + hash.push(text.data(), text.size()); + hash.finish(); + return hash.get_hash_hex(); +} +Direction direction(std::string_view value){ + for (Direction candidate : {Direction::NORTH, Direction::EAST, Direction::SOUTH, Direction::WEST}){ + if (value == direction_name(candidate)){ + return candidate; + } + } + throw ParseException("story replay: unknown direction"); +} +const MapData& map_named(const std::vector& maps, std::string_view name){ + for (const MapData& map : maps){ + if (map.name == name){ + return map; + } + } + throw ParseException(std::format("story replay: unbound map {}", name)); +} +namespace{ +int coordinate(const JsonValue& value){ + const int64_t number = value.to_integer_throw(); + if (number < std::numeric_limits::min() || number > std::numeric_limits::max()){ + throw ParseException("story replay: coordinate outside int32"); + } + return (int)number; +} +void check_pose(const OverworldPose& pose, const std::vector& maps){ + const MapData& map = map_named(maps, pose.position.map); + const TilePos tile = pose.position.point; + if (!in_bounds(map, tile) || is_wall(map, tile) || object_at(map, tile) != nullptr){ + throw ParseException("story replay: pose is not a stable unoccupied tile"); + } + const uint8_t level = elevation_at(map, tile); + if (level != ELEVATION_ANY && level != ELEVATION_IGNORED && level != pose.elevation){ + throw ParseException("story replay: pose elevation disagrees with map"); + } +} +void expect(const JsonObject& root, std::string_view key, std::string_view value){ + if (root.get_string_throw(std::string(key)) != value){ + throw ParseException(std::format("story replay: unsupported {}", key)); + } +} +void check_policy(const JsonValue& value){ + const JsonObject& policy = fields(value, {"basis", "entry", "attempts", "failure", "save"}); + expect(policy, "basis", "synthetic-offline-policy"); + expect(policy, "entry", "scripted-observation"); + expect(policy, "failure", "stop"); + expect(policy, "save", "never"); + if (policy.get_integer_throw("attempts") != 1){ + throw ParseException("story replay: only one stop/no-save attempt is supported"); + } +} +std::vector read_maps(const JsonObject& pins){ + if (pins.empty()){ + throw ParseException("story replay: map pins required"); + } + std::vector maps; + for (const auto& [name, hash] : pins){ + const auto& files = overworld_map_json(); + const auto found = std::find_if(files.begin(), files.end(), [&](const auto& file){ return file.first == name; }); + if (found == files.end() || digest(found->second) != hash.to_string_throw()){ + throw ParseException(std::format("story replay: embedded map hash mismatch: {}", name)); + } + maps.emplace_back(load_map(name)); + } + return maps; +} +void receipts(const JsonObject& actual, const JsonObject* allowed){ + for (const auto& [name, count] : actual){ + if (name.empty() || count.to_integer_throw() <= 0 || (allowed && !allowed->get_value(name))){ + throw ParseException("story replay: unsupported receipt item/quantity"); + } + } +} +void check_capability(const StoryStep& event, const JsonValue& value){ + const JsonObject& spec = fields(value, {"capability", "parameters"}); + const std::string capability = spec.get_string_throw("capability"); + if (event.kind == StoryStepKind::MASH){ + expect(spec, "capability", "scripted-scene/1"); + const JsonObject& params = fields(spec.get_value_throw("parameters"), {"require_received"}); + receipts(params.get_object_throw("require_received"), nullptr); + if (!event.pose_after){ + throw ParseException("story replay: scene needs declared arrival"); + } + return; + } + fields(spec.get_value_throw("parameters"), {}); + if ((event.kind != StoryStepKind::ROUTE || capability != "frlg-runtime-route/1") + && (event.kind != StoryStepKind::STEP || capability != "frlg-runtime-step/1")){ + throw ParseException("story replay: unsupported kind/capability"); + } + if (event.kind == StoryStepKind::ROUTE && !event.target){ + throw ParseException("story replay: route target missing"); + } +} +void check_spatial_options(const JsonObject& capture){ + for (const JsonValue& item : capture.get_object_throw("checkpoint").get_array_throw("events")){ + const JsonObject& step = item.to_object_throw(); + std::set allowed{"id", "kind", "why"}; + const std::string kind = step.get_string_throw("kind"); + if (kind == "route"){ + allowed.insert("target"); + }else if (kind == "step"){ + allowed.insert("direction"); + }else if (kind == "scene"){ + allowed.insert("declared_after"); + }else{ + throw ParseException("story replay: unsupported event kind"); + } + for (const auto& [key, value] : step){ + if (!allowed.contains(key)){ + throw ParseException("story replay: unsupported event option " + key); + } + } + } +} +void revision(std::string_view value){ + if (value.size() != 40 || !std::all_of(value.begin(), value.end(), [](char c){ + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'); + })){ + throw ParseException("story replay: full lowercase revision required"); + } +} +void check_route_names(const ReplayData& data){ + const JsonObject& routes = data.candidate.to_object_throw().get_object_throw("routes"); + size_t count = 0; + for (const StoryStep& event : data.row.steps){ + if (event.kind == StoryStepKind::ROUTE){ + routes.get_array_throw(event.name); + count++; + } + } + if (routes.size() != count){ + throw ParseException("story replay: extra supplied route"); + } +} +void check_route_options(const JsonObject& config){ + const int64_t limit = config.get_integer_throw("max_route_moves"); + if (limit < 1 || limit > 4096){ + throw ParseException("story replay: route move budget outside 1..4096"); + } + const JsonObject& costs = fields(config.get_value_throw("costs"), {"tile", "turn", "grass", "warp"}); + for (const auto& [key, value] : costs){ + const double number = value.to_double_throw(); + if (!std::isfinite(number) || number < 0 || (key == "tile" && number == 0)){ + throw ParseException("story replay: unsupported route cost"); + } + } +} +void check_row(const ReplayData& data){ + if (!data.row.start || data.row.steps.empty()){ + throw ParseException("story replay: nonempty checkpoint and start pose required"); + } + const JsonObject& specs = data.binding.to_object_throw().get_object_throw("capabilities"); + if (specs.size() != data.row.steps.size()){ + throw ParseException("story replay: missing/extra event bindings"); + } + authored_pose(*data.row.start, data.maps); + for (const StoryStep& event : data.row.steps){ + check_capability(event, specs.get_value_throw(event.name)); + if (!event.skip_if.empty() || !event.variant.empty() || event.once_per_save){ + throw ParseException("story replay: unsupported event options"); + } + if (event.target){ + authored_pose(*event.target, data.maps); + } + if (event.pose_after){ + authored_pose(*event.pose_after, data.maps); + } + } +} +} // namespace +OverworldPose read_pose(const JsonValue& value, const std::vector& maps){ + const JsonObject& root = fields(value, {"map", "tile", "facing", "elevation"}); + const JsonArray& tile = root.get_array_throw("tile"); + const int64_t level = root.get_integer_throw("elevation"); + if (tile.size() != 2 || level < 0 || level > 15){ + throw ParseException("story replay: expected a known runtime-local pose"); + } + OverworldPose result{.position = {root.get_string_throw("map"), {coordinate(tile[0]), coordinate(tile[1])}}, + .elevation = (uint8_t)level, .facing = direction(root.get_string_throw("facing"))}; + check_pose(result, maps); + return result; +} +OverworldPose authored_pose(const Pokemon::Pose& value, const std::vector& maps){ + const std::vector tiles = Pokemon::tiles_of(value); + const auto facing = Pokemon::facing_of(value); + if (value.certainty != Pokemon::PoseCertainty::TILE || tiles.size() != 1 || !facing){ + throw ParseException("story replay: one authored tile and facing required"); + } + const MapData& map = map_named(maps, value.map); + OverworldPose result{.position = {value.map, tiles[0]}, .elevation = elevation_at(map, tiles[0]), .facing = *facing}; + check_pose(result, maps); + return result; +} +JsonValue pose_json(const OverworldPose& pose, std::optional facing){ + JsonObject result; + result["map"] = pose.position.map; + JsonArray tile; + tile.push_back(pose.position.point.x); + tile.push_back(pose.position.point.y); + result["tile"] = std::move(tile); + result["elevation"] = pose.elevation; + result["facing"] = facing ? JsonValue(direction_name(*facing)) : JsonValue(); + return result; +} +ReplayData read_data(std::string_view capture, std::string_view binding, std::string_view candidate){ + JsonValue config = parse_json(std::string(binding)); + const JsonObject& root = fields(config, {"format", "capture", "map_revision", "maps", "capabilities", "costs", "max_route_moves", "policy"}); + expect(root, "format", "pokesim-story-replay-binding/1"); + check_policy(root.get_value_throw("policy")); + check_route_options(root); + revision(root.get_string_throw("map_revision")); + const JsonObject& pin = fields(root.get_value_throw("capture"), {"file", "sha256", "source_revision"}); + if (pin.get_string_throw("sha256") != digest(capture)){ + throw ParseException("story replay: capture hash mismatch"); + } + JsonValue document = parse_json(std::string(capture)); + expect(document.to_object_throw(), "source_revision", pin.get_string_throw("source_revision")); + check_spatial_options(document.to_object_throw()); + const std::string source_revision = pin.get_string_throw("source_revision"); + revision(source_revision); + JsonValue plan = parse_json(std::string(candidate)); + const JsonObject& route = fields(plan, {"format", "binding_sha256", "checkpoint", "routes"}); + expect(route, "format", "pokesim-route-candidate/1"); + expect(route, "binding_sha256", digest(binding)); + ReplayData result{.row = read_story_checkpoint_export(document, "replay capture"), + .maps = read_maps(root.get_object_throw("maps")), .binding = std::move(config), + .candidate = std::move(plan), .native_unbound = document.to_object_throw().get_value_throw("unbound").clone(), .binding_hash = digest(binding), .source_revision = source_revision}; + expect(result.candidate.to_object_throw(), "checkpoint", result.row.id); + check_row(result); + check_route_names(result); + return result; +} +ReplayScreen between_steps_screen(std::string_view kind){ + using Pokemon::ScreenEvent; + const Pokemon::ScreenEvents box{ScreenEvent::DIALOG_BOX}; + const Pokemon::ScreenEvents battle{ScreenEvent::BATTLE_STARTED}; + const Pokemon::ScreenEvents black{ScreenEvent::FADE_BLACK}; + ReplayScreen screen; + if (kind == "dialog"){ + screen.frames = {{33, box}}; + screen.look_ms = 400; + }else if (kind == "wild-encounter"){ + screen.frames = {{100, Pokemon::ScreenEvents{ScreenEvent::FADE_WHITE}}, {400, {}}, {1500, battle}}; + screen.look_ms = 2000; + }else if (kind == "trainer-sight"){ + screen.frames = {{100, box}, {2000, black}, {2500, battle}}; + screen.look_ms = 3000; + }else if (kind == "warp"){ + // Long enough after the fade for no battle to follow it. + screen.frames = {{100, black}, {600, {}}}; + screen.look_ms = 9000; + }else{ + throw ParseException("story replay: unknown interrupt kind"); + } + return screen; +} +void validate_scenario(const ReplayData& data, const JsonValue& scenario){ + const JsonObject& root = scenario.to_object_throw(); + for (const auto& [name, value] : root){ + if (name != "entry" && name != "signals" && name != "between_steps" && name != "check_actions"){ + throw ParseException("story replay: unknown scenario field"); + } + if (name == "check_actions"){ + value.to_boolean_throw(); + } + } + const JsonObject& entry = fields(root.get_value_throw("entry"), {"basis", "screen", "pose"}); + expect(entry, "basis", "scripted"); + entry.get_string_throw("screen"); + read_pose(entry.get_value_throw("pose"), data.maps); + for (const auto& [name, signal] : root.get_object_throw("signals")){ + const auto event = std::find_if(data.row.steps.begin(), data.row.steps.end(), [&](const auto& e){ return e.name == name; }); + if (event == data.row.steps.end()){ + throw ParseException("story replay: signal references unknown event"); + } + if (event->kind == StoryStepKind::ROUTE){ + const JsonObject& s = fields(signal, {"kind", "from", "direction"}); + expect(s, "kind", "wild-encounter"); + read_pose(s.get_value_throw("from"), data.maps); + direction(s.get_string_throw("direction")); + }else if (event->kind == StoryStepKind::STEP){ + expect(fields(signal, {"kind"}), "kind", "unconfirmed-transition"); + }else{ + const JsonObject& s = fields(signal, {"kind", "basis", "screen", "pose", "received"}); + expect(s, "kind", "scene-observation"); + expect(s, "basis", "scripted"); + s.get_string_throw("screen"); + read_pose(s.get_value_throw("pose"), data.maps); + const auto& spec = data.binding.to_object_throw().get_object_throw("capabilities").get_object_throw(name); + receipts(s.get_object_throw("received"), &spec.get_object_throw("parameters").get_object_throw("require_received")); + } + } + const JsonObject* between = root.get_object("between_steps"); + if (between == nullptr){ + return; + } + for (const auto& [name, shown] : *between){ + if (std::none_of(data.row.steps.begin(), data.row.steps.end(), [&](const auto& e){ return e.name == name; })){ + throw ParseException("story replay: interrupt before an unknown event"); + } + between_steps_screen(fields(shown, {"kind"}).get_string_throw("kind")); + } +} +} // namespace StoryReplayInternal +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_IO.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_IO.h new file mode 100644 index 0000000000..e56ea624b0 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_IO.h @@ -0,0 +1,60 @@ +/* Offline Story Replay Input Boundary + * From: https://github.com/PokemonAutomation/ + */ +#ifndef PokemonAutomation_PokemonFRLG_StoryReplay_IO_H +#define PokemonAutomation_PokemonFRLG_StoryReplay_IO_H +#include +#include +#include +#include +#include "Common/Cpp/Json/JsonObject.h" +#include "Pokemon/AutoStory/Pokemon_ScreenEvents.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h" +#include "Data/JsonParser.h" +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ +namespace StoryReplayInternal{ +// Owned input documents and the native maps whose raw bytes match their pins. +struct ReplayData{ + StoryCheckpoint row; + std::vector maps; + JsonValue binding; + JsonValue candidate; + JsonValue native_unbound; + std::string binding_hash; + std::string source_revision; +}; +// Strict object boundary: all listed fields required, unknown fields rejected. +const JsonObject& fields(const JsonValue& value, std::initializer_list names); +// SHA-256 of exact input bytes, not a reformatted JSON document. +std::string digest(std::string_view text); +// Direction vocabulary of the shared checkpoint format. +Direction direction(std::string_view value); +// The named map must be bound, never an implicit resource fallback. +const MapData& map_named(const std::vector& maps, std::string_view name); +// Read a scripted runtime-local pose, checking bounds, occupants and elevation. +OverworldPose read_pose(const JsonValue& value, const std::vector& maps); +// Resolve a known one-tile authored pose; this does not create evidence. +OverworldPose authored_pose(const Pokemon::Pose& value, const std::vector& maps); +// JSON snapshot of a known model pose, or a candidate with unknown facing. +JsonValue pose_json(const OverworldPose& pose, std::optional facing); +// Reject bad hashes, capabilities and policies before any executor action. +ReplayData read_data(std::string_view capture, std::string_view binding, std::string_view candidate); +// A scripted screen between two steps: its frames, `ms` after the look +// starts, and how long the look lasts. +struct ReplayScreen{ + std::vector> frames; + int look_ms = 0; +}; +// The screen an interrupt shows between steps, by the scenario's kind: +// "dialog", "wild-encounter", "trainer-sight" or "warp". +ReplayScreen between_steps_screen(std::string_view kind); +// Validate every scripted signal against its event's capability/parameters, +// and every interrupt between steps against its event and kind. +void validate_scenario(const ReplayData& data, const JsonValue& scenario); +} // namespace StoryReplayInternal +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_Tests.cpp new file mode 100644 index 0000000000..bc8603a942 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_Tests.cpp @@ -0,0 +1,230 @@ +/* Shared Parcel Scenario Native Tests + * From: https://github.com/PokemonAutomation/ + */ +#include +#include +#include "Common/Cpp/Exceptions.h" +#include "Common/Cpp/Json/JsonArray.h" +#include "Common/Cpp/Json/JsonObject.h" +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "PokemonFRLG_StoryReplay_IO.h" +#include "PokemonFRLG_StoryReplay.h" +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ +namespace{ +const JsonValue& named_case(const JsonValue& cases, std::string_view name){ + for (const JsonValue& value : cases.to_object_throw().get_array_throw("cases")){ + if (value.to_object_throw().get_string_throw("id") == name){ + return value; + } + } + throw ParseException("native replay: unknown case"); +} +std::string compare_expected(const JsonValue& test, const JsonValue& actual){ + const JsonObject& row = test.to_object_throw(); + const JsonObject& report = actual.to_object_throw(); + const bool actions = row.get_object_throw("input").get_boolean_default("check_actions"); + const JsonObject& expected = row.get_object_throw("expectation"); + if (actions != (expected.get_value("actions") != nullptr)){ + return "Expected actions only when check_actions is true."; + } + for (const auto& [key, value] : expected){ + const JsonValue* observed = report.get_value(key); + if (!observed || value != *observed){ + return std::format("Expected {} {}, got {}", key, value.dump(-1), observed ? observed->dump(-1) : "missing"); + } + } + if (report.get_string_throw("status") == "model_complete"){ + for (const auto& [name, control] : report.get_object_throw("native_timing_controls")){ + if (control.to_object_throw().get_string_throw("status") != "complete"){ + return "Expected successful timed native control for " + name + ": " + control.dump(-1); + } + } + } + return ""; +} +enum class Mutation{ + NONE, CAPTURE_HASH, MAP_HASH, CAPABILITY, SAVE_POLICY, CANDIDATE_DISTANCE, + DROP_ACTION, EXTRA_ACTION, REVERSE_ACTIONS, ACTION_DISTANCE, ACTION_DIRECTION, + // A dialog before the walk to the Mart and before the clerk's scene + // (between_steps): the walk's cleared first, the scene's its own. + DIALOGS_BETWEEN_STEPS, +}; +enum class ExpectedResult{ MATCH, REJECTED, MISMATCH }; +struct ReplayInput{ + std::string_view name; + Mutation mutation = Mutation::NONE; +}; +struct ReplayExpectation{ + ExpectedResult result = ExpectedResult::MATCH; + std::string_view diagnostic; +}; +struct ReplayCase{ + ReplayInput input; + ReplayExpectation expectation; +}; +const ReplayCase CASES[] = { + {.input = {.name = "parcel-all-events"}, .expectation = {}}, + {.input = {.name = "entry-not-overworld"}, .expectation = {}}, + {.input = {.name = "missing-scene-evidence"}, .expectation = {}}, + {.input = {.name = "missing-parcel-receipt"}, .expectation = {}}, + {.input = {.name = "wrong-scene-arrival"}, .expectation = {}}, + {.input = {.name = "unconfirmed-mart-transition"}, .expectation = {}}, + {.input = {.name = "interrupted-route1-grass"}, .expectation = {}}, + {.input = {.name = "parcel-all-events", .mutation = Mutation::CAPTURE_HASH}, .expectation = {.result = ExpectedResult::REJECTED, .diagnostic = "capture hash mismatch"}}, + {.input = {.name = "parcel-all-events", .mutation = Mutation::MAP_HASH}, .expectation = {.result = ExpectedResult::REJECTED, .diagnostic = "map hash mismatch"}}, + {.input = {.name = "parcel-all-events", .mutation = Mutation::CAPABILITY}, .expectation = {.result = ExpectedResult::REJECTED, .diagnostic = "capability"}}, + {.input = {.name = "parcel-all-events", .mutation = Mutation::SAVE_POLICY}, .expectation = {.result = ExpectedResult::REJECTED, .diagnostic = "save"}}, + {.input = {.name = "parcel-all-events", .mutation = Mutation::CANDIDATE_DISTANCE}, .expectation = {.result = ExpectedResult::REJECTED, .diagnostic = "native"}}, + {.input = {.name = "parcel-all-events", .mutation = Mutation::DROP_ACTION}, .expectation = {.result = ExpectedResult::MISMATCH, .diagnostic = "actions"}}, + {.input = {.name = "parcel-all-events", .mutation = Mutation::EXTRA_ACTION}, .expectation = {.result = ExpectedResult::MISMATCH, .diagnostic = "actions"}}, + {.input = {.name = "parcel-all-events", .mutation = Mutation::REVERSE_ACTIONS}, .expectation = {.result = ExpectedResult::MISMATCH, .diagnostic = "actions"}}, + {.input = {.name = "parcel-all-events", .mutation = Mutation::ACTION_DISTANCE}, .expectation = {.result = ExpectedResult::MISMATCH, .diagnostic = "actions"}}, + {.input = {.name = "parcel-all-events", .mutation = Mutation::ACTION_DIRECTION}, .expectation = {.result = ExpectedResult::MISMATCH, .diagnostic = "actions"}}, + {.input = {.name = "parcel-all-events", .mutation = Mutation::DIALOGS_BETWEEN_STEPS}, .expectation = {}}, +}; +void mutate_config(Mutation mutation, JsonObject& binding, JsonObject& candidate){ + switch (mutation){ + case Mutation::CAPTURE_HASH: + binding.get_object_throw("capture")["sha256"] = std::string(64, '0'); + return; + case Mutation::MAP_HASH: + binding.get_object_throw("maps")["Route1"] = std::string(64, '0'); + return; + case Mutation::CAPABILITY: + binding.get_object_throw("capabilities").get_object_throw("parcel_from_clerk")["capability"] = "unknown/1"; + return; + case Mutation::SAVE_POLICY: + binding.get_object_throw("policy")["save"] = "after"; + return; + case Mutation::CANDIDATE_DISTANCE: + candidate.get_object_throw("routes").get_array_throw("below_mart_door")[0].to_object_throw()["tiles"] = 2; + return; + case Mutation::NONE: + case Mutation::DIALOGS_BETWEEN_STEPS: + case Mutation::DROP_ACTION: + case Mutation::EXTRA_ACTION: + case Mutation::REVERSE_ACTIONS: + case Mutation::ACTION_DISTANCE: + case Mutation::ACTION_DIRECTION: + return; + } +} +void mutate_actions(Mutation mutation, JsonValue& report){ + JsonArray& actions = report.to_object_throw().get_array_throw("actions"); + JsonArray replacement; + switch (mutation){ + case Mutation::DROP_ACTION: + for (size_t n = 0; n + 1 < actions.size(); n++){ + replacement.push_back(actions[n].clone()); + } + actions = std::move(replacement); + return; + case Mutation::EXTRA_ACTION: + actions.push_back(actions[actions.size() - 1].clone()); + return; + case Mutation::REVERSE_ACTIONS: + for (size_t n = actions.size(); n > 0; n--){ + replacement.push_back(actions[n - 1].clone()); + } + actions = std::move(replacement); + return; + case Mutation::ACTION_DISTANCE: + actions[0].to_object_throw()["tiles"] = 2; + return; + case Mutation::ACTION_DIRECTION: + actions[0].to_object_throw()["direction"] = "east"; + return; + case Mutation::NONE: + case Mutation::DIALOGS_BETWEEN_STEPS: + case Mutation::CAPTURE_HASH: + case Mutation::MAP_HASH: + case Mutation::CAPABILITY: + case Mutation::SAVE_POLICY: + case Mutation::CANDIDATE_DISTANCE: + return; + } +} +// `test` (a case of cases.json, which is PokemonSimulation's, so it has +// no interrupts between steps) with a dialog before its walk to the Mart +// and before the clerk's scene: expected as before, with the walk's +// dialog cleared as its first action. +JsonValue with_dialogs_between_steps(const JsonValue& test){ + JsonValue out = test.clone(); + JsonObject& row = out.to_object_throw(); + JsonObject between; + for (const char* step : {"below_mart_door", "parcel_from_clerk"}){ + JsonObject shown; + shown["kind"] = "dialog"; + between[step] = std::move(shown); + } + row.get_object_throw("input")["between_steps"] = std::move(between); + JsonArray& expected = row.get_object_throw("expectation").get_array_throw("actions"); + JsonArray actions; + JsonObject cleared; + cleared["event"] = "below_mart_door"; + cleared["kind"] = "interrupt"; + cleared["interrupt"] = "dialog"; + actions.push_back(std::move(cleared)); + for (const JsonValue& action : expected){ + actions.push_back(action.clone()); + } + expected = std::move(actions); + return out; +} +JsonValue run_case(const ReplayInput& input, const JsonValue& test){ + JsonValue binding = parse_json(story_replay_fixture("binding")); + JsonValue candidate = parse_json(story_replay_fixture("route-candidate")); + mutate_config(input.mutation, binding.to_object_throw(), candidate.to_object_throw()); + // Re-pin the mutated test input so capability/policy negatives reach their + // intended validation instead of all failing at an unrelated byte hash. + const std::string raw = binding.dump(-1); + candidate.to_object_throw()["binding_sha256"] = StoryReplayInternal::digest(raw); + JsonValue report = replay_story_checkpoint(story_replay_fixture("capture"), raw, + candidate.dump(-1), test.to_object_throw().get_value_throw("input")); + mutate_actions(input.mutation, report); + return report; +} +class ReplayTest : public UnitTest{ +public: + explicit ReplayTest(ReplayCase test) + : UnitTest(std::format("PokemonFRLG::StoryReplay - {} mutation {}", test.input.name, (int)test.input.mutation)), m_test(test){} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + const JsonValue document = parse_json(story_replay_fixture("cases")); + const JsonValue& named = named_case(document, m_test.input.name); + const JsonValue scripted = m_test.input.mutation == Mutation::DIALOGS_BETWEEN_STEPS + ? with_dialogs_between_steps(named) : named.clone(); + const JsonValue& test = scripted; + try{ + const JsonValue report = run_case(m_test.input, test); + const std::string difference = compare_expected(test, report); + const bool match = difference.empty(); + if (m_test.expectation.result == ExpectedResult::MATCH && match){ + return true; + } + if (m_test.expectation.result == ExpectedResult::MISMATCH && !match + && difference.find(m_test.expectation.diagnostic) != std::string::npos){ + return true; + } + return "Unexpected replay result: " + difference + "\n" + report.dump(-1); + }catch (const Exception& error){ + if (m_test.expectation.result == ExpectedResult::REJECTED + && error.to_str().find(m_test.expectation.diagnostic) != std::string::npos){ + return true; + } + return "Unexpected native replay exception: " + error.to_str(); + } + } +private: + ReplayCase m_test; +}; +} // namespace +void add_tests_StoryReplay(UnitTestDatabase& database){ + for (const ReplayCase& test : CASES){ + database.add(test); + } +} +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/ReplayData/binding.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/ReplayData/binding.json new file mode 100644 index 0000000000..b53d5e1259 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/ReplayData/binding.json @@ -0,0 +1,30 @@ +{ + "format": "pokesim-story-replay-binding/1", + "capture": { + "file": "data/story/native/frlg-parcel-00.05.json", + "sha256": "8b0c04f2e1507aee0f0927a8d022b8b431036431e95f8893aa7a2383360f8504", + "source_revision": "b170b7d2103e887ded825c37fdbdc19fd654fca8" + }, + "map_revision": "158b007ecd14efb5776ebdadc530c1a807ef3541", + "maps": { + "PalletTown_ProfessorOaksLab": "1dedda88f1238eccd6610cbff79c1e5d3cd21b516637b792654746e02b2cdbc8", + "PalletTown": "0dc7446f568097b36a9479954f01afe546cb64c82bb0efa91da0cb66ad950f9f", + "Route1": "78bf43c8bdf7a0c5dcb22880261626abc36d068c0dfbbc242056e12401c44033", + "ViridianCity": "6a671a0a0b06f78f566b89646f6b0f00c91c240a36300dff861f970455f99909", + "ViridianCity_Mart": "1b2b23f8f9a1b1fbe266f12bdac96cdf649d974b057e84bf9cba6befbaa15b3b" + }, + "capabilities": { + "below_mart_door": {"capability": "frlg-runtime-route/1", "parameters": {}}, + "mart_door": {"capability": "frlg-runtime-step/1", "parameters": {}}, + "parcel_from_clerk": {"capability": "scripted-scene/1", "parameters": {"require_received": {"oak-parcel": 1}}} + }, + "costs": {"tile": 1, "turn": 2, "grass": 5, "warp": 1}, + "max_route_moves": 256, + "policy": { + "basis": "synthetic-offline-policy", + "entry": "scripted-observation", + "attempts": 1, + "failure": "stop", + "save": "never" + } +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/ReplayData/cases.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/ReplayData/cases.json new file mode 100644 index 0000000000..75a44bd73d --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/ReplayData/cases.json @@ -0,0 +1,397 @@ +{ + "format": "pokesim-story-replay-cases/1", + "evidence_status": "synthetic-scripted-observations", + "coordinate_frame": "runtime-local", + "cases": [ + { + "id": "parcel-all-events", + "description": "Walk from Oak’s Lab to Viridian, enter the Mart, and receive Oak’s Parcel.", + "input": { + "entry": { + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "PalletTown_ProfessorOaksLab", "tile": [7, 8], "elevation": 3, "facing": "south"} + }, + "signals": { + "parcel_from_clerk": { + "kind": "scene-observation", + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "ViridianCity_Mart", "tile": [4, 3], "elevation": 3, "facing": "west"}, + "received": {"oak-parcel": 1} + } + }, + "check_actions": true + }, + "expectation": { + "status": "model_complete", + "failure": null, + "active_event": null, + "completed_events": ["below_mart_door", "mart_door", "parcel_from_clerk"], + "pose": {"map": "ViridianCity_Mart", "tile": [4, 3], "elevation": 3, "facing": "west"}, + "pose_basis": "scripted-observation", + "candidates": [], + "received": {"oak-parcel": 1}, + "save_performed": false, + "actions": [ + { + "event": "below_mart_door", + "kind": "walk", + "map": "PalletTown_ProfessorOaksLab", + "direction": "west", + "tiles": 1 + }, + { + "event": "below_mart_door", + "kind": "walk", + "map": "PalletTown_ProfessorOaksLab", + "direction": "south", + "tiles": 4 + }, + { + "event": "below_mart_door", + "kind": "transition", + "map": "PalletTown_ProfessorOaksLab", + "direction": "south", + "to_map": "PalletTown", + "via": "warp" + }, + {"event": "below_mart_door", "kind": "walk", "map": "PalletTown", "direction": "west", "tiles": 4}, + {"event": "below_mart_door", "kind": "walk", "map": "PalletTown", "direction": "north", "tiles": 14}, + { + "event": "below_mart_door", + "kind": "transition", + "map": "PalletTown", + "direction": "north", + "to_map": "Route1", + "via": "edge" + }, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "north", "tiles": 7}, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "west", "tiles": 4}, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "north", "tiles": 5}, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "east", "tiles": 4}, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "north", "tiles": 6}, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "west", "tiles": 2}, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "north", "tiles": 4}, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "east", "tiles": 6}, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "north", "tiles": 15}, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "west", "tiles": 6}, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "north", "tiles": 2}, + { + "event": "below_mart_door", + "kind": "transition", + "map": "Route1", + "direction": "north", + "to_map": "ViridianCity", + "via": "edge" + }, + {"event": "below_mart_door", "kind": "walk", "map": "ViridianCity", "direction": "north", "tiles": 19}, + {"event": "below_mart_door", "kind": "walk", "map": "ViridianCity", "direction": "east", "tiles": 14}, + {"event": "below_mart_door", "kind": "turn", "map": "ViridianCity", "direction": "north"}, + { + "event": "mart_door", + "kind": "transition", + "map": "ViridianCity", + "direction": "north", + "to_map": "ViridianCity_Mart", + "via": "warp" + }, + {"event": "parcel_from_clerk", "kind": "scene", "capability": "scripted-scene/1"} + ] + } + }, + { + "id": "entry-not-overworld", + "description": "A dialogue screen is supplied at entry: request no actions.", + "input": { + "entry": { + "basis": "scripted", + "screen": "dialogue", + "pose": {"map": "PalletTown_ProfessorOaksLab", "tile": [7, 8], "elevation": 3, "facing": "south"} + }, + "signals": { + "parcel_from_clerk": { + "kind": "scene-observation", + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "ViridianCity_Mart", "tile": [4, 3], "elevation": 3, "facing": "west"}, + "received": {"oak-parcel": 1} + } + }, + "check_actions": true + }, + "expectation": { + "status": "stopped", + "failure": "entry_requirement_failed", + "active_event": null, + "completed_events": [], + "pose": {"map": "PalletTown_ProfessorOaksLab", "tile": [7, 8], "elevation": 3, "facing": "south"}, + "pose_basis": "scripted-observation", + "candidates": [], + "received": {}, + "save_performed": false, + "actions": [] + } + }, + { + "id": "missing-scene-evidence", + "description": "Reach the Mart, but the clerk scene has no supplied observation.", + "input": { + "entry": { + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "PalletTown_ProfessorOaksLab", "tile": [7, 8], "elevation": 3, "facing": "south"} + }, + "signals": {}, + "check_actions": false + }, + "expectation": { + "status": "stopped", + "failure": "scene_evidence_missing_or_mismatched", + "active_event": "parcel_from_clerk", + "completed_events": ["below_mart_door", "mart_door"], + "pose": null, + "pose_basis": "unknown-after-scene", + "candidates": [], + "received": {}, + "save_performed": false + } + }, + { + "id": "missing-parcel-receipt", + "description": "The clerk scene reports the right pose but no Parcel receipt.", + "input": { + "entry": { + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "PalletTown_ProfessorOaksLab", "tile": [7, 8], "elevation": 3, "facing": "south"} + }, + "signals": { + "parcel_from_clerk": { + "kind": "scene-observation", + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "ViridianCity_Mart", "tile": [4, 3], "elevation": 3, "facing": "west"}, + "received": {} + } + }, + "check_actions": false + }, + "expectation": { + "status": "stopped", + "failure": "scene_evidence_missing_or_mismatched", + "active_event": "parcel_from_clerk", + "completed_events": ["below_mart_door", "mart_door"], + "pose": null, + "pose_basis": "unknown-after-scene", + "candidates": [], + "received": {}, + "save_performed": false + } + }, + { + "id": "wrong-scene-arrival", + "description": "The clerk scene reports a Parcel but the wrong facing.", + "input": { + "entry": { + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "PalletTown_ProfessorOaksLab", "tile": [7, 8], "elevation": 3, "facing": "south"} + }, + "signals": { + "parcel_from_clerk": { + "kind": "scene-observation", + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "ViridianCity_Mart", "tile": [4, 3], "elevation": 3, "facing": "east"}, + "received": {"oak-parcel": 1} + } + }, + "check_actions": false + }, + "expectation": { + "status": "stopped", + "failure": "scene_evidence_missing_or_mismatched", + "active_event": "parcel_from_clerk", + "completed_events": ["below_mart_door", "mart_door"], + "pose": null, + "pose_basis": "unknown-after-scene", + "candidates": [], + "received": {}, + "save_performed": false + } + }, + { + "id": "unconfirmed-mart-transition", + "description": "Request the Mart entry step, but its transition cannot be confirmed.", + "input": { + "entry": { + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "PalletTown_ProfessorOaksLab", "tile": [7, 8], "elevation": 3, "facing": "south"} + }, + "signals": { + "parcel_from_clerk": { + "kind": "scene-observation", + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "ViridianCity_Mart", "tile": [4, 3], "elevation": 3, "facing": "west"}, + "received": {"oak-parcel": 1} + }, + "mart_door": {"kind": "unconfirmed-transition"} + }, + "check_actions": true + }, + "expectation": { + "status": "stopped", + "failure": "transition_unconfirmed", + "active_event": "mart_door", + "completed_events": ["below_mart_door"], + "pose": null, + "pose_basis": "unknown-after-transition", + "candidates": [], + "received": {}, + "save_performed": false, + "actions": [ + { + "event": "below_mart_door", + "kind": "walk", + "map": "PalletTown_ProfessorOaksLab", + "direction": "west", + "tiles": 1 + }, + { + "event": "below_mart_door", + "kind": "walk", + "map": "PalletTown_ProfessorOaksLab", + "direction": "south", + "tiles": 4 + }, + { + "event": "below_mart_door", + "kind": "transition", + "map": "PalletTown_ProfessorOaksLab", + "direction": "south", + "to_map": "PalletTown", + "via": "warp" + }, + {"event": "below_mart_door", "kind": "walk", "map": "PalletTown", "direction": "west", "tiles": 4}, + {"event": "below_mart_door", "kind": "walk", "map": "PalletTown", "direction": "north", "tiles": 14}, + { + "event": "below_mart_door", + "kind": "transition", + "map": "PalletTown", + "direction": "north", + "to_map": "Route1", + "via": "edge" + }, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "north", "tiles": 7}, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "west", "tiles": 4}, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "north", "tiles": 5}, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "east", "tiles": 4}, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "north", "tiles": 6}, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "west", "tiles": 2}, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "north", "tiles": 4}, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "east", "tiles": 6}, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "north", "tiles": 15}, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "west", "tiles": 6}, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "north", "tiles": 2}, + { + "event": "below_mart_door", + "kind": "transition", + "map": "Route1", + "direction": "north", + "to_map": "ViridianCity", + "via": "edge" + }, + {"event": "below_mart_door", "kind": "walk", "map": "ViridianCity", "direction": "north", "tiles": 19}, + {"event": "below_mart_door", "kind": "walk", "map": "ViridianCity", "direction": "east", "tiles": 14}, + {"event": "below_mart_door", "kind": "turn", "map": "ViridianCity", "direction": "north"}, + { + "event": "mart_door", + "kind": "transition", + "map": "ViridianCity", + "direction": "north", + "to_map": "ViridianCity_Mart", + "via": "warp" + } + ] + } + }, + { + "id": "interrupted-route1-grass", + "description": "A Route 1 encounter interrupts movement; retain both tiles and stop before the Mart.", + "input": { + "entry": { + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "PalletTown_ProfessorOaksLab", "tile": [7, 8], "elevation": 3, "facing": "south"} + }, + "signals": { + "parcel_from_clerk": { + "kind": "scene-observation", + "basis": "scripted", + "screen": "overworld", + "pose": {"map": "ViridianCity_Mart", "tile": [4, 3], "elevation": 3, "facing": "west"}, + "received": {"oak-parcel": 1} + }, + "below_mart_door": { + "kind": "wild-encounter", + "from": {"map": "Route1", "tile": [12, 39], "elevation": 3, "facing": "north"}, + "direction": "north" + } + }, + "check_actions": true + }, + "expectation": { + "status": "stopped", + "failure": "encounter_requires_recovery", + "active_event": "below_mart_door", + "completed_events": [], + "pose": null, + "pose_basis": "unknown-after-interruption", + "candidates": [ + {"map": "Route1", "tile": [12, 39], "elevation": 3, "facing": null}, + {"map": "Route1", "tile": [12, 38], "elevation": 3, "facing": null} + ], + "received": {}, + "save_performed": false, + "actions": [ + { + "event": "below_mart_door", + "kind": "walk", + "map": "PalletTown_ProfessorOaksLab", + "direction": "west", + "tiles": 1 + }, + { + "event": "below_mart_door", + "kind": "walk", + "map": "PalletTown_ProfessorOaksLab", + "direction": "south", + "tiles": 4 + }, + { + "event": "below_mart_door", + "kind": "transition", + "map": "PalletTown_ProfessorOaksLab", + "direction": "south", + "to_map": "PalletTown", + "via": "warp" + }, + {"event": "below_mart_door", "kind": "walk", "map": "PalletTown", "direction": "west", "tiles": 4}, + {"event": "below_mart_door", "kind": "walk", "map": "PalletTown", "direction": "north", "tiles": 14}, + { + "event": "below_mart_door", + "kind": "transition", + "map": "PalletTown", + "direction": "north", + "to_map": "Route1", + "via": "edge" + }, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "north", "tiles": 7} + ] + } + } + ] +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/ReplayData/pin.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/ReplayData/pin.json new file mode 100644 index 0000000000..deeca459f8 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/ReplayData/pin.json @@ -0,0 +1,24 @@ +{ + "format": "pokesim-native-replay-preview/1", + "repository": "dayfine/PokemonSimulation", + "revision": "9cb12c52f5f055299e32c2a7cf43e7103c101e3e", + "status": "preview-unmerged-PR-73", + "files": { + "binding": { + "path": "data/story/parcel-pilot/binding.json", + "sha256": "5de39f53f63a9fd6319b4afd2fa85d0b0c468da7e94161fe4ce22bb939bb846e" + }, + "cases": { + "path": "data/story/parcel-pilot/cases.json", + "sha256": "0dee2991120d12f01e67f6e97a4fb815b4d2f54a423739bc1a6aa4f2509f599e" + }, + "capture": { + "path": "data/story/native/frlg-parcel-00.05.json", + "sha256": "8b0c04f2e1507aee0f0927a8d022b8b431036431e95f8893aa7a2383360f8504" + } + }, + "candidate": { + "sha256": "1eeb9d6ba22362ae1854335aa914ed4e3cabad64403628beb430fe8822cca847", + "producer": "checkpoint_replay.run actual route actions; expectations are not read" + } +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/ReplayData/route-candidate.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/ReplayData/route-candidate.json new file mode 100644 index 0000000000..c253702c48 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/ReplayData/route-candidate.json @@ -0,0 +1,63 @@ +{ + "format": "pokesim-route-candidate/1", + "binding_sha256": "5de39f53f63a9fd6319b4afd2fa85d0b0c468da7e94161fe4ce22bb939bb846e", + "checkpoint": "00.05", + "routes": { + "below_mart_door": [ + { + "event": "below_mart_door", + "kind": "walk", + "map": "PalletTown_ProfessorOaksLab", + "direction": "west", + "tiles": 1 + }, + { + "event": "below_mart_door", + "kind": "walk", + "map": "PalletTown_ProfessorOaksLab", + "direction": "south", + "tiles": 4 + }, + { + "event": "below_mart_door", + "kind": "transition", + "map": "PalletTown_ProfessorOaksLab", + "direction": "south", + "to_map": "PalletTown", + "via": "warp" + }, + {"event": "below_mart_door", "kind": "walk", "map": "PalletTown", "direction": "west", "tiles": 4}, + {"event": "below_mart_door", "kind": "walk", "map": "PalletTown", "direction": "north", "tiles": 14}, + { + "event": "below_mart_door", + "kind": "transition", + "map": "PalletTown", + "direction": "north", + "to_map": "Route1", + "via": "edge" + }, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "north", "tiles": 7}, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "west", "tiles": 4}, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "north", "tiles": 5}, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "east", "tiles": 4}, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "north", "tiles": 6}, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "west", "tiles": 2}, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "north", "tiles": 4}, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "east", "tiles": 6}, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "north", "tiles": 15}, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "west", "tiles": 6}, + {"event": "below_mart_door", "kind": "walk", "map": "Route1", "direction": "north", "tiles": 2}, + { + "event": "below_mart_door", + "kind": "transition", + "map": "Route1", + "direction": "north", + "to_map": "ViridianCity", + "via": "edge" + }, + {"event": "below_mart_door", "kind": "walk", "map": "ViridianCity", "direction": "north", "tiles": 19}, + {"event": "below_mart_door", "kind": "walk", "map": "ViridianCity", "direction": "east", "tiles": 14}, + {"event": "below_mart_door", "kind": "turn", "map": "ViridianCity", "direction": "north"} + ] + } +} diff --git a/SerialPrograms/cmake/SourceFiles.cmake b/SerialPrograms/cmake/SourceFiles.cmake index 5210761570..0f9f768a04 100644 --- a/SerialPrograms/cmake/SourceFiles.cmake +++ b/SerialPrograms/cmake/SourceFiles.cmake @@ -102,6 +102,8 @@ file(GLOB CORE_LIBRARY_SOURCES ../Common/Cpp/Json/JsonObject.h ../Common/Cpp/Json/JsonTools.cpp ../Common/Cpp/Json/JsonTools.h + ../Common/Cpp/Json/JsonTools_Tests.cpp + ../Common/Cpp/Json/JsonTools_Tests.h ../Common/Cpp/Json/JsonValue.cpp ../Common/Cpp/Json/JsonValue.h ../Common/Cpp/LifetimeSanitizer.cpp @@ -1861,6 +1863,7 @@ file(GLOB LIBRARY_SOURCES Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecoveryWalk.h Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery_Tests.cpp Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp + Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRouteStrings.cpp Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.cpp Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldSim.h @@ -1950,6 +1953,13 @@ file(GLOB LIBRARY_SOURCES Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/JsonParser.h Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/JsonWriter.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/JsonWriter.h + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay.cpp + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay.h + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_IO.cpp + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_IO.h + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_Executor.cpp + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_Executor.h + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_Tests.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/LegacyJsonFormat.h Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/Json_Tests.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/Json_Tests.h diff --git a/devtools/story-replay/FRLG.cmake b/devtools/story-replay/FRLG.cmake new file mode 100644 index 0000000000..935534e5a9 --- /dev/null +++ b/devtools/story-replay/FRLG.cmake @@ -0,0 +1,15 @@ +# Console-free diagnostic executable; linked against the existing application library. +option(PA_FRLG_STORY_REPLAY "Build the native checkpoint replay diagnostic" OFF) +if(PA_FRLG_STORY_REPLAY) + execute_process(COMMAND git rev-parse HEAD WORKING_DIRECTORY "${REPO_ROOT_DIR}" + OUTPUT_VARIABLE STORY_REPLAY_REVISION OUTPUT_STRIP_TRAILING_WHITESPACE COMMAND_ERROR_IS_FATAL ANY) + execute_process(COMMAND git diff --quiet HEAD WORKING_DIRECTORY "${REPO_ROOT_DIR}" + RESULT_VARIABLE STORY_REPLAY_DIRTY) + if(NOT STORY_REPLAY_DIRTY EQUAL 0) + message(FATAL_ERROR "Commit tracked changes before configuring a provenance-bearing replay.") + endif() + add_executable(StoryCheckpointReplay ../devtools/story-replay/frlg_main.cpp) + target_link_libraries(StoryCheckpointReplay PRIVATE SerialProgramsLib) + apply_common_target_properties(StoryCheckpointReplay) + target_compile_definitions(StoryCheckpointReplay PRIVATE PA_STORY_REPLAY_REVISION="${STORY_REPLAY_REVISION}") +endif() diff --git a/devtools/story-replay/README.md b/devtools/story-replay/README.md new file mode 100644 index 0000000000..810ee222aa --- /dev/null +++ b/devtools/story-replay/README.md @@ -0,0 +1,106 @@ +# Native offline checkpoint replay + +This is the Arduino counterpart of [PokemonSimulation #73](https://github.com/dayfine/PokemonSimulation/pull/73). +It loads the actual evaluated FRLG Parcel capture, runs shared scenarios through +`Pokemon::build_story_checkpoint()` / `run_checkpoint()`, and checks semantic +walks, facing changes, map transitions, scene requests and outcomes. No console, +profile, save or controller is opened by the standalone tool. + +```sh +cmake -S SerialPrograms -B build -DPA_FRLG_STORY_REPLAY=ON +cmake --build build --target StoryCheckpointReplay -j4 +build/StoryCheckpointReplay > /tmp/frlg-parcel.replay.json +``` + +Configure from a clean committed tree so the tool records the actual build +revision. The executable uses embedded preview fixtures and does not fetch data +or run the console program. Native unit tests are registered as +`PokemonFRLG::StoryReplay`; the standard command-line test suite runs them. +GitHub CI builds/tests on the fork; Ubuntu additionally uploads +`frlg-native-replay--` and compares against the pinned Python runner. + +## Inputs versus expectations + +`Programs/AutoStory/ReplayData/` contains exact `binding.json` / `cases.json` +copies from the immutable PokemonSimulation revision in `pin.json`. The capture +itself is the existing `StoryData/frlg-parcel-00.05.json`. These are **PR #73 +preview test fixtures**, not an approved resource/production-plan adoption. +The 53-map production resource pin is unchanged. Every selected native embedded +map must match its Python fixture's exact raw-byte SHA-256 before execution. + +`route-candidate.json` is a separate **input**: the Python planner's actual route +actions. It is generated by `checkpoint_replay.run(binding, scenario)` and never +from `expectation.actions`. CI independently regenerates it from the pinned +Python implementation and checks it against the copied candidate. An assertion +cannot make the native executor walk a desired answer. + +The native adapter validates that candidate against its own production movement +rules, then passes the resulting route to the existing `run_route()` with +scripted IO. It records requests before execution. An interrupted seven-tile +walk remains a seven-tile request; only its two possible positions survive, +with facing unknown. Later actions and saving must remain absent. + +When `input.check_actions` is true, native tests and Python's existing checker +compare the **entire ordered semantic action list**, including an empty list for +a failed entry. There are no button counts or durations in those expectations. +The seven shared cases cover success, failed entry, missing scene observation, +missing receipt, wrong arrival, unconfirmed Mart transition and a Route 1 +encounter. Ten native negative controls reject bad pins/bindings/policies/routes +and detect omitted, extra, reordered or incorrect actions. + +## What is independently exercised + +- The existing native checkpoint engine, including its explicit `from_save` + initialization event. Its raw logs/pose/failure remain in the artifact. +- The existing native collision/warp/edge implementation, exposed by the narrow + prediction wrapper. No second native movement model is added. +- The existing route runner with scripted callbacks. Source connection steps + are annotated on supplied transition legs for the native hold model. +- A separate `OverworldSimWalker` timing control for uninterrupted routes, + including fades and connection stalls; its status, position and violations + are reported and checked for the successful case. +- The native story-cost planner and `follow_overworld_route()` as a separate + alternative. Its legs are exported; they do not drive the supplied-candidate + replay and are not required to equal Python's different-cost plan. + +This is **Python-candidate replay on native code**, not proof that the native +planner chose the Python route, nor full live-executor parity. Exact native +planner-leg parity needs a separate matching-policy contract. + +## Policy and evidence limits + +The diagnostic explicitly binds one attempt, stop on interruption/failure and +no saving, matching Python's profile. It does not change the production game +executor or silently resolve console runtime options. Hops in supplied candidates are explicitly unsupported by this native importer; +no parity with Python’s ledge handling is claimed. Scene observations and +item receipts are synthetic; dialogue/animation and actual inventory are not +simulated. Native checkpoint bookkeeping does not become entry evidence. + +The live executor still uses a nominal same-map STEP pose, authored scene +fallback and end-save behavior. The diagnostic instead resolves the predicted +warp and requires scripted scene evidence. The existing native C++-table versus +loaded-row simulation test preserves the live-wrapper behavior, separately from +this stricter profile. Production save/retry configuration remains unbound. +No native behavior fix is being hidden inside JSON loading. + +The timing control has no encounters. The shared uncertain-encounter case uses +a deliberately scripted IO stop, not measured battle/detector timing. Queued +input, video recognition, real stillness, automatic recovery/rejoin and physical +controller behavior remain outside this conformance slice. Existing game-specific +simulation tests continue to cover their own cases. + +## Review order + +1. Read the [shared readable cases](https://github.com/dayfine/PokemonSimulation/blob/9cb12c52f5f055299e32c2a7cf43e7103c101e3e/data/story/parcel-pilot/CASES.md). +2. `PokemonFRLG_StoryReplay_IO.*` validates pins and bindings; + `PokemonFRLG_StoryReplay_Executor.*` adapts game actions to the existing engine. +3. `PokemonFRLG_StoryReplay_Tests.cpp` contains shared-case checks and negatives. +4. The earlier unpublished loader commit `426de16e7` is reused, preserving its + author/history. The route-formatting extraction is a separate preparation + commit and moves its two functions unchanged; the prediction wrapper then + fits in the existing planner without raising the file-size limit. + +```sh +git diff --color-moved=dimmed-zebra --color-moved-ws=allow-indentation-change \ + origin/frlg/stage7-celadon...frlg/checkpoint-replay +``` diff --git a/devtools/story-replay/frlg_main.cpp b/devtools/story-replay/frlg_main.cpp new file mode 100644 index 0000000000..608ac996b2 --- /dev/null +++ b/devtools/story-replay/frlg_main.cpp @@ -0,0 +1,41 @@ +/* Offline native replay. No console, profile, app instance or controller IO. + * cmake -S SerialPrograms -B build -DPA_FRLG_STORY_REPLAY=ON + * cmake --build build --target StoryCheckpointReplay -j4 + * build/StoryCheckpointReplay > parcel-native-replay.json + * Compare against the pinned PokemonSimulation scenarios in CI. + */ +#include +#include "Common/Cpp/Exceptions.h" +#include "Common/Cpp/Json/JsonArray.h" +#include "Common/Cpp/Json/JsonObject.h" +#include "VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay.h" +int main(int argc, char**){ + using namespace PokemonAutomation; + using namespace NintendoSwitch::PokemonFRLG; + if (argc != 1){ + std::cerr << "Usage: StoryCheckpointReplay > native-replay.json\n"; + return 2; + } + try{ + const JsonValue cases = parse_json(story_replay_fixture("cases")); + JsonArray reports; + for (const JsonValue& test : cases.to_object_throw().get_array_throw("cases")){ + JsonValue report = replay_story_checkpoint(story_replay_fixture("capture"), story_replay_fixture("binding"), + story_replay_fixture("route-candidate"), test.to_object_throw().get_value_throw("input")); + JsonObject item; + item["id"] = test.to_object_throw().get_string_throw("id"); + item["report"] = std::move(report); + reports.push_back(std::move(item)); + } + JsonObject result; + result["format"] = "pokesim-native-replay-suite/1"; + result["native_execution_revision"] = PA_STORY_REPLAY_REVISION; + result["fixture_pin"] = parse_json(story_replay_fixture("pin")); + result["reports"] = std::move(reports); + std::cout << result.dump(-1) << '\n'; + return 0; + }catch (const Exception& error){ + std::cerr << error.to_str() << '\n'; + return 1; + } +}