Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/story-data.yml
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
Expand Up @@ -53,7 +53,7 @@



UnitTestDatabase make_UNIT_TESTS_ALL(){

Check warning on line 56 in SerialPrograms/Source/ComputerPrograms/ComputerPrograms_UnitTestRunner.cpp

View workflow job for this annotation

GitHub Actions / code-health

Code health (advisory)

PokemonAutomation::ComputerPrograms::make_UNIT_TESTS_ALL(): function_lines=40 (advisory above 25, limit 50)
UnitTestDatabase ret;

add_tests_QtEventThreadPool(ret, GlobalThreadPools::computation_normal());
Expand All @@ -78,8 +78,7 @@
Pokemon::add_tests_MapData(ret);
Pokemon::add_tests_RuntimeMap(ret);
Pokemon::add_tests_RouteLog(ret);
Pokemon::add_tests_StoryEvents(ret);
Pokemon::add_tests_StorySegments(ret);
Pokemon::add_tests_AutoStory(ret);
NintendoSwitch::add_tests_CheckOnlineDetector(ret);
NintendoSwitch::add_tests_FailedToConnectDetector(ret);
NintendoSwitch::add_tests_UpdatePopupDetector(ret);
Expand Down
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
Comment thread
dayfine marked this conversation as resolved.
Resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/* Shared Authored Checkpoint JSON

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it only parses and encodes; the header now says so (e8833b3). AuthoredCheckpoint is the shared StoryCheckpoint plus the game's execution object, still raw JSON. We could drop the wrapper by having read_authored_checkpoint() return the checkpoint and pass execution to a game callback, or by letting each game parse its own envelope. Do you want either?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The 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
Loading
Loading