diff --git a/.github/workflows/story-data.yml b/.github/workflows/story-data.yml new file mode 100644 index 0000000000..545299ac51 --- /dev/null +++ b/.github/workflows/story-data.yml @@ -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 diff --git a/SerialPrograms/Source/ComputerPrograms/ComputerPrograms_UnitTestRunner.cpp b/SerialPrograms/Source/ComputerPrograms/ComputerPrograms_UnitTestRunner.cpp index 71e86b9f48..dfbabc0d0d 100644 --- a/SerialPrograms/Source/ComputerPrograms/ComputerPrograms_UnitTestRunner.cpp +++ b/SerialPrograms/Source/ComputerPrograms/ComputerPrograms_UnitTestRunner.cpp @@ -78,8 +78,7 @@ UnitTestDatabase make_UNIT_TESTS_ALL(){ 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); diff --git a/SerialPrograms/Source/Pokemon/AutoStory/Data/Pokemon_AuthoredCheckpointJson.cpp b/SerialPrograms/Source/Pokemon/AutoStory/Data/Pokemon_AuthoredCheckpointJson.cpp new file mode 100644 index 0000000000..c22ec33f98 --- /dev/null +++ b/SerialPrograms/Source/Pokemon/AutoStory/Data/Pokemon_AuthoredCheckpointJson.cpp @@ -0,0 +1,70 @@ +/* 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 validate_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 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 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 diff --git a/SerialPrograms/Source/Pokemon/AutoStory/Data/Pokemon_AuthoredCheckpointJson.h b/SerialPrograms/Source/Pokemon/AutoStory/Data/Pokemon_AuthoredCheckpointJson.h new file mode 100644 index 0000000000..68fa923cae --- /dev/null +++ b/SerialPrograms/Source/Pokemon/AutoStory/Data/Pokemon_AuthoredCheckpointJson.h @@ -0,0 +1,66 @@ +/* Shared Authored Checkpoint JSON + * + * 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 +#include +#include +#include +#include +#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 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; + +// 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 files, std::string_view file, std::string_view game +); + + + +} // namespace Pokemon +} // namespace PokemonAutomation + +#endif diff --git a/SerialPrograms/Source/Pokemon/AutoStory/Data/Pokemon_AuthoredCheckpointJson_Tests.cpp b/SerialPrograms/Source/Pokemon/AutoStory/Data/Pokemon_AuthoredCheckpointJson_Tests.cpp new file mode 100644 index 0000000000..346d31505a --- /dev/null +++ b/SerialPrograms/Source/Pokemon/AutoStory/Data/Pokemon_AuthoredCheckpointJson_Tests.cpp @@ -0,0 +1,171 @@ +/* Shared Authored Checkpoint JSON Tests + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include +#include +#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 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 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> 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/Data/Pokemon_StoryStepJson.cpp b/SerialPrograms/Source/Pokemon/AutoStory/Data/Pokemon_StoryStepJson.cpp new file mode 100644 index 0000000000..dfca06f04f --- /dev/null +++ b/SerialPrograms/Source/Pokemon/AutoStory/Data/Pokemon_StoryStepJson.cpp @@ -0,0 +1,413 @@ +/* Story Step JSON + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include +#include +#include +#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_TilePose.h" +#include "Pokemon_StoryStepJson.h" + +namespace PokemonAutomation{ +namespace Pokemon{ + + +namespace{ + + +// The wire names of the step kinds. +constexpr std::array, 15> KINDS{{ + {StoryStepKind::NEW_GAME, "new_game"}, + {StoryStepKind::OPTIONS, "options"}, + {StoryStepKind::ROUTE, "route"}, + {StoryStepKind::LEGS, "legs"}, + {StoryStepKind::STEP, "step"}, + {StoryStepKind::TALK, "talk"}, + {StoryStepKind::ANSWER, "answer"}, + {StoryStepKind::MASH, "scene"}, + {StoryStepKind::BATTLE, "battle"}, + {StoryStepKind::TRAIN, "train"}, + {StoryStepKind::HEAL, "heal"}, + {StoryStepKind::SHOP, "shop"}, + {StoryStepKind::SAVE, "save"}, + {StoryStepKind::READY, "ready"}, + {StoryStepKind::GAME, "game_action"}, +}}; + +// The wire names of the pose certainties. +constexpr std::array, 5> CERTAINTIES{{ + {PoseCertainty::TILE, "tile"}, + {PoseCertainty::COLUMN, "column"}, + {PoseCertainty::ROW, "row"}, + {PoseCertainty::SET, "set"}, + {PoseCertainty::UNKNOWN, "unknown"}, +}}; + +// The wire names of the directions. +constexpr std::array, 4> DIRECTIONS{{ + {Direction::NORTH, "north"}, + {Direction::EAST, "east"}, + {Direction::SOUTH, "south"}, + {Direction::WEST, "west"}, +}}; + + +// `value` as an object with no field outside `keys`. +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; +} + +template +Enum read_enum(const std::array, N>& names, std::string_view name, std::string_view source){ + for (const auto& [value, label] : names){ + if (label == name){ + return value; + } + } + throw ParseException(std::format("{}: unsupported value {}", source, name)); +} + +template +std::string enum_name(const std::array, N>& names, Enum value){ + for (const auto& [candidate, label] : names){ + if (candidate == value){ + return std::string(label); + } + } + throw ParseException("Unsupported story enum value."); +} + +int integer(const JsonValue& value, std::string_view source){ + const int64_t number = value.to_integer_throw(std::string(source)); + if (number < std::numeric_limits::min() || number > std::numeric_limits::max()){ + throw ParseException(std::format("{}: coordinate exceeds native int", source)); + } + return (int)number; +} + +size_t count(const JsonValue& value, std::string_view source){ + const int64_t number = value.to_integer_throw(std::string(source)); + if (number < 0 || (uint64_t)number > std::numeric_limits::max()){ + throw ParseException(std::format("{}: count is out of range", source)); + } + return (size_t)number; +} + +JsonValue count_json(size_t value){ + if ((uint64_t)value > (uint64_t)std::numeric_limits::max()){ + throw ParseException("Story count exceeds the wire integer range."); + } + return (uint64_t)value; +} + +JsonArray strings_json(std::span strings){ + JsonArray result; + for (const std::string& value : strings){ + result.push_back(value); + } + return result; +} + +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; +} + +JsonArray legs_json(std::span legs){ + JsonArray result; + for (const StoryLeg& leg : legs){ + JsonObject item; + item["direction"] = enum_name(DIRECTIONS, leg.direction); + item["tiles"] = leg.tiles; + result.push_back(std::move(item)); + } + return result; +} + +std::vector read_legs(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, {"direction", "tiles"}, source); + result.push_back({.direction = read_enum(DIRECTIONS, row.get_string_throw("direction"), source), + .tiles = integer(row.get_value_throw("tiles"), source)}); + } + return result; +} + +JsonArray items_json(std::span items){ + JsonArray result; + for (const StoryItem& item : items){ + JsonObject row; + row["item"] = item.item; + row["quantity"] = count_json(item.quantity); + result.push_back(std::move(row)); + } + return result; +} + +std::vector read_items(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, {"item", "quantity"}, source); + result.push_back({.item = row.get_string_throw("item"), .quantity = count(row.get_value_throw("quantity"), source)}); + } + return result; +} + +JsonObject args_json(const StoryArgs& args){ + JsonObject result; + for (const auto& [key, value] : args){ + std::visit([&](const auto& held){ result[key] = held; }, value); + } + return result; +} + +// A step's args: flags, whole numbers and words; any other value fails. +StoryArgs read_args(const JsonValue& value, std::string_view source){ + StoryArgs result; + for (const auto& [key, item] : value.to_object_throw(std::string(source))){ + if (item.is_boolean()){ + result[key] = item.to_boolean_throw(); + }else if (item.is_integer()){ + result[key] = item.to_integer_throw(); + }else if (item.is_string()){ + result[key] = item.to_string_throw(); + }else{ + throw ParseException(std::format("{}: argument {} is not a flag, whole number or word", source, key)); + } + } + return result; +} + +// `result` with the step's yes/no and count fields read from `root`. +StoryStep read_step_options(StoryStep result, const JsonObject& root, std::string_view source){ + if (const JsonValue* field = root.get_value("fight_wild")){ + result.fight_wild = field->to_boolean_throw(); + } + if (const JsonValue* field = root.get_value("yes")){ + result.yes = field->to_boolean_throw(); + } + if (const JsonValue* field = root.get_value("may_lose")){ + result.may_lose = field->to_boolean_throw(); + } + if (const JsonValue* field = root.get_value("battles")){ + result.battles = count(*field, source); + } + return result; +} + + +} // namespace + + +JsonValue story_pose_json(const Pose& pose){ + JsonObject result; + result["map"] = pose.map; + result["certainty"] = enum_name(CERTAINTIES, pose.certainty); + JsonObject where; + where["kind"] = "unknown"; + if (pose.where){ + const auto* tiles = dynamic_cast(pose.where.get()); + if (!tiles){ + throw ParseException("Unsupported story Position subtype."); + } + where["kind"] = "tiles"; + JsonArray positions; + for (const TilePos& tile : tiles->tiles){ + JsonArray pair; + pair.push_back(tile.x); + pair.push_back(tile.y); + positions.push_back(std::move(pair)); + } + where["tiles"] = std::move(positions); + if (tiles->facing){ + where["facing"] = enum_name(DIRECTIONS, *tiles->facing); + } + } + result["where"] = std::move(where); + return result; +} + +Pose read_story_pose(const JsonValue& value, std::string_view source){ + const JsonObject& root = fields(value, {"map", "certainty", "where"}, source); + Pose result; + result.map = root.get_string_throw("map"); + result.certainty = read_enum(CERTAINTIES, root.get_string_throw("certainty"), source); + const JsonObject& where = fields(root.get_value_throw("where"), {"kind", "tiles", "facing"}, source); + const std::string& kind = where.get_string_throw("kind"); + if (kind == "unknown"){ + if (where.size() != 1){ + throw ParseException("Unknown story position has extra fields."); + } + return result; + } + if (kind != "tiles"){ + throw ParseException("Unsupported story position kind."); + } + std::vector tiles; + for (const JsonValue& item : where.get_array_throw("tiles")){ + const JsonArray& pair = item.to_array_throw(std::string(source)); + if (pair.size() != 2){ + throw ParseException("Story tile needs two coordinates."); + } + tiles.push_back({integer(pair[0], source), integer(pair[1], source)}); + } + std::optional facing; + if (const JsonValue* field = where.get_value("facing")){ + facing = read_enum(DIRECTIONS, field->to_string_throw(), source); + } + result.where = std::make_shared(std::move(tiles), facing); + return result; +} + +JsonValue story_step_json(const StoryStep& step){ + JsonObject result; + result["id"] = step.name; + result["kind"] = enum_name(KINDS, step.kind); + if (!step.why.empty()){ + result["why"] = step.why; + } + if (step.target){ + result["target"] = story_pose_json(*step.target); + } + if (step.direction != Direction::NORTH){ + result["direction"] = enum_name(DIRECTIONS, step.direction); + } + if (!step.legs.empty()){ + result["legs"] = legs_json(step.legs); + } + if (step.fight_wild){ + result["fight_wild"] = true; + } + if (!step.yes){ + result["yes"] = false; + } + if (step.may_lose){ + result["may_lose"] = true; + } + if (step.battles){ + result["battles"] = count_json(step.battles); + } + if (!step.items.empty()){ + result["items"] = items_json(step.items); + } + if (!step.variant.empty()){ + result["variant"] = step.variant; + } + if (!step.args.empty()){ + result["args"] = args_json(step.args); + } + if (!step.skip_if.empty()){ + result["skip_if"] = strings_json(step.skip_if); + } + if (step.pose_after){ + result["declared_after"] = story_pose_json(*step.pose_after); + } + return result; +} + +StoryStep read_story_step(const JsonValue& value, std::string_view source){ + const JsonObject& root = fields(value, {"id", "kind", "why", "target", "direction", "legs", "fight_wild", "yes", + "may_lose", "battles", "items", "variant", "args", "skip_if", "declared_after"}, source); + StoryStep result; + result.name = root.get_string_throw("id"); + if (result.name.empty()){ + throw ParseException("Empty story step id."); + } + result.kind = read_enum(KINDS, root.get_string_throw("kind"), source); + if (const JsonValue* field = root.get_value("why")){ + result.why = field->to_string_throw(); + } + if (const JsonValue* field = root.get_value("target")){ + result.target = read_story_pose(*field, source); + } + if (const JsonValue* field = root.get_value("direction")){ + result.direction = read_enum(DIRECTIONS, field->to_string_throw(), source); + } + if (const JsonValue* field = root.get_value("legs")){ + result.legs = read_legs(*field, source); + } + result = read_step_options(std::move(result), root, source); + if (const JsonValue* field = root.get_value("items")){ + result.items = read_items(*field, source); + } + if (const JsonValue* field = root.get_value("variant")){ + result.variant = field->to_string_throw(); + } + if (const JsonValue* field = root.get_value("args")){ + result.args = read_args(*field, source); + } + if (const JsonValue* field = root.get_value("skip_if")){ + result.skip_if = read_strings(*field, source); + } + if (const JsonValue* field = root.get_value("declared_after")){ + result.pose_after = read_story_pose(*field, source); + } + return result; +} + +JsonValue story_checkpoint_json(const StoryCheckpoint& checkpoint){ + JsonObject result; + result["id"] = checkpoint.id; + result["title"] = checkpoint.title; + if (checkpoint.start){ + result["start"] = story_pose_json(*checkpoint.start); + } + JsonArray events; + for (const StoryStep& step : checkpoint.steps){ + events.push_back(story_step_json(step)); + } + result["events"] = std::move(events); + return result; +} + +StoryCheckpoint read_story_checkpoint(const JsonValue& value, std::string_view source){ + const JsonObject& root = fields(value, {"id", "title", "start", "events"}, source); + StoryCheckpoint result; + result.id = root.get_string_throw("id"); + if (result.id.empty()){ + throw ParseException("Empty story checkpoint id."); + } + result.title = root.get_string_throw("title"); + if (const JsonValue* start = root.get_value("start")){ + result.start = read_story_pose(*start, source); + } + std::set names; + for (const JsonValue& event : root.get_array_throw("events")){ + StoryStep step = read_story_step(event, source); + if (step.name.empty() || !names.insert(step.name).second){ + throw ParseException("Empty or duplicate story event id."); + } + result.steps.push_back(std::move(step)); + } + return result; +} + + + +} // namespace Pokemon +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/Pokemon/AutoStory/Data/Pokemon_StoryStepJson.h b/SerialPrograms/Source/Pokemon/AutoStory/Data/Pokemon_StoryStepJson.h new file mode 100644 index 0000000000..e02b3508d0 --- /dev/null +++ b/SerialPrograms/Source/Pokemon/AutoStory/Data/Pokemon_StoryStepJson.h @@ -0,0 +1,48 @@ +/* Story Step JSON + * + * From: https://github.com/PokemonAutomation/ + * + * The shared JSON wire form of the story data types (Types/): poses, + * steps and checkpoints, read strictly and written losslessly. + * + */ + +#ifndef PokemonAutomation_StoryStepJson_H +#define PokemonAutomation_StoryStepJson_H + +#include +#include "Common/Cpp/Json/JsonValue.h" +#include "Pokemon/AutoStory/Pokemon_StoryEvents.h" +#include "Pokemon/AutoStory/Types/Pokemon_StoryCheckpoint.h" +#include "Pokemon/AutoStory/Types/Pokemon_StoryStep.h" + +namespace PokemonAutomation{ +namespace Pokemon{ + + +// Lossless tile-game pose data, including uncertainty and absent position/facing. +// An unsupported Position subtype fails instead of being converted to unknown. +JsonValue story_pose_json(const Pose& pose); + +// Read a tile-game pose; malformed fields and unsupported position tags fail. +Pose read_story_pose(const JsonValue& value, std::string_view source); + + +// Encode every shared step field. This does not bind or execute an action. +JsonValue story_step_json(const StoryStep& step); + +// Read the shared step record; unknown fields/kinds and invalid values fail. +StoryStep read_story_step(const JsonValue& value, std::string_view source); + + +// Encode the common checkpoint; game adapters supply the shared policy/state envelope. +JsonValue story_checkpoint_json(const StoryCheckpoint& checkpoint); + +// Read only the common checkpoint record, retaining ordered steps and start belief. +StoryCheckpoint read_story_checkpoint(const JsonValue& value, std::string_view source); + + + +} // namespace Pokemon +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/Pokemon/AutoStory/Data/Pokemon_StoryStepJson_Tests.cpp b/SerialPrograms/Source/Pokemon/AutoStory/Data/Pokemon_StoryStepJson_Tests.cpp new file mode 100644 index 0000000000..13b986b649 --- /dev/null +++ b/SerialPrograms/Source/Pokemon/AutoStory/Data/Pokemon_StoryStepJson_Tests.cpp @@ -0,0 +1,212 @@ +/* Story Step JSON Tests + * + * From: https://github.com/PokemonAutomation/ + * + * Each case reads a record from JSON and writes it back: the canonical + * form, or a rejection. Built standalone (PA_STORY_DATA_STANDALONE), it + * also checks the shared case file the Python reader checks. + * + */ + +#include +#include +#include +#include "Common/Cpp/Exceptions.h" +#include "Common/Cpp/Json/JsonObject.h" +#include "Common/Cpp/Json/JsonArray.h" +#include "Pokemon_StoryStepJson.h" +#ifndef PA_STORY_DATA_STANDALONE +#include "Pokemon/AutoStory/Pokemon_AutoStory_Tests.h" +#endif + +namespace PokemonAutomation{ +namespace Pokemon{ + + +namespace{ + + +// Which record a case reads. +enum class Record{ + POSE, + STEP, + CHECKPOINT, +}; + +// A record to read, and which kind it is. +struct WireInput{ + std::string_view name; + Record record = Record::STEP; + std::string_view json; +}; + +// What reading it gives: the canonical record, or a rejection. +struct WireExpectation{ + std::string_view canonical = {}; + bool rejected = false; +}; + +struct WireCase{ + WireInput input; + WireExpectation expectation; +}; + +const WireCase CASES[] = { + {.input = {.name = "default fields", .json = R"({"id":"talk","kind":"talk"})"}, .expectation = {.canonical = R"({"id":"talk","kind":"talk"})"}}, + {.input = {.name = "all shared fields", .json = R"({"id":"all","kind":"game_action","why":"source reason","direction":"west","legs":[{"direction":"south","tiles":0}],"fight_wild":true,"yes":false,"may_lose":true,"battles":3,"items":[{"item":"potion","quantity":0}],"variant":"profile","skip_if":["flag"],"target":{"map":"A","certainty":"unknown","where":{"kind":"unknown"}},"declared_after":{"map":"B","certainty":"set","where":{"kind":"tiles","tiles":[[0,0],[1,0]],"facing":"east"}}})"}, .expectation = {.canonical = R"({"id":"all","kind":"game_action","why":"source reason","direction":"west","legs":[{"direction":"south","tiles":0}],"fight_wild":true,"yes":false,"may_lose":true,"battles":3,"items":[{"item":"potion","quantity":0}],"variant":"profile","skip_if":["flag"],"target":{"map":"A","certainty":"unknown","where":{"kind":"unknown"}},"declared_after":{"map":"B","certainty":"set","where":{"kind":"tiles","tiles":[[0,0],[1,0]],"facing":"east"}}})"}}, + {.input = {.name = "unknown facing stays absent", .record = Record::POSE, .json = R"({"map":"M","certainty":"tile","where":{"kind":"tiles","tiles":[[0,0]]}})"}, .expectation = {.canonical = R"({"map":"M","certainty":"tile","where":{"kind":"tiles","tiles":[[0,0]]}})"}}, + {.input = {.name = "empty position differs from no position", .record = Record::POSE, .json = R"({"map":"M","certainty":"unknown","where":{"kind":"tiles","tiles":[]}})"}, .expectation = {.canonical = R"({"map":"M","certainty":"unknown","where":{"kind":"tiles","tiles":[]}})"}}, + {.input = {.name = "ordered checkpoint", .record = Record::CHECKPOINT, .json = R"({"id":"00.05","title":"Parcel","events":[{"id":"first","kind":"route"},{"id":"second","kind":"scene"}]})"}, .expectation = {.canonical = R"({"id":"00.05","title":"Parcel","events":[{"id":"first","kind":"route"},{"id":"second","kind":"scene"}]})"}}, + {.input = {.name = "args", .json = R"({"id":"buy","kind":"game_action","variant":"mart","args":{"map":"CeruleanCity_Mart","budget":3000,"if_present":true}})"}, .expectation = {.canonical = R"({"id":"buy","kind":"game_action","variant":"mart","args":{"budget":3000,"if_present":true,"map":"CeruleanCity_Mart"}})"}}, + {.input = {.name = "empty args are omitted", .json = R"({"id":"buy","kind":"game_action","variant":"mart","args":{}})"}, .expectation = {.canonical = R"({"id":"buy","kind":"game_action","variant":"mart"})"}}, + {.input = {.name = "fractional arg", .json = R"({"id":"a","kind":"game_action","variant":"mart","args":{"budget":1.5}})"}, .expectation = {.rejected = true}}, + {.input = {.name = "nested arg", .json = R"({"id":"a","kind":"game_action","variant":"mart","args":{"items":[1]}})"}, .expectation = {.rejected = true}}, + {.input = {.name = "args not an object", .json = R"({"id":"a","kind":"game_action","variant":"mart","args":["map"]})"}, .expectation = {.rejected = true}}, + {.input = {.name = "unknown field", .json = R"({"id":"a","kind":"talk","mystery":0})"}, .expectation = {.rejected = true}}, + {.input = {.name = "unknown action", .json = R"({"id":"a","kind":"magic"})"}, .expectation = {.rejected = true}}, + {.input = {.name = "negative count", .json = R"({"id":"a","kind":"train","battles":-1})"}, .expectation = {.rejected = true}}, + {.input = {.name = "boolean count", .json = R"({"id":"a","kind":"train","battles":false})"}, .expectation = {.rejected = true}}, + {.input = {.name = "wrong bool", .json = R"({"id":"a","kind":"answer","yes":0})"}, .expectation = {.rejected = true}}, + {.input = {.name = "unknown position", .record = Record::POSE, .json = R"({"map":"M","certainty":"unknown","where":{"kind":"gps"}})"}, .expectation = {.rejected = true}}, + {.input = {.name = "bad coordinate", .record = Record::POSE, .json = R"({"map":"M","certainty":"tile","where":{"kind":"tiles","tiles":[[0,true]]}})"}, .expectation = {.rejected = true}}, + {.input = {.name = "duplicate events", .record = Record::CHECKPOINT, .json = R"({"id":"x","title":"X","events":[{"id":"a","kind":"talk"},{"id":"a","kind":"talk"}]})"}, .expectation = {.rejected = true}}, +}; + + +// `input` read and written back. +JsonValue round_trip(const WireInput& input){ + const JsonValue value = parse_json(std::string(input.json)); + switch (input.record){ + case Record::POSE: + return story_pose_json(read_story_pose(value, input.name)); + case Record::STEP: + return story_step_json(read_story_step(value, input.name)); + case Record::CHECKPOINT: + return story_checkpoint_json(read_story_checkpoint(value, input.name)); + } + throw ParseException("Unknown test record."); +} + +// "" when `test` reads as expected, else what was expected and what was read. +std::string check_case(const WireCase& test){ + try{ + const JsonValue actual = round_trip(test.input); + if (test.expectation.rejected){ + return "Expected rejection, read accepted record."; + } + const JsonValue expected = parse_json(std::string(test.expectation.canonical)); + if (!(actual == expected)){ + return "Expected " + expected.dump() + ", read " + actual.dump(); + } + }catch (const ParseException& error){ + return test.expectation.rejected ? "" : "Unexpected parse failure: " + error.message(); + } + return ""; +} + + +#ifdef PA_STORY_DATA_STANDALONE + +// A shared case file's record kind. +Record record_kind(std::string_view name){ + if (name == "pose"){ + return Record::POSE; + } + if (name == "step"){ + return Record::STEP; + } + if (name == "checkpoint"){ + return Record::CHECKPOINT; + } + throw ParseException("Unsupported shared case record kind."); +} + +// Checks every case of the shared file `filename`; nonzero if any failed. +int check_shared_file(const std::string& filename){ + const JsonValue document = load_json_file(filename); + const JsonObject& root = document.to_object_throw(filename); + if (root.get_string_throw("format") != "pokesim-story-wire-cases/1"){ + throw ParseException("Unsupported story wire case format."); + } + int failed = 0; + for (const JsonValue& value : root.get_array_throw("cases")){ + const JsonObject& row = value.to_object_throw(filename); + const JsonObject& input = row.get_object_throw("input"); + const JsonObject& expected = row.get_object_throw("expectation"); + const std::string source = input.get_value_throw("value").dump(); + const JsonValue* normalized = expected.get_value("canonical"); + const std::string canonical = normalized ? normalized->dump() : ""; + const JsonValue* rejected = expected.get_value("rejected"); + const WireCase test{ + .input = {.name = input.get_string_throw("name"), .record = record_kind(input.get_string_throw("record")), .json = source}, + .expectation = {.canonical = canonical, .rejected = rejected && rejected->to_boolean_throw()}, + }; + const std::string error = check_case(test); + if (!error.empty()){ + std::cerr << test.input.name << ": " << error << '\n'; + failed++; + } + } + std::cout << "Checked " << root.get_array_throw("cases").size() << " shared story records.\n"; + return failed != 0; +} + +#endif + + +#ifndef PA_STORY_DATA_STANDALONE + +class StoryJsonTest : public UnitTest{ +public: + explicit StoryJsonTest(WireCase test) + : UnitTest("Pokemon::StoryStepJson - " + std::string(test.input.name)) + , m_case(test) + {} + + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + const std::string error = check_case(m_case); + return error.empty() ? UnitTestResult(true) : UnitTestResult(error); + } + +private: + WireCase m_case; +}; + +#endif + + +} // namespace + + +#ifndef PA_STORY_DATA_STANDALONE + +void add_tests_StoryStepJson(UnitTestDatabase& database){ + for (const WireCase& test : CASES){ + database.add(test); + } +} + +#endif + + + +} // namespace Pokemon +} // namespace PokemonAutomation + + +#ifdef PA_STORY_DATA_STANDALONE + +int main(int argc, char** argv){ + if (argc == 2){ + return PokemonAutomation::Pokemon::check_shared_file(argv[1]); + } + 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 0d139a39f4..0379857071 100644 --- a/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_AutoStory_Tests.h +++ b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_AutoStory_Tests.h @@ -19,6 +19,30 @@ namespace Pokemon{ void add_tests_StoryEvents(UnitTestDatabase& database); // Registers unit tests for story segment running, verification, and branching. void add_tests_StorySegments(UnitTestDatabase& database); +// Registers unit tests for high-level story step dispatching and table conversion. +void add_tests_StoryStepExecutor(UnitTestDatabase& database); +// Registers unit tests for the lossless shared story-data wire contract. +void add_tests_StoryStepJson(UnitTestDatabase& database); +// Registers unit tests for step args: action specs and reading old variant strings. +void add_tests_StoryArgs(UnitTestDatabase& database); +// Registers unit tests for the shared authored envelope, game identity and embedded-resource loading. +void add_tests_AuthoredCheckpointJson(UnitTestDatabase& database); +// Registers unit tests for the shared checkpoint log lines and stats counts. +void add_tests_StoryLog(UnitTestDatabase& database); +// Registers unit tests for the shared start/end checkpoint range options. +void add_tests_StoryRangeOptions(UnitTestDatabase& database); + +// Registers every suite above, for the unit test runner. +inline void add_tests_AutoStory(UnitTestDatabase& database){ + add_tests_StoryEvents(database); + add_tests_StorySegments(database); + add_tests_StoryStepExecutor(database); + add_tests_StoryStepJson(database); + add_tests_StoryArgs(database); + add_tests_AuthoredCheckpointJson(database); + add_tests_StoryLog(database); + add_tests_StoryRangeOptions(database); +} diff --git a/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_StoryArgs_Tests.cpp b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_StoryArgs_Tests.cpp new file mode 100644 index 0000000000..6f24c76588 --- /dev/null +++ b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_StoryArgs_Tests.cpp @@ -0,0 +1,149 @@ +/* Story Args Tests + * + * From: https://github.com/PokemonAutomation/ + * + * Checking a step's args against an action's specs, and reading an old + * "name key=value" variant into a name and args. Built standalone + * (PA_STORY_DATA_STANDALONE), it runs without the application. + * + */ + +#include +#include +#include +#include +#include "Pokemon_StoryVariant.h" +#include "Types/Pokemon_StoryArgs.h" +#ifndef PA_STORY_DATA_STANDALONE +#include "Pokemon_AutoStory_Tests.h" +#endif + +namespace PokemonAutomation{ +namespace Pokemon{ + + +namespace{ + + +// The args a Mart step takes, as an action declares them. +constexpr StoryArgSpec MART_SPECS[] = { + {.key = "map", .type = StoryArgType::TEXT, .required = true}, + {.key = "budget", .type = StoryArgType::NUMBER, .required = true}, + {.key = "floor", .type = StoryArgType::NUMBER}, + {.key = "if_present", .type = StoryArgType::BOOLEAN}, +}; + +constexpr std::string_view MART_POSITIONAL[] = {"map"}; + + +// An old variant, read with an action's positional keys and checked +// against its specs. +struct ArgsInput{ + std::string_view name; + std::string_view variant; + std::span specs = MART_SPECS; + std::span positional = MART_POSITIONAL; +}; + +// The action name and args it gives, and its errors joined by "; ": the +// split's, else each of the specs'. +struct ArgsExpectation{ + std::string_view action; + StoryArgs args = {}; + std::string_view error = {}; +}; + +struct ArgsCase{ + ArgsInput input; + ArgsExpectation expectation; +}; + +const ArgsCase CASES[] = { + {.input = {.name = "mart", .variant = "mart CeruleanCity_Mart budget=1500 floor=1500 if_present"}, + .expectation = {.action = "mart", .args = {{"map", std::string("CeruleanCity_Mart")}, {"budget", int64_t(1500)}, {"floor", int64_t(1500)}, {"if_present", true}}}}, + {.input = {.name = "plain name", .variant = "run", .specs = {}, .positional = {}}, .expectation = {.action = "run"}}, + {.input = {.name = "words stay words", .variant = "teach tutor forget=tail-whip", .specs = {}, .positional = {}}, + .expectation = {.action = "teach", .args = {{"tutor", true}, {"forget", std::string("tail-whip")}}, .error = "unknown argument \"forget\"; unknown argument \"tutor\""}}, + {.input = {.name = "unknown key", .variant = "mart M budget=1 budgte=1"}, + .expectation = {.action = "mart", .args = {{"map", std::string("M")}, {"budget", int64_t(1)}, {"budgte", int64_t(1)}}, .error = "unknown argument \"budgte\""}}, + {.input = {.name = "wrong type", .variant = "mart M budget=lots"}, + .expectation = {.action = "mart", .args = {{"map", std::string("M")}, {"budget", std::string("lots")}}, .error = "argument \"budget\" has the wrong type"}}, + {.input = {.name = "missing required", .variant = "mart M"}, + .expectation = {.action = "mart", .args = {{"map", std::string("M")}}, .error = "missing argument \"budget\""}}, + {.input = {.name = "repeated key", .variant = "mart M budget=1 budget=2"}, + .expectation = {.action = "mart", .args = {{"map", std::string("M")}, {"budget", int64_t(1)}}, .error = "\"budget=2\" repeats an argument"}}, +}; + + +// "" when `test` reads as expected, else what differs. +std::string check_case(const ArgsCase& test){ + const LegacyVariant read = split_legacy_variant(test.input.variant, test.input.positional); + std::string error = read.error; + if (error.empty()){ + for (const std::string& each : story_args_errors(read.args, test.input.specs)){ + error += (error.empty() ? "" : "; ") + each; + } + } + if (read.name != test.expectation.action || read.args != test.expectation.args){ + return "read \"" + read.name + "\" with other args"; + } + if (error != test.expectation.error){ + return "expected error \"" + std::string(test.expectation.error) + "\", got \"" + error + "\""; + } + return ""; +} + + +#ifndef PA_STORY_DATA_STANDALONE + +class StoryArgsTest : public UnitTest{ +public: + explicit StoryArgsTest(ArgsCase test) + : UnitTest("Pokemon::StoryArgs - " + std::string(test.input.name)) + , m_case(test) + {} + + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + const std::string error = check_case(m_case); + return error.empty() ? UnitTestResult(true) : UnitTestResult(error); + } + +private: + ArgsCase m_case; +}; + +#endif + + +} // namespace + + +#ifndef PA_STORY_DATA_STANDALONE + +void add_tests_StoryArgs(UnitTestDatabase& database){ + for (const ArgsCase& 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_StoryEvents.cpp b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_StoryEvents.cpp index d355460da3..22dfc9293a 100644 --- a/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_StoryEvents.cpp +++ b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_StoryEvents.cpp @@ -68,6 +68,13 @@ Outcome outcome_ok(Pose pose){ outcome.pose = std::move(pose); return outcome; } + +Outcome outcome_unchanged(){ + Outcome outcome; + outcome.changed_nothing = true; + return outcome; +} + Outcome outcome_failed(std::string failure, std::string detail){ Outcome outcome; outcome.failure = std::move(failure); @@ -78,6 +85,10 @@ std::string to_string(const Outcome& outcome){ return outcome.detail.empty() ? outcome.failure : outcome.failure + ": " + outcome.detail; } +Outcome outcome_game_not_running(){ + return outcome_failed(std::string(GAME_NOT_RUNNING), "the game is not running: the Switch HOME menu is showing"); +} + Condition condition(std::string name, Check check){ return Condition{std::move(name), std::move(check)}; } @@ -102,11 +113,6 @@ const char* recovery_name(RecoveryKind kind){ } -std::string event_log_line(const std::string& checkpoint, size_t number, const std::string& event, const std::string& status){ - return std::format("[Event] {}/{} {}: {}", checkpoint, number, event, status); -} - - namespace{ // Runs an action; if it succeeded knowing where it left the player, that @@ -156,6 +162,8 @@ Outcome attempt_event(Event& event, RunState& state){ return check_expects(event, state); } +} // namespace + void apply_interrupt_pose(Pose& pose, InterruptPose effect){ switch (effect){ case InterruptPose::UNCHANGED: @@ -172,6 +180,8 @@ void apply_interrupt_pose(Pose& pose, InterruptPose effect){ } } +namespace{ + // One event being run, with what it has used up. struct EventRun{ @@ -257,6 +267,9 @@ Recovery* next_recovery(EventRun& run, const std::string& failure){ // is answered in turn, so escalation goes on from there. std::optional recover(EventRun& run, Outcome failure){ while (true){ + if (failure.failure == GAME_NOT_RUNNING){ + return failure; + } Recovery* recovery = next_recovery(run, failure.failure); if (recovery == nullptr){ return failure; @@ -392,7 +405,7 @@ Outcome finish_checkpoint(RunState& state, Checkpoint& checkpoint){ CheckpointReport run_checkpoint(RunState& state, Checkpoint& checkpoint, EventLogger& log){ CheckpointReport report; const std::string& id = checkpoint.id; - log(std::format("[Checkpoint] {}: start, {} events", id, checkpoint.events.size())); + log(checkpoint_start_line(id, checkpoint.events.size())); std::optional failing = first_failing(checkpoint.start, state); if (failing){ return stop_checkpoint(report, log, id, outcome_failed("start_failed", *failing)); diff --git a/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_StoryEvents.h b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_StoryEvents.h index 0a2673ff38..fc560ca675 100644 --- a/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_StoryEvents.h +++ b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_StoryEvents.h @@ -32,8 +32,10 @@ #include #include #include +#include #include #include "Common/Cpp/MoveOnlyFunction.h" +#include "Pokemon_StoryLog.h" namespace PokemonAutomation{ namespace Pokemon{ @@ -97,6 +99,10 @@ struct Outcome{ std::string failure; std::string detail; std::optional pose; + // A succeeded action that left the game as it found it: a read, a + // check, a TM the lead already knows. A reload of the same save undoes + // nothing it did. + bool changed_nothing = false; bool succeeded() const; }; @@ -104,11 +110,20 @@ struct Outcome{ Outcome outcome_ok(); // Constructs a successful event outcome updating the run pose. Outcome outcome_ok(Pose pose); +// outcome_ok() with `changed_nothing`. +Outcome outcome_unchanged(); // Constructs a failed event outcome with reason and optional diagnostic detail. Outcome outcome_failed(std::string failure, std::string detail = ""); // "probe_exhausted: 4 rows": the failure and its detail, if any. std::string to_string(const Outcome& outcome); +// The failure of a step that found the Switch's HOME menu instead of the +// game. Nothing in the story can answer it, as every press goes to the +// console's menus: no recovery runs and no reload, the run stops. +inline constexpr std::string_view GAME_NOT_RUNNING = "game_not_running"; +// GAME_NOT_RUNNING, saying so for the run log. +Outcome outcome_game_not_running(); + // Callable action executed for a story event. using Act = MoveOnlyFunction; @@ -189,6 +204,8 @@ struct Interrupt{ InterruptPose pose = InterruptPose::UNCHANGED; ResumePolicy resume = ResumePolicy::RETRY_EVENT; }; +// Writes what an interrupt did (`effect`) into `pose`. +void apply_interrupt_pose(Pose& pose, InterruptPose effect); // Story milestone containing pre/post conditions and a series of events. @@ -222,11 +239,6 @@ struct CheckpointReport{ using EventLogger = MoveOnlyFunction; -// One log line, in a format the lab watcher parses: -// [Event] 00.04/3 talk_mom: start -// [Event] 00.04/3 talk_mom: failed: expect_failed: has_parcel -std::string event_log_line(const std::string& checkpoint, size_t number, const std::string& event, const std::string& status); - // Problems an author should fix before running: unnamed or empty events, // missing actions, recoveries out of escalation order. std::vector lint_checkpoint(const Checkpoint& checkpoint); diff --git a/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_StoryLog.cpp b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_StoryLog.cpp new file mode 100644 index 0000000000..0a9262d2c0 --- /dev/null +++ b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_StoryLog.cpp @@ -0,0 +1,73 @@ +/* Story Log + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include "Pokemon_StoryLog.h" + +namespace PokemonAutomation{ +namespace Pokemon{ + + +namespace{ + +std::string resets_text(size_t attempt){ + if (attempt <= 1){ + return "one-shot"; + } + return std::format("{} reset{}", attempt - 1, attempt == 2 ? "" : "s"); +} + +} // namespace + + +std::string event_log_line(std::string_view checkpoint, size_t number, std::string_view event, std::string_view status){ + return std::format("[Event] {}/{} {}: {}", checkpoint, number, event, status); +} + +std::string checkpoint_start_line(std::string_view checkpoint, std::optional events){ + if (!events){ + return std::format("[Checkpoint] {}: start", checkpoint); + } + return std::format("[Checkpoint] {}: start, {} events", checkpoint, *events); +} + +std::string checkpoint_attempt_line(std::string_view checkpoint, size_t attempt, std::optional attempts){ + if (!attempts){ + return std::format("[Checkpoint] {}: attempt {}", checkpoint, attempt); + } + return std::format("[Checkpoint] {}: attempt {}/{}", checkpoint, attempt, *attempts); +} + +std::string checkpoint_pass_line(std::string_view checkpoint, size_t attempt, size_t in_place){ + return std::format( + "[Checkpoint] {}: passed on attempt {} ({}){}", + checkpoint, attempt, resets_text(attempt), + in_place == 0 ? "" : std::format(", {} in place", in_place) + ); +} + +std::string checkpoint_reset_line(std::string_view checkpoint, size_t next_attempt){ + return std::format("[Checkpoint] {}: failed, resetting for attempt {}", checkpoint, next_attempt); +} + +std::string segment_reload_line(std::string_view segment, std::string_view checkpoint, size_t next_attempt, size_t attempts){ + return std::format( + "[Segment] {}: {} failed, reloading for attempt {}/{}", + segment, checkpoint, next_attempt, attempts + ); +} + + +void record_story_counts(AutoStoryStats& stats, const StoryRunCounts& counts){ + stats.m_segment += counts.segments; + stats.m_checkpoint += counts.checkpoints; + stats.m_reset += counts.resets; +} + + + +} // namespace Pokemon +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_StoryLog.h b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_StoryLog.h new file mode 100644 index 0000000000..f9576e1e69 --- /dev/null +++ b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_StoryLog.h @@ -0,0 +1,76 @@ +/* Story Log + * + * From: https://github.com/PokemonAutomation/ + * + * The log lines an AutoStory program writes around its checkpoints, in + * one format for every game: the story engine (Pokemon_StoryEvents.h, + * Pokemon_StorySegments.h) writes them, and a game with its own checkpoint + * loop (SV's) writes the same lines, so the lab watcher and whoever reads + * a log find each checkpoint's start, attempts, resets and pass the same + * way. Also the program's stats counts. Nothing here runs a story. + * + */ + +#ifndef PokemonAutomation_Pokemon_StoryLog_H +#define PokemonAutomation_Pokemon_StoryLog_H + +#include +#include +#include +#include +#include "Pokemon_AutoStoryStats.h" + +namespace PokemonAutomation{ +namespace Pokemon{ + + +// One event's line: +// [Event] 00.04/3 talk_mom: start +// [Event] 00.04/3 talk_mom: failed: expect_failed: has_parcel +std::string event_log_line(std::string_view checkpoint, size_t number, std::string_view event, std::string_view status); + +// A checkpoint starts; the story engine also says it has `events` events: +// [Checkpoint] 00.04: start +// [Checkpoint] 00.04: start, 3 events +std::string checkpoint_start_line(std::string_view checkpoint, std::optional events = std::nullopt); + +// An attempt at a checkpoint starts, counting from 1, out of the +// program's limit of `attempts` if it has one: +// [Checkpoint] 00.04: attempt 2 +// [Checkpoint] 00.04: attempt 2/3 +std::string checkpoint_attempt_line( + std::string_view checkpoint, size_t attempt, std::optional attempts = std::nullopt +); + +// A checkpoint passed on attempt `attempt`, after `in_place` recoveries in +// place: +// [Checkpoint] 00.03: passed on attempt 1 (one-shot) +// [Checkpoint] 00.04: passed on attempt 3 (2 resets), 1 in place +std::string checkpoint_pass_line(std::string_view checkpoint, size_t attempt, size_t in_place = 0); + +// An attempt failed and the game resets to the checkpoint's save for +// attempt `next_attempt`: +// [Checkpoint] 00.04: failed, resetting for attempt 2 +std::string checkpoint_reset_line(std::string_view checkpoint, size_t next_attempt); +// The story engine reloads within segment `segment`, out of `attempts`: +// [Segment] 00: 00.02 failed, reloading for attempt 2/3 +std::string segment_reload_line(std::string_view segment, std::string_view checkpoint, size_t next_attempt, size_t attempts); + + +// What a story run adds to the program's stats. +struct StoryRunCounts{ + size_t segments = 0; + // Checkpoints passed (and saved at). + size_t checkpoints = 0; + // Game resets or reloads after failed attempts. + size_t resets = 0; +}; + +// Adds `counts` to the shared AutoStory stats. +void record_story_counts(AutoStoryStats& stats, const StoryRunCounts& counts); + + + +} // namespace Pokemon +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_StoryLog_Tests.cpp b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_StoryLog_Tests.cpp new file mode 100644 index 0000000000..0eacf7a54b --- /dev/null +++ b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_StoryLog_Tests.cpp @@ -0,0 +1,173 @@ +/* Story Log Tests + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include +#include +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "Pokemon_AutoStory_Tests.h" +#include "Pokemon_StoryLog.h" + +namespace PokemonAutomation{ +namespace Pokemon{ + +namespace{ + +// A formatter's line, made when the table is built. +struct StoryLogInput{ + std::string name; + std::string line; +}; + +// The exact line: the lab watcher and the engine tests read these. +struct StoryLogExpectation{ + std::string line; +}; + +// One formatter call and the line it must make. +struct StoryLogCase{ + StoryLogInput input; + StoryLogExpectation expectation; +}; + +class Test_StoryLog : public UnitTest{ +public: + Test_StoryLog(StoryLogCase test_case) + : UnitTest("Pokemon::StoryLog - " + test_case.input.name) + , m_case(std::move(test_case)) + {} + + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + if (m_case.input.line != m_case.expectation.line){ + return "Expected \"" + m_case.expectation.line + "\", got \"" + m_case.input.line + "\""; + } + return true; + } + +private: + const StoryLogCase m_case; +}; + +const StoryLogCase STORY_LOG_CASES[] = { + { + .input = {.name = "event", .line = event_log_line("00.04", 3, "talk_mom", "start")}, + .expectation = {.line = "[Event] 00.04/3 talk_mom: start"}, + }, + { + .input = {.name = "checkpoint start", .line = checkpoint_start_line("00.04")}, + .expectation = {.line = "[Checkpoint] 00.04: start"}, + }, + { + .input = {.name = "checkpoint start with its events", .line = checkpoint_start_line("00.04", 3)}, + .expectation = {.line = "[Checkpoint] 00.04: start, 3 events"}, + }, + { + .input = {.name = "attempt", .line = checkpoint_attempt_line("00.04", 2)}, + .expectation = {.line = "[Checkpoint] 00.04: attempt 2"}, + }, + { + .input = {.name = "attempt out of a limit", .line = checkpoint_attempt_line("00.04", 2, 3)}, + .expectation = {.line = "[Checkpoint] 00.04: attempt 2/3"}, + }, + { + .input = {.name = "one-shot pass", .line = checkpoint_pass_line("00.03", 1)}, + .expectation = {.line = "[Checkpoint] 00.03: passed on attempt 1 (one-shot)"}, + }, + { + .input = {.name = "pass after one reset", .line = checkpoint_pass_line("00.04", 2)}, + .expectation = {.line = "[Checkpoint] 00.04: passed on attempt 2 (1 reset)"}, + }, + { + .input = {.name = "pass after resets and recoveries in place", .line = checkpoint_pass_line("00.04", 3, 1)}, + .expectation = {.line = "[Checkpoint] 00.04: passed on attempt 3 (2 resets), 1 in place"}, + }, + { + .input = {.name = "reset", .line = checkpoint_reset_line("00.04", 2)}, + .expectation = {.line = "[Checkpoint] 00.04: failed, resetting for attempt 2"}, + }, + { + .input = {.name = "reload within a segment", .line = segment_reload_line("00", "00.02", 2, 3)}, + .expectation = {.line = "[Segment] 00: 00.02 failed, reloading for attempt 2/3"}, + }, +}; + + +// Counts added to fresh stats, in order. +struct StoryCountsInput{ + std::string name; + std::vector added; +}; + +// The stats after them: counts add up rather than replace. +struct StoryCountsExpectation{ + uint64_t segments; + uint64_t checkpoints; + uint64_t resets; +}; + +// Counts recorded into the AutoStory stats and the totals they must reach. +struct StoryCountsCase{ + StoryCountsInput input; + StoryCountsExpectation expectation; +}; + +class Test_StoryCounts : public UnitTest{ +public: + Test_StoryCounts(StoryCountsCase test_case) + : UnitTest("Pokemon::StoryLog - " + test_case.input.name) + , m_case(std::move(test_case)) + {} + + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + AutoStoryStats stats; + for (const StoryRunCounts& counts : m_case.input.added){ + record_story_counts(stats, counts); + } + const StoryCountsExpectation& expected = m_case.expectation; + if (stats.m_segment != expected.segments || stats.m_checkpoint != expected.checkpoints || stats.m_reset != expected.resets){ + return "Expected " + std::to_string(expected.segments) + " segments, " + std::to_string(expected.checkpoints) + + " checkpoints and " + std::to_string(expected.resets) + " resets, got " + + std::to_string(stats.m_segment) + ", " + std::to_string(stats.m_checkpoint) + + " and " + std::to_string(stats.m_reset); + } + return true; + } + +private: + const StoryCountsCase m_case; +}; + +const StoryCountsCase STORY_COUNTS_CASES[] = { + { + .input = {.name = "a segment's counts in the stats", .added = {{.segments = 1, .checkpoints = 2, .resets = 3}}}, + .expectation = {.segments = 1, .checkpoints = 2, .resets = 3}, + }, + { + .input = { + .name = "a checkpoint loop's counts add up", + .added = {{.segments = 0, .checkpoints = 1, .resets = 0}, {.segments = 0, .checkpoints = 0, .resets = 1}, {.segments = 0, .checkpoints = 1, .resets = 0}}, + }, + .expectation = {.segments = 0, .checkpoints = 2, .resets = 1}, + }, +}; + +} // namespace + + +void add_tests_StoryLog(UnitTestDatabase& database){ + for (const StoryLogCase& test_case : STORY_LOG_CASES){ + database.add(test_case); + } + for (const StoryCountsCase& test_case : STORY_COUNTS_CASES){ + database.add(test_case); + } +} + + + +} // namespace Pokemon +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_StorySegments.cpp b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_StorySegments.cpp index 0d112b0170..d4eec835b5 100644 --- a/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_StorySegments.cpp +++ b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_StorySegments.cpp @@ -6,6 +6,7 @@ #include #include +#include "Pokemon_StoryLog.h" #include "Pokemon_StorySegments.h" namespace PokemonAutomation{ @@ -102,28 +103,61 @@ Outcome reload(RunState& state, SegmentHooks& hooks){ return outcome_ok(); } +// The policy's in-place recovery, after each event's own: the game's +// recover hook, if any, then the event again. Counts into `used`. +void add_policy_recoveries(Checkpoint& checkpoint, const SegmentPolicy& policy, SegmentHooks& hooks, size_t& used){ + if (policy.recoveries_per_event == 0){ + return; + } + for (Event& event : checkpoint.events){ + Recovery recovery; + recovery.kind = RecoveryKind::REANCHOR; + recovery.attempts = policy.recoveries_per_event; + recovery.why = "the segment's policy: recover in place before reloading"; + recovery.prepare = [&hooks, &used, name = event.name](RunState& state){ + used++; + return hooks.recover ? hooks.recover(state, name) : outcome_ok(); + }; + event.recoveries.push_back(std::move(recovery)); + } +} + // One row to success, reloading and rebuilding after each failed attempt. -// Returns the last attempt's report, or the reload's failure. +// Returns the last attempt's report, or the reload's failure; `run` says +// how many attempts and recoveries it took. CheckpointReport run_row( RunState& state, const Segment& segment, CheckpointSpec& spec, - const SegmentPolicy& policy, SegmentHooks& hooks, SegmentReport& report + const SegmentPolicy& policy, SegmentHooks& hooks, SegmentReport& report, CheckpointRun& run ){ for (size_t attempt = 1; ; attempt++){ + run.attempts = attempt; Checkpoint checkpoint = spec.build(); if (spec.saves && !checkpoint.save){ checkpoint.save = [&hooks](RunState& state){ return hooks.save ? hooks.save(state) : outcome_failed("no_save_hook"); }; } + add_policy_recoveries(checkpoint, policy, hooks, run.recoveries); EventLogger log = [&hooks](const std::string& line){ say(hooks, line); }; CheckpointReport result = run_checkpoint(state, checkpoint, log); - if (result.completed || attempt >= policy.attempts_per_checkpoint){ + if (result.completed){ + run.passed = true; + say(hooks, checkpoint_pass_line(spec.id, attempt, run.recoveries)); return result; } - say(hooks, std::format( - "[Segment] {}: {} failed, reloading for attempt {}/{}", - segment.id, spec.id, attempt + 1, policy.attempts_per_checkpoint - )); + const bool not_running = result.failure.failure == GAME_NOT_RUNNING; + if (not_running || policy.stop_at_first_failure){ + say(hooks, std::format("[Segment] {}: {} failed; {}", segment.id, spec.id, not_running + ? "the game is not running, so no reload" + : "stopping at the first failure, the game stays where it failed" + )); + report.stopped_at_first_failure = !not_running; + return result; + } + if (attempt >= policy.attempts_per_checkpoint){ + return result; + } + say(hooks, segment_reload_line(segment.id, spec.id, attempt + 1, policy.attempts_per_checkpoint)); report.reloads++; Outcome reloaded = reload(state, hooks); if (!reloaded.succeeded()){ @@ -148,6 +182,29 @@ SegmentReport stop_segment(SegmentReport& report, SegmentHooks& hooks, const std } // namespace +bool CheckpointRun::one_shot() const{ + return passed && attempts == 1 && recoveries == 0; +} + +size_t SegmentReport::one_shot_passes() const{ + size_t count = 0; + for (const CheckpointRun& run : runs){ + count += run.one_shot() ? 1 : 0; + } + return count; +} + +StoryRunCounts SegmentReport::story_counts() const{ + StoryRunCounts counts{.segments = 1, .checkpoints = 0, .resets = reloads}; + for (const CheckpointRun& run : runs){ + if (run.passed){ + counts.checkpoints++; + } + } + return counts; +} + + SegmentReport run_segment( RunState& state, Segment& segment, const SegmentRange& range, const SegmentPolicy& policy, SegmentHooks& hooks @@ -161,7 +218,8 @@ SegmentReport run_segment( for (size_t c = selected.first; c <= selected.last; c++){ CheckpointSpec& spec = segment.checkpoints[c]; report.checkpoints_run++; - CheckpointReport result = run_row(state, segment, spec, policy, hooks, report); + report.runs.push_back(CheckpointRun{.id = spec.id}); + CheckpointReport result = run_row(state, segment, spec, policy, hooks, report, report.runs.back()); if (!result.completed){ report.failed_checkpoint = spec.id; report.failed_event = result.failed_name; @@ -169,6 +227,7 @@ SegmentReport run_segment( } } report.completed = true; + say(hooks, std::format("[Segment] {}: {} of {} checkpoints one-shot", segment.id, report.one_shot_passes(), report.checkpoints_run)); say(hooks, std::format("[Segment] {}: done, {} checkpoints", segment.id, report.checkpoints_run)); return report; } diff --git a/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_StorySegments.h b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_StorySegments.h index a63b8b4be9..051e9a702a 100644 --- a/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_StorySegments.h +++ b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_StorySegments.h @@ -22,6 +22,7 @@ #include #include "Common/Cpp/MoveOnlyFunction.h" #include "Pokemon_StoryEvents.h" +#include "Pokemon_StoryLog.h" namespace PokemonAutomation{ namespace Pokemon{ @@ -47,10 +48,14 @@ struct Segment{ // What the game lends the runner: how to save, how to reload the last save // (the run state is emptied first; the reload's Outcome says where the // player stands), and where to log. +// `recover`, if set, is the game's way of recovering in place from a failed +// event (re-anchor on a landmark, close a stray dialogue) before the event +// is tried again; see SegmentPolicy::recoveries_per_event. struct SegmentHooks{ Act save; Act reload; EventLogger log; + MoveOnlyFunction recover; // Optional: each [Segment], [Checkpoint] and [Event] line again, // without its tag ("00.03/2 below_oaks_trigger: start"), for the video // overlay, so whoever watches the stream sees the step under way; and, @@ -60,8 +65,16 @@ struct SegmentHooks{ // Retry and timeout configuration for story segment execution. struct SegmentPolicy{ - // Runs of one checkpoint, reloading between them, before the segment stops. + // Runs of one checkpoint, reloading its save between them, before the + // segment stops. size_t attempts_per_checkpoint = 3; + // Lab: stop at the first failed attempt without reloading, leaving the + // game where it failed for a look. + bool stop_at_first_failure = false; + // Times each event may recover in place (SegmentHooks::recover, then + // the event again) once its own recoveries are used up, before its + // checkpoint fails and is reloaded. 0 keeps a failure a reload. + size_t recoveries_per_event = 0; }; // The checkpoints to run, by id, both inclusive. An unset end is the @@ -72,9 +85,22 @@ struct SegmentRange{ }; +// Summary of checkpoint attempts, recoveries, and pass status. +struct CheckpointRun{ + std::string id; + bool passed = false; + size_t attempts = 0; + // In-place recoveries (SegmentPolicy::recoveries_per_event) used, in all attempts. + size_t recoveries = 0; + + bool one_shot() const; +}; + // Execution results and failure diagnostics for a story segment run. struct SegmentReport{ bool completed = false; + // The run stopped at its first failure (SegmentPolicy::stop_at_first_failure). + bool stopped_at_first_failure = false; // Both empty when nothing failed; the event is empty when a start // check, an end check, a save or a reload failed. std::string failed_checkpoint; @@ -83,13 +109,24 @@ struct SegmentReport{ size_t checkpoints_run = 0; // Reloads after failed attempts. size_t reloads = 0; + // Each checkpoint run, in order, the failed one last. + std::vector runs; + + size_t one_shot_passes() const; + // What the run adds to the program's stats (record_story_counts()): + // the segment, each checkpoint that passed, each reload. + StoryRunCounts story_counts() const; }; // Runs the checkpoints in `range`, in order. Logs through `hooks.log`, // around the checkpoint lines: // [Segment] 00: start, 3 checkpoints // [Segment] 00: 00.02 failed, reloading for attempt 2/3 +// [Checkpoint] 00.02: passed on attempt 2 (1 reset) +// [Checkpoint] 00.03: passed on attempt 1 (one-shot) +// [Segment] 00: 2 of 3 checkpoints one-shot // [Segment] 00: done, 3 checkpoints +// [Segment] 00: 00.02 failed; stopping at the first failure, the game stays where it failed // [Segment] 00: failed at 00.02 talk_mom: expect_failed: dialogue opened SegmentReport run_segment( RunState& state, Segment& segment, const SegmentRange& range, diff --git a/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_StoryStepExecutor.cpp b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_StoryStepExecutor.cpp new file mode 100644 index 0000000000..e7d4cfeeb1 --- /dev/null +++ b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_StoryStepExecutor.cpp @@ -0,0 +1,156 @@ +/* Story Step Executor + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include +#include "Pokemon_StoryStepExecutor.h" + +namespace PokemonAutomation{ +namespace Pokemon{ + + +Outcome unsupported_step(const StoryStep& step){ + return outcome_failed("unsupported_step", std::format("{} {}", story_step_kind_name(step.kind), step.name)); +} + + +void StoryStepExecutor::begin_attempt(const StoryCheckpoint&){} + +bool StoryStepExecutor::holds(std::string_view condition, RunState& state){ + return state.flags.contains(std::string(condition)); +} + + +Event story_event(const StoryStep& step, StoryStepExecutor& executor){ + Event event; + event.name = step.name; + event.why = step.why; + for (const std::string& name : step.skip_if){ + event.skip_if.push_back(condition(name, [&executor, name](RunState& state){ + return executor.holds(name, state); + })); + } + event.act = [&executor, &step](RunState& state){ + return executor.run(state, step); + }; + event.pose_learned = step.pose_after; + return event; +} + +namespace{ + +// The first event of a checkpoint that starts from a save: where the save +// leaves the player, for a run that reloaded it. +Event from_save_event(const Pose& start){ + Event event; + event.name = "from_save"; + event.why = "where the checkpoint's save leaves the player"; + event.act = [](RunState&){ return outcome_ok(); }; + event.pose_learned = start; + return event; +} + +} // namespace + +namespace{ + +// `event`, for a once_per_save step known in `prepared` as `key`: skipped +// while `prepared` has it, and added to it when it succeeds changing +// nothing. +void run_once_per_save(Event& event, std::string key, PreparedSteps& prepared){ + event.skip_if.push_back(condition("done_from_this_save", [&prepared, key](RunState&){ + return prepared.contains(key); + })); + event.act = [act = std::move(event.act), &prepared, key](RunState& state) mutable{ + Outcome outcome = act(state); + if (outcome.succeeded() && outcome.changed_nothing){ + prepared.insert(key); + } + return outcome; + }; +} + +} // namespace + +Checkpoint build_story_checkpoint(const StoryCheckpoint& data, StoryStepExecutor& executor, PreparedSteps* prepared){ + executor.begin_attempt(data); + Checkpoint checkpoint; + checkpoint.id = data.id; + if (data.start){ + checkpoint.events.push_back(from_save_event(*data.start)); + } + for (const StoryStep& step : data.steps){ + checkpoint.events.push_back(story_event(step, executor)); + if (prepared != nullptr && step.once_per_save){ + run_once_per_save(checkpoint.events.back(), data.id + "/" + step.name, *prepared); + } + } + return checkpoint; +} + + +namespace{ + +// What a step of its kind must have, or "" if it has it. +std::string missing_field(const StoryStep& step){ + switch (step.kind){ + case StoryStepKind::ROUTE: + case StoryStepKind::READY: + return step.target ? "" : "no target"; + case StoryStepKind::LEGS: + if (step.legs.empty()){ + return "no legs"; + } + for (const StoryLeg& leg : step.legs){ + if (leg.tiles <= 0){ + return "a leg of no tiles"; + } + } + return ""; + case StoryStepKind::TRAIN: + return step.battles > 0 ? "" : "no battles"; + case StoryStepKind::SHOP: + return step.items.empty() ? "no items" : ""; + case StoryStepKind::GAME: + return step.variant.empty() ? "no variant" : ""; + default: + return ""; + } +} + +} // namespace + +std::vector lint_story_checkpoint(const StoryCheckpoint& data){ + std::vector problems; + if (data.id.empty()){ + problems.emplace_back("a checkpoint has no id"); + } + std::set names; + for (size_t c = 0; c < data.steps.size(); c++){ + const StoryStep& step = data.steps[c]; + const std::string where = std::format("{} step {}", data.id, c + 1); + if (step.name.empty()){ + problems.emplace_back(where + ": no name"); + }else if (!names.insert(step.name).second){ + problems.emplace_back(std::format("{}: a second step named {}", where, step.name)); + } + const std::string missing = missing_field(step); + if (!missing.empty()){ + problems.emplace_back(std::format("{} {}: {} {}", where, step.name, story_step_kind_name(step.kind), missing)); + } + // skip_if's conditions must all hold; once per save is one more. + if (step.once_per_save && !step.skip_if.empty()){ + problems.emplace_back(std::format("{} {}: once per save with skip_if", where, step.name)); + } + } + return problems; +} + + + +} // namespace Pokemon +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_StoryStepExecutor.h b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_StoryStepExecutor.h new file mode 100644 index 0000000000..c4b1c0774c --- /dev/null +++ b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_StoryStepExecutor.h @@ -0,0 +1,103 @@ +/* Story Step Executor + * + * From: https://github.com/PokemonAutomation/ + * + * One vocabulary of story steps for the tile games (FRLG, BDSP): a route, + * a fixed walk, a step onto a trigger, a talk, a YES/NO, a scene, a + * battle, the nurse, a shop, a save, and so on. A checkpoint is a list of + * such steps, plain data (Types/), so a game's story is a table its unit + * tests can plan without a console. + * + * build_story_checkpoint() turns a table row into engine events + * (Pokemon_StoryEvents.h). Each event's action asks the game's + * StoryStepExecutor to do its step; a game implements the kinds it uses, + * and a step it has no way of doing fails by name (unsupported_step()) + * instead of doing something else. + * + * Steps never match dialogue or menu text: they look for screen state (a + * dialogue box, an arrow, a fade, a battle menu, colours, positions). The + * few reads that need text (item names in a shop or the Bag) take the + * game's language from the program's options (StoryRunOptions), and with + * no reader for it fall back to checks that need none (a row's position, + * digits, a changed screen), so a run never fails because its language + * is not supported. + * + * Poses are the game's own (Pose, with a TilePose for these games): a + * checkpoint's start, a scene's end and a route's target are poses, and + * an executor that knows where a step left the player says so in its + * Outcome, which the engine writes into the run. + * + */ + +#ifndef PokemonAutomation_Pokemon_StoryStepExecutor_H +#define PokemonAutomation_Pokemon_StoryStepExecutor_H + +#include +#include +#include +#include +#include "Pokemon_StoryEvents.h" +#include "Pokemon_TilePose.h" +#include "Types/Pokemon_StoryCheckpoint.h" +#include "Types/Pokemon_StoryStep.h" +#include "Types/Pokemon_StoryStepKind.h" + +namespace PokemonAutomation{ +namespace Pokemon{ + + +// How a game does its story steps: an interface with no data. run() is the +// game's: it does the step's kind its own way (FRLG and BDSP bind kinds and +// variants to actions in a table), and returns unsupported_step() for a +// kind it has no way of doing. +class StoryStepExecutor{ +public: + virtual ~StoryStepExecutor() = default; + + // A checkpoint is being built for a new attempt: build_story_checkpoint() + // calls this first, so a game can start the attempt afresh (trainers + // who can see us again, the row's own settings). Does nothing by default. + virtual void begin_attempt(const StoryCheckpoint& data); + + // Does `step`, from the run so far. + virtual Outcome run(RunState& state, const StoryStep& step) = 0; + + // Whether the condition named in a step's skip_if holds: by default, + // whether the run has the flag (set by an earlier step's action). + virtual bool holds(std::string_view condition, RunState& state); +}; + +// "unsupported_step: TRAIN train_in_grass": what an executor without the kind returns. +Outcome unsupported_step(const StoryStep& step); + + +// The step as an event that runs it with `executor`. Both must outlive +// the event. +Event story_event(const StoryStep& step, StoryStepExecutor& executor); + +// The once_per_save steps done from the save the game is on that changed +// nothing, as "/". The game keeps it across reloads and +// empties it when it saves or loads another save. +using PreparedSteps = std::set; + +// The checkpoint `data` as events: "from_save" first when it has a start +// (the start pose, for a run that reloaded its save), then one event per +// step. `data` and `executor` must outlive the checkpoint. Saving is the +// game's: it sets the checkpoint's save, or adds a SAVE step. +// With `prepared`, each once_per_save step is skipped while `prepared` has +// it, and added to it when it succeeds changing nothing; `prepared` must +// then outlive the checkpoint. Without, once_per_save steps run every time. +Checkpoint build_story_checkpoint( + const StoryCheckpoint& data, StoryStepExecutor& executor, PreparedSteps* prepared = nullptr +); + +// Problems an author should fix before running: an empty id, unnamed or +// repeated steps, a route or READY without a target, empty legs, a GAME +// step without a variant, TRAIN without battles, SHOP without items. +std::vector lint_story_checkpoint(const StoryCheckpoint& data); + + + +} // namespace Pokemon +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_StoryStepExecutor_Tests.cpp b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_StoryStepExecutor_Tests.cpp new file mode 100644 index 0000000000..903dca6714 --- /dev/null +++ b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_StoryStepExecutor_Tests.cpp @@ -0,0 +1,840 @@ +/* Story Steps Tests + * + * From: https://github.com/PokemonAutomation/ + * + * Story tables built into checkpoints and run against a fake executor + * that writes down each step it is asked to do: the order of the events, + * skipped steps, the pose each step leaves, and how a failure is named. + * Nothing here belongs to one game. + * + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "Pokemon/Options/Pokemon_GameLanguages.h" +#include "Pokemon/Options/Pokemon_StoryRunOptions.h" +#include "Pokemon_AutoStorySelect.h" +#include "Pokemon_StoryEvents.h" +#include "Pokemon_StorySegments.h" +#include "Pokemon_StoryStepExecutor.h" +#include "Pokemon_TilePose.h" +#include "Pokemon_AutoStory_Tests.h" + +namespace PokemonAutomation{ +namespace Pokemon{ + + +namespace{ + +// A world of two maps. Routes arrive on their target; a step moves a tile +// from the run's pose. Each step it does is written down as "KIND name", +// with the pose it started from when that is known. +class FakeExecutor : public StoryStepExecutor{ +public: + std::vector trace; + std::set> conditions; + // Steps by name that always fail, and how. + std::set failing; + // Steps by name that fail this many more times, then pass. + std::map failures_left; + // The pose each step started from, by name. + std::vector> poses_seen; + // The checkpoints begun, one per attempt. + std::vector attempts; + // Game steps by name that change nothing in the game. + std::set unchanged; + + virtual void begin_attempt(const StoryCheckpoint& data) override{ + attempts.push_back(data.id); + } + + virtual Outcome run(RunState& state, const StoryStep& step) override{ + poses_seen.emplace_back(step.name, to_string(state.pose)); + trace.push_back(std::string(story_step_kind_name(step.kind)) + " " + step.name); + if (failing.contains(step.name)){ + return outcome_failed("blocked", step.name + " was blocked"); + } + size_t& left = failures_left[step.name]; + if (left > 0){ + left--; + return outcome_failed("blocked", step.name + " was blocked"); + } + return run_kind(state, step); + } + + virtual bool holds(std::string_view condition, RunState& state) override{ + return conditions.contains(condition) || StoryStepExecutor::holds(condition, state); + } + +private: + // The kinds this world has; any other is unsupported. + Outcome run_kind(RunState& state, const StoryStep& step){ + switch (step.kind){ + case StoryStepKind::ROUTE: + return outcome_ok(*step.target); + case StoryStepKind::STEP: + return step_one_tile(state, step); + case StoryStepKind::TALK: + case StoryStepKind::MASH: + return outcome_ok(); + case StoryStepKind::BATTLE: + // The first battle is lost; a step that may lose goes on. + return step.may_lose ? outcome_ok() : outcome_failed("blackout"); + case StoryStepKind::SHOP: + return shop(step); + case StoryStepKind::GAME: + return game(state, step); + default: + return unsupported_step(step); + } + } + + Outcome step_one_tile(RunState& state, const StoryStep& step){ + const std::vector tiles = tiles_of(state.pose); + if (tiles.size() != 1){ + return outcome_failed("no_pose", to_string(state.pose)); + } + return outcome_ok(tile_pose(state.pose.map, {tiles[0].next(step.direction)}, step.direction)); + } + + Outcome shop(const StoryStep& step){ + for (const StoryItem& item : step.items){ + trace.push_back(" bought " + std::to_string(item.quantity) + " " + item.item); + } + return outcome_ok(); + } + + // A game step sets a run flag named for its variant. + Outcome game(RunState& state, const StoryStep& step){ + trace.push_back(" game " + step.variant); + state.flags.insert(step.variant); + return unchanged.contains(step.name) ? outcome_unchanged() : outcome_ok(); + } +}; + +StoryStep make(StoryStepKind kind, std::string name){ + StoryStep step; + step.kind = kind; + step.name = std::move(name); + return step; +} + +StoryStep route_to(std::string name, Pose target){ + StoryStep step = make(StoryStepKind::ROUTE, std::move(name)); + step.target = std::move(target); + return step; +} + +StoryStep step_into(std::string name, Direction direction){ + StoryStep step = make(StoryStepKind::STEP, std::move(name)); + step.direction = direction; + return step; +} + +StoryStep scene(std::string name, std::optional pose_after){ + StoryStep step = make(StoryStepKind::MASH, std::move(name)); + step.pose_after = std::move(pose_after); + return step; +} + +// A lab checkpoint: to the table, a trigger, the scene into the lab, a +// talk, a shop, a game step. +StoryCheckpoint lab_checkpoint(){ + StoryStep shop = make(StoryStepKind::SHOP, "potions"); + shop.items = {{.item = "potion", .quantity = 5}, {.item = "antidote", .quantity = 3}}; + StoryStep game = make(StoryStepKind::GAME, "dance"); + game.variant = "dance"; + return StoryCheckpoint{ + .id = "00.03", + .title = "to the lab", + .start = tile_pose("Town", {{6, 8}}, Direction::SOUTH), + .steps = { + route_to("below_trigger", tile_pose("Town", {{12, 2}}, Direction::NORTH)), + step_into("trigger", Direction::NORTH), + scene("to_the_lab", tile_pose("Lab", {{6, 4}}, Direction::NORTH)), + make(StoryStepKind::TALK, "talk_oak"), + shop, + game, + }, + }; +} + +struct Run{ + CheckpointReport report; + RunState state; + std::vector lines; +}; + +Run run(const StoryCheckpoint& data, StoryStepExecutor& executor){ + Run ret; + Checkpoint checkpoint = build_story_checkpoint(data, executor); + EventLogger log = [&ret](const std::string& line){ ret.lines.push_back(line); }; + ret.report = run_checkpoint(ret.state, checkpoint, log); + return ret; +} + +std::string joined(std::span lines){ + std::string text; + for (const std::string& line : lines){ + text += "\n " + line; + } + return text; +} + + +UnitTestResult test_events_in_table_order(){ + const StoryCheckpoint data = lab_checkpoint(); + FakeExecutor executor; + const Run result = run(data, executor); + const std::vector expected{ + "ROUTE below_trigger", + "STEP trigger", + "MASH to_the_lab", + "TALK talk_oak", + "SHOP potions", + " bought 5 potion", + " bought 3 antidote", + "GAME dance", + " game dance", + }; + if (!result.report.completed || executor.trace != expected){ + return "Expected the steps in table order:" + joined(expected) + "\nbut ran:" + joined(executor.trace); + } + // "from_save" is the engine's own event: the executor is not asked. + const std::string first = event_log_line("00.03", 1, "from_save", "start"); + if (result.lines.empty() || std::find(result.lines.begin(), result.lines.end(), first) == result.lines.end()){ + return "Expected the from_save event first:" + joined(result.lines); + } + return true; +} + +UnitTestResult test_skip_if(){ + StoryCheckpoint data = lab_checkpoint(); + data.steps[0].skip_if = {"has_parcel"}; + data.steps[3].skip_if = {"has_parcel", "talked"}; + FakeExecutor executor; + executor.conditions = {"has_parcel"}; + // The route is skipped; the talk needs both conditions, so it runs. + const Run result = run(data, executor); + if (!result.report.completed || result.report.events_skipped != 1){ + return "Expected one step skipped, got " + std::to_string(result.report.events_skipped) + ":" + joined(result.lines); + } + if (std::find(executor.trace.begin(), executor.trace.end(), "ROUTE below_trigger") != executor.trace.end()){ + return "Expected the route skipped:" + joined(executor.trace); + } + if (std::find(executor.trace.begin(), executor.trace.end(), "TALK talk_oak") == executor.trace.end()){ + return "Expected the talk run, one of its conditions not holding:" + joined(executor.trace); + } + + // By default a condition is a run flag: the game step "dance" sets + // one, and a talk after it that needs it is skipped. + StoryStep after = make(StoryStepKind::TALK, "talk_after_dance"); + after.skip_if = {"dance"}; + data = lab_checkpoint(); + data.steps.push_back(after); + FakeExecutor plain; + const Run flagged = run(data, plain); + if (!flagged.report.completed || flagged.report.events_skipped != 1 || plain.trace.back() != " game dance"){ + return "Expected the talk after the dance skipped on its flag:" + joined(plain.trace); + } + return true; +} + +UnitTestResult test_pose_propagation(){ + const StoryCheckpoint data = lab_checkpoint(); + FakeExecutor executor; + const Run result = run(data, executor); + // Each step starts where the one before left the player: the save, the + // route's target, a tile north of it, the scene's end, and the scene's + // end again after a talk that says nothing of where it leaves us. + const std::vector> expected{ + {"below_trigger", "Town (6, 8) facing south (tile)"}, + {"trigger", "Town (12, 2) facing north (tile)"}, + {"to_the_lab", "Town (12, 1) facing north (tile)"}, + {"talk_oak", "Lab (6, 4) facing north (tile)"}, + {"potions", "Lab (6, 4) facing north (tile)"}, + {"dance", "Lab (6, 4) facing north (tile)"}, + }; + if (!result.report.completed || executor.poses_seen != expected){ + std::string text; + for (const auto& item : executor.poses_seen){ + text += "\n " + item.first + ": " + item.second; + } + return "Unexpected poses at the steps' starts:" + text; + } + if (to_string(result.state.pose) != "Lab (6, 4) facing north (tile)"){ + return "Expected the run to end at the scene's end, got " + to_string(result.state.pose); + } + return true; +} + +UnitTestResult test_failure_names_the_step(){ + const StoryCheckpoint data = lab_checkpoint(); + FakeExecutor executor; + executor.failing = {"trigger"}; + const Run result = run(data, executor); + // Event 3: from_save is event 1. + if (result.report.completed || result.report.failed_event != 3 || result.report.failed_name != "trigger" + || result.report.failure.failure != "blocked" + ){ + return "Expected a failure at event 3 trigger, blocked:" + joined(result.lines); + } + if (executor.trace.size() != 2){ + return "Expected nothing run after the failed step:" + joined(executor.trace); + } + return true; +} + +UnitTestResult test_unsupported_and_lost_battles(){ + // The fake has no TRAIN, and loses battles a step may not lose. + StoryStep train = make(StoryStepKind::TRAIN, "train"); + train.battles = 3; + FakeExecutor executor; + Run result = run(StoryCheckpoint{.id = "01", .title = "", .start = std::nullopt, .steps = {train}}, executor); + if (result.report.completed || result.report.failure.failure != "unsupported_step" || result.report.failure.detail != "TRAIN train"){ + return "Expected TRAIN unsupported:" + joined(result.lines); + } + StoryStep rival = make(StoryStepKind::BATTLE, "rival"); + rival.may_lose = true; + StoryStep brock = make(StoryStepKind::BATTLE, "brock"); + result = run(StoryCheckpoint{.id = "02", .title = "", .start = std::nullopt, .steps = {rival, brock}}, executor); + if (result.report.completed || result.report.failed_name != "brock" || result.report.failure.failure != "blackout"){ + return "Expected the rival's battle passed and Brock's lost:" + joined(result.lines); + } + return true; +} + +// Runs `tables` as one segment whose saves and reloads go into the +// executor's trace and whose log lines go into `lines`. Whatever blocked a +// step has gone by the reload. +SegmentReport run_tables(std::span tables, FakeExecutor& executor, std::vector& lines){ + Segment segment; + segment.id = "00"; + for (const StoryCheckpoint& data : tables){ + segment.checkpoints.push_back(CheckpointSpec{ + .id = data.id, .title = data.title, .saves = true, + .build = [&data, &executor]{ return build_story_checkpoint(data, executor); }, + }); + } + SegmentHooks hooks; + hooks.save = [&executor](RunState&){ + executor.trace.push_back("save"); + return outcome_ok(); + }; + hooks.reload = [&executor](RunState&){ + executor.trace.push_back("reload"); + executor.failing.clear(); + return outcome_ok(); + }; + hooks.log = [&lines](const std::string& line){ lines.push_back(line); }; + RunState state; + return run_segment(state, segment, SegmentRange{}, SegmentPolicy{}, hooks); +} + +UnitTestResult test_segment_of_tables(){ + // Two rows built into a segment, run with saves and a reload from the + // game's hooks: the second row's first attempt fails, its retry starts + // over from its start pose. + const std::vector tables{ + lab_checkpoint(), + StoryCheckpoint{ + .id = "00.04", + .title = "the parcel", + .start = tile_pose("Lab", {{6, 4}}, Direction::NORTH), + .steps = {make(StoryStepKind::TALK, "give_parcel")}, + }, + }; + FakeExecutor executor; + executor.failing = {"give_parcel"}; + std::vector lines; + const SegmentReport report = run_tables(tables, executor, lines); + const std::vector tail{"save", "TALK give_parcel", "reload", "TALK give_parcel", "save"}; + const bool tail_matches = executor.trace.size() >= tail.size() + && std::equal(tail.begin(), tail.end(), executor.trace.end() - (std::ptrdiff_t)tail.size()); + if (!report.completed || report.reloads != 1 || !tail_matches){ + return "Expected 00.04 reloaded once and passed:" + joined(executor.trace) + joined(lines); + } + // Each attempt is begun anew: 00.03 once, 00.04 twice. + if (executor.attempts != std::vector{"00.03", "00.04", "00.04"}){ + return "Expected the executor told of each attempt:" + joined(executor.attempts); + } + // The report and the log tell a one-shot pass from a retried one. + const bool runs_ok = report.runs.size() == 2 + && report.runs[0].one_shot() && report.runs[0].attempts == 1 + && !report.runs[1].one_shot() && report.runs[1].attempts == 2 && report.runs[1].passed + && report.one_shot_passes() == 1; + const auto has = [&lines](std::string_view line){ + return std::find(lines.begin(), lines.end(), line) != lines.end(); + }; + if (!runs_ok + || !has("[Checkpoint] 00.03: passed on attempt 1 (one-shot)") + || !has("[Checkpoint] 00.04: passed on attempt 2 (1 reset)") + || !has("[Segment] 00: 1 of 2 checkpoints one-shot") + ){ + return "Expected 00.03 one-shot and 00.04 passed on attempt 2:" + joined(lines); + } + return true; +} + + +// The two tables of test_segment_of_tables() as a segment run by `executor`, +// with hooks that write saves, reloads and recoveries into its trace. +struct PolicyLab{ + std::vector tables{ + lab_checkpoint(), + StoryCheckpoint{ + .id = "00.04", + .title = "the parcel", + .start = tile_pose("Lab", {{6, 4}}, Direction::NORTH), + .steps = {make(StoryStepKind::TALK, "give_parcel")}, + }, + }; + FakeExecutor executor; + std::vector lines; + + SegmentReport run(const SegmentPolicy& policy){ + Segment segment; + segment.id = "00"; + for (const StoryCheckpoint& data : tables){ + segment.checkpoints.push_back(CheckpointSpec{ + .id = data.id, .title = data.title, .saves = true, + .build = [&data, this]{ return build_story_checkpoint(data, executor); }, + }); + } + SegmentHooks hooks; + hooks.save = [this](RunState&){ + executor.trace.push_back("save"); + return outcome_ok(); + }; + hooks.reload = [this](RunState&){ + executor.trace.push_back("reload"); + return outcome_ok(); + }; + hooks.recover = [this](RunState&, const std::string& event){ + executor.trace.push_back("recover " + event); + return outcome_ok(); + }; + hooks.log = [this](const std::string& line){ lines.push_back(line); }; + RunState state; + return run_segment(state, segment, SegmentRange{}, policy, hooks); + } + bool has(std::string_view line) const{ + return std::find(lines.begin(), lines.end(), line) != lines.end(); + } + size_t count(std::string_view item) const{ + return (size_t)std::count(executor.trace.begin(), executor.trace.end(), item); + } +}; + +UnitTestResult test_policy_attempts_then_fail(){ + // Three attempts by default, then the segment stops on the last one's failure. + PolicyLab lab; + lab.executor.failing = {"give_parcel"}; + const SegmentReport report = lab.run(SegmentPolicy{}); + if (report.completed || report.reloads != 2 || lab.count("TALK give_parcel") != 3 + || report.runs.size() != 2 || report.runs[1].passed || report.runs[1].attempts != 3 + || report.failed_checkpoint != "00.04" || report.failure.failure != "blocked" + ){ + return "Expected 00.04 tried 3 times, then failed:" + joined(lab.executor.trace) + joined(lab.lines); + } + return true; +} + +UnitTestResult test_policy_stop_at_first_failure(){ + PolicyLab lab; + lab.executor.failing = {"give_parcel"}; + const SegmentReport report = lab.run(SegmentPolicy{.stop_at_first_failure = true}); + if (report.completed || !report.stopped_at_first_failure || report.reloads != 0 || lab.count("reload") != 0 + || lab.count("TALK give_parcel") != 1 + || !lab.has("[Segment] 00: 00.04 failed; stopping at the first failure, the game stays where it failed") + ){ + return "Expected a stop at the first failure, with no reload:" + joined(lab.executor.trace) + joined(lab.lines); + } + return true; +} + +UnitTestResult test_policy_recovers_in_place(){ + // The talk fails twice; two recoveries in place get it through + // without a reload, but not one-shot. + PolicyLab lab; + lab.executor.failures_left = {{"give_parcel", 2}}; + const SegmentReport report = lab.run(SegmentPolicy{.recoveries_per_event = 2}); + if (!report.completed || report.reloads != 0 || lab.count("recover give_parcel") != 2 + || report.runs.size() != 2 || report.runs[1].recoveries != 2 || report.runs[1].one_shot() + || !lab.has("[Checkpoint] 00.04: passed on attempt 1 (one-shot), 2 in place") + ){ + return "Expected 00.04 recovered in place twice:" + joined(lab.executor.trace) + joined(lab.lines); + } + // Past the budget, the checkpoint is reloaded as before. + PolicyLab reloaded; + reloaded.executor.failures_left = {{"give_parcel", 2}}; + const SegmentReport second = reloaded.run(SegmentPolicy{.recoveries_per_event = 1}); + if (!second.completed || second.reloads != 1 || second.runs[1].attempts != 2){ + return "Expected a reload once the in-place budget was used:" + joined(reloaded.executor.trace) + joined(reloaded.lines); + } + return true; +} + +// A boss's checkpoint: a TEACH of a move the lead knows (changes +// nothing), a TEACH that teaches, both once per save, then the battle, +// lost on the first attempt. The segment's saves empty `prepared`. +struct BossLab{ + StoryCheckpoint table{ + .id = "00.15b", + .title = "the rival", + .start = tile_pose("Cerulean", {{23, 7}}, Direction::NORTH), + .steps = {once("teach_known"), once("teach_new"), make(StoryStepKind::BATTLE, "rival")}, + }; + FakeExecutor executor; + PreparedSteps prepared; + std::vector lines; + + static StoryStep once(std::string name){ + StoryStep step = make(StoryStepKind::GAME, name); + step.variant = name; + step.once_per_save = true; + return step; + } + BossLab(){ + executor.unchanged = {"teach_known"}; + } + Segment segment(){ + Segment segment; + segment.id = "00"; + segment.checkpoints.push_back(CheckpointSpec{ + .id = table.id, .title = table.title, .saves = true, + .build = [this]{ return build_story_checkpoint(table, executor, &prepared); }, + }); + return segment; + } + SegmentHooks hooks(){ + SegmentHooks hooks; + hooks.save = [this](RunState&){ + executor.trace.push_back("save"); + prepared.clear(); + return outcome_ok(); + }; + hooks.reload = [this](RunState&){ + executor.trace.push_back("reload"); + return outcome_ok(); + }; + hooks.log = [this](const std::string& line){ lines.push_back(line); }; + return hooks; + } + size_t count(std::string_view item) const{ + return (size_t)std::count(executor.trace.begin(), executor.trace.end(), item); + } +}; + +// A retry after a reload of the same save skips the preparation that +// changed nothing and goes to the battle; the TEACH that taught runs +// again, as the reload undid it. The save at the end empties the memory. +UnitTestResult test_once_per_save_retry(){ + BossLab lab; + StoryStep battle = make(StoryStepKind::BATTLE, "rival"); + battle.may_lose = true; + lab.table.steps.back() = battle; + lab.executor.failures_left = {{"rival", 1}}; + Segment segment = lab.segment(); + SegmentHooks hooks = lab.hooks(); + RunState state; + const SegmentReport report = run_segment(state, segment, SegmentRange{}, SegmentPolicy{}, hooks); + if (!report.completed || report.reloads != 1 || lab.count("GAME teach_known") != 1 + || lab.count("GAME teach_new") != 2 || lab.count("BATTLE rival") != 2 + ){ + return "Expected the known TEACH once, the new one twice, the battle twice:" + joined(lab.executor.trace) + joined(lab.lines); + } + if (!lab.prepared.empty()){ + return "Expected the save to empty the prepared steps."; + } + return true; +} + +// Verify mode: three runs from one save; the preparation that changed +// nothing runs in the first only. +UnitTestResult test_once_per_save_verify(){ + BossLab lab; + StoryStep battle = make(StoryStepKind::BATTLE, "rival"); + battle.may_lose = true; + lab.table.steps.back() = battle; + Segment segment = lab.segment(); + SegmentHooks hooks = lab.hooks(); + RunState state; + const VerifyReport report = verify_checkpoint(state, segment, "00.15b", VerifyPolicy{}, hooks); + if (!report.verified || report.runs != 3 || lab.count("GAME teach_known") != 1 + || lab.count("GAME teach_new") != 3 || lab.count("BATTLE rival") != 3 + ){ + return "Expected the known TEACH once in 3 runs, the new one each run:" + joined(lab.executor.trace) + joined(lab.lines); + } + // Without the memory, every run prepares. + FakeExecutor plain; + plain.unchanged = {"teach_known"}; + Segment without; + without.id = "00"; + without.checkpoints.push_back(CheckpointSpec{ + .id = lab.table.id, .title = "", .saves = true, + .build = [&]{ return build_story_checkpoint(lab.table, plain); }, + }); + RunState again; + verify_checkpoint(again, without, "00.15b", VerifyPolicy{}, hooks); + if (std::count(plain.trace.begin(), plain.trace.end(), "GAME teach_known") != 3){ + return "Expected every run to prepare without PreparedSteps:" + joined(plain.trace); + } + return true; +} + + +// Two sections of a story, as BDSP has segments 00 and 01. +const std::vector& two_sections(){ + static const std::vector rows{ + StoryCheckpoint{.id = "00.01", .title = "Leave the house", .start = std::nullopt, .steps = {}}, + StoryCheckpoint{.id = "00.02", .title = "Walk to Sandgem Town", .start = std::nullopt, .steps = {}}, + StoryCheckpoint{.id = "01.01", .title = "Jubilife to Barry", .start = std::nullopt, .steps = {}}, + }; + return rows; +} + +const StringSelectDatabase& two_sections_database(){ + static const StringSelectDatabase database = story_checkpoint_database(two_sections()); + return database; +} + +// The dropdowns list the checkpoints as "id title", slugged by their index +// in the whole story, which is story_index() of a section's own index. +UnitTestResult test_checkpoint_dropdowns(){ + const StringSelectDatabase& database = two_sections_database(); + const std::vector expected{"00.01 Leave the house", "00.02 Walk to Sandgem Town", "01.01 Jubilife to Barry"}; + if (database.size() != expected.size()){ + return "Expected 3 entries, got " + std::to_string(database.size()); + } + for (size_t c = 0; c < expected.size(); c++){ + if (database[c].display_name != expected[c] || database[c].slug != std::to_string(c)){ + return "Unexpected entry " + std::to_string(c) + ": " + database[c].slug + " " + database[c].display_name; + } + } + if (database[story_index(true, 0, 2)].display_name != "01.01 Jubilife to Barry"){ + return "Expected the second section's first entry at story_index(true, 0, 2)."; + } + if (story_checkpoint_label(StoryCheckpoint{.id = "00.09", .title = "Pewter Gym: Camper Liam, then Brock", .start = std::nullopt, .steps = {}}) + != "00.09 Pewter Gym: Camper Liam, then Brock" + ){ + return "Unexpected label."; + } + StoryRunOptions options(database, Gen8ToGen9Languages_Database()); + if (options.start_index() != 0 || options.end_index() != 2 || !options.check_validity().empty()){ + return "Expected the whole story by default."; + } + options.START_CHECKPOINT.set_by_index(2); + options.END_CHECKPOINT.set_by_index(1); + if (options.check_validity().empty()){ + return "Expected a start after the end to be refused."; + } + return true; +} + +// A segment's report in the shared AutoStory stats. +UnitTestResult test_story_stats(){ + SegmentReport report; + report.reloads = 2; + report.runs = { + CheckpointRun{.id = "00.03", .passed = true, .attempts = 1, .recoveries = 0}, + CheckpointRun{.id = "00.04", .passed = true, .attempts = 3, .recoveries = 0}, + CheckpointRun{.id = "00.05", .passed = false, .attempts = 1, .recoveries = 0}, + }; + AutoStoryStats stats; + record_story_counts(stats, report.story_counts()); + if (stats.m_segment != 1 || stats.m_checkpoint != 2 || stats.m_reset != 2){ + return "Expected 1 segment, 2 checkpoints and 2 resets."; + } + return true; +} + +// The program's option group: 3 attempts, no stop, Thorough, by default; +// Fast when chosen. +UnitTestResult test_run_options(){ + StoryRunOptions options(two_sections_database(), Gen8ToGen9Languages_Database()); + const SegmentPolicy policy = options.policy(); + if (policy.attempts_per_checkpoint != 3 || policy.stop_at_first_failure || options.check_level() != StoryCheckLevel::THOROUGH){ + return "Expected 3 attempts, no stop at the first failure, and Thorough checks by default."; + } + options.CHECKS.set(StoryCheckLevel::FAST); + if (options.check_level() != StoryCheckLevel::FAST){ + return "Expected Fast checks once chosen."; + } + // Every game language can be chosen, read or not. + if (options.language() != Language::English){ + return "Expected English by default."; + } + options.LANGUAGE.set(Language::Japanese); + if (options.language() != Language::Japanese || !options.check_validity().empty()){ + return "Expected Japanese to be chosen and valid, reader or not."; + } + return true; +} + +// Verify mode: off by default; 3 runs and 2 passes when on, for one +// checkpoint only, never needing more passes than runs. +UnitTestResult test_verify_options(){ + StoryRunOptions options(two_sections_database(), Gen8ToGen9Languages_Database()); + if (options.VERIFY.policy()){ + return "Expected verify mode off by default."; + } + options.VERIFY.set_enabled(true); + if (options.check_validity().empty()){ + return "Expected verify mode refused over more than one checkpoint."; + } + options.END_CHECKPOINT.set_by_index(options.start_index()); + const std::optional policy = options.VERIFY.policy(); + if (!options.check_validity().empty() || !policy || policy->runs != 3 || policy->passes_needed != 2){ + return "Expected 3 runs and 2 passes on one checkpoint: " + options.check_validity(); + } + options.VERIFY.RUNS.set(2); + options.VERIFY.PASSES.set(3); + if (options.check_validity().empty()){ + return "Expected more passes than runs refused."; + } + return true; +} + +UnitTestResult test_lint(){ + StoryStep no_target = make(StoryStepKind::ROUTE, "walk"); + StoryStep again = make(StoryStepKind::TALK, "walk"); + StoryStep bad_legs = make(StoryStepKind::LEGS, "legs"); + bad_legs.legs = {{.direction = Direction::NORTH, .tiles = 0}}; + StoryStep no_variant = make(StoryStepKind::GAME, ""); + const StoryCheckpoint data{.id = "", .title = "", .start = std::nullopt, .steps = {no_target, again, bad_legs, no_variant}}; + const std::vector expected{ + "a checkpoint has no id", + " step 1 walk: ROUTE no target", + " step 2: a second step named walk", + " step 3 legs: LEGS a leg of no tiles", + " step 4: no name", + " step 4 : GAME no variant", + }; + const std::vector problems = lint_story_checkpoint(data); + if (problems != expected){ + return "Unexpected lint:" + joined(problems); + } + if (!lint_story_checkpoint(lab_checkpoint()).empty()){ + return "Expected the lab checkpoint to lint clean:" + joined(lint_story_checkpoint(lab_checkpoint())); + } + return true; +} + + +struct StoryStepInput{ + std::string name; + UnitTestResult (*test)(); +}; + +struct StoryStepExpectation{ + std::string_view description; +}; + +struct StoryStepTestCase{ + StoryStepInput input; + StoryStepExpectation expectation; +}; + +class Test_StoryStepExecutor : public UnitTest{ +public: + explicit Test_StoryStepExecutor(StoryStepTestCase scenario) + : UnitTest("Pokemon::StoryStepExecutor - " + scenario.input.name) + , m_scenario(std::move(scenario)) + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + return m_scenario.input.test(); + } + +private: + StoryStepTestCase m_scenario; +}; + +const StoryStepTestCase STORY_STEP_CASES[] = { + { + .input = {.name = "steps run in table order", .test = test_events_in_table_order}, + .expectation = {.description = "executes story steps sequentially as defined in table"}, + }, + { + .input = {.name = "skip_if asks the executor", .test = test_skip_if}, + .expectation = {.description = "delegates skip condition check to executor"}, + }, + { + .input = {.name = "each step starts where the last left the player", .test = test_pose_propagation}, + .expectation = {.description = "propagates player pose across sequential step executions"}, + }, + { + .input = {.name = "a failure names its step", .test = test_failure_names_the_step}, + .expectation = {.description = "records failing step identifier on error"}, + }, + { + .input = {.name = "unsupported kinds and lost battles", .test = test_unsupported_and_lost_battles}, + .expectation = {.description = "flags unsupported step types and handles battle defeat"}, + }, + { + .input = {.name = "tables as a segment, with a reload", .test = test_segment_of_tables}, + .expectation = {.description = "converts step tables into segment with checkpoint reload support"}, + }, + { + .input = {.name = "lint", .test = test_lint}, + .expectation = {.description = "verifies lint validation rules on story checkpoints"}, + }, + { + .input = {.name = "the run options' defaults", .test = test_run_options}, + .expectation = {.description = "validates initial default story run option values"}, + }, + { + .input = {.name = "the run options' verify mode", .test = test_verify_options}, + .expectation = {.description = "configures verification mode options and passes count"}, + }, + { + .input = {.name = "start and end checkpoint dropdowns", .test = test_checkpoint_dropdowns}, + .expectation = {.description = "populates start and end dropdown selections correctly"}, + }, + { + .input = {.name = "a segment's run in the AutoStory stats", .test = test_story_stats}, + .expectation = {.description = "updates story run statistics following segment completion"}, + }, + { + .input = {.name = "policy: attempts per checkpoint, then fail", .test = test_policy_attempts_then_fail}, + .expectation = {.description = "enforces max checkpoint attempt policy before terminal failure"}, + }, + { + .input = {.name = "policy: stop at the first failure", .test = test_policy_stop_at_first_failure}, + .expectation = {.description = "halts execution immediately when stop_at_first_failure is enabled"}, + }, + { + .input = {.name = "policy: recover in place before reloading", .test = test_policy_recovers_in_place}, + .expectation = {.description = "applies in-place recovery handler prior to reloading save"}, + }, + { + .input = {.name = "once per save: a retry skips what changed nothing", .test = test_once_per_save_retry}, + .expectation = {.description = "skips steps that changed nothing during once per save retry"}, + }, + { + .input = {.name = "once per save: verify runs prepare once", .test = test_once_per_save_verify}, + .expectation = {.description = "executes once per save prepare step only once during verification"}, + }, +}; + +} // namespace + + +void add_tests_StoryStepExecutor(UnitTestDatabase& database){ + for (const auto& scenario : STORY_STEP_CASES){ + database.add(scenario); + } +} + + + +} // namespace Pokemon +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_StoryVariant.cpp b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_StoryVariant.cpp new file mode 100644 index 0000000000..a10163f584 --- /dev/null +++ b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_StoryVariant.cpp @@ -0,0 +1,98 @@ +/* Story Variant + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include "Pokemon_StoryVariant.h" + +namespace PokemonAutomation{ +namespace Pokemon{ + + +std::vector variant_words(std::string_view text){ + std::vector words; + while (!text.empty()){ + const size_t space = text.find(' '); + const std::string_view word = text.substr(0, space); + if (!word.empty()){ + words.push_back(word); + } + if (space == std::string_view::npos){ + break; + } + text.remove_prefix(space + 1); + } + return words; +} + +std::optional variant_pair(std::string_view word){ + const size_t equals = word.find('='); + if (equals == std::string_view::npos || equals == 0){ + return std::nullopt; + } + return VariantPair{word.substr(0, equals), word.substr(equals + 1)}; +} + +std::string_view variant_name(std::string_view variant){ + const std::vector words = variant_words(variant); + if (words.empty() || variant_pair(words[0])){ + return {}; + } + return words[0]; +} + +std::optional variant_value(std::string_view variant, std::string_view key){ + for (std::string_view word : variant_words(variant)){ + const std::optional pair = variant_pair(word); + if (pair && pair->key == key){ + return pair->value; + } + } + return std::nullopt; +} + +std::optional variant_number(std::string_view text, size_t max_digits){ + if (text.empty() || text.size() > max_digits){ + return std::nullopt; + } + size_t value = 0; + for (char c : text){ + if (c < '0' || c > '9'){ + return std::nullopt; + } + value = value * 10 + size_t(c - '0'); + } + return value; +} + +LegacyVariant split_legacy_variant(std::string_view variant, std::span positional){ + LegacyVariant result; + const std::vector words = variant_words(variant); + result.name = variant_name(variant); + size_t next = 0; + for (size_t c = result.name.empty() ? 0 : 1; c < words.size(); c++){ + std::string key; + StoryArgValue value = true; + if (const std::optional pair = variant_pair(words[c])){ + key = pair->key; + const std::optional number = variant_number(pair->value, 18); + value = number ? StoryArgValue(int64_t(*number)) : StoryArgValue(std::string(pair->value)); + }else if (next < positional.size()){ + key = positional[next++]; + value = std::string(words[c]); + }else{ + key = words[c]; + } + if (!result.args.emplace(std::move(key), std::move(value)).second){ + result.error = "\"" + std::string(words[c]) + "\" repeats an argument"; + return result; + } + } + return result; +} + + + +} // namespace Pokemon +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_StoryVariant.h b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_StoryVariant.h new file mode 100644 index 0000000000..c81e6de25c --- /dev/null +++ b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_StoryVariant.h @@ -0,0 +1,75 @@ +/* Story Variant + * + * From: https://github.com/PokemonAutomation/ + * + * Reading a step's variant (StoryStep::variant): a name, then words split + * on spaces, some of them "key=value" pairs and small numbers. A table + * writes "mart PewterCity_Mart budget=2000 potion=5"; the name ("mart") + * picks the step's action (Pokemon_StoryActions.h) and the action reads + * the rest. + * + * Deprecated: a step's variant is now a plain action name and its + * arguments are structured (StoryStep::args, Types/Pokemon_StoryArgs.h). + * split_legacy_variant() reads an old variant into that form while plans + * and callers move over; the word helpers go once none reads them. + * + */ + +#ifndef PokemonAutomation_Pokemon_StoryVariant_H +#define PokemonAutomation_Pokemon_StoryVariant_H + +#include +#include +#include +#include +#include +#include +#include "Types/Pokemon_StoryArgs.h" + +namespace PokemonAutomation{ +namespace Pokemon{ + + +// `text`'s words, split on spaces, empty words dropped. +std::vector variant_words(std::string_view text); + +// A word "key=value" as its two halves. +struct VariantPair{ + std::string_view key; + std::string_view value; +}; + +// `word` split at its first '='; nothing without one or with an empty key. +std::optional variant_pair(std::string_view word); + +// The variant's name: its first word, unless that is a "key=value" pair. +// Empty for an empty variant or one of pairs only. +std::string_view variant_name(std::string_view variant); + +// The value of the first "key=value" word with `key`; nothing if none. +std::optional variant_value(std::string_view variant, std::string_view key); + +// `text` as a number of up to `max_digits` decimal digits; nothing if it +// isn't one. +std::optional variant_number(std::string_view text, size_t max_digits = 6); + + +// An old variant as an action name and its args, or why it can't be read. +struct LegacyVariant{ + std::string name; + StoryArgs args; + std::string error; +}; + +// `variant` written the old way ("mart CeruleanCity_Mart budget=3000 +// if_present"): its first word is the name; the next plain words take the +// keys in `positional`, in order, and plain words after those are flags; +// "key=value" words are numbers when all digits, else words. It reads no +// action's specs: check the args against them (story_args_errors()). +LegacyVariant split_legacy_variant(std::string_view variant, std::span positional = {}); + + + +} // namespace Pokemon +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/Pokemon/AutoStory/Types/Pokemon_StoryArgs.cpp b/SerialPrograms/Source/Pokemon/AutoStory/Types/Pokemon_StoryArgs.cpp new file mode 100644 index 0000000000..2ce9395904 --- /dev/null +++ b/SerialPrograms/Source/Pokemon/AutoStory/Types/Pokemon_StoryArgs.cpp @@ -0,0 +1,86 @@ +/* Story Args + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include +#include "Pokemon_StoryArgs.h" + +namespace PokemonAutomation{ +namespace Pokemon{ + + +namespace{ + + +template +const Type* find_arg(const StoryArgs& args, std::string_view key){ + const auto iter = args.find(key); + return iter == args.end() ? nullptr : std::get_if(&iter->second); +} + +bool has_type(const StoryArgValue& value, StoryArgType type){ + switch (type){ + case StoryArgType::BOOLEAN: + return std::holds_alternative(value); + case StoryArgType::NUMBER: + return std::holds_alternative(value); + case StoryArgType::TEXT: + return std::holds_alternative(value); + } + return false; +} + +const StoryArgSpec* find_spec(std::span specs, std::string_view key){ + for (const StoryArgSpec& spec : specs){ + if (spec.key == key){ + return &spec; + } + } + return nullptr; +} + + +} // namespace + + +std::optional story_arg_number(const StoryArgs& args, std::string_view key){ + const int64_t* value = find_arg(args, key); + return value ? std::optional(*value) : std::nullopt; +} + +std::optional story_arg_text(const StoryArgs& args, std::string_view key){ + const std::string* value = find_arg(args, key); + return value ? std::optional(*value) : std::nullopt; +} + +bool story_arg_flag(const StoryArgs& args, std::string_view key){ + const bool* value = find_arg(args, key); + return value != nullptr && *value; +} + +std::vector story_args_errors(const StoryArgs& args, std::span specs){ + std::vector errors; + for (const auto& [key, value] : args){ + const StoryArgSpec* spec = find_spec(specs, key); + if (spec == nullptr){ + errors.emplace_back(std::format("unknown argument \"{}\"", key)); + }else if (!has_type(value, spec->type)){ + errors.emplace_back(std::format("argument \"{}\" has the wrong type", key)); + } + } + for (const StoryArgSpec& spec : specs){ + if (spec.required && args.find(spec.key) == args.end()){ + errors.emplace_back(std::format("missing argument \"{}\"", spec.key)); + } + } + return errors; +} + + + +} // namespace Pokemon +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/Pokemon/AutoStory/Types/Pokemon_StoryArgs.h b/SerialPrograms/Source/Pokemon/AutoStory/Types/Pokemon_StoryArgs.h new file mode 100644 index 0000000000..9556c85dfe --- /dev/null +++ b/SerialPrograms/Source/Pokemon/AutoStory/Types/Pokemon_StoryArgs.h @@ -0,0 +1,69 @@ +/* Story Args + * + * From: https://github.com/PokemonAutomation/ + * + * A step's arguments (StoryStep::args): named flags, whole numbers and + * words that its action reads, e.g. a Mart step's {"map": + * "CeruleanCity_Mart", "budget": 3000}. Each action declares the ones it + * takes (StoryArgSpec); a game checks a step's args against them when it + * loads the step, so a typo fails at load instead of mid-run. + * + */ + +#ifndef PokemonAutomation_Pokemon_StoryArgs_H +#define PokemonAutomation_Pokemon_StoryArgs_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace PokemonAutomation{ +namespace Pokemon{ + + +// One argument's value: a flag, a whole number or a word. +using StoryArgValue = std::variant; + +// A step's arguments by name. +using StoryArgs = std::map>; + + +// The number `key` holds; nothing if absent or not a number. +std::optional story_arg_number(const StoryArgs& args, std::string_view key); + +// The word `key` holds; nothing if absent or not a word. +std::optional story_arg_text(const StoryArgs& args, std::string_view key); + +// Whether flag `key` is set; absent is unset. +bool story_arg_flag(const StoryArgs& args, std::string_view key); + + +// The type an action declares for an argument. +enum class StoryArgType{ + BOOLEAN, + NUMBER, + TEXT, +}; + +// An argument an action takes. +struct StoryArgSpec{ + std::string_view key; + StoryArgType type = StoryArgType::TEXT; + bool required = false; +}; + +// Each way `args` don't fit `specs`: a key no spec names, a value of +// another type, a required one missing. Empty when they fit. +std::vector story_args_errors(const StoryArgs& args, std::span specs); + + + +} // namespace Pokemon +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/Pokemon/AutoStory/Types/Pokemon_StoryCheckLevel.cpp b/SerialPrograms/Source/Pokemon/AutoStory/Types/Pokemon_StoryCheckLevel.cpp new file mode 100644 index 0000000000..8502162a15 --- /dev/null +++ b/SerialPrograms/Source/Pokemon/AutoStory/Types/Pokemon_StoryCheckLevel.cpp @@ -0,0 +1,26 @@ +/* Story Checks + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include "Pokemon_StoryCheckLevel.h" + +namespace PokemonAutomation{ +namespace Pokemon{ + + +std::string story_check_level_name(StoryCheckLevel level){ + switch (level){ + case StoryCheckLevel::THOROUGH: + return "thorough"; + case StoryCheckLevel::FAST: + return "fast"; + } + return "?"; +} + + + +} // namespace Pokemon +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/Pokemon/AutoStory/Types/Pokemon_StoryCheckLevel.h b/SerialPrograms/Source/Pokemon/AutoStory/Types/Pokemon_StoryCheckLevel.h new file mode 100644 index 0000000000..ddb86dfb27 --- /dev/null +++ b/SerialPrograms/Source/Pokemon/AutoStory/Types/Pokemon_StoryCheckLevel.h @@ -0,0 +1,35 @@ +/* Story Check Level + * + * From: https://github.com/PokemonAutomation/ + * + * How much a story step reads the screen to confirm what it did. The + * program's options choose it (StoryRunOptions); each game decides what + * its steps check at each level. + * + */ + +#ifndef PokemonAutomation_Pokemon_StoryCheckLevel_H +#define PokemonAutomation_Pokemon_StoryCheckLevel_H + +#include + +namespace PokemonAutomation{ +namespace Pokemon{ + + +// How much a step reads the screen to confirm what it did. +enum class StoryCheckLevel{ + // Confirm on screen: purchases, the Bag, status after battles, position. + THOROUGH, + // In a rush: trust counts and timings. + FAST, +}; + +// Returns the readable string representation of a story check level. +std::string story_check_level_name(StoryCheckLevel level); + + + +} // namespace Pokemon +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/Pokemon/AutoStory/Types/Pokemon_StoryCheckpoint.h b/SerialPrograms/Source/Pokemon/AutoStory/Types/Pokemon_StoryCheckpoint.h new file mode 100644 index 0000000000..ca72d4ee47 --- /dev/null +++ b/SerialPrograms/Source/Pokemon/AutoStory/Types/Pokemon_StoryCheckpoint.h @@ -0,0 +1,37 @@ +/* Story Checkpoint + * + * From: https://github.com/PokemonAutomation/ + * + * A checkpoint of a tile game's story table as plain data: where its save + * leaves the player and its steps in order. build_story_checkpoint() + * (Pokemon_StoryStepExecutor.h) turns it into engine events. + * + */ + +#ifndef PokemonAutomation_Pokemon_StoryCheckpoint_H +#define PokemonAutomation_Pokemon_StoryCheckpoint_H + +#include +#include +#include +#include "Pokemon/AutoStory/Pokemon_StoryEvents.h" +#include "Pokemon_StoryStep.h" + +namespace PokemonAutomation{ +namespace Pokemon{ + + +// Milestone row in the story table grouping related steps and starting pose. +struct StoryCheckpoint{ + std::string id; + std::string title; + // Where its starting save leaves the player; none for a new game. + std::optional start; + std::vector steps; +}; + + + +} // namespace Pokemon +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/Pokemon/AutoStory/Types/Pokemon_StoryStep.h b/SerialPrograms/Source/Pokemon/AutoStory/Types/Pokemon_StoryStep.h new file mode 100644 index 0000000000..ef59d1e8a8 --- /dev/null +++ b/SerialPrograms/Source/Pokemon/AutoStory/Types/Pokemon_StoryStep.h @@ -0,0 +1,92 @@ +/* Story Step + * + * From: https://github.com/PokemonAutomation/ + * + * One step of a tile game's story (FRLG, BDSP) as plain data: its kind + * and the fields that kind reads. A game's executor does it + * (Pokemon_StoryStepExecutor.h); the shared JSON reads and writes it + * (Data/Pokemon_StoryStepJson.h). + * + */ + +#ifndef PokemonAutomation_Pokemon_StoryStep_H +#define PokemonAutomation_Pokemon_StoryStep_H + +#include +#include +#include +#include +#include "Pokemon/AutoStory/Pokemon_StoryEvents.h" +#include "Pokemon/Navigation/Pokemon_Grid.h" +#include "Pokemon_StoryArgs.h" +#include "Pokemon_StoryStepKind.h" + +namespace PokemonAutomation{ +namespace Pokemon{ + + +// A straight leg of a fixed walk. +struct StoryLeg{ + Direction direction = Direction::NORTH; + int tiles = 0; +}; + +// An item and how many to get. +struct StoryItem{ + std::string item; + size_t quantity = 0; +}; + +// Specification of an atomic story task with actions and postconditions. +struct StoryStep{ + StoryStepKind kind = StoryStepKind::TALK; + std::string name; + std::string why; + + // ROUTE: where to go; with several tiles, any of them. READY: the nurse. + std::optional target; + // STEP + Direction direction = Direction::NORTH; + // LEGS + std::vector legs; + // ROUTE, LEGS: fight wild battles on the way instead of running. + bool fight_wild = false; + // ANSWER + bool yes = true; + // BATTLE: losing goes on (a rival's first battle). + bool may_lose = false; + // TRAIN + size_t battles = 0; + // SHOP + std::vector items; + // How this game does the kind, when it has more than one way: a + // route's plan costs, a door lined up by sight, the Mart lady instead + // of the clerk. Empty is the game's usual way. GAME: the step's name + // in the game's executor. A plain name; what the action needs beyond + // it goes in `args`. + std::string variant; + // The arguments the variant's action reads (a Mart's map and budget), + // checked against the ones it declares when the game loads the step. + StoryArgs args; + + // Skipped when all of these hold (and there is at least one): names + // the game's executor answers (StoryStepExecutor::holds()), run flags + // unless the game reads them off the screen. + std::vector skip_if; + // Preparation for what follows, done once per save: a TEACH, a check, + // a read. Once it has succeeded and changed nothing + // (Outcome::changed_nothing), the attempts after a reload of the same + // save skip it, so a retry goes from the reload straight to the + // battle. One that changed the game (a TM taught) runs again, as the + // reload undid it. Only with build_story_checkpoint()'s PreparedSteps. + bool once_per_save = false; + // Where the step leaves the player, when its action does not say: the + // end of a scene that moves the player. + std::optional pose_after; +}; + + + +} // namespace Pokemon +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/Pokemon/AutoStory/Types/Pokemon_StoryStepKind.cpp b/SerialPrograms/Source/Pokemon/AutoStory/Types/Pokemon_StoryStepKind.cpp new file mode 100644 index 0000000000..28b755779b --- /dev/null +++ b/SerialPrograms/Source/Pokemon/AutoStory/Types/Pokemon_StoryStepKind.cpp @@ -0,0 +1,52 @@ +/* Story Step Kind + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include "Pokemon_StoryStepKind.h" + +namespace PokemonAutomation{ +namespace Pokemon{ + + +std::string story_step_kind_name(StoryStepKind kind){ + switch (kind){ + case StoryStepKind::NEW_GAME: + return "NEW_GAME"; + case StoryStepKind::OPTIONS: + return "OPTIONS"; + case StoryStepKind::ROUTE: + return "ROUTE"; + case StoryStepKind::LEGS: + return "LEGS"; + case StoryStepKind::STEP: + return "STEP"; + case StoryStepKind::TALK: + return "TALK"; + case StoryStepKind::ANSWER: + return "ANSWER"; + case StoryStepKind::MASH: + return "MASH"; + case StoryStepKind::BATTLE: + return "BATTLE"; + case StoryStepKind::TRAIN: + return "TRAIN"; + case StoryStepKind::HEAL: + return "HEAL"; + case StoryStepKind::SHOP: + return "SHOP"; + case StoryStepKind::SAVE: + return "SAVE"; + case StoryStepKind::READY: + return "READY"; + case StoryStepKind::GAME: + return "GAME"; + } + return "?"; +} + + + +} // namespace Pokemon +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/Pokemon/AutoStory/Types/Pokemon_StoryStepKind.h b/SerialPrograms/Source/Pokemon/AutoStory/Types/Pokemon_StoryStepKind.h new file mode 100644 index 0000000000..2e43aa8118 --- /dev/null +++ b/SerialPrograms/Source/Pokemon/AutoStory/Types/Pokemon_StoryStepKind.h @@ -0,0 +1,45 @@ +/* Story Step Kind + * + * From: https://github.com/PokemonAutomation/ + * + * The kinds of story step the tile games (FRLG, BDSP) share. A game's + * executor does each kind its own way (Pokemon_StoryStepExecutor.h). + * + */ + +#ifndef PokemonAutomation_Pokemon_StoryStepKind_H +#define PokemonAutomation_Pokemon_StoryStepKind_H + +#include + +namespace PokemonAutomation{ +namespace Pokemon{ + + +// Action category classification for individual story steps. +enum class StoryStepKind{ + NEW_GAME, // title screen (no save) to the first room + OPTIONS, // the game's options as the story needs them + ROUTE, // plan and walk to `target` + LEGS, // walk `legs` as given + STEP, // one step `direction`: onto a trigger, into a door + TALK, // A at what the player faces + ANSWER, // text to a YES/NO, then `yes` + MASH, // text until the overworld is back: a scene + BATTLE, // text into a battle, fight it, text to the overworld + TRAIN, // fight `battles` wild battles where the player stands + HEAL, // the nurse, from in front of her or of the Center's door + SHOP, // `items` from a shop + SAVE, // save the game where the player stands + READY, // before a big battle: items, and the nurse at `target` if they can't + GAME, // a step only this game has, named by `variant` +}; + +// Returns the readable string representation of a story step kind. +std::string story_step_kind_name(StoryStepKind kind); + + + +} // namespace Pokemon +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/Pokemon/Options/Pokemon_GameLanguages.cpp b/SerialPrograms/Source/Pokemon/Options/Pokemon_GameLanguages.cpp new file mode 100644 index 0000000000..42f0dadf08 --- /dev/null +++ b/SerialPrograms/Source/Pokemon/Options/Pokemon_GameLanguages.cpp @@ -0,0 +1,43 @@ +/* Game Languages + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include "Pokemon_GameLanguages.h" + +namespace PokemonAutomation{ +namespace Pokemon{ + + +const EnumDropdownDatabase& Gen8ToGen9Languages_Database(){ + static const EnumDropdownDatabase database({ + {Language::English, "en", "English"}, + {Language::Japanese, "ja", "Japanese"}, + {Language::Spanish, "es", "Spanish"}, + {Language::French, "fr", "French"}, + {Language::German, "de", "German"}, + {Language::Italian, "it", "Italian"}, + {Language::Korean, "ko", "Korean"}, + {Language::ChineseSimplified, "zh-Hans", "Chinese (Simplified)"}, + {Language::ChineseTraditional, "zh-Hant", "Chinese (Traditional)"}, + }); + return database; +} + +const EnumDropdownDatabase& FireRedLeafGreenLanguages_Database(){ + static const EnumDropdownDatabase database({ + {Language::English, "en", "English"}, + {Language::Japanese, "ja", "Japanese"}, + {Language::Spanish, "es", "Spanish"}, + {Language::French, "fr", "French"}, + {Language::German, "de", "German"}, + {Language::Italian, "it", "Italian"}, + }); + return database; +} + + + +} // namespace Pokemon +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/Pokemon/Options/Pokemon_GameLanguages.h b/SerialPrograms/Source/Pokemon/Options/Pokemon_GameLanguages.h new file mode 100644 index 0000000000..eec094dfaf --- /dev/null +++ b/SerialPrograms/Source/Pokemon/Options/Pokemon_GameLanguages.h @@ -0,0 +1,35 @@ +/* Game Languages + * + * From: https://github.com/PokemonAutomation/ + * + * The languages each game can be played in, as dropdown databases for a + * program's language option. A program offers its game's set, so a user + * can't pick a language the game doesn't have. + * + */ + +#ifndef PokemonAutomation_Pokemon_GameLanguages_H +#define PokemonAutomation_Pokemon_GameLanguages_H + +#include "Common/Cpp/Options/EnumDropdownDatabase.h" +#include "CommonFramework/Language.h" + +namespace PokemonAutomation{ +namespace Pokemon{ + + +// The nine languages of the Switch games of generations 8 and 9: Sword and +// Shield, Brilliant Diamond and Shining Pearl, Legends: Arceus, Scarlet and +// Violet, Legends: Z-A. Japanese, English, French, Italian, German, +// Spanish, Korean, Chinese (Simplified and Traditional). +const EnumDropdownDatabase& Gen8ToGen9Languages_Database(); + +// The six languages of FireRed and LeafGreen: Japanese, English, French, +// Italian, German, Spanish. +const EnumDropdownDatabase& FireRedLeafGreenLanguages_Database(); + + + +} // namespace Pokemon +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/Pokemon/Options/Pokemon_StoryRangeOptions.cpp b/SerialPrograms/Source/Pokemon/Options/Pokemon_StoryRangeOptions.cpp new file mode 100644 index 0000000000..d294e5e386 --- /dev/null +++ b/SerialPrograms/Source/Pokemon/Options/Pokemon_StoryRangeOptions.cpp @@ -0,0 +1,51 @@ +/* Story Range Options + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include "Pokemon_StoryRangeOptions.h" + +namespace PokemonAutomation{ +namespace Pokemon{ + + +StoryRangeOptions::StoryRangeOptions(std::string label, const StringSelectDatabase& checkpoints, LockMode end_lock) + : GroupOption(std::move(label), LockMode::LOCK_WHILE_RUNNING) + , START_CHECKPOINT( + "Start checkpoint:
The program starts from this checkpoint's save.", + checkpoints, + LockMode::LOCK_WHILE_RUNNING, + 0 + ) + , END_CHECKPOINT( + "End checkpoint:
The program stops after this checkpoint.", + checkpoints, + end_lock, + checkpoints.size() == 0 ? 0 : checkpoints.size() - 1 + ) +{ + PA_ADD_OPTION(START_CHECKPOINT); + PA_ADD_OPTION(END_CHECKPOINT); +} + +size_t StoryRangeOptions::start_index() const{ + return START_CHECKPOINT.index(); +} + +size_t StoryRangeOptions::end_index() const{ + return END_CHECKPOINT.index(); +} + +std::string StoryRangeOptions::check_validity() const{ + if (start_index() > end_index()){ + return "The start checkpoint comes after the end checkpoint."; + } + return GroupOption::check_validity(); +} + + + +} // namespace Pokemon +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/Pokemon/Options/Pokemon_StoryRangeOptions.h b/SerialPrograms/Source/Pokemon/Options/Pokemon_StoryRangeOptions.h new file mode 100644 index 0000000000..809f3324d2 --- /dev/null +++ b/SerialPrograms/Source/Pokemon/Options/Pokemon_StoryRangeOptions.h @@ -0,0 +1,53 @@ +/* Story Range Options + * + * From: https://github.com/PokemonAutomation/ + * + * The start and end checkpoint dropdowns of an AutoStory program, over one + * list of the story's checkpoints, and the check that the start does not + * come after the end. StoryRunOptions (Pokemon_StoryRunOptions.h) is this + * group plus the run policy. + * + */ + +#ifndef PokemonAutomation_Pokemon_StoryRangeOptions_H +#define PokemonAutomation_Pokemon_StoryRangeOptions_H + +#include +#include "Common/Cpp/Options/GroupOption.h" +#include "CommonTools/Options/StringSelectOption.h" + +namespace PokemonAutomation{ +namespace Pokemon{ + + +// A program shows the group, or adds START_CHECKPOINT and END_CHECKPOINT +// to its own options under its own keys (keeping the keys its saved +// settings already use) and calls check_validity() from its own. +class StoryRangeOptions : public GroupOption{ +public: + // `checkpoints`: the dropdown database (make_story_select_database() + // or story_checkpoint_database()), kept alive by the caller. The + // dropdowns start on its first and last entries. `end_lock`: + // UNLOCK_WHILE_RUNNING for a program that reads the end after each + // checkpoint, so the end can be moved during a run. + StoryRangeOptions( + std::string label, const StringSelectDatabase& checkpoints, + LockMode end_lock = LockMode::LOCK_WHILE_RUNNING + ); + + // The checkpoints chosen, by index in the database, both inclusive. + size_t start_index() const; + size_t end_index() const; + // "The start checkpoint comes after the end checkpoint." if it does. + virtual std::string check_validity() const override; + +public: + StringSelectOption START_CHECKPOINT; + StringSelectOption END_CHECKPOINT; +}; + + + +} // namespace Pokemon +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/Pokemon/Options/Pokemon_StoryRangeOptions_Tests.cpp b/SerialPrograms/Source/Pokemon/Options/Pokemon_StoryRangeOptions_Tests.cpp new file mode 100644 index 0000000000..8a632cc469 --- /dev/null +++ b/SerialPrograms/Source/Pokemon/Options/Pokemon_StoryRangeOptions_Tests.cpp @@ -0,0 +1,135 @@ +/* Story Range Options Tests + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include +#include +#include +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "Pokemon/AutoStory/Pokemon_AutoStorySelect.h" +#include "Pokemon/AutoStory/Pokemon_AutoStory_Tests.h" +#include "Pokemon_StoryRangeOptions.h" + +namespace PokemonAutomation{ +namespace Pokemon{ + +namespace{ + +// A stand-in for a story checkpoint: only its name is read. +struct NamedCheckpoint{ + std::string label; + std::string name() const{ + return label; + } +}; + +// Two tutorial checkpoints, then three of the main story, as SV's list is +// laid out. +const std::vector>& story_list(){ + static const std::vector> list = []{ + std::vector> ret; + for (std::string_view label : {"tutorial 0", "tutorial 1", "main 0", "main 1", "main 2"}){ + ret.emplace_back(std::make_unique(NamedCheckpoint{.label = std::string(label)})); + } + return ret; + }(); + return list; +} + +// The main story's dropdowns: entries 2..4 of the list, slugged 2..4. +const StringSelectDatabase& main_story_database(){ + static const StringSelectDatabase database = make_story_select_database(story_list(), 2, story_list().size()); + return database; +} + +// The start and end set by slug, as saved settings load them; empty +// leaves the default. +struct StoryRangeInput{ + std::string name; + std::string start_slug; + std::string end_slug; +}; + +// The indexes in the dropdown, and whether the range is valid. +struct StoryRangeExpectation{ + size_t start; + size_t end; + bool valid; +}; + +// A range chosen in the main story's dropdowns and what the group reads from it. +struct StoryRangeCase{ + StoryRangeInput input; + StoryRangeExpectation expectation; +}; + +class Test_StoryRange : public UnitTest{ +public: + Test_StoryRange(StoryRangeCase test_case) + : UnitTest("Pokemon::StoryRangeOptions - " + test_case.input.name) + , m_case(std::move(test_case)) + {} + + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + StoryRangeOptions range("Checkpoints", main_story_database()); + if (!m_case.input.start_slug.empty()){ + range.START_CHECKPOINT.set_by_slug(m_case.input.start_slug); + } + if (!m_case.input.end_slug.empty()){ + range.END_CHECKPOINT.set_by_slug(m_case.input.end_slug); + } + const StoryRangeExpectation& expected = m_case.expectation; + if (range.start_index() != expected.start || range.end_index() != expected.end){ + return "Expected indexes " + std::to_string(expected.start) + " to " + std::to_string(expected.end) + + ", got " + std::to_string(range.start_index()) + " to " + std::to_string(range.end_index()); + } + const std::string error = range.check_validity(); + if (error.empty() != expected.valid){ + return expected.valid ? "Expected a valid range, got: " + error : "Expected the range refused."; + } + if (!expected.valid && error != "The start checkpoint comes after the end checkpoint."){ + return "Unexpected message: " + error; + } + return true; + } + +private: + const StoryRangeCase m_case; +}; + +const StoryRangeCase STORY_RANGE_CASES[] = { + { + .input = {.name = "the whole database by default", .start_slug = "", .end_slug = ""}, + .expectation = {.start = 0, .end = 2, .valid = true}, + }, + { + .input = {.name = "slugs are whole-list indexes, indexes are the dropdown's", .start_slug = "3", .end_slug = "4"}, + .expectation = {.start = 1, .end = 2, .valid = true}, + }, + { + .input = {.name = "one checkpoint", .start_slug = "3", .end_slug = "3"}, + .expectation = {.start = 1, .end = 1, .valid = true}, + }, + { + .input = {.name = "a start after the end is refused", .start_slug = "4", .end_slug = "2"}, + .expectation = {.start = 2, .end = 0, .valid = false}, + }, +}; + +} // namespace + + +void add_tests_StoryRangeOptions(UnitTestDatabase& database){ + for (const StoryRangeCase& test_case : STORY_RANGE_CASES){ + database.add(test_case); + } +} + + + +} // namespace Pokemon +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/Pokemon/Options/Pokemon_StoryRunOptions.cpp b/SerialPrograms/Source/Pokemon/Options/Pokemon_StoryRunOptions.cpp new file mode 100644 index 0000000000..32696bf4d6 --- /dev/null +++ b/SerialPrograms/Source/Pokemon/Options/Pokemon_StoryRunOptions.cpp @@ -0,0 +1,133 @@ +/* Story Run Options + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include "Pokemon_StoryRunOptions.h" + +namespace PokemonAutomation{ +namespace Pokemon{ + + +const EnumDropdownDatabase& StoryCheckLevel_Database(){ + static const EnumDropdownDatabase database({ + {StoryCheckLevel::THOROUGH, "thorough", "Thorough: confirm purchases, the Bag, status and position on screen"}, + {StoryCheckLevel::FAST, "fast", "Fast: trust counts and timings"}, + }); + return database; +} + + +std::string story_checkpoint_label(const StoryCheckpoint& checkpoint){ + return checkpoint.title.empty() ? checkpoint.id : checkpoint.id + " " + checkpoint.title; +} + +StringSelectDatabase story_checkpoint_database(std::span rows){ + StringSelectDatabase database; + for (size_t c = 0; c < rows.size(); c++){ + database.add_entry(StringSelectEntry(std::to_string(c), story_checkpoint_label(rows[c]))); + } + return database; +} + + +StoryVerifyOptions::StoryVerifyOptions() + : GroupOption( + "Verify Mode: run the start checkpoint (also the end) from its save several times, never saving at its end", + LockMode::LOCK_WHILE_RUNNING, + EnableMode::DEFAULT_DISABLED + ) + , RUNS( + "Runs:
Runs of the checkpoint, each from its save.", + LockMode::LOCK_WHILE_RUNNING, + 3, 1, 20 + ) + , PASSES( + "Passes needed:
Runs that must pass for the checkpoint to be verified.", + LockMode::LOCK_WHILE_RUNNING, + 2, 1, 20 + ) +{ + PA_ADD_OPTION(RUNS); + PA_ADD_OPTION(PASSES); +} + +std::optional StoryVerifyOptions::policy() const{ + if (!enabled()){ + return std::nullopt; + } + return VerifyPolicy{.runs = RUNS, .passes_needed = PASSES}; +} + +std::string StoryVerifyOptions::check_validity() const{ + if (enabled() && PASSES > RUNS){ + return "Verify mode can't need more passes than it has runs."; + } + return GroupOption::check_validity(); +} + + +StoryRunOptions::StoryRunOptions(const StringSelectDatabase& checkpoints, const EnumDropdownDatabase& languages) + : StoryRangeOptions("Story Run", checkpoints) + , ATTEMPTS_PER_CHECKPOINT( + "Attempts per checkpoint:
A failed checkpoint reloads its save and runs again, up to this many times in all.", + LockMode::LOCK_WHILE_RUNNING, + 3, 1, 20 + ) + , STOP_AT_FIRST_FAILURE( + "Stop at the first failure:
Stop without reloading, leaving the game where it failed for a look.", + LockMode::LOCK_WHILE_RUNNING, + false + ) + , CHECKS( + "Checks:
Thorough reads the screen to confirm what each step did; Fast trusts counts and timings, for a run in a rush.", + StoryCheckLevel_Database(), + LockMode::LOCK_WHILE_RUNNING, + StoryCheckLevel::THOROUGH + ) + , LANGUAGE( + "Game language:
For the few reads that need text (item names in a shop or the Bag). " + "Story steps never match dialogue text; with no reader for this language they check positions, digits and the screen instead.", + languages, + LockMode::LOCK_WHILE_RUNNING, + Language::English + ) +{ + PA_ADD_OPTION(ATTEMPTS_PER_CHECKPOINT); + PA_ADD_OPTION(STOP_AT_FIRST_FAILURE); + PA_ADD_OPTION(CHECKS); + PA_ADD_OPTION(LANGUAGE); + PA_ADD_OPTION(VERIFY); +} + +std::string StoryRunOptions::check_validity() const{ + std::string error = StoryRangeOptions::check_validity(); + if (!error.empty()){ + return error; + } + if (VERIFY.enabled() && start_index() != end_index()){ + return "Verify mode runs one checkpoint: set the end checkpoint to the start checkpoint."; + } + return error; +} + +SegmentPolicy StoryRunOptions::policy() const{ + SegmentPolicy policy; + policy.attempts_per_checkpoint = ATTEMPTS_PER_CHECKPOINT; + policy.stop_at_first_failure = STOP_AT_FIRST_FAILURE; + return policy; +} + +StoryCheckLevel StoryRunOptions::check_level() const{ + return CHECKS; +} + +Language StoryRunOptions::language() const{ + return LANGUAGE; +} + + + +} // namespace Pokemon +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/Pokemon/Options/Pokemon_StoryRunOptions.h b/SerialPrograms/Source/Pokemon/Options/Pokemon_StoryRunOptions.h new file mode 100644 index 0000000000..1fe685c5fb --- /dev/null +++ b/SerialPrograms/Source/Pokemon/Options/Pokemon_StoryRunOptions.h @@ -0,0 +1,103 @@ +/* Story Run Options + * + * From: https://github.com/PokemonAutomation/ + * + * The options every AutoStory program shows for where to start and end, how + * hard to try and how much to check: the shared range group + * (Pokemon_StoryRangeOptions.h) plus the run policy, read into the shared + * SegmentPolicy (Pokemon/AutoStory/Pokemon_StorySegments.h), the checks + * level (Types/Pokemon_StoryCheckLevel.h) and the language the game's steps + * read text in. + * + */ + +#ifndef PokemonAutomation_Pokemon_StoryRunOptions_H +#define PokemonAutomation_Pokemon_StoryRunOptions_H + +#include +#include +#include +#include +#include "Common/Cpp/Options/BooleanCheckBoxOption.h" +#include "Common/Cpp/Options/EnumDropdownOption.h" +#include "Common/Cpp/Options/GroupOption.h" +#include "Common/Cpp/Options/SimpleIntegerOption.h" +#include "CommonFramework/Language.h" +#include "CommonTools/Options/StringSelectOption.h" +#include "Pokemon/AutoStory/Pokemon_StorySegments.h" +#include "Pokemon/AutoStory/Types/Pokemon_StoryCheckLevel.h" +#include "Pokemon/AutoStory/Types/Pokemon_StoryCheckpoint.h" +#include "Pokemon_StoryRangeOptions.h" + +namespace PokemonAutomation{ +namespace Pokemon{ + + +// A checkpoint's label in the start and end dropdowns, as SV labels its +// segments: "00.09 Pewter Gym: Camper Liam, then Brock". +std::string story_checkpoint_label(const StoryCheckpoint& checkpoint); + +// The start/end dropdown database of a story's checkpoints, `rows` in +// order, slugged by their index in it, as make_story_select_database() +// slugs SV's. A game whose story is in sections (BDSP's segments 00 and +// 01) passes them joined: a section's entry i is then story_index(true, +// i, first of the section). The program keeps it in a function-local +// static: the options hold a reference. +StringSelectDatabase story_checkpoint_database(std::span rows); + + +// Verify mode, off by default (verify_checkpoint()): the start checkpoint +// RUNS times from its save, never saving at its end; verified when at +// least PASSES pass. For a battle that has lost twice or more, given its +// own checkpoint saved right before it. +class StoryVerifyOptions : public GroupOption{ +public: + StoryVerifyOptions(); + + // The runs and passes, unset when verify mode is off. + std::optional policy() const; + virtual std::string check_validity() const override; + +public: + SimpleIntegerOption RUNS; + SimpleIntegerOption PASSES; +}; + + +// The range group (START_CHECKPOINT, END_CHECKPOINT, start_index(), +// end_index()) with the run policy added to the same group, so the saved +// settings keep one flat object. +class StoryRunOptions : public StoryRangeOptions{ +public: + // `checkpoints`: story_checkpoint_database() of the game's story, kept + // alive by the caller; the dropdowns start on its first and last. + // `languages`: the ones the game has (Pokemon_GameLanguages.h). + StoryRunOptions(const StringSelectDatabase& checkpoints, const EnumDropdownDatabase& languages); + + // The range's check, then verify mode's: one checkpoint only. + virtual std::string check_validity() const override; + + // The policy these options set. In-place recoveries are the game's to + // set, not the user's. + SegmentPolicy policy() const; + // The checks level, for the game's steps to read. + StoryCheckLevel check_level() const; + // The game's language, for the steps that read text. + Language language() const; + +public: + SimpleIntegerOption ATTEMPTS_PER_CHECKPOINT; + BooleanCheckBoxOption STOP_AT_FIRST_FAILURE; + EnumDropdownOption CHECKS; + // Any language the game has, whether or not a reader supports it: + // steps without one use language-free checks, so none is refused. + EnumDropdownOption LANGUAGE; + // On, it runs one checkpoint: the start, which must also be the end. + StoryVerifyOptions VERIFY; +}; + + + +} // namespace Pokemon +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/cmake/SourceFiles.cmake b/SerialPrograms/cmake/SourceFiles.cmake index ba4906e546..1e121e4ab0 100644 --- a/SerialPrograms/cmake/SourceFiles.cmake +++ b/SerialPrograms/cmake/SourceFiles.cmake @@ -1481,6 +1481,8 @@ file(GLOB LIBRARY_SOURCES Source/Pokemon/Options/Pokemon_BoxSortingTable.cpp Source/Pokemon/Options/Pokemon_BoxSortingTable.h Source/Pokemon/Options/Pokemon_EncounterBotOptions.h + Source/Pokemon/Options/Pokemon_GameLanguages.cpp + Source/Pokemon/Options/Pokemon_GameLanguages.h Source/Pokemon/Options/Pokemon_HomeSpriteSelectOption.cpp Source/Pokemon/Options/Pokemon_HomeSpriteSelectOption.h Source/Pokemon/Options/Pokemon_IvJudgeOption.cpp @@ -1491,19 +1493,47 @@ file(GLOB LIBRARY_SOURCES Source/Pokemon/Options/Pokemon_NameSelectWidget.h Source/Pokemon/Options/Pokemon_StatsHuntFilter.cpp Source/Pokemon/Options/Pokemon_StatsHuntFilter.h + Source/Pokemon/Options/Pokemon_StoryRangeOptions.cpp + Source/Pokemon/Options/Pokemon_StoryRangeOptions.h + Source/Pokemon/Options/Pokemon_StoryRangeOptions_Tests.cpp + Source/Pokemon/Options/Pokemon_StoryRunOptions.cpp + Source/Pokemon/Options/Pokemon_StoryRunOptions.h + Source/Pokemon/AutoStory/Data/Pokemon_AuthoredCheckpointJson.cpp + Source/Pokemon/AutoStory/Data/Pokemon_AuthoredCheckpointJson.h + Source/Pokemon/AutoStory/Data/Pokemon_AuthoredCheckpointJson_Tests.cpp + Source/Pokemon/AutoStory/Data/Pokemon_StoryStepJson.cpp + Source/Pokemon/AutoStory/Data/Pokemon_StoryStepJson.h + Source/Pokemon/AutoStory/Data/Pokemon_StoryStepJson_Tests.cpp Source/Pokemon/AutoStory/Pokemon_AutoStorySelect.h Source/Pokemon/AutoStory/Pokemon_AutoStorySelect_Tests.cpp Source/Pokemon/AutoStory/Pokemon_AutoStorySelect_Tests.h Source/Pokemon/AutoStory/Pokemon_AutoStoryStats.h Source/Pokemon/AutoStory/Pokemon_AutoStory_Tests.h + Source/Pokemon/AutoStory/Pokemon_StoryArgs_Tests.cpp Source/Pokemon/AutoStory/Pokemon_StoryEvents.cpp Source/Pokemon/AutoStory/Pokemon_StoryEvents.h Source/Pokemon/AutoStory/Pokemon_StoryEvents_Tests.cpp + Source/Pokemon/AutoStory/Pokemon_StoryLog.cpp + Source/Pokemon/AutoStory/Pokemon_StoryLog.h + Source/Pokemon/AutoStory/Pokemon_StoryLog_Tests.cpp Source/Pokemon/AutoStory/Pokemon_StorySegments.cpp Source/Pokemon/AutoStory/Pokemon_StorySegments.h Source/Pokemon/AutoStory/Pokemon_StorySegments_Tests.cpp + Source/Pokemon/AutoStory/Pokemon_StoryStepExecutor.cpp + Source/Pokemon/AutoStory/Pokemon_StoryStepExecutor.h + Source/Pokemon/AutoStory/Pokemon_StoryStepExecutor_Tests.cpp + Source/Pokemon/AutoStory/Pokemon_StoryVariant.cpp + Source/Pokemon/AutoStory/Pokemon_StoryVariant.h Source/Pokemon/AutoStory/Pokemon_TilePose.cpp Source/Pokemon/AutoStory/Pokemon_TilePose.h + Source/Pokemon/AutoStory/Types/Pokemon_StoryArgs.cpp + Source/Pokemon/AutoStory/Types/Pokemon_StoryArgs.h + Source/Pokemon/AutoStory/Types/Pokemon_StoryCheckLevel.cpp + Source/Pokemon/AutoStory/Types/Pokemon_StoryCheckLevel.h + Source/Pokemon/AutoStory/Types/Pokemon_StoryCheckpoint.h + Source/Pokemon/AutoStory/Types/Pokemon_StoryStep.h + Source/Pokemon/AutoStory/Types/Pokemon_StoryStepKind.cpp + Source/Pokemon/AutoStory/Types/Pokemon_StoryStepKind.h Source/Pokemon/Pokemon_AdvRng.cpp Source/Pokemon/Pokemon_AdvRng.h Source/Pokemon/Pokemon_BdspRng.cpp diff --git a/devtools/story-data-tests/CMakeLists.txt b/devtools/story-data-tests/CMakeLists.txt new file mode 100644 index 0000000000..84271bd814 --- /dev/null +++ b/devtools/story-data-tests/CMakeLists.txt @@ -0,0 +1,49 @@ +# Offline codec checks, no Qt application or controller initialization: +# cmake -S devtools/story-data-tests -B build/story-data-tests +# cmake --build build/story-data-tests -j4 +# ctest --test-dir build/story-data-tests --output-on-failure +cmake_minimum_required(VERSION 3.24) +project(StoryDataTests LANGUAGES CXX) +enable_testing() +get_filename_component(ROOT "../.." ABSOLUTE) +set(STORY "${ROOT}/SerialPrograms/Source/Pokemon/AutoStory") +add_executable(StoryDataTests + "${STORY}/Data/Pokemon_StoryStepJson.cpp" + "${STORY}/Data/Pokemon_StoryStepJson_Tests.cpp" + "${STORY}/Pokemon_TilePose.cpp" + "${STORY}/Types/Pokemon_StoryArgs.cpp" + "${ROOT}/SerialPrograms/Source/Pokemon/Navigation/Pokemon_Grid.cpp" + "${ROOT}/Common/Cpp/Exceptions.cpp" + "${ROOT}/Common/Cpp/Json/JsonValue.cpp" + "${ROOT}/Common/Cpp/Json/JsonObject.cpp" + "${ROOT}/Common/Cpp/Json/JsonArray.cpp" + "${ROOT}/Common/Cpp/Json/JsonTools.cpp" + "${ROOT}/Common/Cpp/Filesystem/FileIO.cpp" + "${ROOT}/Common/Cpp/Filesystem/FilePath.cpp" + "${ROOT}/Common/Cpp/Strings/Unicode.cpp") +# 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}/Data/Pokemon_StoryStepJson_Tests.cpp") +add_executable(AuthoredCheckpointTests ${STORY_DATA_SOURCES} + "${STORY}/Data/Pokemon_AuthoredCheckpointJson.cpp" + "${STORY}/Data/Pokemon_AuthoredCheckpointJson_Tests.cpp") +add_executable(StoryArgsTests + "${STORY}/Types/Pokemon_StoryArgs.cpp" + "${STORY}/Pokemon_StoryVariant.cpp" + "${STORY}/Pokemon_StoryArgs_Tests.cpp") + +foreach(TEST_TARGET IN ITEMS StoryDataTests AuthoredCheckpointTests StoryArgsTests) +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(${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(${TEST_TARGET} PRIVATE -Wall -Wextra -Werror) +endif() +endforeach() +add_test(NAME StoryData COMMAND StoryDataTests) +add_test(NAME AuthoredCheckpoint COMMAND AuthoredCheckpointTests) +add_test(NAME StoryArgs COMMAND StoryArgsTests) diff --git a/devtools/story-data-tests/README.md b/devtools/story-data-tests/README.md new file mode 100644 index 0000000000..310cea3ea3 --- /dev/null +++ b/devtools/story-data-tests/README.md @@ -0,0 +1,37 @@ +# Shared story-record codec tests + +Run without Qt, controllers, profiles or game saves: + +```sh +cmake -S devtools/story-data-tests -B build/story-data-tests +cmake --build build/story-data-tests -j4 +ctest --test-dir build/story-data-tests --output-on-failure +build/story-data-tests/StoryDataTests /path/to/PokemonSimulation/data/story/wire-conformance.json +``` + +For a multi-configuration generator, add `--config Debug` to the build, +`-C Debug` to CTest and use `build/story-data-tests/Debug/StoryDataTests.exe`. +The same native cases are registered in the application's unit-test runner. + +`Pokemon_StoryStepJson` encodes and reads the common `StoryCheckpoint`, +`StoryStep` and tile-based `Pose` records. It retains action order, alternative +tiles, certainty, absent facing and explicit false/zero values. Unknown fields, +action/position tags and invalid primitive types fail. Native default values +are omitted canonically; encoding does not establish semantic route validity. + +The portable workflow also reads the 31 synthetic conformance cases at +[PokemonSimulation revision 866613f](https://github.com/dayfine/PokemonSimulation/tree/866613fa03e5c4a5eb7e57c91b59fafb44fc7ee3/data/story). +Python reads the same input/expectation records. This fixture revision is a +contract-test dependency, not an approved map-resource revision. + +See the [shared checkpoint contract](https://github.com/dayfine/PokemonSimulation/blob/866613fa03e5c4a5eb7e57c91b59fafb44fc7ee3/docs/story/CHECKPOINT_CONTRACT.md) +for the field boundary and proposed common requirements, state, save/retry and +handoff envelope. Both games can use those policies; their menu/detector/action +bindings remain game specific. + +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. diff --git a/docs/design/AutoStory.md b/docs/design/AutoStory.md index d14601e42e..4e5d1eb607 100644 --- a/docs/design/AutoStory.md +++ b/docs/design/AutoStory.md @@ -885,3 +885,48 @@ among the agents is recorded outside this doc. trade-back, or a levelled Pokémon brought in from another save. Optional trades the game itself offers (e.g. the Vermilion Farfetch'd) are allowed only behind an option. + +## 10. Step arguments (user, 2026-11-09) + +A story step's `variant` used to carry its action's arguments as free text, +for example `"mart CeruleanCity_Mart budget=1500 floor=1500 potion=3 +awakening=2"`. Each action parsed its own words, so a typo or a missing +argument showed up only when the step ran, and the Python reader in +PokemonSimulation had to copy every action's parser to read a plan. + +Now `variant` is only the action's name, the key it is registered under in a +game's action table. Its arguments move to `args`, a JSON object on the step +whose values are flags, whole numbers or words (`StoryStep::args`, +`Types/Pokemon_StoryArgs.h`). Each action declares the arguments it takes, +with a type and whether each one is required (`StoryArgSpec`). A game checks +every step's `args` against those specs when it loads the plan, so an +unknown key, a word where a number belongs or a missing budget fails at +load. Items to buy stay in the step's `items` list and are not repeated in +`args`. The shared JSON codec reads and writes `args` without knowing any +action, and omits it when it is empty. + +During the migration, a plan may still carry an old variant string. +`split_legacy_variant()` turns it into a name and args: the leading plain +words take the action's positional keys (a Mart's `map`), `key=value` +words become numbers when they are all digits and words otherwise, and any +remaining plain word becomes a flag (`if_present`). The word helpers in +`Pokemon_StoryVariant.h` are deprecated and will be removed once no action +reads them. + +The actions that take arguments today, all FRLG (BDSP's are listed for a +later change): + +| Action | Arguments | Notes | +|---|---|---| +| `mart` | `map` word, required; `budget` number, required; `floor` number | What to buy goes in `items`. | +| `teach` | `item` word (`tm03`, `hm01`, `tutor`), `move` word, `slot` number, all required; `forget` word | | +| `pc` | `op` word (`deposit`), `slot` number, `box` number, all required; `if_present` flag | | +| `grind`, `grind_check` | `slot`, `level`, `encounters` numbers and `pace` word, required; `heal_every`, `chance` numbers; `beat` word | `grind_check` takes the same arguments. | +| `capture` | `species` word, `catches`, `encounters`, `balls` numbers, all required; `pace` word | | +| `level` | `slot`, `min` numbers, required | Built in C++, not in a plan. | +| `fossil` | `fossil` word (`helix`, `dome`), required | | +| `use_item` | `item` word, required; `slot` number | 00.30 onwards. | +| `lift_floor` | `from`, `to` words, required | 00.30 onwards. | + +Plain names such as `run`, `trainer_line`, `cut_tree` and `bike` take no +arguments and stay as they are.