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/CMakeLists.txt b/SerialPrograms/CMakeLists.txt index 753c2a0c68..bd7c93a134 100644 --- a/SerialPrograms/CMakeLists.txt +++ b/SerialPrograms/CMakeLists.txt @@ -233,6 +233,8 @@ pa_embed_text_files(SerialProgramsLib "${FRLG_MAP_RESOURCES}" 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/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/Data/JsonParser.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/JsonParser.cpp new file mode 100644 index 0000000000..780b3a9a88 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/JsonParser.cpp @@ -0,0 +1,233 @@ +/* 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 "Pokemon/AutoStory/Pokemon_AuthoredCheckpointJson.h" +#include "LegacyJsonFormat.h" +#include "JsonParser.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +// 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", 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)); + } +} + +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) = std::move(parsed.checkpoint); + 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; +} + + +} // 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){ + 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){ + 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}, + 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){ + 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/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/PokemonFRLG_AutoStoryExport.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/JsonWriter.cpp similarity index 77% rename from SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryExport.cpp rename to SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/JsonWriter.cpp index a0b85c21ea..069658edee 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryExport.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/JsonWriter.cpp @@ -1,25 +1,22 @@ -/* FRLG Authored Checkpoint Export +/* Legacy FRLG Checkpoint JSON Writer * From: https://github.com/PokemonAutomation/ */ + #include #include -#include "Common/Cpp/Exceptions.h" #include "Common/Cpp/Json/JsonArray.h" #include "Common/Cpp/Json/JsonObject.h" #include "Pokemon/AutoStory/Pokemon_StoryStepJson.h" -#include "PokemonFRLG_AutoStoryExport.h" +#include "LegacyJsonFormat.h" +#include "JsonWriter.h" + namespace PokemonAutomation{ namespace NintendoSwitch{ namespace PokemonFRLG{ + + namespace{ -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){ @@ -27,12 +24,14 @@ JsonArray strings(const std::vector& values){ } 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; @@ -43,6 +42,7 @@ JsonObject stats_json(const Stats& stats){ result["speed"] = stats.speed; return result; } + JsonObject member_json(const PartyMember& member){ JsonObject result; result["species"] = member.species; @@ -60,6 +60,7 @@ JsonObject member_json(const PartyMember& member){ result["stats"] = member.stats ? JsonValue(stats_json(*member.stats)) : JsonValue(); return result; } + JsonObject supplies_json(const Supplies& supplies){ JsonObject result; result["potions"] = supplies.potions; @@ -83,21 +84,26 @@ JsonObject supplies_json(const Supplies& supplies){ result["wild_foes"] = strings(supplies.wild_foes); return result; } + + } // 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(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"] = menu_name(checkpoint.menu); + native["menu"] = StoryJsonInternal::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(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/Data/Json_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/Json_Tests.cpp new file mode 100644 index 0000000000..2b4715df94 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/Data/Json_Tests.cpp @@ -0,0 +1,282 @@ +/* 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 "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 = { + .events = {"below_mart_door", "mart_door", "parcel_from_clerk"}, .menu = "full", + .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; + state.potions = 2; + state.antidotes = 3; + state.parlyz_heals = 4; + state.pocket = {"antidote", ""}; + state.status = StatusCondition::POISON; + state.hurt = true; + state.hp_red = true; + state.hp_fill = 0.25; + state.lead = PartyMember{.species = "wartortle", .moves = {{"bite", 0}}, + .moves_through_level = 18, .moves_read = true, .level = 19, + .stats = Stats{.hp = 50, .attack = 20, .defense = 30, .sp_attack = 40, .sp_defense = 35, .speed = 25}}; + state.protected_slots = {0, 2}; + state.foes = {"staryu"}; + state.trainer = "misty"; + state.risk = BattleRisk::HIGH; + 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); + } + const JsonValue encoded = story_checkpoint_export(row); + const JsonObject& root = encoded.to_object_throw(); + const JsonObject& native = root.get_object_throw("native_fields"); + if (native.get_string_throw("menu") != test.expectation.menu){ + return "Expected menu " + std::string(test.expectation.menu) + ", read " + native.get_string_throw("menu"); + } + if (!test.expectation.native_json.empty() && !(JsonValue(native.clone()) == parse_json(std::string(test.expectation.native_json)))){ + return "Expected native fields " + std::string(test.expectation.native_json) + ", read " + native.dump(); + } + const auto decoded = Pokemon::read_story_checkpoint(root.get_value_throw("checkpoint"), "export 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); + } + return names == test.expectation.events ? "" : "Expected Parcel's three authored events in order."; +} + +class ExportTest : public UnitTest{ +public: + explicit ExportTest(ExportCase test) + : UnitTest("PokemonFRLG::CheckpointExport - " + std::string(test.input.id) + (test.input.custom ? " custom" : " authored")), m_test(test){} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + for (const auto& row : story_checkpoints()){ + if (row.id == m_test.input.id){ + const std::string error = check_export(row, m_test); + return error.empty() ? UnitTestResult(true) : UnitTestResult(error); + } + } + return UnitTestResult("Expected checkpoint " + std::string(m_test.input.id) + ", read no matching row."); + } +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; + 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; + 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"); +} + +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}}, + {.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(const LoadInput& input){ + for (const auto& [name, text] : autostory_checkpoint_json()){ + if (name == (input.authored ? "frlg-parcel-00.05.plan" : "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 = 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); + 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(test.input)); + if (test.input.mutate){ + test.input.mutate(capture.to_object_throw()); + } + try{ + 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; + } + 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_AutoStoryJson(UnitTestDatabase& database){ + for (const ExportCase& test : CASES){ + database.add(test); + } + for (const LoadCase& test : LOAD_CASES){ + 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_AutoStoryExport.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryExport.h deleted file mode 100644 index f2cc73c6b2..0000000000 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryExport.h +++ /dev/null @@ -1,18 +0,0 @@ -/* FRLG Authored Checkpoint Export - * From: https://github.com/PokemonAutomation/ - */ -#ifndef PokemonAutomation_PokemonFRLG_AutoStoryExport_H -#define PokemonAutomation_PokemonFRLG_AutoStoryExport_H -#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); -} // 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 deleted file mode 100644 index bc4e0f3f12..0000000000 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryExport_Tests.cpp +++ /dev/null @@ -1,102 +0,0 @@ -/* FRLG Checkpoint Export Tests - * From: https://github.com/PokemonAutomation/ - */ -#include "Common/Cpp/Json/JsonArray.h" -#include "Common/Cpp/Json/JsonObject.h" -#include "Pokemon/AutoStory/Pokemon_StoryStepJson.h" -#include "PokemonFRLG_AutoStoryExport.h" -#include "PokemonFRLG_AutoStoryTable.h" -#include "PokemonFRLG_AutoStoryExport_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 = { - .events = {"below_mart_door", "mart_door", "parcel_from_clerk"}, .menu = "full", - .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; - state.potions = 2; - state.antidotes = 3; - state.parlyz_heals = 4; - state.pocket = {"antidote", ""}; - state.status = StatusCondition::POISON; - state.hurt = true; - state.hp_red = true; - state.hp_fill = 0.25; - state.lead = PartyMember{.species = "wartortle", .moves = {{"bite", 0}}, - .moves_through_level = 18, .moves_read = true, .level = 19, - .stats = Stats{.hp = 50, .attack = 20, .defense = 30, .sp_attack = 40, .sp_defense = 35, .speed = 25}}; - state.protected_slots = {0, 2}; - state.foes = {"staryu"}; - state.trainer = "misty"; - state.risk = BattleRisk::HIGH; - state.foe_out = 0; - state.wild_foes = {"pidgey"}; -} -std::string check_export(StoryCheckpoint row, const ExportCase& test){ - if (test.input.custom){ - customize(row); - } - const JsonValue encoded = story_checkpoint_export(row); - const JsonObject& root = encoded.to_object_throw(); - const JsonObject& native = root.get_object_throw("native_fields"); - if (native.get_string_throw("menu") != test.expectation.menu){ - return "Expected menu " + std::string(test.expectation.menu) + ", read " + native.get_string_throw("menu"); - } - if (!test.expectation.native_json.empty() && !(JsonValue(native.clone()) == parse_json(std::string(test.expectation.native_json)))){ - return "Expected native fields " + std::string(test.expectation.native_json) + ", read " + native.dump(); - } - const auto decoded = Pokemon::read_story_checkpoint(root.get_value_throw("checkpoint"), "export test"); - if (!(Pokemon::story_checkpoint_json(decoded) == Pokemon::story_checkpoint_json(row))){ - return "Expected every authored common field to survive export/import."; - } - std::vector names; - for (const auto& step : decoded.steps){ - names.push_back(step.name); - } - return names == test.expectation.events ? "" : "Expected Parcel's three authored events in order."; -} -class ExportTest : public UnitTest{ -public: - explicit ExportTest(ExportCase test) - : UnitTest("PokemonFRLG::CheckpointExport - " + std::string(test.input.id) + (test.input.custom ? " custom" : " authored")), m_test(test){} - virtual UnitTestResult run(Logger&, CancellableScope&) const override{ - for (const auto& row : story_checkpoints()){ - if (row.id == m_test.input.id){ - const std::string error = check_export(row, m_test); - return error.empty() ? UnitTestResult(true) : UnitTestResult(error); - } - } - return UnitTestResult("Expected checkpoint " + std::string(m_test.input.id) + ", read no matching row."); - } -private: - ExportCase m_test; -}; -} // namespace -void add_tests_AutoStoryExport(UnitTestDatabase& database){ - for (const ExportCase& test : CASES){ - database.add(test); - } -} -} // namespace PokemonFRLG -} // namespace NintendoSwitch -} // namespace PokemonAutomation 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 b420fb235c..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,6 +33,7 @@ #include "PokemonFRLG_AutoStoryEvents.h" #include "PokemonFRLG_AutoStoryPaceSim.h" #include "PokemonFRLG_AutoStoryRowSim.h" +#include "Data/JsonParser.h" #include "PokemonFRLG_AutoStoryTable.h" namespace PokemonAutomation{ @@ -328,7 +329,7 @@ struct AutoStorySimulationInput{ }; struct AutoStorySimulationExpectation{ - const char* description; + std::string description; }; struct AutoStorySimulationTestCase{ @@ -887,7 +888,42 @@ UnitTestResult test_out_of_bill_cottage(){ } +// 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.rows = {*authored}; + Simulation from_data; + from_data.rows = {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 = "00.05 loaded row parity", .body = test_data_row_parity}, .expectation = {.description = "loaded row preserves events, trace and save pose"}}, {.input = {.name = "new game to Brock, in order, each start where the last save stood", .body = test_whole_story}, .expectation = {.description = "runs full story progression from new game through Brock gym"}}, {.input = {.name = "start and end checkpoints", .body = test_start_and_end}, .expectation = {.description = "executes segment bounded by start and end checkpoints"}}, {.input = {.name = "a failed walk reloads its checkpoint's save", .body = test_retry_from_save}, .expectation = {.description = "reloads checkpoint save state on simulated movement failure"}}, diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/PokemonFRLG_AutoStoryTable.cpp index 1f579f2983..3f88fe03e1 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 "Data/JsonParser.h" #include "PokemonFRLG_AutoStoryChoice.h" #include "PokemonFRLG_AutoStoryGrind.h" #include "PokemonFRLG_AutoStoryMart.h" @@ -81,13 +82,10 @@ 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 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 +798,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 new file mode 100644 index 0000000000..55a5b23722 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/README.md @@ -0,0 +1,27 @@ +# FRLG authored checkpoint pilot + +`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 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`. + +## The two gates + +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. + +`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. + +## Semantics + +- 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. + +No console is required for either gate. + +## Native JSON code + +`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. 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..2090ba206e --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Programs/AutoStory/StoryData/frlg-parcel-00.05.json @@ -0,0 +1,71 @@ +{ + "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", + "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" + }, + { + "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" + } + ] + }, + "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", + "status": "native-table-export", + "state_basis": "native-table-bookkeeping", + "unbound": ["runtime_configuration", "map_pins", "executor_bindings", "entry_evidence", "effective_save_retry_policy"] +} diff --git a/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 8d0569c5bf..7f4e1de880 100644 --- a/SerialPrograms/cmake/SourceFiles.cmake +++ b/SerialPrograms/cmake/SourceFiles.cmake @@ -1915,10 +1915,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_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