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.