From 426de16e7dc24de72b462655793c7f6a8efe7bcc Mon Sep 17 00:00:00 2001 From: dayfine Date: Sun, 8 Nov 2026 01:27:05 -0800 Subject: [PATCH 01/15] PokemonFRLG: Load an exported checkpoint record back into a story row read_story_checkpoint_export() is the inverse of story_checkpoint_export(): it reads a pokesim-native-checkpoint-export/1 record (the shared checkpoint through Pokemon::read_story_checkpoint, plus FRLG's menu and supplies) into the same StoryCheckpoint type that story_checkpoints() holds, so build_checkpoint() and make_story_segment() take loaded rows unchanged. The envelope constants are checked, every native field is required and unknown fields fail, so a record is read as written or refused. PokemonSimulation's data/story/native/frlg-parcel-00.05.json (its commit 051785fa251f37b71eb661d4b583435d70f69439, the export tool's output for row 00.05) is built into the program from StoryData/ as autostory_checkpoint_json(), and load_story_checkpoint() reads it. Tests: CheckpointExport reads the capture and checks it exports back to the same file and to the same JSON as table row 00.05, and refuses eight altered copies; the existing export cases also round-trip every native field. AutoStory - Simulation runs the loaded 00.05 and the table's 00.05 through FakeGame and compares describe(), the trace, the log lines and the save pose. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013VUubhHeYSMGSSDXZkKZss --- SerialPrograms/CMakeLists.txt | 2 + .../AutoStory/PokemonFRLG_AutoStoryExport.cpp | 184 +++++++++++++++++- .../AutoStory/PokemonFRLG_AutoStoryExport.h | 19 ++ .../PokemonFRLG_AutoStoryExport_Tests.cpp | 138 +++++++++++++ .../PokemonFRLG_AutoStorySimulation_Tests.cpp | 43 +++- .../StoryData/frlg-parcel-00.05.json | 67 +++++++ 6 files changed, 447 insertions(+), 6 deletions(-) create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/frlg-parcel-00.05.json diff --git a/SerialPrograms/CMakeLists.txt b/SerialPrograms/CMakeLists.txt index 73729ebde9..8c013541af 100644 --- a/SerialPrograms/CMakeLists.txt +++ b/SerialPrograms/CMakeLists.txt @@ -231,6 +231,8 @@ pa_embed_text_files(SerialProgramsLib Source/VideoGames/PokemonFRLG/Overworld/Ma VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.h PokemonAutomation::NintendoSwitch::PokemonFRLG overworld_map_json) pa_embed_text_files(SerialProgramsLib Source/VideoGames/PokemonFRLG/Programs/AutoStory/BattleData 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/PokemonFRLG_AutoStoryExport.h PokemonAutomation::NintendoSwitch::PokemonFRLG autostory_checkpoint_json) # Create binary target diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryExport.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryExport.cpp index a0b85c21ea..82b19da268 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryExport.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryExport.cpp @@ -1,6 +1,8 @@ /* FRLG Authored Checkpoint Export * From: https://github.com/PokemonAutomation/ */ +#include +#include #include #include #include "Common/Cpp/Exceptions.h" @@ -12,6 +14,18 @@ namespace PokemonAutomation{ namespace NintendoSwitch{ namespace PokemonFRLG{ namespace{ +// The envelope constants the export writes and the reader requires. +constexpr std::string_view FORMAT = "pokesim-native-checkpoint-export/1"; +constexpr std::string_view GAME = "frlg"; +constexpr std::string_view STATUS = "native-table-export"; +constexpr std::string_view STATE_BASIS = "native-table-bookkeeping"; +const std::vector& unbound_fields(){ + static const std::vector UNBOUND{ + "runtime_configuration", "map_pins", "executor_bindings", "entry_evidence", "effective_save_retry_policy", + }; + return UNBOUND; +} + const char* menu_name(StartMenu menu){ switch (menu){ case StartMenu::BEFORE_POKEMON: return "before_pokemon"; @@ -83,21 +97,181 @@ JsonObject supplies_json(const Supplies& supplies){ result["wild_foes"] = strings(supplies.wild_foes); return result; } + +// Reading. Every field is required and no other is allowed, as in the +// shared codec: the record says what the table said, not what it left out. +const JsonObject& fields(const JsonValue& value, std::initializer_list keys, std::string_view source){ + const JsonObject& object = value.to_object_throw(std::string(source)); + for (const auto& [key, unused] : object){ + bool known = false; + for (std::string_view allowed : keys){ + known |= key == allowed; + } + if (!known){ + throw ParseException(std::format("{}: unsupported field {}", source, key)); + } + } + return object; +} +void expect_string(const JsonObject& root, const std::string& key, std::string_view expected, std::string_view source){ + const std::string& actual = root.get_string_throw(key, std::string(source)); + if (actual != expected){ + throw ParseException(std::format("{}: {} is {}, not {}", source, key, actual, expected)); + } +} +// The value of `values` that `name` names `label`. +template +Enum read_named(std::initializer_list values, const char* (*name)(Enum), const std::string& label, std::string_view source){ + for (Enum value : values){ + if (label == name(value)){ + return value; + } + } + throw ParseException(std::format("{}: unsupported value {}", source, label)); +} +// An integer within [`low`, `high`], as the native field's type holds it. +int64_t bounded(const JsonValue& value, int64_t low, int64_t high, std::string_view source){ + const int64_t number = value.to_integer_throw(std::string(source)); + if (number < low || number > high){ + throw ParseException(std::format("{}: {} is out of range", source, number)); + } + return number; +} +int read_int(const JsonValue& value, std::string_view source){ + return (int)bounded(value, std::numeric_limits::min(), std::numeric_limits::max(), source); +} +uint8_t read_byte(const JsonValue& value, std::string_view source){ + return (uint8_t)bounded(value, 0, std::numeric_limits::max(), source); +} +uint16_t read_word(const JsonValue& value, std::string_view source){ + return (uint16_t)bounded(value, 0, std::numeric_limits::max(), source); +} +size_t read_count(const JsonValue& value, std::string_view source){ + return (size_t)bounded(value, 0, std::numeric_limits::max(), source); +} +std::vector read_strings(const JsonValue& value, std::string_view source){ + std::vector result; + for (const JsonValue& item : value.to_array_throw(std::string(source))){ + result.push_back(item.to_string_throw(std::string(source))); + } + return result; +} +std::optional read_stats(const JsonValue& value, std::string_view source){ + if (value.is_null()){ + return std::nullopt; + } + const JsonObject& root = fields(value, {"hp", "attack", "defense", "sp_attack", "sp_defense", "speed"}, source); + return Stats{ + .hp = read_word(root.get_value_throw("hp"), source), + .attack = read_word(root.get_value_throw("attack"), source), + .defense = read_word(root.get_value_throw("defense"), source), + .sp_attack = read_word(root.get_value_throw("sp_attack"), source), + .sp_defense = read_word(root.get_value_throw("sp_defense"), source), + .speed = read_word(root.get_value_throw("speed"), source), + }; +} +std::vector read_moves(const JsonValue& value, std::string_view source){ + std::vector result; + for (const JsonValue& item : value.to_array_throw(std::string(source))){ + const JsonObject& row = fields(item, {"move", "pp"}, source); + result.push_back({.move = row.get_string_throw("move"), .pp = read_byte(row.get_value_throw("pp"), source)}); + } + return result; +} +PartyMember read_member(const JsonValue& value, std::string_view source){ + const JsonObject& root = fields(value, {"species", "moves", "moves_through_level", "moves_read", "level", "stats"}, source); + return PartyMember{ + .species = root.get_string_throw("species"), + .moves = read_moves(root.get_value_throw("moves"), source), + .moves_through_level = read_byte(root.get_value_throw("moves_through_level"), source), + .moves_read = root.get_boolean_throw("moves_read"), + .level = read_byte(root.get_value_throw("level"), source), + .stats = read_stats(root.get_value_throw("stats"), source), + }; +} +std::vector read_slots(const JsonValue& value, std::string_view source){ + std::vector result; + for (const JsonValue& item : value.to_array_throw(std::string(source))){ + result.push_back(read_count(item, source)); + } + return result; +} +std::optional read_foe_out(const JsonValue& value, std::string_view source){ + if (value.is_null()){ + return std::nullopt; + } + return read_count(value, source); +} +Supplies read_supplies(const JsonValue& value, std::string_view source){ + const JsonObject& root = fields(value, {"potions", "antidotes", "parlyz_heals", "pocket", "status", "hurt", "hp_red", + "hp_fill", "lead", "protected_slots", "foes", "trainer", "risk", "foe_out", "wild_foes"}, source); + Supplies result; + result.potions = read_int(root.get_value_throw("potions"), source); + result.antidotes = read_int(root.get_value_throw("antidotes"), source); + result.parlyz_heals = read_int(root.get_value_throw("parlyz_heals"), source); + result.pocket = read_strings(root.get_value_throw("pocket"), source); + result.status = read_named({StatusCondition::NONE, StatusCondition::POISON, StatusCondition::PARALYSIS, StatusCondition::OTHER}, + status_name, root.get_string_throw("status"), source); + result.hurt = root.get_boolean_throw("hurt"); + result.hp_red = root.get_boolean_throw("hp_red"); + result.hp_fill = root.get_double_throw("hp_fill"); + result.lead = read_member(root.get_value_throw("lead"), source); + result.protected_slots = read_slots(root.get_value_throw("protected_slots"), source); + result.foes = read_strings(root.get_value_throw("foes"), source); + result.trainer = root.get_string_throw("trainer"); + result.risk = read_named({BattleRisk::LOW, BattleRisk::HIGH}, battle_risk_name, root.get_string_throw("risk"), source); + result.foe_out = read_foe_out(root.get_value_throw("foe_out"), source); + result.wild_foes = read_strings(root.get_value_throw("wild_foes"), source); + return result; +} +void check_envelope(const JsonObject& root, std::string_view source){ + expect_string(root, "format", FORMAT, source); + expect_string(root, "game", GAME, source); + expect_string(root, "status", STATUS, source); + expect_string(root, "state_basis", STATE_BASIS, source); + if (read_strings(root.get_value_throw("unbound"), source) != unbound_fields()){ + throw ParseException(std::format("{}: unbound is not the export's list", source)); + } + if (root.get_string_throw("source_revision").empty()){ + throw ParseException(std::format("{}: source_revision names no revision", source)); + } +} } // namespace JsonValue story_checkpoint_export(const StoryCheckpoint& checkpoint){ JsonObject result; - result["format"] = "pokesim-native-checkpoint-export/1"; - result["game"] = "frlg"; - result["status"] = "native-table-export"; + result["format"] = std::string(FORMAT); + result["game"] = std::string(GAME); + result["status"] = std::string(STATUS); result["checkpoint"] = Pokemon::story_checkpoint_json(checkpoint); JsonObject native; native["menu"] = menu_name(checkpoint.menu); native["supplies"] = supplies_json(checkpoint.supplies); result["native_fields"] = std::move(native); - result["state_basis"] = "native-table-bookkeeping"; - result["unbound"] = strings({"runtime_configuration", "map_pins", "executor_bindings", "entry_evidence", "effective_save_retry_policy"}); + result["state_basis"] = std::string(STATE_BASIS); + result["unbound"] = strings(unbound_fields()); return result; } +StoryCheckpoint read_story_checkpoint_export(const JsonValue& value, std::string_view source){ + const JsonObject& root = fields(value, + {"format", "game", "status", "checkpoint", "native_fields", "state_basis", "unbound", "source_revision"}, source); + check_envelope(root, source); + StoryCheckpoint result; + Pokemon::StoryCheckpoint& common = result; + common = Pokemon::read_story_checkpoint(root.get_value_throw("checkpoint"), source); + const JsonObject& native = fields(root.get_value_throw("native_fields"), {"menu", "supplies"}, source); + result.menu = read_named({StartMenu::BEFORE_POKEMON, StartMenu::BEFORE_POKEDEX, StartMenu::FULL}, + menu_name, native.get_string_throw("menu"), source); + result.supplies = read_supplies(native.get_value_throw("supplies"), source); + return result; +} +StoryCheckpoint load_story_checkpoint(std::string_view file){ + for (const auto& [name, text] : autostory_checkpoint_json()){ + if (name == file){ + return read_story_checkpoint_export(parse_json(text), std::format("StoryData/{}.json", file)); + } + } + throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, std::format("StoryData/{}.json is not built into the program.", file)); +} } // namespace PokemonFRLG } // namespace NintendoSwitch } // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryExport.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryExport.h index f2cc73c6b2..3e0b740e57 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryExport.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryExport.h @@ -3,6 +3,10 @@ */ #ifndef PokemonAutomation_PokemonFRLG_AutoStoryExport_H #define PokemonAutomation_PokemonFRLG_AutoStoryExport_H +#include +#include +#include +#include #include "Common/Cpp/Json/JsonValue.h" #include "PokemonFRLG_AutoStoryEvents.h" namespace PokemonAutomation{ @@ -12,6 +16,21 @@ namespace PokemonFRLG{ // Native supplies are bookkeeping, not observed state or entry requirements. // Runtime options, map pins, effective policies and execution remain unbound. JsonValue story_checkpoint_export(const StoryCheckpoint& checkpoint); +// The inverse: a row from a `pokesim-native-checkpoint-export/1` record for +// FRLG, as story_checkpoints() would hold it, for build_checkpoint(). The +// envelope's format, game, status, state basis and unbound list must be the +// ones the export writes, and `source_revision` must name a revision; the +// loader cannot check which. Every native field is required: a value the +// record lacks is not a default. Unknown fields and values fail (ParseException). +StoryCheckpoint read_story_checkpoint_export(const JsonValue& value, std::string_view source); +// The checkpoint records built into the program from +// Programs/AutoStory/StoryData/*.json (cmake/EmbedTextFiles.cmake): +// (file name without ".json", its text), sorted by name. +const std::vector>& autostory_checkpoint_json(); +// The record `file` of autostory_checkpoint_json(), read with +// read_story_checkpoint_export(). Throws InternalProgramError for a file +// that isn't built in. +StoryCheckpoint load_story_checkpoint(std::string_view file); } // namespace PokemonFRLG } // namespace NintendoSwitch } // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryExport_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryExport_Tests.cpp index bc4e0f3f12..64a355f708 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryExport_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryExport_Tests.cpp @@ -1,6 +1,7 @@ /* FRLG Checkpoint Export Tests * From: https://github.com/PokemonAutomation/ */ +#include "Common/Cpp/Exceptions.h" #include "Common/Cpp/Json/JsonArray.h" #include "Common/Cpp/Json/JsonObject.h" #include "Pokemon/AutoStory/Pokemon_StoryStepJson.h" @@ -52,6 +53,12 @@ void customize(StoryCheckpoint& row){ state.foe_out = 0; state.wild_foes = {"pidgey"}; } +// The export as the tool writes it to a file: with its source revision. +JsonValue exported_file(const StoryCheckpoint& row, const std::string& revision){ + JsonValue result = story_checkpoint_export(row); + result.to_object_throw()["source_revision"] = revision; + return result; +} std::string check_export(StoryCheckpoint row, const ExportCase& test){ if (test.input.custom){ customize(row); @@ -69,6 +76,11 @@ std::string check_export(StoryCheckpoint row, const ExportCase& test){ if (!(Pokemon::story_checkpoint_json(decoded) == Pokemon::story_checkpoint_json(row))){ return "Expected every authored common field to survive export/import."; } + // Every native field too: the whole file, read back, exports the same. + const StoryCheckpoint reread = read_story_checkpoint_export(exported_file(row, "test"), "export test"); + if (!(exported_file(reread, "test") == exported_file(row, "test"))){ + return "Expected every native field to survive export/import, read " + story_checkpoint_export(reread).dump(-1); + } std::vector names; for (const auto& step : decoded.steps){ names.push_back(step.name); @@ -91,11 +103,137 @@ class ExportTest : public UnitTest{ private: ExportCase m_test; }; + + +// Reading the Parcel capture built into the program (StoryData/ +// frlg-parcel-00.05.json, PokemonSimulation's data/story/native copy of +// the export tool's output): as written, it is row 00.05 and exports back +// to the same file; changed, it is refused rather than read with a guess. +struct LoadInput{ + std::string_view name; + // Applied to the capture before reading; none reads it as written. + void (*mutate)(JsonObject& root) = nullptr; +}; +struct LoadExpectation{ + // The table row the loaded one must export like; empty when rejected. + std::string_view row = {}; + bool rejected = false; +}; +struct LoadCase{ + LoadInput input; + LoadExpectation expectation; +}; +// `object` without `key`: JsonObject has no erase. +JsonObject without(const JsonObject& object, std::string_view key){ + JsonObject result; + for (const auto& [name, value] : object){ + if (name != key){ + result[name] = value.clone(); + } + } + return result; +} +void add_top_level_field(JsonObject& root){ + root["mystery"] = 1; +} +void change_format(JsonObject& root){ + root["format"] = "pokesim-story-plan/1"; +} +void add_native_field(JsonObject& root){ + root.get_object_throw("native_fields")["speed"] = "fast"; +} +void unknown_menu(JsonObject& root){ + root.get_object_throw("native_fields")["menu"] = "before_bag"; +} +void drop_potions(JsonObject& root){ + JsonObject& native = root.get_object_throw("native_fields"); + native["supplies"] = without(native.get_object_throw("supplies"), "potions"); +} +void negative_pp(JsonObject& root){ + JsonObject& supplies = root.get_object_throw("native_fields").get_object_throw("supplies"); + supplies.get_object_throw("lead").get_array_throw("moves")[0].to_object_throw()["pp"] = -1; +} +void foe_out_as_text(JsonObject& root){ + root.get_object_throw("native_fields").get_object_throw("supplies")["foe_out"] = "0"; +} +void drop_revision(JsonObject& root){ + root = without(root, "source_revision"); +} +const LoadCase LOAD_CASES[] = { + {.input = {.name = "the Parcel capture is row 00.05"}, .expectation = {.row = "00.05"}}, + {.input = {.name = "an unknown envelope field", .mutate = add_top_level_field}, .expectation = {.rejected = true}}, + {.input = {.name = "another format", .mutate = change_format}, .expectation = {.rejected = true}}, + {.input = {.name = "an unknown native field", .mutate = add_native_field}, .expectation = {.rejected = true}}, + {.input = {.name = "an unknown START menu", .mutate = unknown_menu}, .expectation = {.rejected = true}}, + {.input = {.name = "a missing supplies field", .mutate = drop_potions}, .expectation = {.rejected = true}}, + {.input = {.name = "PP below zero", .mutate = negative_pp}, .expectation = {.rejected = true}}, + {.input = {.name = "foe_out as text", .mutate = foe_out_as_text}, .expectation = {.rejected = true}}, + {.input = {.name = "no source revision", .mutate = drop_revision}, .expectation = {.rejected = true}}, +}; +const std::string& parcel_text(){ + for (const auto& [name, text] : autostory_checkpoint_json()){ + if (name == "frlg-parcel-00.05"){ + return text; + } + } + throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "StoryData/frlg-parcel-00.05.json is not built into the program."); +} +const StoryCheckpoint* table_row(std::string_view id){ + for (const StoryCheckpoint& row : story_checkpoints()){ + if (row.id == id){ + return &row; + } + } + return nullptr; +} +std::string check_loaded(const StoryCheckpoint& loaded, const JsonValue& capture, const LoadCase& test){ + const std::string& revision = capture.to_object_throw().get_string_throw("source_revision"); + if (!(exported_file(loaded, revision) == capture)){ + return "Expected the loaded row to export as the capture, read " + story_checkpoint_export(loaded).dump(-1); + } + const StoryCheckpoint* row = table_row(test.expectation.row); + if (!row){ + return "Expected table row " + std::string(test.expectation.row) + ", read no matching row."; + } + if (!(story_checkpoint_export(loaded) == story_checkpoint_export(*row))){ + return "Expected the loaded row to export as the table's " + row->id + ", read " + story_checkpoint_export(loaded).dump(-1); + } + return ""; +} +std::string check_load(const LoadCase& test){ + JsonValue capture = parse_json(parcel_text()); + if (test.input.mutate){ + test.input.mutate(capture.to_object_throw()); + } + try{ + const StoryCheckpoint loaded = read_story_checkpoint_export(capture, test.input.name); + if (test.expectation.rejected){ + return "Expected rejection, read checkpoint " + loaded.id; + } + return check_loaded(loaded, capture, test); + }catch (const ParseException& error){ + return test.expectation.rejected ? "" : "Unexpected parse failure: " + error.message(); + } +} +class LoadTest : public UnitTest{ +public: + explicit LoadTest(LoadCase test) + : UnitTest("PokemonFRLG::CheckpointExport - read " + std::string(test.input.name)), m_test(test){} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + const std::string error = check_load(m_test); + return error.empty() ? UnitTestResult(true) : UnitTestResult(error); + } +private: + LoadCase m_test; +}; } // namespace void add_tests_AutoStoryExport(UnitTestDatabase& database){ for (const ExportCase& test : CASES){ database.add(test); } + for (const LoadCase& test : LOAD_CASES){ + database.add(test); + } } } // namespace PokemonFRLG } // namespace NintendoSwitch diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp index bf6ac032a3..db443cd9fa 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp @@ -24,6 +24,7 @@ #include "Pokemon/AutoStory/Pokemon_StorySegments.h" #include "Pokemon/AutoStory/Pokemon_StorySteps.h" #include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h" +#include "PokemonFRLG_AutoStoryExport.h" #include "PokemonFRLG_AutoStoryTable.h" namespace PokemonAutomation{ @@ -224,6 +225,8 @@ class FakeGame : public Pokemon::StoryStepExecutor{ // checkpoint saves at its end (hooks.save stands for save_game()). struct Simulation{ FakeGame game; + // The rows run: the whole table unless a test sets its own. + std::vector table = story_checkpoints(); std::vector lines; // Where each save stood: the end of its checkpoint. std::vector> saves; @@ -234,7 +237,7 @@ struct Simulation{ game.set_language(Language::Japanese); Pokemon::Segment segment; segment.id = "00"; - for (const StoryCheckpoint& data : story_checkpoints()){ + for (const StoryCheckpoint& data : table){ segment.checkpoints.push_back(Pokemon::CheckpointSpec{ .id = data.id, .title = data.title, .saves = true, .build = [this, &data]{ return Pokemon::build_story_checkpoint(data, game); }, @@ -389,6 +392,40 @@ UnitTestResult test_gives_up_after_attempts(){ return true; } +// 00.05 as the table authors it and as the data file built into the +// program (StoryData/frlg-parcel-00.05.json, the export tool's capture of +// that row): the loaded row builds the same events, and run against the +// fake game walks the same route, leaves the same trace and saves in the +// same place. +UnitTestResult test_data_row_parity(){ + const std::vector& table = story_checkpoints(); + const auto authored = std::find_if(table.begin(), table.end(), [](const StoryCheckpoint& row){ return row.id == "00.05"; }); + if (authored == table.end()){ + return "Expected row 00.05 in the table."; + } + const StoryCheckpoint loaded = load_story_checkpoint("frlg-parcel-00.05"); + FakeGame describer; + const std::string authored_events = Pokemon::describe(Pokemon::build_story_checkpoint(*authored, describer)); + const std::string loaded_events = Pokemon::describe(Pokemon::build_story_checkpoint(loaded, describer)); + if (authored_events != loaded_events){ + return "Expected the same events from the table and the data:\n" + authored_events + "\n" + loaded_events; + } + Simulation from_table; + from_table.table = {*authored}; + Simulation from_data; + from_data.table = {loaded}; + const bool completed = from_table.run({}).completed && from_data.run({}).completed; + if (!completed || from_table.game.trace != from_data.game.trace || from_table.lines != from_data.lines){ + return "Expected the same run from the table and the data:" + joined(from_table.game.trace) + joined(from_data.game.trace); + } + if (from_table.saves.size() != 1 || from_data.saves.size() != 1 + || Pokemon::to_string(from_table.saves[0].second) != Pokemon::to_string(from_data.saves[0].second) + ){ + return "Expected both runs to save once, in the same place:" + joined(from_table.lines) + joined(from_data.lines); + } + return true; +} + const AutoStorySimulationTestCase AUTO_STORY_SIMULATION_CASES[] = { { .input = {.name = "new game to Brock, in order, each start where the last save stood", .body = test_whole_story}, @@ -406,6 +443,10 @@ const AutoStorySimulationTestCase AUTO_STORY_SIMULATION_CASES[] = { .input = {.name = "a battle lost every time stops after 3 attempts", .body = test_gives_up_after_attempts}, .expectation = {.description = "aborts after three consecutive battle blackouts"}, }, + { + .input = {.name = "00.05 from the data file runs as the table's row", .body = test_data_row_parity}, + .expectation = {.description = "the loaded checkpoint builds the same events and leaves the same fake-game trace"}, + }, }; } // namespace diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/frlg-parcel-00.05.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/frlg-parcel-00.05.json new file mode 100644 index 0000000000..7dbce4122c --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/frlg-parcel-00.05.json @@ -0,0 +1,67 @@ +{ + "checkpoint": { + "events": [ + { + "id": "below_mart_door", + "kind": "route", + "target": { + "certainty": "tile", + "map": "ViridianCity", + "where": {"facing": "north", "kind": "tiles", "tiles": [[36, 20]]} + }, + "why": "Route 1, running from wild battles" + }, + {"id": "mart_door", "kind": "step", "why": "the clerk's scene starts on entering"}, + { + "declared_after": { + "certainty": "tile", + "map": "ViridianCity_Mart", + "where": {"facing": "west", "kind": "tiles", "tiles": [[4, 3]]} + }, + "id": "parcel_from_clerk", + "kind": "scene", + "why": "the clerk gives OAK'S PARCEL" + } + ], + "id": "00.05", + "start": { + "certainty": "tile", + "map": "PalletTown_ProfessorOaksLab", + "where": {"facing": "south", "kind": "tiles", "tiles": [[7, 8]]} + }, + "title": "Route 1 to Viridian Mart: Oak's Parcel" + }, + "format": "pokesim-native-checkpoint-export/1", + "game": "frlg", + "native_fields": { + "menu": "before_pokedex", + "supplies": { + "antidotes": 0, + "foe_out": null, + "foes": [], + "hp_fill": 1.0, + "hp_red": false, + "hurt": false, + "lead": { + "level": 0, + "moves": [{"move": "tackle", "pp": 35}, {"move": "tail-whip", "pp": 30}], + "moves_read": false, + "moves_through_level": 5, + "species": "squirtle", + "stats": null + }, + "parlyz_heals": 0, + "pocket": ["potion", "antidote", "parlyz-heal"], + "potions": 0, + "protected_slots": [], + "risk": "low", + "status": "no status", + "trainer": "", + "wild_foes": [] + } + }, + "source_revision": "b170b7d2103e887ded825c37fdbdc19fd654fca8", + "state_basis": "native-table-bookkeeping", + "status": "native-table-export", + "unbound": ["runtime_configuration", "map_pins", "executor_bindings", "entry_evidence", "effective_save_retry_policy"] +} From eb8ed7e7ab8f3de082af7ce7f9cc8bfbe74483a8 Mon Sep 17 00:00:00 2001 From: dayfine Date: Sun, 8 Nov 2026 11:57:38 -0800 Subject: [PATCH 02/15] Move unchanged overworld route formatting out of the planner implementation --- .../Overworld/PokemonFRLG_OverworldRoute.cpp | 28 ------------- .../PokemonFRLG_OverworldRouteStrings.cpp | 39 +++++++++++++++++++ SerialPrograms/cmake/SourceFiles.cmake | 1 + 3 files changed, 40 insertions(+), 28 deletions(-) create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRouteStrings.cpp diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp index 47158d6cf9..eeda1a0a10 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}, 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/cmake/SourceFiles.cmake b/SerialPrograms/cmake/SourceFiles.cmake index 3d792d6451..010a497249 100644 --- a/SerialPrograms/cmake/SourceFiles.cmake +++ b/SerialPrograms/cmake/SourceFiles.cmake @@ -1836,6 +1836,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 From 4f2629db5dc4c1697f322e84ce98967c761722cb Mon Sep 17 00:00:00 2001 From: dayfine Date: Sun, 8 Nov 2026 12:17:40 -0800 Subject: [PATCH 03/15] Replay shared FRLG Parcel scenarios through the native checkpoint engine and walker --- .../workflows/cpp-ci-serial-programs-base.yml | 89 +++- SerialPrograms/CMakeLists.txt | 4 + .../Overworld/PokemonFRLG_OverworldRoute.cpp | 23 + .../Overworld/PokemonFRLG_OverworldRoute.h | 17 + .../PokemonFRLG_AutoStoryExport_Tests.cpp | 2 + .../AutoStory/PokemonFRLG_StoryReplay.cpp | 43 ++ .../AutoStory/PokemonFRLG_StoryReplay.h | 29 ++ .../PokemonFRLG_StoryReplay_Executor.cpp | 320 ++++++++++++++ .../PokemonFRLG_StoryReplay_Executor.h | 67 +++ .../AutoStory/PokemonFRLG_StoryReplay_IO.cpp | 307 ++++++++++++++ .../AutoStory/PokemonFRLG_StoryReplay_IO.h | 47 +++ .../PokemonFRLG_StoryReplay_Tests.cpp | 194 +++++++++ .../AutoStory/ReplayData/binding.json | 30 ++ .../Programs/AutoStory/ReplayData/cases.json | 397 ++++++++++++++++++ .../Programs/AutoStory/ReplayData/pin.json | 24 ++ .../AutoStory/ReplayData/route-candidate.json | 63 +++ SerialPrograms/cmake/SourceFiles.cmake | 7 + devtools/story-replay/FRLG.cmake | 15 + devtools/story-replay/README.md | 106 +++++ devtools/story-replay/frlg_main.cpp | 41 ++ 20 files changed, 1824 insertions(+), 1 deletion(-) create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay.cpp create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay.h create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_Executor.cpp create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_Executor.h create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_IO.cpp create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_IO.h create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_Tests.cpp create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/ReplayData/binding.json create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/ReplayData/cases.json create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/ReplayData/pin.json create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/ReplayData/route-candidate.json create mode 100644 devtools/story-replay/FRLG.cmake create mode 100644 devtools/story-replay/README.md create mode 100644 devtools/story-replay/frlg_main.cpp diff --git a/.github/workflows/cpp-ci-serial-programs-base.yml b/.github/workflows/cpp-ci-serial-programs-base.yml index e2393a205f..cb4b76c3f2 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: 020a12db154b8a832bfbdd371884f35c18563319 + 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: Install Qt uses: jurplel/install-qt-action@v4 with: @@ -223,7 +267,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 @@ -249,6 +293,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/SerialPrograms/CMakeLists.txt b/SerialPrograms/CMakeLists.txt index ceac98eeeb..5560095c05 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/PokemonFRLG_AutoStoryExport.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/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp index eeda1a0a10..8556442dd0 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp @@ -344,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/Programs/AutoStory/PokemonFRLG_AutoStoryExport_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryExport_Tests.cpp index 64a355f708..fa23863055 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryExport_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryExport_Tests.cpp @@ -7,6 +7,7 @@ #include "Pokemon/AutoStory/Pokemon_StoryStepJson.h" #include "PokemonFRLG_AutoStoryExport.h" #include "PokemonFRLG_AutoStoryTable.h" +#include "PokemonFRLG_StoryReplay.h" #include "PokemonFRLG_AutoStoryExport_Tests.h" namespace PokemonAutomation{ namespace NintendoSwitch{ @@ -228,6 +229,7 @@ class LoadTest : public UnitTest{ }; } // namespace void add_tests_AutoStoryExport(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..1deec198fe --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_Executor.cpp @@ -0,0 +1,320 @@ +/* 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)) +{} +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 = Pokemon::StoryStepExecutor::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); +} +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::route(Pokemon::RunState&, 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::step(Pokemon::RunState&, 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::mash(Pokemon::RunState&, 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 == "require_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..4a7fd7db47 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_Executor.h @@ -0,0 +1,67 @@ +/* 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 "Common/Cpp/Json/JsonArray.h" +#include "Pokemon/AutoStory/Pokemon_StorySteps.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRecovery.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; +}; +// 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); + // Existing engine dispatch with ordered authored completions and lost-pose handling. + 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; +protected: + virtual Pokemon::Outcome route(Pokemon::RunState& state, const StoryStep& step) override; + virtual Pokemon::Outcome step(Pokemon::RunState& state, const StoryStep& step) override; + virtual Pokemon::Outcome mash(Pokemon::RunState& state, const StoryStep& step) override; +private: + 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); + 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; +}; +} // 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..e9a8f2f349 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_IO.cpp @@ -0,0 +1,307 @@ +/* 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; +} +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 != "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")); + } + } +} +} // 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..9fbe8e4f90 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_IO.h @@ -0,0 +1,47 @@ +/* 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 "Common/Cpp/Json/JsonObject.h" +#include "VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h" +#include "PokemonFRLG_AutoStoryExport.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); +// Validate every scripted signal against its event's capability/parameters. +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..1e05db21e2 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_Tests.cpp @@ -0,0 +1,194 @@ +/* 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, +}; +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"}}, +}; +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::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::CAPTURE_HASH: + case Mutation::MAP_HASH: + case Mutation::CAPABILITY: + case Mutation::SAVE_POLICY: + case Mutation::CANDIDATE_DISTANCE: + return; + } +} +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& test = named_case(document, m_test.input.name); + 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..dcd2ee5b52 --- /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": "076438644e5cc609a728d0e625564d3517b8a2984a18c8a23af2fe6017b37475", + "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..a024d10225 --- /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": "020a12db154b8a832bfbdd371884f35c18563319", + "status": "preview-unmerged-PR-73", + "files": { + "binding": { + "path": "data/story/parcel-pilot/binding.json", + "sha256": "12abcd4827a639c65667e49b69b5ef17d4dd74b95633a12fa62a2a5179855a51" + }, + "cases": { + "path": "data/story/parcel-pilot/cases.json", + "sha256": "0dee2991120d12f01e67f6e97a4fb815b4d2f54a423739bc1a6aa4f2509f599e" + }, + "capture": { + "path": "data/story/native/frlg-parcel-00.05.json", + "sha256": "076438644e5cc609a728d0e625564d3517b8a2984a18c8a23af2fe6017b37475" + } + }, + "candidate": { + "sha256": "bcab835c3d5c1cf4ab11478fd1eca3224842813fe92df23aca8b9871fefa8582", + "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..dc0216930b --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/ReplayData/route-candidate.json @@ -0,0 +1,63 @@ +{ + "format": "pokesim-route-candidate/1", + "binding_sha256": "12abcd4827a639c65667e49b69b5ef17d4dd74b95633a12fa62a2a5179855a51", + "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 010a497249..09f31de556 100644 --- a/SerialPrograms/cmake/SourceFiles.cmake +++ b/SerialPrograms/cmake/SourceFiles.cmake @@ -1915,6 +1915,13 @@ file(GLOB LIBRARY_SOURCES Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySummary.h Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryExport.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryExport.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/PokemonFRLG_AutoStoryExport_Tests.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryExport_Tests.h Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp 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..8c1567b051 --- /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/020a12db154b8a832bfbdd371884f35c18563319/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; + } +} From 26256c713d287198f3ee4f2f6dd6a9509ef59fe7 Mon Sep 17 00:00:00 2001 From: dayfine Date: Sun, 8 Nov 2026 12:52:10 -0800 Subject: [PATCH 04/15] Document Parcel JSON equivalence checks and migration lifetime --- .../Programs/AutoStory/StoryData/README.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/README.md diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/README.md b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/README.md new file mode 100644 index 0000000000..a11bf382e5 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/README.md @@ -0,0 +1,18 @@ +# FRLG checkpoint JSON pilot + +`frlg-parcel-00.05.json` describes checkpoint `00.05` (Oak’s Parcel). It is a capture of the native table, using `pokesim-native-checkpoint-export/1`, including its FRLG extension fields. `source_revision` records the original capture revision; it is not the revision currently being tested. + +The current C++ story table remains the production source of truth. This pilot adds a strict reader and two native checks: + +1. **Data equivalence:** parse the JSON into `StoryCheckpoint`, export it again, and compare it with the current compiled table’s `00.05` row. Both the portable events and serialized FRLG fields must match. A changed table with stale JSON fails this check. +2. **Execution equivalence:** build both rows through the same story engine and run both through the existing fake-game simulator. Compare the event description, execution trace, engine log and saved position. + +Read `check_loaded()` in `PokemonFRLG_AutoStoryExport_Tests.cpp` and `test_data_row_parity()` in `PokemonFRLG_AutoStorySimulation_Tests.cpp` for these assertions. Run the registered native tests through the repository’s normal command-line test mode; CI runs them on Linux, Windows and macOS. No console is needed. + +This checks equality of the **serialized contract** and execution under the fake-game model. It does not validate real controller timing, detectors, scene internals, live state, or fields the exporter does not serialize. It does not switch the production executor to JSON or establish a general format for every game. + +## Migration + +Once production loads the checkpoint from data, remove its duplicate C++ row and the table-equivalence assertion. Keep the strict reader, malformed-input tests, and tests of expected actions and outcomes. Retain the exporter only while other C++ rows need conversion. + +The cross-repository route replay diagnostic is a separate follow-up. Its Python route candidates and scripted observations are not required to load this JSON or check it against the table. From 9458752aa5a97def02b109a2b03182ad70dd734a Mon Sep 17 00:00:00 2001 From: dayfine Date: Sun, 8 Nov 2026 13:13:32 -0800 Subject: [PATCH 05/15] Order Parcel JSON for readers and distinguish snapshot from requirements --- .../Programs/AutoStory/StoryData/README.md | 14 ++++++++ .../StoryData/frlg-parcel-00.05.json | 32 +++++++++++-------- 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/README.md b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/README.md index a11bf382e5..b2731b8d84 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/README.md +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/README.md @@ -16,3 +16,17 @@ This checks equality of the **serialized contract** and execution under the fake Once production loads the checkpoint from data, remove its duplicate C++ row and the table-equivalence assertion. Keep the strict reader, malformed-input tests, and tests of expected actions and outcomes. Retain the exporter only while other C++ rows need conversion. The cross-repository route replay diagnostic is a separate follow-up. Its Python route candidates and scripted observations are not required to load this JSON or check it against the table. + +## Field meanings and authoring direction + +Checkpoint fields are ordered `id`, `title`, `start`, `events`; each event is an expanded block beginning with `id`, `kind`. Keep coordinate pairs and short pose fields compact. JSON object order is presentation only. + +- `declared_after`: the author-declared pose after an event. It is a fallback/expectation, not evidence of the actual map, tile or facing. The current scene executor advances dialogue until the overworld returns; it does not independently localize the player or verify the Parcel receipt. +- `status: native-table-export`: this document was exported from a compiled story table. It does not claim the checkpoint ran or succeeded. +- `state_basis: native-table-bookkeeping`: the saved values are authored/in-memory bookkeeping, not observed entry state. Defaults such as `level: 0` or `hp_fill: 1` must not become requirements merely because they were exported. +- `format: pokesim-native-checkpoint-export/1`: selects the versioned export contract. Its JSON Schema lives in [PokemonSimulation](https://github.com/dayfine/PokemonSimulation/blob/main/schemas/native-checkpoint-export.schema.json); its shared checkpoint structure is [story-checkpoint.schema.json](https://github.com/dayfine/PokemonSimulation/blob/main/schemas/story-checkpoint.schema.json). The native reader separately enforces its supported contract. +- `native_fields`: FRLG extensions outside the common checkpoint events/poses. This pilot retains the full serialized menu/supplies snapshot for comparison with the C++ table. It is not a list of verified preconditions, nor a checksum proving that earlier checkpoints ran. + +For data-authored checkpoints, separate **requirements** (only state this checkpoint depends on), **effects/expected exit state** (what it should change), **runtime state** (inventory, party, observations), and **export provenance**. Entry requirements summarize the relevant consequences of previous progress; unrelated counters, default values and transient battle bookkeeping should not be copied into every checkpoint. A later checkpoint must verify its requirements or explicitly declare an assumption, including when starting from a save or returning from a handoff. + +This PR preserves the export contract for equivalence testing. A lean executable-plan contract and its required observation/executor bindings are a separate migration step; simply deleting snapshot fields would weaken the comparison. diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/frlg-parcel-00.05.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/frlg-parcel-00.05.json index 7dbce4122c..2090ba206e 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/frlg-parcel-00.05.json +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/frlg-parcel-00.05.json @@ -1,5 +1,14 @@ { + "format": "pokesim-native-checkpoint-export/1", + "game": "frlg", "checkpoint": { + "id": "00.05", + "title": "Route 1 to Viridian Mart: Oak's Parcel", + "start": { + "certainty": "tile", + "map": "PalletTown_ProfessorOaksLab", + "where": {"facing": "south", "kind": "tiles", "tiles": [[7, 8]]} + }, "events": [ { "id": "below_mart_door", @@ -11,28 +20,23 @@ }, "why": "Route 1, running from wild battles" }, - {"id": "mart_door", "kind": "step", "why": "the clerk's scene starts on entering"}, { + "id": "mart_door", + "kind": "step", + "why": "the clerk's scene starts on entering" + }, + { + "id": "parcel_from_clerk", + "kind": "scene", "declared_after": { "certainty": "tile", "map": "ViridianCity_Mart", "where": {"facing": "west", "kind": "tiles", "tiles": [[4, 3]]} }, - "id": "parcel_from_clerk", - "kind": "scene", "why": "the clerk gives OAK'S PARCEL" } - ], - "id": "00.05", - "start": { - "certainty": "tile", - "map": "PalletTown_ProfessorOaksLab", - "where": {"facing": "south", "kind": "tiles", "tiles": [[7, 8]]} - }, - "title": "Route 1 to Viridian Mart: Oak's Parcel" + ] }, - "format": "pokesim-native-checkpoint-export/1", - "game": "frlg", "native_fields": { "menu": "before_pokedex", "supplies": { @@ -61,7 +65,7 @@ } }, "source_revision": "b170b7d2103e887ded825c37fdbdc19fd654fca8", - "state_basis": "native-table-bookkeeping", "status": "native-table-export", + "state_basis": "native-table-bookkeeping", "unbound": ["runtime_configuration", "map_pins", "executor_bindings", "entry_evidence", "effective_save_retry_policy"] } From f5302787f31c82a79998e2da9ede23b750a4cf89 Mon Sep 17 00:00:00 2001 From: dayfine Date: Sun, 8 Nov 2026 13:14:06 -0800 Subject: [PATCH 06/15] Refresh replay preview pins after readable Parcel JSON formatting --- .github/workflows/cpp-ci-serial-programs-base.yml | 2 +- .../Programs/AutoStory/ReplayData/binding.json | 2 +- .../PokemonFRLG/Programs/AutoStory/ReplayData/pin.json | 8 ++++---- .../Programs/AutoStory/ReplayData/route-candidate.json | 2 +- devtools/story-replay/README.md | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/cpp-ci-serial-programs-base.yml b/.github/workflows/cpp-ci-serial-programs-base.yml index cb4b76c3f2..989ffabfa9 100644 --- a/.github/workflows/cpp-ci-serial-programs-base.yml +++ b/.github/workflows/cpp-ci-serial-programs-base.yml @@ -106,7 +106,7 @@ jobs: uses: actions/checkout@v7 with: repository: dayfine/PokemonSimulation - ref: 020a12db154b8a832bfbdd371884f35c18563319 + ref: 9cb12c52f5f055299e32c2a7cf43e7103c101e3e path: StoryReplaySource - name: Verify replay preview bytes and independently regenerate its route input diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/ReplayData/binding.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/ReplayData/binding.json index dcd2ee5b52..b53d5e1259 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/ReplayData/binding.json +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/ReplayData/binding.json @@ -2,7 +2,7 @@ "format": "pokesim-story-replay-binding/1", "capture": { "file": "data/story/native/frlg-parcel-00.05.json", - "sha256": "076438644e5cc609a728d0e625564d3517b8a2984a18c8a23af2fe6017b37475", + "sha256": "8b0c04f2e1507aee0f0927a8d022b8b431036431e95f8893aa7a2383360f8504", "source_revision": "b170b7d2103e887ded825c37fdbdc19fd654fca8" }, "map_revision": "158b007ecd14efb5776ebdadc530c1a807ef3541", diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/ReplayData/pin.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/ReplayData/pin.json index a024d10225..deeca459f8 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/ReplayData/pin.json +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/ReplayData/pin.json @@ -1,12 +1,12 @@ { "format": "pokesim-native-replay-preview/1", "repository": "dayfine/PokemonSimulation", - "revision": "020a12db154b8a832bfbdd371884f35c18563319", + "revision": "9cb12c52f5f055299e32c2a7cf43e7103c101e3e", "status": "preview-unmerged-PR-73", "files": { "binding": { "path": "data/story/parcel-pilot/binding.json", - "sha256": "12abcd4827a639c65667e49b69b5ef17d4dd74b95633a12fa62a2a5179855a51" + "sha256": "5de39f53f63a9fd6319b4afd2fa85d0b0c468da7e94161fe4ce22bb939bb846e" }, "cases": { "path": "data/story/parcel-pilot/cases.json", @@ -14,11 +14,11 @@ }, "capture": { "path": "data/story/native/frlg-parcel-00.05.json", - "sha256": "076438644e5cc609a728d0e625564d3517b8a2984a18c8a23af2fe6017b37475" + "sha256": "8b0c04f2e1507aee0f0927a8d022b8b431036431e95f8893aa7a2383360f8504" } }, "candidate": { - "sha256": "bcab835c3d5c1cf4ab11478fd1eca3224842813fe92df23aca8b9871fefa8582", + "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 index dc0216930b..c253702c48 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/ReplayData/route-candidate.json +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/ReplayData/route-candidate.json @@ -1,6 +1,6 @@ { "format": "pokesim-route-candidate/1", - "binding_sha256": "12abcd4827a639c65667e49b69b5ef17d4dd74b95633a12fa62a2a5179855a51", + "binding_sha256": "5de39f53f63a9fd6319b4afd2fa85d0b0c468da7e94161fe4ce22bb939bb846e", "checkpoint": "00.05", "routes": { "below_mart_door": [ diff --git a/devtools/story-replay/README.md b/devtools/story-replay/README.md index 8c1567b051..810ee222aa 100644 --- a/devtools/story-replay/README.md +++ b/devtools/story-replay/README.md @@ -91,7 +91,7 @@ simulation tests continue to cover their own cases. ## Review order -1. Read the [shared readable cases](https://github.com/dayfine/PokemonSimulation/blob/020a12db154b8a832bfbdd371884f35c18563319/data/story/parcel-pilot/CASES.md). +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. From f5fb08392f1702f9163691c7ca0779bcac305953 Mon Sep 17 00:00:00 2001 From: dayfine Date: Sun, 8 Nov 2026 13:24:46 -0800 Subject: [PATCH 07/15] Load Parcel story table entry from authored JSON and replay it in both repos --- .../workflows/cpp-ci-serial-programs-base.yml | 30 +++++++++++ .../AutoStory/PokemonFRLG_AutoStoryData.cpp | 54 +++++++++++++++++++ .../AutoStory/PokemonFRLG_AutoStoryData.h | 19 +++++++ .../PokemonFRLG_AutoStoryExport_Tests.cpp | 22 +++++--- .../AutoStory/PokemonFRLG_AutoStoryTable.cpp | 14 ++--- .../Programs/AutoStory/StoryData/README.md | 39 ++++++-------- .../StoryData/frlg-parcel-00.05.plan.json | 42 +++++++++++++++ SerialPrograms/cmake/SourceFiles.cmake | 2 + 8 files changed, 183 insertions(+), 39 deletions(-) create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryData.cpp create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryData.h create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/frlg-parcel-00.05.plan.json diff --git a/.github/workflows/cpp-ci-serial-programs-base.yml b/.github/workflows/cpp-ci-serial-programs-base.yml index e2393a205f..c27db9594d 100644 --- a/.github/workflows/cpp-ci-serial-programs-base.yml +++ b/.github/workflows/cpp-ci-serial-programs-base.yml @@ -101,6 +101,36 @@ jobs: operation: prepare consumer-directory: Arduino-Source + - name: Checkout authored checkpoint simulator + if: startsWith(inputs.os, 'ubuntu') && inputs.run-tests + uses: actions/checkout@v7 + with: + repository: dayfine/PokemonSimulation + ref: 8cb831caf8bfe1f06b1aec9a4a7e55e6e09b0604 + path: AuthoredStorySource + + - name: Check identical authored Parcel JSON and replay its scenarios + if: startsWith(inputs.os, 'ubuntu') && inputs.run-tests + env: + PYTHONPATH: AuthoredStorySource/src + run: | + python3 - <<'PYTHON' + from pathlib import Path + from pokesim.story.replay_binding import load_binding + from pokesim.story.replay_cases import read_cases, check_case + from pokesim.story.checkpoint_replay import run + source = Path('AuthoredStorySource') + folder = source / 'data/story/parcel-pilot' + native = Path('Arduino-Source/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData') + assert (native / 'frlg-parcel-00.05.plan.json').read_bytes() == (folder / 'frlg-parcel-00.05.plan.json').read_bytes() + binding = load_binding(folder / 'authored-binding.json', source) + cases = read_cases(folder / 'cases.json') + for case in cases: + check_case(case, run(binding, case['input'])) + print(case['id'] + ': authored JSON replay passed') + assert len(cases) == 7 + PYTHON + - name: Install Qt uses: jurplel/install-qt-action@v4 with: diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryData.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryData.cpp new file mode 100644 index 0000000000..8e5105f403 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryData.cpp @@ -0,0 +1,54 @@ +/* Authored FRLG Checkpoint Data + * From: https://github.com/PokemonAutomation/ + */ +#include "Common/Cpp/Exceptions.h" +#include "Common/Cpp/Json/JsonObject.h" +#include "Pokemon/AutoStory/Pokemon_StoryStepJson.h" +#include "PokemonFRLG_AutoStoryExport.h" +#include "PokemonFRLG_AutoStoryData.h" +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ +namespace{ +void exact_fields(const JsonObject& value, std::initializer_list names){ + if (value.size() != names.size()){ + throw ParseException("Authored checkpoint: missing or extra fields."); + } + for (std::string_view name : names){ + value.get_value_throw(std::string(name)); + } +} +} // namespace +StoryCheckpoint read_authored_checkpoint(const JsonValue& value, std::string_view source){ + const JsonObject& root = value.to_object_throw(std::string(source)); + exact_fields(root, {"format", "game", "checkpoint", "execution"}); + if (root.get_string_throw("format") != "pokesim-authored-checkpoint/1" || root.get_string_throw("game") != "frlg"){ + throw ParseException("Unsupported authored checkpoint format/game."); + } + const JsonObject& execution = root.get_object_throw("execution"); + exact_fields(execution, {"menu"}); + StoryCheckpoint result; + static_cast(result) = Pokemon::read_story_checkpoint(root.get_value_throw("checkpoint"), source); + const std::string& menu = execution.get_string_throw("menu"); + if (menu == "before_pokemon"){ + result.menu = StartMenu::BEFORE_POKEMON; + }else if (menu == "before_pokedex"){ + result.menu = StartMenu::BEFORE_POKEDEX; + }else if (menu == "full"){ + result.menu = StartMenu::FULL; + }else{ + throw ParseException("Unsupported authored checkpoint menu."); + } + return result; +} +StoryCheckpoint load_authored_checkpoint(std::string_view file){ + for (const auto& [name, text] : autostory_checkpoint_json()){ + if (name == file){ + return read_authored_checkpoint(parse_json(text), file); + } + } + throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "Authored checkpoint is not embedded: " + std::string(file)); +} +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryData.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryData.h new file mode 100644 index 0000000000..536731a96b --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryData.h @@ -0,0 +1,19 @@ +/* Authored FRLG Checkpoint Data + * From: https://github.com/PokemonAutomation/ + */ +#ifndef PokemonAutomation_PokemonFRLG_AutoStoryData_H +#define PokemonAutomation_PokemonFRLG_AutoStoryData_H +#include "Common/Cpp/Json/JsonValue.h" +#include "PokemonFRLG_AutoStoryEvents.h" +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ +// Read a data-authored checkpoint, retaining native bookkeeping defaults. +// Unknown envelope fields/menu values and malformed common actions throw ParseException. +StoryCheckpoint read_authored_checkpoint(const JsonValue& value, std::string_view source); +// Load an embedded authored plan by filename without .json; no controller IO. +StoryCheckpoint load_authored_checkpoint(std::string_view file); +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryExport_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryExport_Tests.cpp index 64a355f708..8bec8a74f1 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryExport_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryExport_Tests.cpp @@ -5,6 +5,7 @@ #include "Common/Cpp/Json/JsonArray.h" #include "Common/Cpp/Json/JsonObject.h" #include "Pokemon/AutoStory/Pokemon_StoryStepJson.h" +#include "PokemonFRLG_AutoStoryData.h" #include "PokemonFRLG_AutoStoryExport.h" #include "PokemonFRLG_AutoStoryTable.h" #include "PokemonFRLG_AutoStoryExport_Tests.h" @@ -113,6 +114,7 @@ struct LoadInput{ std::string_view name; // Applied to the capture before reading; none reads it as written. void (*mutate)(JsonObject& root) = nullptr; + bool authored = false; }; struct LoadExpectation{ // The table row the loaded one must export like; empty when rejected. @@ -159,7 +161,14 @@ void foe_out_as_text(JsonObject& root){ void drop_revision(JsonObject& root){ root = without(root, "source_revision"); } +void unknown_execution_menu(JsonObject& root){ + root.get_object_throw("execution")["menu"] = "guess"; +} const LoadCase LOAD_CASES[] = { + {.input = {.name = "authored Parcel plan", .authored = true}, .expectation = {.row = "00.05"}}, + {.input = {.name = "authored plan unknown field", .mutate = add_top_level_field, .authored = true}, .expectation = {.rejected = true}}, + {.input = {.name = "authored plan unknown format", .mutate = change_format, .authored = true}, .expectation = {.rejected = true}}, + {.input = {.name = "authored plan unknown menu", .mutate = unknown_execution_menu, .authored = true}, .expectation = {.rejected = true}}, {.input = {.name = "the Parcel capture is row 00.05"}, .expectation = {.row = "00.05"}}, {.input = {.name = "an unknown envelope field", .mutate = add_top_level_field}, .expectation = {.rejected = true}}, {.input = {.name = "another format", .mutate = change_format}, .expectation = {.rejected = true}}, @@ -170,9 +179,9 @@ const LoadCase LOAD_CASES[] = { {.input = {.name = "foe_out as text", .mutate = foe_out_as_text}, .expectation = {.rejected = true}}, {.input = {.name = "no source revision", .mutate = drop_revision}, .expectation = {.rejected = true}}, }; -const std::string& parcel_text(){ +const std::string& parcel_text(const LoadInput& input){ for (const auto& [name, text] : autostory_checkpoint_json()){ - if (name == "frlg-parcel-00.05"){ + if (name == (input.authored ? "frlg-parcel-00.05.plan" : "frlg-parcel-00.05")){ return text; } } @@ -187,8 +196,8 @@ const StoryCheckpoint* table_row(std::string_view id){ return nullptr; } std::string check_loaded(const StoryCheckpoint& loaded, const JsonValue& capture, const LoadCase& test){ - const std::string& revision = capture.to_object_throw().get_string_throw("source_revision"); - if (!(exported_file(loaded, revision) == capture)){ + const std::string revision = test.input.authored ? "" : capture.to_object_throw().get_string_throw("source_revision"); + if (!test.input.authored && !(exported_file(loaded, revision) == capture)){ return "Expected the loaded row to export as the capture, read " + story_checkpoint_export(loaded).dump(-1); } const StoryCheckpoint* row = table_row(test.expectation.row); @@ -201,12 +210,13 @@ std::string check_loaded(const StoryCheckpoint& loaded, const JsonValue& capture return ""; } std::string check_load(const LoadCase& test){ - JsonValue capture = parse_json(parcel_text()); + JsonValue capture = parse_json(parcel_text(test.input)); if (test.input.mutate){ test.input.mutate(capture.to_object_throw()); } try{ - const StoryCheckpoint loaded = read_story_checkpoint_export(capture, test.input.name); + const StoryCheckpoint loaded = test.input.authored ? read_authored_checkpoint(capture, test.input.name) + : read_story_checkpoint_export(capture, test.input.name); if (test.expectation.rejected){ return "Expected rejection, read checkpoint " + loaded.id; } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp index 1f579f2983..f3519a0453 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp @@ -5,6 +5,7 @@ */ #include "PokemonFRLG_AutoStoryCapture.h" +#include "PokemonFRLG_AutoStoryData.h" #include "PokemonFRLG_AutoStoryChoice.h" #include "PokemonFRLG_AutoStoryGrind.h" #include "PokemonFRLG_AutoStoryMart.h" @@ -82,12 +83,7 @@ StoryCheckpoint starter(){ } StoryCheckpoint parcel(){ - return row("00.05", "Route 1 to Viridian Mart: Oak's Parcel", at(LAB, 7, 8, SOUTH), {}, StartMenu::BEFORE_POKEDEX, { - route("below_mart_door", at(VIRIDIAN, 36, 20, NORTH), "Route 1, running from wild battles"), - step_into("mart_door", NORTH, "the clerk's scene starts on entering"), - // ApproachCounter: up 4 from the mat, then faces the clerk. - scene("parcel_from_clerk", at(VIRIDIAN_MART, 4, 3, WEST), "the clerk gives OAK'S PARCEL"), - }); + return load_authored_checkpoint("frlg-parcel-00.05.plan"); } StoryCheckpoint pokedex(){ @@ -800,6 +796,6 @@ const std::vector& story_checkpoints(){ -} // namespace PokemonFRLG -} // namespace NintendoSwitch -} // namespace PokemonAutomation +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/README.md b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/README.md index b2731b8d84..1b8aef3a64 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/README.md +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/README.md @@ -1,32 +1,23 @@ -# FRLG checkpoint JSON pilot +# FRLG authored checkpoint pilot -`frlg-parcel-00.05.json` describes checkpoint `00.05` (Oak’s Parcel). It is a capture of the native table, using `pokesim-native-checkpoint-export/1`, including its FRLG extension fields. `source_revision` records the original capture revision; it is not the revision currently being tested. +`frlg-parcel-00.05.plan.json` is the production definition of checkpoint `00.05`. The story table loads it with `load_authored_checkpoint()`: there is no second C++ Parcel event list. Its `pokesim-authored-checkpoint/1` contract contains the common checkpoint plus FRLG executor configuration (`execution.menu`). It has no export revision, bookkeeping snapshot, or claimed observation. -The current C++ story table remains the production source of truth. This pilot adds a strict reader and two native checks: +The same file is checked into PokemonSimulation at `data/story/parcel-pilot/frlg-parcel-00.05.plan.json`; the paired CI check compares its exact bytes and runs the existing seven replay scenarios there. The schema is `schemas/authored-checkpoint.schema.json`, referencing `story-checkpoint.schema.json`. -1. **Data equivalence:** parse the JSON into `StoryCheckpoint`, export it again, and compare it with the current compiled table’s `00.05` row. Both the portable events and serialized FRLG fields must match. A changed table with stale JSON fails this check. -2. **Execution equivalence:** build both rows through the same story engine and run both through the existing fake-game simulator. Compare the event description, execution trace, engine log and saved position. +## The two gates -Read `check_loaded()` in `PokemonFRLG_AutoStoryExport_Tests.cpp` and `test_data_row_parity()` in `PokemonFRLG_AutoStorySimulation_Tests.cpp` for these assertions. Run the registered native tests through the repository’s normal command-line test mode; CI runs them on Linux, Windows and macOS. No console is needed. +1. Native tests load the actual table entry through JSON, run the existing engine/fake-game simulator, and compare its events/trace/save pose with the captured pre-migration checkpoint. Existing full-story progression tests also use the JSON-backed table entry. Unsupported fields/menu values and malformed common actions are rejected. +2. PokemonSimulation loads the same authored file with explicit map/action bindings and scripted evidence, then asserts the seven scenarios, including the full successful semantic action sequence and interruption/failure cases. It does not import the native supplies snapshot as requirements. -This checks equality of the **serialized contract** and execution under the fake-game model. It does not validate real controller timing, detectors, scene internals, live state, or fields the exporter does not serialize. It does not switch the production executor to JSON or establish a general format for every game. +`frlg-parcel-00.05.json` is the old table-export fixture used for migration regression checks only. Its provenance and snapshot fields are not part of the authored plan. It can be removed once the native expectations are independent of the capture. -## Migration +## Semantics -Once production loads the checkpoint from data, remove its duplicate C++ row and the table-equivalence assertion. Keep the strict reader, malformed-input tests, and tests of expected actions and outcomes. Retain the exporter only while other C++ rows need conversion. +- Checkpoint fields are ordered `id`, `title`, `start`, `events`; each event has an expanded block beginning with `id`, `kind`. +- `start` is the assumed checkpoint/save pose, not a localization observation. +- `declared_after` preserves the current scene executor’s author-declared arrival fallback. It is not independent evidence of position or Parcel receipt. Real door/scene verification remains a separate executor change. +- `execution.menu` configures the existing FRLG save implementation. Inventory, party and battle bookkeeping keep their existing native initialization; they are neither asserted preconditions nor observations. +- This first contract makes no new inventory/party requirements. Add explicit requirements only when an action depends on them, with supported verification/assumption semantics on both sides. Relevant prior progress is not proof of a complete execution history. +- The simulator’s map pins, scene evidence and stop/no-save policy live in the binding and cases, not in the authored checkpoint. Python replay is a model check; native fake-game execution checks integration, not live timing/detector correctness. -The cross-repository route replay diagnostic is a separate follow-up. Its Python route candidates and scripted observations are not required to load this JSON or check it against the table. - -## Field meanings and authoring direction - -Checkpoint fields are ordered `id`, `title`, `start`, `events`; each event is an expanded block beginning with `id`, `kind`. Keep coordinate pairs and short pose fields compact. JSON object order is presentation only. - -- `declared_after`: the author-declared pose after an event. It is a fallback/expectation, not evidence of the actual map, tile or facing. The current scene executor advances dialogue until the overworld returns; it does not independently localize the player or verify the Parcel receipt. -- `status: native-table-export`: this document was exported from a compiled story table. It does not claim the checkpoint ran or succeeded. -- `state_basis: native-table-bookkeeping`: the saved values are authored/in-memory bookkeeping, not observed entry state. Defaults such as `level: 0` or `hp_fill: 1` must not become requirements merely because they were exported. -- `format: pokesim-native-checkpoint-export/1`: selects the versioned export contract. Its JSON Schema lives in [PokemonSimulation](https://github.com/dayfine/PokemonSimulation/blob/main/schemas/native-checkpoint-export.schema.json); its shared checkpoint structure is [story-checkpoint.schema.json](https://github.com/dayfine/PokemonSimulation/blob/main/schemas/story-checkpoint.schema.json). The native reader separately enforces its supported contract. -- `native_fields`: FRLG extensions outside the common checkpoint events/poses. This pilot retains the full serialized menu/supplies snapshot for comparison with the C++ table. It is not a list of verified preconditions, nor a checksum proving that earlier checkpoints ran. - -For data-authored checkpoints, separate **requirements** (only state this checkpoint depends on), **effects/expected exit state** (what it should change), **runtime state** (inventory, party, observations), and **export provenance**. Entry requirements summarize the relevant consequences of previous progress; unrelated counters, default values and transient battle bookkeeping should not be copied into every checkpoint. A later checkpoint must verify its requirements or explicitly declare an assumption, including when starting from a save or returning from a handoff. - -This PR preserves the export contract for equivalence testing. A lean executable-plan contract and its required observation/executor bindings are a separate migration step; simply deleting snapshot fields would weaken the comparison. +No console is required for either gate. diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/frlg-parcel-00.05.plan.json b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/frlg-parcel-00.05.plan.json new file mode 100644 index 0000000000..6df2d7a0d2 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/frlg-parcel-00.05.plan.json @@ -0,0 +1,42 @@ +{ + "format": "pokesim-authored-checkpoint/1", + "game": "frlg", + "checkpoint": { + "id": "00.05", + "title": "Route 1 to Viridian Mart: Oak's Parcel", + "start": { + "certainty": "tile", + "map": "PalletTown_ProfessorOaksLab", + "where": {"facing": "south", "kind": "tiles", "tiles": [[7, 8]]} + }, + "events": [ + { + "id": "below_mart_door", + "kind": "route", + "target": { + "certainty": "tile", + "map": "ViridianCity", + "where": {"facing": "north", "kind": "tiles", "tiles": [[36, 20]]} + }, + "why": "Route 1, running from wild battles" + }, + { + "id": "mart_door", + "kind": "step", + "why": "the clerk's scene starts on entering", + "direction": "north" + }, + { + "id": "parcel_from_clerk", + "kind": "scene", + "declared_after": { + "certainty": "tile", + "map": "ViridianCity_Mart", + "where": {"facing": "west", "kind": "tiles", "tiles": [[4, 3]]} + }, + "why": "the clerk gives OAK'S PARCEL" + } + ] + }, + "execution": {"menu": "before_pokedex"} +} diff --git a/SerialPrograms/cmake/SourceFiles.cmake b/SerialPrograms/cmake/SourceFiles.cmake index 3d792d6451..36b05078a3 100644 --- a/SerialPrograms/cmake/SourceFiles.cmake +++ b/SerialPrograms/cmake/SourceFiles.cmake @@ -1912,6 +1912,8 @@ file(GLOB LIBRARY_SOURCES Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySummary.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySummary.h + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryData.cpp + Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryData.h Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryExport.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryExport.h Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryExport_Tests.cpp From b319e0d555f134f75ec523d9fa72eb42671ab3e8 Mon Sep 17 00:00:00 2001 From: dayfine Date: Mon, 9 Nov 2026 00:58:59 -0800 Subject: [PATCH 08/15] Common: test that an empty JSON object survives dump and parse JsonValue::dump() writes an empty JsonObject as "null", so a document re-dumped after parsing loses every "{}" and the reader sees null. This test is red until to_nlohmann() builds objects as objects. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013VUubhHeYSMGSSDXZkKZss --- Common/Cpp/Json/JsonTools_Tests.cpp | 60 +++++++++++++++++++ Common/Cpp/Json/JsonTools_Tests.h | 21 +++++++ .../ComputerPrograms_UnitTestRunner.cpp | 2 + SerialPrograms/cmake/SourceFiles.cmake | 2 + 4 files changed, 85 insertions(+) create mode 100644 Common/Cpp/Json/JsonTools_Tests.cpp create mode 100644 Common/Cpp/Json/JsonTools_Tests.h diff --git a/Common/Cpp/Json/JsonTools_Tests.cpp b/Common/Cpp/Json/JsonTools_Tests.cpp new file mode 100644 index 0000000000..97ea48d765 --- /dev/null +++ b/Common/Cpp/Json/JsonTools_Tests.cpp @@ -0,0 +1,60 @@ +/* JSON Tools Tests + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" +#include "JsonObject.h" +#include "JsonValue.h" +#include "JsonTools_Tests.h" + +namespace PokemonAutomation{ +namespace{ + + + +class Test_JsonTools_EmptyObjectRoundTrip : public UnitTest{ +public: + Test_JsonTools_EmptyObjectRoundTrip() + : UnitTest("Common::JsonTools - Empty object round trip") + {} + + virtual UnitTestResult run(Logger& logger, CancellableScope& scope) const override{ + const std::string empty = JsonValue(JsonObject()).dump(-1); + if (empty != "{}"){ + return UnitTestResult("dump of an empty object: expected {}, got " + empty); + } + + const std::string text = parse_json(R"({"array":[],"object":{}})").dump(-1); + const JsonValue reparsed = parse_json(text); + const JsonObject* root = reparsed.to_object(); + if (root == nullptr){ + return UnitTestResult("reparsed document: expected an object, got " + text); + } + const JsonValue* object = root->get_value("object"); + if (object == nullptr || !object->is_object()){ + return UnitTestResult("nested empty object: expected an object, got " + text); + } + const JsonValue* array = root->get_value("array"); + if (array == nullptr || !array->is_array()){ + return UnitTestResult("nested empty array: expected an array, got " + text); + } + return true; + } +}; + + + +} // anonymous namespace + + + +void add_tests_JsonTools(UnitTestDatabase& database){ + database.add(); +} + + + +} diff --git a/Common/Cpp/Json/JsonTools_Tests.h b/Common/Cpp/Json/JsonTools_Tests.h new file mode 100644 index 0000000000..fdf4d826ac --- /dev/null +++ b/Common/Cpp/Json/JsonTools_Tests.h @@ -0,0 +1,21 @@ +/* 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{ + + + +void add_tests_JsonTools(UnitTestDatabase& database); + + + +} +#endif diff --git a/SerialPrograms/Source/ComputerPrograms/ComputerPrograms_UnitTestRunner.cpp b/SerialPrograms/Source/ComputerPrograms/ComputerPrograms_UnitTestRunner.cpp index 17a842be52..0a83d660fd 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" @@ -56,6 +57,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/cmake/SourceFiles.cmake b/SerialPrograms/cmake/SourceFiles.cmake index db863c369f..b9832f5266 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 From e321063428acead759ebe460c50dba5c7eebd85c Mon Sep 17 00:00:00 2001 From: dayfine Date: Mon, 9 Nov 2026 00:59:35 -0800 Subject: [PATCH 09/15] Common: dump an empty JsonObject as {} instead of null to_nlohmann() started each object as a default nlohmann::json, which is null until a key is added, so an empty object came out as null. Build it as object_t, as arrays already use array_t. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013VUubhHeYSMGSSDXZkKZss --- Common/Cpp/Json/JsonTools.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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); } From bca87ade987c96de36e88ca0fdd2491d0d58d0bb Mon Sep 17 00:00:00 2001 From: dayfine Date: Mon, 9 Nov 2026 01:01:10 -0800 Subject: [PATCH 10/15] FRLG replay: report a failed entry check as entry_requirement_failed The replay puts its entry check in the checkpoint's start conditions, which the engine reports as "start_failed", not "require_failed", so the entry-not-overworld case got the raw engine code. The JSON dump bug hid this until now. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013VUubhHeYSMGSSDXZkKZss --- .../Programs/AutoStory/PokemonFRLG_StoryReplay_Executor.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_Executor.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_Executor.cpp index 1deec198fe..7c15b57cef 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_Executor.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_Executor.cpp @@ -293,7 +293,7 @@ JsonValue ReplayExecutor::report(const Pokemon::CheckpointReport& result, const 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 == "require_failed" ? "entry_requirement_failed" : result.failure.failure); + 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(); From 6ebd110d72a9c0144437aeff03d0feeb0405b5a9 Mon Sep 17 00:00:00 2001 From: dayfine Date: Mon, 9 Nov 2026 01:01:49 -0800 Subject: [PATCH 11/15] Common: table the JSON round-trip test cases One case per container shape, registered from a named table, so a failure names the shape that lost its type. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013VUubhHeYSMGSSDXZkKZss --- Common/Cpp/Json/JsonTools_Tests.cpp | 65 ++++++++++++++++++----------- Common/Cpp/Json/JsonTools_Tests.h | 3 +- 2 files changed, 43 insertions(+), 25 deletions(-) diff --git a/Common/Cpp/Json/JsonTools_Tests.cpp b/Common/Cpp/Json/JsonTools_Tests.cpp index 97ea48d765..f548ac8a34 100644 --- a/Common/Cpp/Json/JsonTools_Tests.cpp +++ b/Common/Cpp/Json/JsonTools_Tests.cpp @@ -4,9 +4,10 @@ * */ +#include #include +#include #include "Common/Cpp/TestRunners/UnitTestDatabase.h" -#include "JsonObject.h" #include "JsonValue.h" #include "JsonTools_Tests.h" @@ -15,46 +16,62 @@ namespace{ -class Test_JsonTools_EmptyObjectRoundTrip : public UnitTest{ +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: - Test_JsonTools_EmptyObjectRoundTrip() - : UnitTest("Common::JsonTools - Empty object round trip") + 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 empty = JsonValue(JsonObject()).dump(-1); - if (empty != "{}"){ - return UnitTestResult("dump of an empty object: expected {}, got " + empty); - } - - const std::string text = parse_json(R"({"array":[],"object":{}})").dump(-1); - const JsonValue reparsed = parse_json(text); - const JsonObject* root = reparsed.to_object(); - if (root == nullptr){ - return UnitTestResult("reparsed document: expected an object, got " + text); - } - const JsonValue* object = root->get_value("object"); - if (object == nullptr || !object->is_object()){ - return UnitTestResult("nested empty object: expected an object, got " + text); + 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 JsonValue* array = root->get_value("array"); - if (array == nullptr || !array->is_array()){ - return UnitTestResult("nested empty array: expected an array, got " + text); + 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; }; -} // anonymous namespace +} // namespace void add_tests_JsonTools(UnitTestDatabase& database){ - database.add(); + 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 index fdf4d826ac..76d61d4b7c 100644 --- a/Common/Cpp/Json/JsonTools_Tests.h +++ b/Common/Cpp/Json/JsonTools_Tests.h @@ -13,9 +13,10 @@ namespace PokemonAutomation{ +// Register the JSON dump/parse round-trip tests. void add_tests_JsonTools(UnitTestDatabase& database); -} +} // namespace PokemonAutomation #endif From f335b405b9c9e1cbccc3888a09d5d74d99b374b1 Mon Sep 17 00:00:00 2001 From: dayfine Date: Mon, 9 Nov 2026 01:39:46 -0800 Subject: [PATCH 12/15] FRLG StoryReplay: run ReplayExecutor through the story action table ReplayExecutor binds FRLG's rows (story_action_rows()) to its scripted route, step and scene, and every other action to unsupported, in place of the per-kind virtuals. Its guard lets a failed step's exception through, as before: a replay never reloads. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013VUubhHeYSMGSSDXZkKZss --- .../PokemonFRLG_StoryReplay_Executor.cpp | 25 ++++++++++++++++--- .../PokemonFRLG_StoryReplay_Executor.h | 23 +++++++++++++---- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_Executor.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_Executor.cpp index 7c15b57cef..cad3c915d9 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_Executor.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_Executor.cpp @@ -46,7 +46,24 @@ void check_candidate_move(const JsonObject& command, const OverworldPose& from, 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) {} +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); } @@ -64,7 +81,7 @@ Pokemon::Checkpoint ReplayExecutor::build(){ } Pokemon::Outcome ReplayExecutor::run(Pokemon::RunState& state, const StoryStep& step){ m_active = step.name; - Pokemon::Outcome outcome = Pokemon::StoryStepExecutor::run(state, step); + Pokemon::Outcome outcome = m_executor.run(state, step); if (outcome.succeeded()){ m_completed.push_back(step.name); }else if (!m_known){ @@ -213,7 +230,7 @@ StepOutcome ReplayExecutor::walk_leg(const RouteStep& step){ m_basis = "model-prediction"; return StepOutcome{}; } -Pokemon::Outcome ReplayExecutor::route(Pokemon::RunState&, const StoryStep& step){ +Pokemon::Outcome ReplayExecutor::replay_route(const StoryStep& step){ default_plan(step); const std::vector route = prepare_route(step); size_t moves = 0; @@ -240,7 +257,7 @@ Pokemon::Outcome ReplayExecutor::route(Pokemon::RunState&, const StoryStep& step } return Pokemon::outcome_ok(native_pose(m_pose)); } -Pokemon::Outcome ReplayExecutor::step(Pokemon::RunState&, const StoryStep& step){ +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"); @@ -256,7 +273,7 @@ Pokemon::Outcome ReplayExecutor::step(Pokemon::RunState&, const StoryStep& step) m_basis = "model-prediction"; return Pokemon::outcome_ok(native_pose(m_pose)); } -Pokemon::Outcome ReplayExecutor::mash(Pokemon::RunState&, const StoryStep& step){ +Pokemon::Outcome ReplayExecutor::replay_scene(const StoryStep& step){ JsonObject action; action["event"] = step.name; action["kind"] = "scene"; diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_Executor.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_Executor.h index 4a7fd7db47..70249dc09c 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_Executor.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_Executor.h @@ -4,8 +4,10 @@ #ifndef PokemonAutomation_PokemonFRLG_StoryReplay_Executor_H #define PokemonAutomation_PokemonFRLG_StoryReplay_Executor_H #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_AutoStoryStepTable.h" #include "PokemonFRLG_StoryReplay_IO.h" namespace PokemonAutomation{ namespace NintendoSwitch{ @@ -16,23 +18,31 @@ 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); - // Existing engine dispatch with ordered authored completions and lost-pose handling. + // FRLG's story action table (story_action_rows()) with ordered authored + // completions and lost-pose handling. 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; -protected: - virtual Pokemon::Outcome route(Pokemon::RunState& state, const StoryStep& step) override; - virtual Pokemon::Outcome step(Pokemon::RunState& state, const StoryStep& step) override; - virtual Pokemon::Outcome mash(Pokemon::RunState& state, const StoryStep& step) override; 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; @@ -59,6 +69,9 @@ class ReplayExecutor : public Pokemon::StoryStepExecutor{ 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; }; } // namespace StoryReplayInternal } // namespace PokemonFRLG From 18c5bb90e40632a07c9638a540d618b5b27e850a Mon Sep 17 00:00:00 2001 From: dayfine Date: Mon, 9 Nov 2026 01:42:39 -0800 Subject: [PATCH 13/15] FRLG StoryReplay: a ReplayExecutor can't be copied Its table's actions point at it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013VUubhHeYSMGSSDXZkKZss --- .../Programs/AutoStory/PokemonFRLG_StoryReplay_Executor.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_Executor.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_Executor.h index 70249dc09c..498c0d02cb 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_Executor.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_Executor.h @@ -29,6 +29,9 @@ 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. virtual Pokemon::Outcome run(Pokemon::RunState& state, const StoryStep& step) override; From 1e4985088222199f2e2eecb3d287cf3de59991c6 Mon Sep 17 00:00:00 2001 From: dayfine Date: Mon, 9 Nov 2026 04:44:57 -0800 Subject: [PATCH 14/15] FRLG story replay: clear interrupts between steps Binds the FRLG between-step interrupts (step_interrupts()) to the native checkpoint replay. A scenario may script what shows before a step (between_steps: a dialog, a wild encounter, a trainer's sight or a warp); the look shows it once, and its handler records it as an "interrupt" action, a warp also losing the pose. Without between_steps the replay and its reports are as before; cases.json stays PokemonSimulation's copy, so the new case is a mutation of parcel-all-events in the tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013VUubhHeYSMGSSDXZkKZss --- .../PokemonFRLG_StoryReplay_Executor.cpp | 37 +++++++++++++++++- .../PokemonFRLG_StoryReplay_Executor.h | 13 ++++++- .../AutoStory/PokemonFRLG_StoryReplay_IO.cpp | 31 ++++++++++++++- .../AutoStory/PokemonFRLG_StoryReplay_IO.h | 15 +++++++- .../PokemonFRLG_StoryReplay_Tests.cpp | 38 ++++++++++++++++++- 5 files changed, 129 insertions(+), 5 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_Executor.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_Executor.cpp index cad3c915d9..f10f353d50 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_Executor.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_Executor.cpp @@ -48,7 +48,10 @@ ReplayExecutor::ReplayExecutor(const ReplayData& data, const JsonValue& scenario , 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; } @@ -93,6 +96,38 @@ 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)); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_Executor.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_Executor.h index 498c0d02cb..c7b869b711 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_Executor.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_Executor.h @@ -3,10 +3,13 @@ */ #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{ @@ -33,7 +36,8 @@ class ReplayExecutor : public Pokemon::StoryStepExecutor{ 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. + // 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(); @@ -55,6 +59,11 @@ class ReplayExecutor : public Pokemon::StoryStepExecutor{ 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; @@ -75,6 +84,8 @@ class ReplayExecutor : public Pokemon::StoryStepExecutor{ ReplayStepGuard m_guard; Pokemon::StoryActionTable m_table; Pokemon::TableExecutor m_executor; + Pokemon::StoryInterrupts m_interrupts; + std::set m_shown; }; } // namespace StoryReplayInternal } // namespace PokemonFRLG diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_IO.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_IO.cpp index e9a8f2f349..e0027d38be 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_IO.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_IO.cpp @@ -264,10 +264,29 @@ ReplayData read_data(std::string_view capture, std::string_view binding, std::st 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}; + if (kind == "dialog"){ + return {{{33, box}}, 400}; + } + if (kind == "wild-encounter"){ + return {{{100, {ScreenEvent::FADE_WHITE}}, {400, {}}, {1500, battle}}, 2000}; + } + if (kind == "trainer-sight"){ + return {{{100, box}, {2000, {ScreenEvent::FADE_BLACK}}, {2500, battle}}, 3000}; + } + if (kind == "warp"){ + // Long enough after the fade for no battle to follow it. + return {{{100, {ScreenEvent::FADE_BLACK}}, {600, {}}}, 9000}; + } + throw ParseException("story replay: unknown interrupt kind"); +} 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 != "check_actions"){ + if (name != "entry" && name != "signals" && name != "between_steps" && name != "check_actions"){ throw ParseException("story replay: unknown scenario field"); } if (name == "check_actions"){ @@ -300,6 +319,16 @@ void validate_scenario(const ReplayData& data, const JsonValue& scenario){ 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 diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_IO.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_IO.h index ec227f4983..e56ea624b0 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_IO.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_IO.h @@ -5,7 +5,10 @@ #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{ @@ -38,7 +41,17 @@ OverworldPose authored_pose(const Pokemon::Pose& value, const std::vector 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); -// Validate every scripted signal against its event's capability/parameters. +// 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 diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_Tests.cpp index 1e05db21e2..bc8603a942 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_Tests.cpp @@ -47,6 +47,9 @@ std::string compare_expected(const JsonValue& test, const JsonValue& actual){ 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{ @@ -79,6 +82,7 @@ const ReplayCase CASES[] = { {.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){ @@ -98,6 +102,7 @@ void mutate_config(Mutation mutation, JsonObject& binding, JsonObject& candidate 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: @@ -132,6 +137,7 @@ void mutate_actions(Mutation mutation, JsonValue& report){ 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: @@ -140,6 +146,33 @@ void mutate_actions(Mutation mutation, JsonValue& report){ 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")); @@ -159,7 +192,10 @@ class ReplayTest : public UnitTest{ : 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& test = named_case(document, m_test.input.name); + 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); From 89308481aed7c90b90214e8eb2faec972255306a Mon Sep 17 00:00:00 2001 From: dayfine Date: Mon, 9 Nov 2026 04:48:34 -0800 Subject: [PATCH 15/15] FRLG story replay: a flatter interrupt screen table Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013VUubhHeYSMGSSDXZkKZss --- .../AutoStory/PokemonFRLG_StoryReplay_IO.cpp | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_IO.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_IO.cpp index e0027d38be..6fc8ea63c0 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_IO.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_StoryReplay_IO.cpp @@ -268,20 +268,25 @@ 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"){ - return {{{33, box}}, 400}; - } - if (kind == "wild-encounter"){ - return {{{100, {ScreenEvent::FADE_WHITE}}, {400, {}}, {1500, battle}}, 2000}; - } - if (kind == "trainer-sight"){ - return {{{100, box}, {2000, {ScreenEvent::FADE_BLACK}}, {2500, battle}}, 3000}; - } - if (kind == "warp"){ + 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. - return {{{100, {ScreenEvent::FADE_BLACK}}, {600, {}}}, 9000}; + screen.frames = {{100, black}, {600, {}}}; + screen.look_ms = 9000; + }else{ + throw ParseException("story replay: unknown interrupt kind"); } - 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();