From 426de16e7dc24de72b462655793c7f6a8efe7bcc Mon Sep 17 00:00:00 2001 From: dayfine Date: Sun, 8 Nov 2026 01:27:05 -0800 Subject: [PATCH 1/7] 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 26256c713d287198f3ee4f2f6dd6a9509ef59fe7 Mon Sep 17 00:00:00 2001 From: dayfine Date: Sun, 8 Nov 2026 12:52:10 -0800 Subject: [PATCH 2/7] 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 3/7] 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 f5fb08392f1702f9163691c7ca0779bcac305953 Mon Sep 17 00:00:00 2001 From: dayfine Date: Sun, 8 Nov 2026 13:24:46 -0800 Subject: [PATCH 4/7] 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 514180f93889463fa16fcf5069aa41a198c387c7 Mon Sep 17 00:00:00 2001 From: dayfine Date: Sun, 8 Nov 2026 15:46:25 -0800 Subject: [PATCH 5/7] Separate FRLG checkpoint JSON parsing and migration writing --- SerialPrograms/CMakeLists.txt | 2 +- .../PokemonFRLG/PokemonFRLG_Tests.cpp | 4 +- .../JsonParser.cpp} | 168 +++++++----------- .../Programs/AutoStory/Data/JsonParser.h | 47 +++++ .../Programs/AutoStory/Data/JsonWriter.cpp | 109 ++++++++++++ .../Programs/AutoStory/Data/JsonWriter.h | 26 +++ .../Json_Tests.cpp} | 42 ++++- .../Json_Tests.h} | 10 +- .../AutoStory/Data/LegacyJsonFormat.h | 53 ++++++ .../AutoStory/PokemonFRLG_AutoStoryData.cpp | 54 ------ .../AutoStory/PokemonFRLG_AutoStoryData.h | 19 -- .../AutoStory/PokemonFRLG_AutoStoryExport.h | 37 ---- .../PokemonFRLG_AutoStorySimulation_Tests.cpp | 46 ++--- .../AutoStory/PokemonFRLG_AutoStoryTable.cpp | 4 +- .../Programs/AutoStory/StoryData/README.md | 4 + SerialPrograms/cmake/SourceFiles.cmake | 13 +- 16 files changed, 387 insertions(+), 251 deletions(-) rename SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/{PokemonFRLG_AutoStoryExport.cpp => Data/JsonParser.cpp} (66%) create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/JsonParser.h create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/JsonWriter.cpp create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/JsonWriter.h rename SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/{PokemonFRLG_AutoStoryExport_Tests.cpp => Data/Json_Tests.cpp} (95%) rename SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/{PokemonFRLG_AutoStoryExport_Tests.h => Data/Json_Tests.h} (66%) create mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/LegacyJsonFormat.h delete mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryData.cpp delete mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryData.h delete mode 100644 SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryExport.h diff --git a/SerialPrograms/CMakeLists.txt b/SerialPrograms/CMakeLists.txt index ceac98eeeb..bd7c93a134 100644 --- a/SerialPrograms/CMakeLists.txt +++ b/SerialPrograms/CMakeLists.txt @@ -234,7 +234,7 @@ pa_embed_text_files(SerialProgramsLib "${FRLG_MAP_RESOURCES}" 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) + VideoGames/PokemonFRLG/Programs/AutoStory/Data/JsonParser.h PokemonAutomation::NintendoSwitch::PokemonFRLG autostory_checkpoint_json) # Create binary target diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp index 0b5eec80e5..cd7311f935 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp @@ -52,7 +52,7 @@ #include "Programs/AutoStory/PokemonFRLG_AutoStoryRngLoop.h" #include "Programs/AutoStory/PokemonFRLG_AutoStoryTable.h" #include "Programs/AutoStory/PokemonFRLG_AutoStoryTrashCans.h" -#include "Programs/AutoStory/PokemonFRLG_AutoStoryExport_Tests.h" +#include "Programs/AutoStory/Data/Json_Tests.h" #include "Programs/RngManipulation/PokemonFRLG_GiftRngAttempts_Tests.h" #include "Programs/RngManipulation/PokemonFRLG_RngPlanInputs_Tests.h" #include "Programs/RngManipulation/PokemonFRLG_RngPlanLog_Tests.h" @@ -932,7 +932,7 @@ void add_tests(UnitTestDatabase& database){ add_tests_OverworldSim(database); add_tests_PlayerFacingReader(database); add_tests_AutoStory(database); - add_tests_AutoStoryExport(database); + add_tests_AutoStoryJson(database); add_tests_AutoStorySimulation(database); add_tests_AutoStoryRows(database); add_tests_AutoStoryTrashCans(database); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryExport.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/JsonParser.cpp similarity index 66% rename from SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryExport.cpp rename to SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/JsonParser.cpp index 82b19da268..f370385b92 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryExport.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/JsonParser.cpp @@ -1,101 +1,31 @@ -/* FRLG Authored Checkpoint Export +/* FRLG Checkpoint JSON Parser * From: https://github.com/PokemonAutomation/ */ + #include #include #include -#include #include "Common/Cpp/Exceptions.h" #include "Common/Cpp/Json/JsonArray.h" #include "Common/Cpp/Json/JsonObject.h" #include "Pokemon/AutoStory/Pokemon_StoryStepJson.h" -#include "PokemonFRLG_AutoStoryExport.h" +#include "LegacyJsonFormat.h" +#include "JsonParser.h" + 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"; - case StartMenu::BEFORE_POKEDEX: return "before_pokedex"; - case StartMenu::FULL: return "full"; - } - throw ParseException("Unsupported FRLG START menu."); -} -JsonArray strings(const std::vector& values){ - JsonArray result; - for (const std::string& value : values){ - result.push_back(value); - } - return result; -} -int64_t count(size_t value){ - if (value > (size_t)std::numeric_limits::max()){ - throw ParseException("FRLG checkpoint count exceeds JSON integer range."); - } - return (int64_t)value; -} -JsonObject stats_json(const Stats& stats){ - JsonObject result; - result["hp"] = stats.hp; - result["attack"] = stats.attack; - result["defense"] = stats.defense; - result["sp_attack"] = stats.sp_attack; - result["sp_defense"] = stats.sp_defense; - result["speed"] = stats.speed; - return result; -} -JsonObject member_json(const PartyMember& member){ - JsonObject result; - result["species"] = member.species; - JsonArray moves; - for (const KnownMove& move : member.moves){ - JsonObject entry; - entry["move"] = move.move; - entry["pp"] = move.pp; - moves.push_back(std::move(entry)); +void exact_fields(const JsonObject& value, std::initializer_list names){ + if (value.size() != names.size()){ + throw ParseException("Authored checkpoint: missing or extra fields."); } - result["moves"] = std::move(moves); - result["moves_through_level"] = member.moves_through_level; - result["moves_read"] = member.moves_read; - result["level"] = member.level; - result["stats"] = member.stats ? JsonValue(stats_json(*member.stats)) : JsonValue(); - return result; -} -JsonObject supplies_json(const Supplies& supplies){ - JsonObject result; - result["potions"] = supplies.potions; - result["antidotes"] = supplies.antidotes; - result["parlyz_heals"] = supplies.parlyz_heals; - result["pocket"] = strings(supplies.pocket); - result["status"] = status_name(supplies.status); - result["hurt"] = supplies.hurt; - result["hp_red"] = supplies.hp_red; - result["hp_fill"] = supplies.hp_fill; - result["lead"] = member_json(supplies.lead); - JsonArray protected_slots; - for (size_t slot : supplies.protected_slots){ - protected_slots.push_back(count(slot)); + for (std::string_view name : names){ + value.get_value_throw(std::string(name)); } - result["protected_slots"] = std::move(protected_slots); - result["foes"] = strings(supplies.foes); - result["trainer"] = supplies.trainer; - result["risk"] = battle_risk_name(supplies.risk); - result["foe_out"] = supplies.foe_out ? JsonValue(count(*supplies.foe_out)) : JsonValue(); - result["wild_foes"] = strings(supplies.wild_foes); - return result; } // Reading. Every field is required and no other is allowed, as in the @@ -113,12 +43,14 @@ const JsonObject& fields(const JsonValue& value, std::initializer_list Enum read_named(std::initializer_list values, const char* (*name)(Enum), const std::string& label, std::string_view source){ @@ -129,6 +61,7 @@ Enum read_named(std::initializer_list values, const char* (*name)(Enum), c } 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)); @@ -137,18 +70,23 @@ int64_t bounded(const JsonValue& value, int64_t low, int64_t high, std::string_v } 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))){ @@ -156,6 +94,7 @@ std::vector read_strings(const JsonValue& value, std::string_view s } return result; } + std::optional read_stats(const JsonValue& value, std::string_view source){ if (value.is_null()){ return std::nullopt; @@ -170,6 +109,7 @@ std::optional read_stats(const JsonValue& value, std::string_view 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))){ @@ -178,6 +118,7 @@ std::vector read_moves(const JsonValue& value, std::string_view sourc } 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{ @@ -189,6 +130,7 @@ PartyMember read_member(const JsonValue& value, std::string_view 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))){ @@ -196,12 +138,14 @@ std::vector read_slots(const JsonValue& value, std::string_view 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); @@ -224,33 +168,54 @@ Supplies read_supplies(const JsonValue& value, std::string_view 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()){ + expect_string(root, "format", StoryJsonInternal::FORMAT, source); + expect_string(root, "game", StoryJsonInternal::GAME, source); + expect_string(root, "status", StoryJsonInternal::STATUS, source); + expect_string(root, "state_basis", StoryJsonInternal::STATE_BASIS, source); + if (read_strings(root.get_value_throw("unbound"), source) != StoryJsonInternal::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"] = 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"] = std::string(STATE_BASIS); - result["unbound"] = strings(unbound_fields()); + +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)); +} + 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); @@ -260,10 +225,11 @@ StoryCheckpoint read_story_checkpoint_export(const JsonValue& value, std::string 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); + StoryJsonInternal::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){ @@ -272,6 +238,8 @@ StoryCheckpoint load_story_checkpoint(std::string_view 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/Data/JsonParser.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/JsonParser.h new file mode 100644 index 0000000000..9b20466141 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/JsonParser.h @@ -0,0 +1,47 @@ +/* FRLG Checkpoint JSON Parser + * From: https://github.com/PokemonAutomation/ + */ + +#ifndef PokemonAutomation_PokemonFRLG_CheckpointJsonParser_H +#define PokemonAutomation_PokemonFRLG_CheckpointJsonParser_H + +#include +#include +#include +#include +#include "Common/Cpp/Json/JsonValue.h" +#include "VideoGames/PokemonFRLG/Programs/AutoStory/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); + + +// Embedded StoryData/*.json records: (filename without .json, text), sorted by name. +const std::vector>& autostory_checkpoint_json(); + + +// Migration only: read a captured native-table export, including all bookkeeping. +// Validate the format/game/status/state basis/unbound list and require a nonempty +// source revision. Unknown fields/values and missing native fields throw ParseException. +// The reader cannot verify the source revision against the captured table. +StoryCheckpoint read_story_checkpoint_export(const JsonValue& value, std::string_view source); + +// Migration only: read an embedded export capture by filename without .json. +// Throws InternalProgramError if the file is not built into the program. +StoryCheckpoint load_story_checkpoint(std::string_view file); + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation + +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/JsonWriter.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/JsonWriter.cpp new file mode 100644 index 0000000000..069658edee --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/JsonWriter.cpp @@ -0,0 +1,109 @@ +/* Legacy FRLG Checkpoint JSON Writer + * From: https://github.com/PokemonAutomation/ + */ + +#include +#include +#include "Common/Cpp/Json/JsonArray.h" +#include "Common/Cpp/Json/JsonObject.h" +#include "Pokemon/AutoStory/Pokemon_StoryStepJson.h" +#include "LegacyJsonFormat.h" +#include "JsonWriter.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +JsonArray strings(const std::vector& values){ + JsonArray result; + for (const std::string& value : values){ + result.push_back(value); + } + return result; +} + +int64_t count(size_t value){ + if (value > (size_t)std::numeric_limits::max()){ + throw ParseException("FRLG checkpoint count exceeds JSON integer range."); + } + return (int64_t)value; +} + +JsonObject stats_json(const Stats& stats){ + JsonObject result; + result["hp"] = stats.hp; + result["attack"] = stats.attack; + result["defense"] = stats.defense; + result["sp_attack"] = stats.sp_attack; + result["sp_defense"] = stats.sp_defense; + result["speed"] = stats.speed; + return result; +} + +JsonObject member_json(const PartyMember& member){ + JsonObject result; + result["species"] = member.species; + JsonArray moves; + for (const KnownMove& move : member.moves){ + JsonObject entry; + entry["move"] = move.move; + entry["pp"] = move.pp; + moves.push_back(std::move(entry)); + } + result["moves"] = std::move(moves); + result["moves_through_level"] = member.moves_through_level; + result["moves_read"] = member.moves_read; + result["level"] = member.level; + result["stats"] = member.stats ? JsonValue(stats_json(*member.stats)) : JsonValue(); + return result; +} + +JsonObject supplies_json(const Supplies& supplies){ + JsonObject result; + result["potions"] = supplies.potions; + result["antidotes"] = supplies.antidotes; + result["parlyz_heals"] = supplies.parlyz_heals; + result["pocket"] = strings(supplies.pocket); + result["status"] = status_name(supplies.status); + result["hurt"] = supplies.hurt; + result["hp_red"] = supplies.hp_red; + result["hp_fill"] = supplies.hp_fill; + result["lead"] = member_json(supplies.lead); + JsonArray protected_slots; + for (size_t slot : supplies.protected_slots){ + protected_slots.push_back(count(slot)); + } + result["protected_slots"] = std::move(protected_slots); + result["foes"] = strings(supplies.foes); + result["trainer"] = supplies.trainer; + result["risk"] = battle_risk_name(supplies.risk); + result["foe_out"] = supplies.foe_out ? JsonValue(count(*supplies.foe_out)) : JsonValue(); + result["wild_foes"] = strings(supplies.wild_foes); + return result; +} + + +} // namespace + +JsonValue story_checkpoint_export(const StoryCheckpoint& checkpoint){ + JsonObject result; + result["format"] = std::string(StoryJsonInternal::FORMAT); + result["game"] = std::string(StoryJsonInternal::GAME); + result["status"] = std::string(StoryJsonInternal::STATUS); + result["checkpoint"] = Pokemon::story_checkpoint_json(checkpoint); + JsonObject native; + native["menu"] = StoryJsonInternal::menu_name(checkpoint.menu); + native["supplies"] = supplies_json(checkpoint.supplies); + result["native_fields"] = std::move(native); + result["state_basis"] = std::string(StoryJsonInternal::STATE_BASIS); + result["unbound"] = strings(StoryJsonInternal::unbound_fields()); + return result; +} + + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/JsonWriter.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/JsonWriter.h new file mode 100644 index 0000000000..0fa8d2d229 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/JsonWriter.h @@ -0,0 +1,26 @@ +/* Legacy FRLG Checkpoint JSON Writer + * From: https://github.com/PokemonAutomation/ + */ + +#ifndef PokemonAutomation_PokemonFRLG_CheckpointJsonWriter_H +#define PokemonAutomation_PokemonFRLG_CheckpointJsonWriter_H + +#include "Common/Cpp/Json/JsonValue.h" +#include "VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +// Migration diagnostics only: capture shared fields and FRLG bookkeeping. +// This is not the authored production format or observed entry requirements. +// Runtime configuration, map pins, policies and executor bindings remain unbound. +JsonValue story_checkpoint_export(const StoryCheckpoint& checkpoint); + + +} // 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/Data/Json_Tests.cpp similarity index 95% rename from SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryExport_Tests.cpp rename to SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/Json_Tests.cpp index 8bec8a74f1..2b4715df94 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryExport_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/Json_Tests.cpp @@ -1,31 +1,38 @@ /* 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" -#include "PokemonFRLG_AutoStoryData.h" -#include "PokemonFRLG_AutoStoryExport.h" -#include "PokemonFRLG_AutoStoryTable.h" -#include "PokemonFRLG_AutoStoryExport_Tests.h" +#include "JsonParser.h" +#include "JsonWriter.h" +#include "VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.h" +#include "Json_Tests.h" + namespace PokemonAutomation{ namespace NintendoSwitch{ namespace PokemonFRLG{ + namespace{ + struct ExportInput{ std::string_view id; bool custom = false; }; + struct ExportExpectation{ std::vector events; std::string_view menu; std::string_view native_json = {}; }; + struct ExportCase{ ExportInput input; ExportExpectation expectation; }; + const ExportCase CASES[] = { {.input = {.id = "00.05"}, .expectation = {.events = {"below_mart_door", "mart_door", "parcel_from_clerk"}, .menu = "before_pokedex"}}, {.input = {.id = "00.05", .custom = true}, .expectation = { @@ -33,6 +40,7 @@ const ExportCase CASES[] = { .native_json = R"({"menu":"full","supplies":{"potions":2,"antidotes":3,"parlyz_heals":4,"pocket":["antidote",""],"status":"PSN","hurt":true,"hp_red":true,"hp_fill":0.25,"lead":{"species":"wartortle","moves":[{"move":"bite","pp":0}],"moves_through_level":18,"moves_read":true,"level":19,"stats":{"hp":50,"attack":20,"defense":30,"sp_attack":40,"sp_defense":35,"speed":25}},"protected_slots":[0,2],"foes":["staryu"],"trainer":"misty","risk":"high","foe_out":0,"wild_foes":["pidgey"]}})" }}, }; + void customize(StoryCheckpoint& row){ row.menu = StartMenu::FULL; Supplies& state = row.supplies; @@ -54,12 +62,14 @@ 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); @@ -88,6 +98,7 @@ std::string check_export(StoryCheckpoint row, const ExportCase& test){ } return names == test.expectation.events ? "" : "Expected Parcel's three authored events in order."; } + class ExportTest : public UnitTest{ public: explicit ExportTest(ExportCase test) @@ -116,15 +127,18 @@ struct LoadInput{ void (*mutate)(JsonObject& root) = nullptr; bool authored = false; }; + 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; @@ -135,35 +149,45 @@ JsonObject without(const JsonObject& object, std::string_view key){ } 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"); } + 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}}, @@ -179,6 +203,7 @@ 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 LoadInput& input){ for (const auto& [name, text] : autostory_checkpoint_json()){ if (name == (input.authored ? "frlg-parcel-00.05.plan" : "frlg-parcel-00.05")){ @@ -187,6 +212,7 @@ const std::string& parcel_text(const LoadInput& input){ } 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){ @@ -195,6 +221,7 @@ 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 = test.input.authored ? "" : capture.to_object_throw().get_string_throw("source_revision"); if (!test.input.authored && !(exported_file(loaded, revision) == capture)){ @@ -209,6 +236,7 @@ std::string check_loaded(const StoryCheckpoint& loaded, const JsonValue& capture } return ""; } + std::string check_load(const LoadCase& test){ JsonValue capture = parse_json(parcel_text(test.input)); if (test.input.mutate){ @@ -225,6 +253,7 @@ std::string check_load(const LoadCase& test){ return test.expectation.rejected ? "" : "Unexpected parse failure: " + error.message(); } } + class LoadTest : public UnitTest{ public: explicit LoadTest(LoadCase test) @@ -236,8 +265,10 @@ class LoadTest : public UnitTest{ private: LoadCase m_test; }; + } // namespace -void add_tests_AutoStoryExport(UnitTestDatabase& database){ + +void add_tests_AutoStoryJson(UnitTestDatabase& database){ for (const ExportCase& test : CASES){ database.add(test); } @@ -245,6 +276,7 @@ void add_tests_AutoStoryExport(UnitTestDatabase& database){ database.add(test); } } + } // namespace PokemonFRLG } // namespace NintendoSwitch } // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryExport_Tests.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/Json_Tests.h similarity index 66% rename from SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryExport_Tests.h rename to SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/Json_Tests.h index 90eabc74a9..b43a86f3d1 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryExport_Tests.h +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/Json_Tests.h @@ -1,14 +1,18 @@ /* FRLG Checkpoint Export Tests * From: https://github.com/PokemonAutomation/ */ -#ifndef PokemonAutomation_PokemonFRLG_AutoStoryExport_Tests_H -#define PokemonAutomation_PokemonFRLG_AutoStoryExport_Tests_H + +#ifndef PokemonAutomation_PokemonFRLG_AutoStoryJson_Tests_H +#define PokemonAutomation_PokemonFRLG_AutoStoryJson_Tests_H + #include "Common/Cpp/TestRunners/UnitTestDatabase.h" + namespace PokemonAutomation{ namespace NintendoSwitch{ namespace PokemonFRLG{ + // Register evaluated-table and non-default native-extension preservation tests. -void add_tests_AutoStoryExport(UnitTestDatabase& database); +void add_tests_AutoStoryJson(UnitTestDatabase& database); } // namespace PokemonFRLG } // namespace NintendoSwitch } // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/LegacyJsonFormat.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/LegacyJsonFormat.h new file mode 100644 index 0000000000..527273360c --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/LegacyJsonFormat.h @@ -0,0 +1,53 @@ +/* Legacy FRLG Checkpoint Export Format + * From: https://github.com/PokemonAutomation/ + */ + +#ifndef PokemonAutomation_PokemonFRLG_LegacyJsonFormat_H +#define PokemonAutomation_PokemonFRLG_LegacyJsonFormat_H + +#include +#include +#include +#include "Common/Cpp/Exceptions.h" +#include "VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryEvents.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace StoryJsonInternal{ + +// The envelope constants the export writes and the reader requires. +constexpr std::string_view FORMAT = "pokesim-native-checkpoint-export/1"; +// Game identifier in the legacy envelope. +constexpr std::string_view GAME = "frlg"; +// Marks a captured table row rather than an authored plan. +constexpr std::string_view STATUS = "native-table-export"; +// Describes the native bookkeeping snapshot, not observed game state. +constexpr std::string_view STATE_BASIS = "native-table-bookkeeping"; +// Fields deliberately left outside the captured native table state. +inline const std::vector& unbound_fields(){ + static const std::vector UNBOUND{ + "runtime_configuration", "map_pins", "executor_bindings", "entry_evidence", "effective_save_retry_policy", + }; + return UNBOUND; +} + +// Stable legacy spellings of the native START menu variants. +inline const char* menu_name(StartMenu menu){ + switch (menu){ + case StartMenu::BEFORE_POKEMON: return "before_pokemon"; + case StartMenu::BEFORE_POKEDEX: return "before_pokedex"; + case StartMenu::FULL: return "full"; + } + throw ParseException("Unsupported FRLG START menu."); +} + +} // namespace StoryJsonInternal + +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation + +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryData.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryData.cpp deleted file mode 100644 index 8e5105f403..0000000000 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryData.cpp +++ /dev/null @@ -1,54 +0,0 @@ -/* 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 deleted file mode 100644 index 536731a96b..0000000000 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryData.h +++ /dev/null @@ -1,19 +0,0 @@ -/* 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.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryExport.h deleted file mode 100644 index 3e0b740e57..0000000000 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryExport.h +++ /dev/null @@ -1,37 +0,0 @@ -/* FRLG Authored Checkpoint Export - * From: https://github.com/PokemonAutomation/ - */ -#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{ -namespace NintendoSwitch{ -namespace PokemonFRLG{ -// Capture evaluated shared fields and every FRLG checkpoint extension. -// 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 -#endif 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 73ee47e043..f435c6c48d 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStorySimulation_Tests.cpp @@ -33,7 +33,7 @@ #include "PokemonFRLG_AutoStoryEvents.h" #include "PokemonFRLG_AutoStoryPaceSim.h" #include "PokemonFRLG_AutoStoryRowSim.h" -#include "PokemonFRLG_AutoStoryExport.h" +#include "Data/JsonParser.h" #include "PokemonFRLG_AutoStoryTable.h" namespace PokemonAutomation{ @@ -323,36 +323,36 @@ struct Simulation{ }; -struct AutoStorySimulationInput{ - std::string name; - UnitTestResult (*body)(); -}; - -struct AutoStorySimulationExpectation{ - const char* description; -}; - -struct AutoStorySimulationTestCase{ - AutoStorySimulationInput input; - AutoStorySimulationExpectation expectation; -}; - +struct AutoStorySimulationInput{ + std::string name; + UnitTestResult (*body)(); +}; + +struct AutoStorySimulationExpectation{ + std::string description; +}; + +struct AutoStorySimulationTestCase{ + AutoStorySimulationInput input; + AutoStorySimulationExpectation expectation; +}; + class Test_AutoStorySimulation : public UnitTest{ public: - explicit Test_AutoStorySimulation(AutoStorySimulationTestCase scenario) - : UnitTest("PokemonFRLG::AutoStory - Simulation: " + scenario.input.name) - , m_scenario(std::move(scenario)) + explicit Test_AutoStorySimulation(AutoStorySimulationTestCase scenario) + : UnitTest("PokemonFRLG::AutoStory - Simulation: " + scenario.input.name) + , m_scenario(std::move(scenario)) {} virtual UnitTestResult run(Logger&, CancellableScope&) const override{ try{ - return m_scenario.input.body(); + return m_scenario.input.body(); }catch (Exception& e){ return std::string(e.name()) + ": " + e.message(); } } private: - AutoStorySimulationTestCase m_scenario; + AutoStorySimulationTestCase m_scenario; }; @@ -950,6 +950,6 @@ void add_tests_AutoStorySimulation(UnitTestDatabase& database){ -} // namespace PokemonFRLG -} // namespace NintendoSwitch -} // namespace PokemonAutomation +} // namespace PokemonFRLG +} // namespace NintendoSwitch +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp index f3519a0453..3f88fe03e1 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp @@ -5,7 +5,7 @@ */ #include "PokemonFRLG_AutoStoryCapture.h" -#include "PokemonFRLG_AutoStoryData.h" +#include "Data/JsonParser.h" #include "PokemonFRLG_AutoStoryChoice.h" #include "PokemonFRLG_AutoStoryGrind.h" #include "PokemonFRLG_AutoStoryMart.h" @@ -82,6 +82,8 @@ StoryCheckpoint starter(){ }); } +// TODO: Load an ordered series of authored checkpoints together and construct +// a partial story table, instead of loading one file in each row factory. StoryCheckpoint parcel(){ return load_authored_checkpoint("frlg-parcel-00.05.plan"); } diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/README.md b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/README.md index 1b8aef3a64..0df53c7675 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/README.md +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/README.md @@ -21,3 +21,7 @@ The same file is checked into PokemonSimulation at `data/story/parcel-pilot/frlg - 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. No console is required for either gate. + +## Native JSON code + +`../Data/JsonParser.*` owns authored checkpoint loading and the legacy capture reader. `JsonWriter.*` is retained for migration comparisons and export diagnostics; production authored loading does not depend on it. `Json_Tests.*` keeps the codec tests beside those sources. Once migration expectations are independent of the old capture, remove the legacy reader, writer, and `LegacyJsonFormat.h`; retain the authored parser. diff --git a/SerialPrograms/cmake/SourceFiles.cmake b/SerialPrograms/cmake/SourceFiles.cmake index 36b05078a3..5e5bb9322c 100644 --- a/SerialPrograms/cmake/SourceFiles.cmake +++ b/SerialPrograms/cmake/SourceFiles.cmake @@ -1912,12 +1912,13 @@ 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 - Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryExport_Tests.h + Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/JsonParser.cpp + Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/JsonParser.h + Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/JsonWriter.cpp + Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/JsonWriter.h + Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/LegacyJsonFormat.h + Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/Json_Tests.cpp + Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/Json_Tests.h Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.h Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTableCeladon.cpp From fd5f5b10dd7cee51112aea8d14be685589968363 Mon Sep 17 00:00:00 2001 From: dayfine Date: Sun, 8 Nov 2026 15:53:59 -0800 Subject: [PATCH 6/7] Share authored checkpoint envelopes and embedded resource loading --- .../ComputerPrograms_UnitTestRunner.cpp | 1 + .../Pokemon_AuthoredCheckpointJson.cpp | 67 +++++++++ .../Pokemon_AuthoredCheckpointJson.h | 53 +++++++ .../Pokemon_AuthoredCheckpointJson_Tests.cpp | 139 ++++++++++++++++++ .../AutoStory/Pokemon_AutoStory_Tests.h | 3 + SerialPrograms/cmake/SourceFiles.cmake | 3 + devtools/story-data-tests/CMakeLists.txt | 22 ++- devtools/story-data-tests/README.md | 2 + 8 files changed, 284 insertions(+), 6 deletions(-) create mode 100644 SerialPrograms/Source/Pokemon/AutoStory/Pokemon_AuthoredCheckpointJson.cpp create mode 100644 SerialPrograms/Source/Pokemon/AutoStory/Pokemon_AuthoredCheckpointJson.h create mode 100644 SerialPrograms/Source/Pokemon/AutoStory/Pokemon_AuthoredCheckpointJson_Tests.cpp diff --git a/SerialPrograms/Source/ComputerPrograms/ComputerPrograms_UnitTestRunner.cpp b/SerialPrograms/Source/ComputerPrograms/ComputerPrograms_UnitTestRunner.cpp index 4792436378..df98fc4df2 100644 --- a/SerialPrograms/Source/ComputerPrograms/ComputerPrograms_UnitTestRunner.cpp +++ b/SerialPrograms/Source/ComputerPrograms/ComputerPrograms_UnitTestRunner.cpp @@ -82,6 +82,7 @@ UnitTestDatabase make_UNIT_TESTS_ALL(){ Pokemon::add_tests_StorySegments(ret); Pokemon::add_tests_StorySteps(ret); Pokemon::add_tests_StoryStepJson(ret); + Pokemon::add_tests_AuthoredCheckpointJson(ret); NintendoSwitch::add_tests_CheckOnlineDetector(ret); NintendoSwitch::add_tests_FailedToConnectDetector(ret); NintendoSwitch::add_tests_UpdatePopupDetector(ret); diff --git a/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_AuthoredCheckpointJson.cpp b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_AuthoredCheckpointJson.cpp new file mode 100644 index 0000000000..699ece6df1 --- /dev/null +++ b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_AuthoredCheckpointJson.cpp @@ -0,0 +1,67 @@ +/* Shared Authored Checkpoint JSON + * From: https://github.com/PokemonAutomation/ + */ + +#include +#include +#include "Common/Cpp/Exceptions.h" +#include "Pokemon_StoryStepJson.h" +#include "Pokemon_AuthoredCheckpointJson.h" + +namespace PokemonAutomation{ +namespace Pokemon{ + + +void require_story_json_fields( + const JsonObject& object, std::initializer_list fields, std::string_view source +){ + if (object.size() != fields.size()){ + throw ParseException(std::format("{}: missing or extra authored checkpoint fields.", source)); + } + for (std::string_view field : fields){ + object.get_value_throw(std::string(field), std::string(source)); + } +} + +AuthoredCheckpoint read_authored_checkpoint( + const JsonValue& value, std::string_view expected_game, std::string_view source +){ + const JsonObject& root = value.to_object_throw(std::string(source)); + require_story_json_fields(root, {"format", "game", "checkpoint", "execution"}, source); + if (expected_game.empty() || root.get_string_throw("format") != "pokesim-authored-checkpoint/1" + || root.get_string_throw("game") != expected_game + ){ + throw ParseException(std::format("{}: unsupported authored checkpoint format/game.", source)); + } + return { + .checkpoint = read_story_checkpoint(root.get_value_throw("checkpoint"), source), + .execution = root.get_object_throw("execution", std::string(source)).clone(), + }; +} + +JsonValue authored_checkpoint_json(const StoryCheckpoint& checkpoint, std::string_view game, JsonObject execution){ + if (game.empty()){ + throw ParseException("Authored checkpoint game must not be empty."); + } + JsonObject result; + result["format"] = "pokesim-authored-checkpoint/1"; + result["game"] = std::string(game); + result["checkpoint"] = story_checkpoint_json(checkpoint); + result["execution"] = std::move(execution); + return result; +} + +AuthoredCheckpoint load_authored_checkpoint( + std::span> files, std::string_view file, std::string_view expected_game +){ + for (const auto& [name, text] : files){ + if (name == file){ + return read_authored_checkpoint(parse_json(text), expected_game, file); + } + } + throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "Authored checkpoint is not embedded: " + std::string(file)); +} + + +} // namespace Pokemon +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_AuthoredCheckpointJson.h b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_AuthoredCheckpointJson.h new file mode 100644 index 0000000000..1b1be213dd --- /dev/null +++ b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_AuthoredCheckpointJson.h @@ -0,0 +1,53 @@ +/* Shared Authored Checkpoint JSON + * From: https://github.com/PokemonAutomation/ + */ + +#ifndef PokemonAutomation_AuthoredCheckpointJson_H +#define PokemonAutomation_AuthoredCheckpointJson_H + +#include +#include +#include +#include +#include +#include "Common/Cpp/Json/JsonObject.h" +#include "Pokemon_StorySteps.h" + +namespace PokemonAutomation{ +namespace Pokemon{ + + +// Common story data and an owned execution object for the game adapter to validate. +// Parsing this document does not validate game policies or bind/execute actions. +struct AuthoredCheckpoint{ + StoryCheckpoint checkpoint; + JsonObject execution; +}; + +// Require exactly these fields, including fields whose value is null/zero/false. +// Missing/unknown fields throw ParseException with the document source. +void require_story_json_fields( + const JsonObject& object, std::initializer_list fields, std::string_view source +); + +// Read the strict authored envelope and common checkpoint for expected_game. +// The game adapter must still validate execution fields and values before use. +AuthoredCheckpoint read_authored_checkpoint( + const JsonValue& value, std::string_view expected_game, std::string_view source +); + +// Encode the authored envelope; execution is supplied by the game adapter. +// This does not encode game bookkeeping or the legacy native-export envelope. +JsonValue authored_checkpoint_json(const StoryCheckpoint& checkpoint, std::string_view game, JsonObject execution); + +// Find and parse an embedded (filename without .json, text) resource for this game. +// Missing resources throw InternalProgramError; malformed documents throw ParseException. +AuthoredCheckpoint load_authored_checkpoint( + std::span> files, std::string_view file, std::string_view expected_game +); + + +} // namespace Pokemon +} // namespace PokemonAutomation + +#endif diff --git a/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_AuthoredCheckpointJson_Tests.cpp b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_AuthoredCheckpointJson_Tests.cpp new file mode 100644 index 0000000000..7aba0e12f4 --- /dev/null +++ b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_AuthoredCheckpointJson_Tests.cpp @@ -0,0 +1,139 @@ +/* Shared Authored Checkpoint JSON Tests + * From: https://github.com/PokemonAutomation/ + */ + +#include +#include +#include "Common/Cpp/Exceptions.h" +#include "Pokemon_AuthoredCheckpointJson.h" +#include "Pokemon_StoryStepJson.h" +#ifndef PA_STORY_DATA_STANDALONE +#include "Pokemon_AutoStory_Tests.h" +#endif + +namespace PokemonAutomation{ +namespace Pokemon{ +namespace{ + +// Documents intentionally contain no game headers or native bookkeeping. +const std::string FRLG = R"({"format":"pokesim-authored-checkpoint/1","game":"frlg","checkpoint":{"id":"00.05","title":"Parcel","events":[{"id":"first","kind":"route"},{"id":"second","kind":"scene"}]},"execution":{"menu":"before_pokedex"}})"; +const std::string BDSP = R"({"format":"pokesim-authored-checkpoint/1","game":"bdsp","checkpoint":{"id":"01.03","title":"Mine","events":[{"id":"roark","kind":"talk"}]},"execution":{"on_failure":"reload_and_retry","saves_in":"Oreburgh City"}})"; + +enum class Failure{ NONE, PARSE, MISSING_RESOURCE }; + +struct AuthoredInput{ + std::string name; + std::string json; + std::string game = "frlg"; + bool resource = false; + std::string file = "plan"; +}; + +struct AuthoredExpectation{ + std::string checkpoint = "00.05"; + std::string execution = R"({"menu":"before_pokedex"})"; + std::vector events = {"first", "second"}; + Failure failure = Failure::NONE; +}; + +struct AuthoredCase{ + AuthoredInput input; + AuthoredExpectation expectation; +}; + +const AuthoredCase CASES[] = { + {.input = {.name = "FRLG envelope", .json = FRLG}, .expectation = {}}, + {.input = {.name = "BDSP embedded resource", .json = BDSP, .game = "bdsp", .resource = true}, .expectation = { + .checkpoint = "01.03", .execution = R"({"on_failure":"reload_and_retry","saves_in":"Oreburgh City"})", .events = {"roark"}}}, + {.input = {.name = "FRLG embedded resource", .json = FRLG, .resource = true}, .expectation = {}}, + {.input = {.name = "wrong game", .json = BDSP}, .expectation = {.failure = Failure::PARSE}}, + {.input = {.name = "empty expected game", .json = FRLG, .game = ""}, .expectation = {.failure = Failure::PARSE}}, + {.input = {.name = "missing resource", .json = FRLG, .resource = true, .file = "absent"}, .expectation = {.failure = Failure::MISSING_RESOURCE}}, + {.input = {.name = "invalid embedded JSON", .json = "{", .resource = true}, .expectation = {.failure = Failure::PARSE}}, + {.input = {.name = "wrong format", .json = R"({"format":"future","game":"frlg","checkpoint":{},"execution":{}})"}, .expectation = {.failure = Failure::PARSE}}, + {.input = {.name = "missing execution", .json = R"({"format":"pokesim-authored-checkpoint/1","game":"frlg","checkpoint":{}})"}, .expectation = {.failure = Failure::PARSE}}, + {.input = {.name = "same count wrong field", .json = R"({"format":"pokesim-authored-checkpoint/1","game":"frlg","checkpoint":{},"extra":{}})"}, .expectation = {.failure = Failure::PARSE}}, + {.input = {.name = "extra envelope field", .json = R"({"format":"pokesim-authored-checkpoint/1","game":"frlg","checkpoint":{},"execution":{},"extra":0})"}, .expectation = {.failure = Failure::PARSE}}, + {.input = {.name = "execution must be object", .json = R"({"format":"pokesim-authored-checkpoint/1","game":"frlg","checkpoint":{"id":"x","title":"X","events":[]},"execution":false})"}, .expectation = {.failure = Failure::PARSE}}, + {.input = {.name = "invalid common action", .json = R"({"format":"pokesim-authored-checkpoint/1","game":"frlg","checkpoint":{"id":"x","title":"X","events":[{"id":"bad","kind":"magic"}]},"execution":{}})"}, .expectation = {.failure = Failure::PARSE}}, + {.input = {.name = "execution stays game owned", .json = R"({"format":"pokesim-authored-checkpoint/1","game":"future","checkpoint":{"id":"x","title":"X","events":[]},"execution":{"zero":0,"false":false,"null":null}})", .game = "future"}, .expectation = { + .checkpoint = "x", .execution = R"({"zero":0,"false":false,"null":null})", .events = {}}}, +}; + +std::string check_loaded(const AuthoredCheckpoint& actual, const AuthoredCase& test){ + if (test.expectation.failure != Failure::NONE){ + return "Expected rejection, read accepted authored checkpoint."; + } + if (actual.checkpoint.id != test.expectation.checkpoint){ + return "Expected checkpoint " + test.expectation.checkpoint + ", read " + actual.checkpoint.id; + } + if (!(JsonValue(actual.execution.clone()) == parse_json(test.expectation.execution))){ + return "Expected execution " + test.expectation.execution + ", read " + actual.execution.dump(); + } + std::vector events; + for (const StoryStep& step : actual.checkpoint.steps){ + events.push_back(step.name); + } + if (events != test.expectation.events){ + return "Expected authored event IDs in their original order."; + } + const JsonValue encoded = authored_checkpoint_json(actual.checkpoint, test.input.game, actual.execution.clone()); + const AuthoredCheckpoint reread = read_authored_checkpoint(encoded, test.input.game, test.input.name); + if (!(story_checkpoint_json(actual.checkpoint) == story_checkpoint_json(reread.checkpoint)) || actual.execution != reread.execution){ + return "Expected checkpoint and execution to survive the shared writer/reader round trip."; + } + return ""; +} + +std::string check_case(const AuthoredCase& test){ + try{ + const std::vector> files = {{"plan", test.input.json}}; + const AuthoredCheckpoint actual = test.input.resource + ? load_authored_checkpoint(files, test.input.file, test.input.game) + : read_authored_checkpoint(parse_json(test.input.json), test.input.game, test.input.name); + return check_loaded(actual, test); + }catch (const ParseException& error){ + return test.expectation.failure == Failure::PARSE ? "" : "Unexpected parse failure: " + error.message(); + }catch (const InternalProgramError& error){ + return test.expectation.failure == Failure::MISSING_RESOURCE ? "" : "Unexpected resource failure: " + error.message(); + } +} + +#ifndef PA_STORY_DATA_STANDALONE +class AuthoredJsonTest : public UnitTest{ +public: + explicit AuthoredJsonTest(AuthoredCase test) + : UnitTest("Pokemon::AuthoredCheckpointJson - " + test.input.name), m_case(std::move(test)){} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + const std::string error = check_case(m_case); + return error.empty() ? UnitTestResult(true) : UnitTestResult(error); + } +private: + AuthoredCase m_case; +}; +#endif + +} // namespace + +#ifndef PA_STORY_DATA_STANDALONE +void add_tests_AuthoredCheckpointJson(UnitTestDatabase& database){ + for (const AuthoredCase& test : CASES){ + database.add(test); + } +} +#endif + +} // namespace Pokemon +} // namespace PokemonAutomation + +#ifdef PA_STORY_DATA_STANDALONE +int main(){ + int failed = 0; + for (const auto& test : PokemonAutomation::Pokemon::CASES){ + const std::string error = PokemonAutomation::Pokemon::check_case(test); + std::cout << test.input.name << ": " << (error.empty() ? "PASS" : error) << '\n'; + failed += !error.empty(); + } + return failed != 0; +} +#endif diff --git a/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_AutoStory_Tests.h b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_AutoStory_Tests.h index 1f712fc7ae..5d0bbe1172 100644 --- a/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_AutoStory_Tests.h +++ b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_AutoStory_Tests.h @@ -27,6 +27,9 @@ void add_tests_StorySteps(UnitTestDatabase& database); // Lossless shared story-data wire contract. void add_tests_StoryStepJson(UnitTestDatabase& database); +// Shared authored envelope, game identity and embedded-resource loading. +void add_tests_AuthoredCheckpointJson(UnitTestDatabase& database); + } // namespace Pokemon } // namespace PokemonAutomation #endif diff --git a/SerialPrograms/cmake/SourceFiles.cmake b/SerialPrograms/cmake/SourceFiles.cmake index dc08917bb4..7be1fec8ae 100644 --- a/SerialPrograms/cmake/SourceFiles.cmake +++ b/SerialPrograms/cmake/SourceFiles.cmake @@ -1504,6 +1504,9 @@ file(GLOB LIBRARY_SOURCES Source/Pokemon/AutoStory/Pokemon_StorySegments.cpp Source/Pokemon/AutoStory/Pokemon_StorySegments.h Source/Pokemon/AutoStory/Pokemon_StorySegments_Tests.cpp + Source/Pokemon/AutoStory/Pokemon_AuthoredCheckpointJson.cpp + Source/Pokemon/AutoStory/Pokemon_AuthoredCheckpointJson.h + Source/Pokemon/AutoStory/Pokemon_AuthoredCheckpointJson_Tests.cpp Source/Pokemon/AutoStory/Pokemon_StoryStepJson.cpp Source/Pokemon/AutoStory/Pokemon_StoryStepJson.h Source/Pokemon/AutoStory/Pokemon_StoryStepJson_Tests.cpp diff --git a/devtools/story-data-tests/CMakeLists.txt b/devtools/story-data-tests/CMakeLists.txt index 9bfc400ae3..978db4f6d0 100644 --- a/devtools/story-data-tests/CMakeLists.txt +++ b/devtools/story-data-tests/CMakeLists.txt @@ -20,14 +20,24 @@ add_executable(StoryDataTests "${ROOT}/Common/Cpp/Filesystem/FileIO.cpp" "${ROOT}/Common/Cpp/Filesystem/FilePath.cpp" "${ROOT}/Common/Cpp/Strings/Unicode.cpp") -target_include_directories(StoryDataTests PRIVATE "${ROOT}" "${ROOT}/SerialPrograms/Source") -target_compile_features(StoryDataTests PRIVATE cxx_std_23) -target_compile_definitions(StoryDataTests PRIVATE PA_STORY_DATA_STANDALONE) +# Reuse the Qt/controller-free support sources; each codec has its own test main. +get_target_property(STORY_DATA_SOURCES StoryDataTests SOURCES) +list(REMOVE_ITEM STORY_DATA_SOURCES "${STORY}/Pokemon_StoryStepJson_Tests.cpp") +add_executable(AuthoredCheckpointTests ${STORY_DATA_SOURCES} + "${STORY}/Pokemon_AuthoredCheckpointJson.cpp" + "${STORY}/Pokemon_AuthoredCheckpointJson_Tests.cpp") + +foreach(TEST_TARGET IN ITEMS StoryDataTests AuthoredCheckpointTests) +target_include_directories(${TEST_TARGET} PRIVATE "${ROOT}" "${ROOT}/SerialPrograms/Source") +target_compile_features(${TEST_TARGET} PRIVATE cxx_std_23) +target_compile_definitions(${TEST_TARGET} PRIVATE PA_STORY_DATA_STANDALONE) if(MSVC) - target_compile_definitions(StoryDataTests PRIVATE _CRT_SECURE_NO_WARNINGS) - target_compile_options(StoryDataTests PRIVATE /W4 /WX) + target_compile_definitions(${TEST_TARGET} PRIVATE _CRT_SECURE_NO_WARNINGS) + target_compile_options(${TEST_TARGET} PRIVATE /W4 /WX) set_property(SOURCE "${ROOT}/Common/Cpp/Filesystem/FileIO.cpp" APPEND PROPERTY COMPILE_OPTIONS /wd4505) else() - target_compile_options(StoryDataTests PRIVATE -Wall -Wextra -Werror) + target_compile_options(${TEST_TARGET} PRIVATE -Wall -Wextra -Werror) endif() +endforeach() add_test(NAME StoryData COMMAND StoryDataTests) +add_test(NAME AuthoredCheckpoint COMMAND AuthoredCheckpointTests) diff --git a/devtools/story-data-tests/README.md b/devtools/story-data-tests/README.md index 2c32b00685..310cea3ea3 100644 --- a/devtools/story-data-tests/README.md +++ b/devtools/story-data-tests/README.md @@ -33,3 +33,5 @@ These are synthetic codec tests, not exports of either game's authored story tables or captured controller replay. Evaluated FRLG/BDSP table exporters, policy/state envelope binding and execution parity remain subsequent work. No gameplay behavior changes here. + +The same CTest command also runs `AuthoredCheckpointTests`: shared authored-envelope parsing/writing and embedded-resource lookup for FRLG, BDSP, and opaque future game settings. Game adapters must validate their own execution object before running anything. The shared codec does not interpret FRLG menus, BDSP save-card policy, native bookkeeping, or controller evidence. From 164bbce00eb282bded3d223fbfc828903980a2db Mon Sep 17 00:00:00 2001 From: dayfine Date: Sun, 8 Nov 2026 15:56:14 -0800 Subject: [PATCH 7/7] Use shared authored checkpoint loading for FRLG --- .../Programs/AutoStory/Data/JsonParser.cpp | 40 +++++++------------ .../Programs/AutoStory/StoryData/README.md | 2 +- 2 files changed, 15 insertions(+), 27 deletions(-) diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/JsonParser.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/JsonParser.cpp index f370385b92..780b3a9a88 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/JsonParser.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/JsonParser.cpp @@ -5,10 +5,12 @@ #include #include #include +#include #include "Common/Cpp/Exceptions.h" #include "Common/Cpp/Json/JsonArray.h" #include "Common/Cpp/Json/JsonObject.h" #include "Pokemon/AutoStory/Pokemon_StoryStepJson.h" +#include "Pokemon/AutoStory/Pokemon_AuthoredCheckpointJson.h" #include "LegacyJsonFormat.h" #include "JsonParser.h" @@ -19,15 +21,6 @@ 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)); - } -} - // 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){ @@ -182,18 +175,11 @@ void check_envelope(const JsonObject& root, std::string_view source){ } } -} // 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 apply_execution(Pokemon::AuthoredCheckpoint parsed, std::string_view source){ + const JsonObject& execution = parsed.execution; + Pokemon::require_story_json_fields(execution, {"menu"}, source); StoryCheckpoint result; - static_cast(result) = Pokemon::read_story_checkpoint(root.get_value_throw("checkpoint"), source); + static_cast(result) = std::move(parsed.checkpoint); const std::string& menu = execution.get_string_throw("menu"); if (menu == "before_pokemon"){ result.menu = StartMenu::BEFORE_POKEMON; @@ -207,13 +193,15 @@ StoryCheckpoint read_authored_checkpoint(const JsonValue& value, std::string_vie return result; } + +} // namespace + +StoryCheckpoint read_authored_checkpoint(const JsonValue& value, std::string_view source){ + return apply_execution(Pokemon::read_authored_checkpoint(value, "frlg", source), source); +} + 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)); + return apply_execution(Pokemon::load_authored_checkpoint(autostory_checkpoint_json(), file, "frlg"), file); } StoryCheckpoint read_story_checkpoint_export(const JsonValue& value, std::string_view source){ diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/README.md b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/README.md index 0df53c7675..55a5b23722 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/README.md +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/README.md @@ -24,4 +24,4 @@ No console is required for either gate. ## Native JSON code -`../Data/JsonParser.*` owns authored checkpoint loading and the legacy capture reader. `JsonWriter.*` is retained for migration comparisons and export diagnostics; production authored loading does not depend on it. `Json_Tests.*` keeps the codec tests beside those sources. Once migration expectations are independent of the old capture, remove the legacy reader, writer, and `LegacyJsonFormat.h`; retain the authored parser. +`Pokemon/AutoStory/Pokemon_AuthoredCheckpointJson.*` owns the common authored envelope, checkpoint codec delegation, and embedded-resource lookup. `../Data/JsonParser.*` validates FRLG execution settings and retains the legacy capture reader. `JsonWriter.*` is retained for migration comparisons and export diagnostics; production authored loading does not depend on it. `Json_Tests.*` keeps the codec tests beside those sources. Once migration expectations are independent of the old capture, remove the legacy reader, writer, and `LegacyJsonFormat.h`; retain the authored parser.