Repository navigation
AutoStory: story step types and engine (split from #122, layer 1 of 3) #312
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: nav/shared
Are you sure you want to change the base?
Changes from all commits
10f0583
5b974f0
1b74c82
dd9529b
babbf85
48c10b7
c766e87
d3e856e
093d1ab
a940ce0
e8833b3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| name: Story data codec | ||
| on: | ||
| pull_request: | ||
| workflow_dispatch: | ||
| jobs: | ||
| native-codec: | ||
| strategy: | ||
| fail-fast: false | ||
| matrix: | ||
| os: [ubuntu-latest, macos-latest, windows-latest] | ||
| runs-on: ${{ matrix.os }} | ||
| steps: | ||
| - uses: actions/checkout@v7 | ||
| - uses: actions/checkout@v7 | ||
| with: | ||
| repository: dayfine/PokemonSimulation | ||
| # Synthetic contract cases, not an approved map/resource pin. | ||
| ref: 866613fa03e5c4a5eb7e57c91b59fafb44fc7ee3 | ||
| path: publisher | ||
| - uses: actions/setup-python@v6 | ||
| with: | ||
| python-version: '3.12' | ||
| - name: Check the Python reader against the same cases | ||
| working-directory: publisher | ||
| env: | ||
| PYTHONPATH: src | ||
| run: python -m unittest pokesim.story.test_checkpoint_wire | ||
| - run: cmake -S devtools/story-data-tests -B build-story-data -DCMAKE_BUILD_TYPE=Debug | ||
| - run: cmake --build build-story-data --config Debug -j4 | ||
| - run: ctest --test-dir build-story-data -C Debug --output-on-failure | ||
| - if: runner.os != 'Windows' | ||
| run: ./build-story-data/StoryDataTests publisher/data/story/wire-conformance.json | ||
| - if: runner.os == 'Windows' | ||
| run: ./build-story-data/Debug/StoryDataTests.exe publisher/data/story/wire-conformance.json |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| /* Shared Authored Checkpoint JSON | ||
| * | ||
| * From: https://github.com/PokemonAutomation/ | ||
| * | ||
| */ | ||
|
|
||
| #include <format> | ||
| #include <utility> | ||
| #include "Common/Cpp/Exceptions.h" | ||
| #include "Pokemon_StoryStepJson.h" | ||
| #include "Pokemon_AuthoredCheckpointJson.h" | ||
|
|
||
| namespace PokemonAutomation{ | ||
| namespace Pokemon{ | ||
|
|
||
|
|
||
| void validate_json_fields( | ||
| const JsonObject& object, std::initializer_list<std::string_view> 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 game, std::string_view source | ||
| ){ | ||
| const JsonObject& root = value.to_object_throw(std::string(source)); | ||
| validate_json_fields(root, {"format", "game", "checkpoint", "execution"}, source); | ||
| if (game.empty() || root.get_string_throw("format") != "pokesim-authored-checkpoint/1" | ||
| || root.get_string_throw("game") != 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<const EmbeddedJsonFile> files, std::string_view file, std::string_view game | ||
| ){ | ||
| for (const auto& [name, text] : files){ | ||
| if (name == file){ | ||
| return read_authored_checkpoint(parse_json(text), game, file); | ||
| } | ||
| } | ||
| throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "Authored checkpoint is not embedded: " + std::string(file)); | ||
| } | ||
|
|
||
|
|
||
|
|
||
| } // namespace Pokemon | ||
| } // namespace PokemonAutomation |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| /* Shared Authored Checkpoint JSON | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. is this simply parsing? AuthoredCheckpoint - I wonder if we need to keep both at the same time
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, it only parses and encodes; the header now says so (e8833b3).
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. the main feedback is to keep IO and in-game logic separate so parsing should be done in one layer, then everything else no longer need JSON, is that fair / possible? |
||
| * | ||
| * From: https://github.com/PokemonAutomation/ | ||
| * | ||
| * Reading and writing an authored checkpoint file: the shared | ||
| * StoryCheckpoint (Pokemon_StoryStepJson.h) in an envelope that names the | ||
| * game, plus the game's own execution object, left as JSON for the game | ||
| * to read. Only parsing and encoding: nothing here binds or runs a step. | ||
| * | ||
| */ | ||
|
|
||
| #ifndef PokemonAutomation_AuthoredCheckpointJson_H | ||
| #define PokemonAutomation_AuthoredCheckpointJson_H | ||
|
|
||
| #include <initializer_list> | ||
| #include <span> | ||
| #include <string> | ||
| #include <string_view> | ||
| #include <utility> | ||
| #include "Common/Cpp/Json/JsonObject.h" | ||
| #include "Pokemon/AutoStory/Types/Pokemon_StoryCheckpoint.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 validate_json_fields( | ||
| const JsonObject& object, std::initializer_list<std::string_view> fields, std::string_view source | ||
| ); | ||
|
|
||
| // Read the strict authored envelope and common checkpoint of `game`. | ||
| // The game adapter must still validate execution fields and values before use. | ||
| AuthoredCheckpoint read_authored_checkpoint( | ||
| const JsonValue& value, std::string_view 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); | ||
|
|
||
| // A JSON file built into the program: its name without ".json", and its | ||
| // text. | ||
| using EmbeddedJsonFile = std::pair<std::string, std::string>; | ||
|
|
||
| // Find embedded file `file` among `files` and parse it as `game`'s | ||
| // authored checkpoint. Missing resources throw InternalProgramError; | ||
| // malformed documents throw ParseException. | ||
| AuthoredCheckpoint load_authored_checkpoint( | ||
| std::span<const EmbeddedJsonFile> files, std::string_view file, std::string_view game | ||
| ); | ||
|
|
||
|
|
||
|
|
||
| } // namespace Pokemon | ||
| } // namespace PokemonAutomation | ||
|
|
||
| #endif | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,171 @@ | ||
| /* Shared Authored Checkpoint JSON Tests | ||
| * | ||
| * From: https://github.com/PokemonAutomation/ | ||
| * | ||
| */ | ||
|
|
||
| #include <iostream> | ||
| #include <string> | ||
| #include <utility> | ||
| #include <vector> | ||
| #include "Common/Cpp/Exceptions.h" | ||
| #include "Pokemon_AuthoredCheckpointJson.h" | ||
| #include "Pokemon_StoryStepJson.h" | ||
| #ifndef PA_STORY_DATA_STANDALONE | ||
| #include "Pokemon/AutoStory/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"}})"; | ||
|
|
||
| // How a case fails, if it does. | ||
| 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<std::string> 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 = {}}}, | ||
| }; | ||
|
|
||
|
|
||
| // "" when `actual` is what `test` expects and survives a write and a | ||
| // read, else what was expected and what was read. | ||
| 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<std::string> 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 ""; | ||
| } | ||
|
|
||
| // "" when `test` reads or fails as expected. | ||
| std::string check_case(const AuthoredCase& test){ | ||
| try{ | ||
| const std::vector<std::pair<std::string, std::string>> 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<AuthoredJsonTest>(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 |
Uh oh!
There was an error while loading. Please reload this page.