diff --git a/docs/G-wire-schema.md b/docs/G-wire-schema.md index 1cbeda5..30b7606 100644 --- a/docs/G-wire-schema.md +++ b/docs/G-wire-schema.md @@ -88,6 +88,12 @@ both directions — the reader `select()`s from what it just read, the writer fr to write — so a body goes back out as whatever it came in as, and a key with no shape falls to a generic `Node` and still round-trips. +The `CD` custom-data blocks are the same problem one level up, and worse: the key is not even +adjacent. `CDT` lists the block ids and the `CD` frames follow in that order, so a block's body is +selected by the id at the **same ordinal** in a different frame. `SaveGame::io()` walks the two in +step and `select()`s from the id's suffix, in both directions; a suffix with no shape (today, +`.TurnCommands_v5`) falls to a generic `Node`. + Two more things the table's `prim` column will not save you from: * **A `bool` and an `int` item are the same size only when the tag length makes the padding agree.** @@ -96,3 +102,9 @@ Two more things the table's `prim` column will not save you from: * **An empty string is four zero bytes**, byte-identical to the int 0, so a string field that is empty in all available data round-trips perfectly while typed as an int. That is what hid `Game::SystemParams`'s name field. +* **A container whose count is 0 in every save hides its element type completely.** The table names + the element class, but the framing is a property of the *helper* that writes it, not of the + element: `VectorHelper>` writes each element as a **frame** holding one + int, not as a bare int, because `StreamableEnum`'s own writer is a nested object. `SysMem`, `mts` + and `nalat` were all typed as int arrays on the strength of the decorated name and all three were + wrong in the same way — invisibly, because none of them has ever had an element. diff --git a/src/mars/stream/shapes.h b/src/mars/stream/shapes.h index b0cad74..79f2235 100644 --- a/src/mars/stream/shapes.h +++ b/src/mars/stream/shapes.h @@ -5,9 +5,9 @@ // by the game with a NULL name ("." on disk) or carry a reference name whose // disk spelling differs, and are matched by position. Bodies the format keeps // opaque are held as generic Nodes so a shape can be re-emitted byte-for-byte. -// After the SvSctOb round only three regions are still carried that way: the CD -// custom-data blocks, the RNG state block (correctly opaque) and Attrib (an empty -// frame in every save). +// After the AIAgent round only two regions are still carried that way: the RNG +// state block (correctly opaque) and the one CD block per save whose CDT id ends +// ".TurnCommands_v5", which needs a save with issued orders before it can be typed. #pragma once #include @@ -790,6 +790,20 @@ struct ShipSectionID { // Game::ShipSectionID: two "." ints ar.rest(extra); } }; +// Mars::StreamableEnum: a framed wrapper whose body is a single "." int. It +// is how an enum-valued key or element reaches the stream, so it is a frame and +// not a bare int -- a distinction our saves cannot show, because every container +// that holds one has count 0. +struct StreamEnum { + static constexpr const char* kStreamName = ""; + int32_t value = 0; + std::vector extra; + template + void io(Ar& ar) { + ar.i32(R("value"), value); + ar.rest(extra); + } +}; struct GunWeapon { // DW2: the weapon in a gun bank, named either by id or by name static constexpr const char* kStreamName = "DW2"; bool bID = false; // true: the weapon is identified by id, false: by family name @@ -1990,7 +2004,9 @@ struct SVSOSwarmQueen { // EncID 10 std::vector designIds; std::vector hives; std::vector queens; - std::vector sysMem; // VectorHelper> + // VectorHelper>: each element is a "." FRAME holding one + // "." int, not a bare int. Count 0 in every save, so nothing exercises it. + std::vector sysMem; std::vector extra; template void io(Ar& ar) { @@ -2069,7 +2085,7 @@ struct SVSOVonNeumann { // EncID 1 static constexpr const char* kStreamName = ""; std::vector defeats; int32_t rusav = 0, nenc = 0, nmb = 0, nml = 0, nbb = 0, nbl = 0, nskb = 0, nskl = 0; - std::vector mts; // VectorHelper> + std::vector mts; // VectorHelper>: framed elements int32_t ntm = 0, ntb = 0; bool sken = false; int32_t skdid = 0, skhid = 0, skfid = 0, sktq = 0, skt = 0, sktc = 0; @@ -2268,6 +2284,281 @@ struct ScriptObjects { // Game::SVSOSots -- the SvSctOb body } }; +// --- the strategy AI agent's cache, one CD block per AI player ------------------ +// +// `CDT` lists the custom-data ids in order and one `CD` frame follows per id. An +// id ending in ".AIAgent" selects Game::StrategyAIAgent::Streamable, whose whole +// Write is unconditional -- every item below is present in every block. The +// shapes here follow that writer item for item; the notes repo carries the +// derivation (findings/objects/aiagent-block.md). + +// Game::AttribMap: a string->string map, written as a "." count followed by two +// "." strings per entry. Empty in every save we hold, so the pair layout is a +// hypothesis from the writer, not something the data exercises. +struct AttribMap { + static constexpr const char* kStreamName = ""; + struct Entry { + std::string key, value; + }; + std::vector entries; + std::vector extra; + template + void io(Ar& ar) { + ar.narr(R("n"), entries, [](Ar& a, Entry& e) { + a.str(R("key"), e.key); + a.str(R("value"), e.value); + }); + ar.rest(extra); + } +}; + +// Game::AIWeightMap: a "." count then, per entry, a "." key frame and a "." +// float weight. Four instantiations exist and all four share this wire shape; +// only the key body differs (StreamEnum, or ShipSectionID's two ints). +template +struct AIWeightMap { + static constexpr const char* kStreamName = ""; + struct Entry { + Key key; + float weight = 0; + }; + std::vector entries; + std::vector extra; + template + void io(Ar& ar) { + ar.narr(R("n"), entries, [](Ar& a, Entry& e) { + a.obj(R("key"), e.key); + a.f32(R("w"), e.weight); + }); + ar.rest(extra); + } +}; +using AIWeightMapEnum = AIWeightMap; +using AIWeightMapSection = AIWeightMap; + +struct AISituation { // Game::AISituation + static constexpr const char* kStreamName = "AISit"; + AIWeightMapSection sections; + AIWeightMapEnum weaponFamilies; + std::vector extra; + template + void io(Ar& ar) { + ar.obj(A("AISitSecs"), sections); + ar.obj(A("AISitWeapFams"), weaponFamilies); + ar.rest(extra); + } +}; + +// Game::AISystem writes one AISituation and nothing else -- and the situation it +// writes is default-constructed at the call, not read from the object, so every +// AISys body on disk is an empty pair of weight maps regardless of game state. +struct AISystem { + static constexpr const char* kStreamName = "AISys"; + AISituation situation; + std::vector extra; + template + void io(Ar& ar) { + ar.obj(A("AISysSit"), situation); + ar.rest(extra); + } +}; + +struct DesignNameGen { // Game::StrategyAIAgent::DesignNameGen + static constexpr const char* kStreamName = "AIDNG"; + struct Name { + ShipSectionID section; + std::string name; + }; + std::vector names; // dnnc counts the (dnid, dnnm) pairs + std::vector extra; + template + void io(Ar& ar) { + ar.narr(A("dnnc"), names, [](Ar& a, Name& e) { + a.obj(A("dnid"), e.section); + a.str(A("dnnm"), e.name); + }); + ar.rest(extra); + } +}; + +struct AIAutoPeaceRun { // Game::AIAutoPeaceRun + static constexpr const char* kStreamName = ""; + int32_t sid = 0, tn0 = 0, tn1 = 0; + std::vector extra; + template + void io(Ar& ar) { + ar.i32(A("sid"), sid); + ar.i32(A("tn0"), tn0); + ar.i32(A("tn1"), tn1); + ar.rest(extra); + } +}; + +struct CombatPlayerStats { // Game::CombatPlayerStats + static constexpr const char* kStreamName = "CRPlSv2"; + float rpBon = 0; + int32_t rpBonT = 0, savBonus = 0; + bool maintHF = false; + // Game::TacReport has a computed count and three trailing scalar runs that no + // save we hold exercises; its body stays carried rather than typed on a guess. + std::vector tacReports; + std::vector extra; + template + void io(Ar& ar) { + ar.f32(A("RPBon"), rpBon); + ar.i32(A("RPBonT"), rpBonT); + ar.i32(A("SavBonus"), savBonus); + ar.b(A("MaintHF"), maintHF); + ar.carr(A("TacReports"), tacReports); + ar.rest(extra); + } +}; + +struct AICombatReport { // Game::AICombatReport + static constexpr const char* kStreamName = "CmbR"; + int32_t crTrn = 0; + bool crPce = false; + StreamEnum crSys; + CombatPlayerStats crPlSv2; + std::vector extra; + template + void io(Ar& ar) { + ar.i32(A("CRTrn"), crTrn); + ar.b(A("CRPce"), crPce); + ar.obj(A("CRSys"), crSys); + ar.obj(A("CRPlSv2"), crPlSv2); + ar.rest(extra); + } +}; + +// Game::AIPlayerRequestStamp is a POD reached only through a specialised helper, +// so it has no RTTI class and no entry in the wire table; the two named ints come +// straight from the helper's own writer. +struct AIRequestStamp { + static constexpr const char* kStreamName = ""; + int32_t pid = 0, trn = 0; + std::vector extra; + template + void io(Ar& ar) { + ar.i32(A("pid"), pid); + ar.i32(A("trn"), trn); + ar.rest(extra); + } +}; + +struct StrategyAIAgent { // Game::StrategyAIAgent::Streamable + static constexpr const char* kStreamName = "CD"; + struct DesignShare { // one entry of the map `dsh` counts + int32_t pid = 0; + std::vector turns; + }; + struct AISysEntry { + int32_t id = 0; + AISystem sys; + }; + struct ColonyLoss { + int32_t turn = 0, sysID = 0, plID = 0; + }; + struct Provocation { + int32_t id = 0; + float value = 0; + }; + + AttribMap attrib; + AIWeightMapEnum turnPriorities; + AISituation situation; + AIWeightMapEnum playerHate; + std::vector requestStamps; + // `dsh` is the _Mysize of the map the (pid, trns) pairs are walked out of, so + // it is the element count and not a scalar field. + std::vector designShares; + std::vector borderStability; // NBStab x (BStabPl, BStabTn) + std::vector menaceBlacklist; // NMBlst x (MBlstSy, MBlstTn) + DesignNameGen designNames; + int32_t aiHivJ = 0, sdFlT = 0; + std::vector newAlliances; // nalat + int32_t lnat = 0, lat = 0; + std::vector systems; // AINumSys x (AISysID, AISys) + std::vector combatReports; + std::vector colonyLosses; + std::vector provocations; + std::vector techScores; + int32_t fct = 0; + std::vector autoPeaceRuns; + std::vector extra; + + template + void io(Ar& ar) { + ar.obj(A("AIAttr"), attrib); + ar.obj(A("AITurnPris"), turnPriorities); + ar.obj(A("AISit"), situation); + ar.obj(A("AIPlyHat"), playerHate); + ar.carr(A("prs2"), requestStamps); + ar.narr(A("dsh"), designShares, [](Ar& a, DesignShare& e) { + a.i32(A("pid"), e.pid); + a.carr(A("trns"), e.turns); + }); + ar.narr(A("NBStab"), borderStability, [](Ar& a, IntPair& e) { + a.i32(A("BStabPl"), e.a); + a.i32(A("BStabTn"), e.b); + }); + ar.narr(A("NMBlst"), menaceBlacklist, [](Ar& a, IntPair& e) { + a.i32(A("MBlstSy"), e.a); + a.i32(A("MBlstTn"), e.b); + }); + ar.obj(A("AIDNG"), designNames); + ar.i32(A("AIHivJ"), aiHivJ); + ar.i32(A("SDFlT"), sdFlT); + ar.carr(A("nalat"), newAlliances); + ar.i32(A("lnat"), lnat); + ar.i32(A("lat"), lat); + ar.narr(A("AINumSys"), systems, [](Ar& a, AISysEntry& e) { + a.i32(A("AISysID"), e.id); + a.obj(A("AISys"), e.sys); + }); + ar.narr(A("NCmbR"), combatReports, [](Ar& a, AICombatReport& e) { a.obj(A("CmbR"), e); }); + ar.narr(A("NumCL"), colonyLosses, [](Ar& a, ColonyLoss& e) { + a.i32(A("CLTn"), e.turn); + a.i32(A("CLSyID"), e.sysID); + a.i32(A("CLPlID"), e.plID); + }); + ar.narr(A("NPrv"), provocations, [](Ar& a, Provocation& e) { + a.i32(A("NPrvId"), e.id); + a.f32(A("NPrvVa"), e.value); + }); + ar.narr(A("NTecS"), techScores, [](Ar& a, int32_t& e) { a.i32(A("TecS"), e); }); + ar.i32(A("fct"), fct); + ar.carr(A("apr"), autoPeaceRuns); + ar.rest(extra); + } +}; + +// One `CD` frame, keyed by the id at the same ordinal in `CDT`. The other id +// suffix in our saves, ".TurnCommands_v5", stays carried: its writer's recovered +// sequence cannot be aligned to a no-orders save even as a subsequence, so it +// needs a save with issued orders before anything can be typed. +struct CustomDataBlock { + static constexpr const char* kStreamName = "CD"; + enum class Which { Unknown, AIAgent }; + Which which = Which::Unknown; + StrategyAIAgent aiAgent; + std::vector unknown; + + void select(const std::string& id) { + static const std::string suffix = ".AIAgent"; + which = (id.size() >= suffix.size() && id.compare(id.size() - suffix.size(), suffix.size(), suffix) == 0) + ? Which::AIAgent + : Which::Unknown; + } + template + void io(Ar& ar) { + switch (which) { + case Which::AIAgent: aiAgent.io(ar); break; + case Which::Unknown: ar.rest(unknown); break; + } + } +}; + struct Sim { static constexpr const char* kStreamName = "Sim"; std::string keyPath; @@ -2275,7 +2566,7 @@ struct Sim { std::vector playerIds, designIds, systemIds, fleetIds, shipIds, tradeIds; int32_t modCount = 0, frame = 0, gameID = 0; std::optional aiDifficultyID; - Node attrib; + AttribMap attrib; // StreamableHelper: a "." count, 0 in every save std::optional rand; Node rng; // opaque MT19937 blob: frame with one "." raw item (mt[624] + left, 3 pad bytes) std::string gameName; @@ -2324,7 +2615,7 @@ struct Sim { ar.i32(A("Frame"), frame); ar.i32(A("GameID"), gameID); ar.opt_i32(A("AIDifficultyID"), aiDifficultyID); - ar.any(A("Attrib"), attrib); + ar.obj(A("Attrib"), attrib); ar.opt_i32(A("Rand"), rand); ar.raw_frame(A("RNG"), rng); ar.str(A("GameName"), gameName); @@ -2393,14 +2684,21 @@ struct SaveGame { // root: Summary, CreateParams, Sim, CDT, then one opaque CD CreateParams createParams; Sim sim; CdTable cdTable; - std::vector customData; + std::vector customData; template void io(Ar& ar) { ar.obj(A("Summary"), summary); ar.obj(A("CreateParams"), createParams); ar.obj(A("Sim"), sim); ar.obj(A("CDT"), cdTable); - ar.repeat("CD", customData, [](Ar& a, Node& e) { a.any(A("CD"), e); }); + // The CD frames carry no key of their own: the id at the same ordinal in + // CDT selects the body, in both directions. + size_t k = 0; + ar.repeat("CD", customData, [&](Ar& a, CustomDataBlock& e) { + e.select(k < cdTable.ids.size() ? cdTable.ids[k] : std::string()); + ++k; + a.obj(A("CD"), e); + }); } }; diff --git a/tests/mars_stream/test_save.cpp b/tests/mars_stream/test_save.cpp index 954bc88..fc1e985 100644 --- a/tests/mars_stream/test_save.cpp +++ b/tests/mars_stream/test_save.cpp @@ -123,7 +123,7 @@ static void check_save(const std::string& path, const char* dump_dir) { std::printf(" %s=%zu", worst[i].second.c_str(), worst[i].first); std::printf("\n"); // Ratchet, not a target: typing a body must never silently regress. - CHECK(pct >= 97.5); + CHECK(pct >= 99.8); } // --- round trips ------------------------------------------------------------------ diff --git a/tests/mars_stream/test_stream.cpp b/tests/mars_stream/test_stream.cpp index 0f3dfc9..73f8947 100644 --- a/tests/mars_stream/test_stream.cpp +++ b/tests/mars_stream/test_stream.cpp @@ -591,6 +591,173 @@ static void test_svsctob_variants() { CHECK(wrong.id == 17 && wrong.swarm.asg.empty()); } +// --- 8c. the AIAgent custom-data block ------------------------------------------------- +// Every container in the block has count 0 in all four saves we hold, so the real +// data exercises the frames and none of the element layouts. This test populates +// each of them, which is the only check those layouts get. +static void test_aiagent_block() { + shapes::StrategyAIAgent ai; + + // Game::AttribMap: a "." count then two "." strings per entry. The second pair + // is empty on both sides -- an empty string is four zero bytes, the same as the + // int 0, which is exactly how a mistyped string field stays invisible. + ai.attrib.entries = {{"aggression", "high"}, {"", ""}}; + + // AIWeightMap keys are FRAMES (Mars::StreamableEnum, or ShipSectionID's two + // ints), never bare ints, and each key is followed by a "." float. + ai.turnPriorities.entries.push_back({{7, {}}, 0.5f}); + ai.situation.sections.entries.push_back({{2, 44, {}}, 1.5f}); + ai.situation.weaponFamilies.entries.push_back({{3, {}}, -2.0f}); + ai.playerHate.entries.push_back({{16, {}}, 0.25f}); + + ai.requestStamps.push_back({16, 4, {}}); + // `dsh` counts (pid, trns) pairs walked out of a map; trns is a VectorHelper. + ai.designShares.push_back({16, {1, 2, 3}}); + ai.designShares.push_back({32, {}}); + ai.borderStability.push_back({16, 5}); + ai.menaceBlacklist.push_back({272, 9}); + ai.designNames.names.push_back({{5, 37, {}}, "Ravenous"}); + ai.designNames.names.push_back({{5, 63, {}}, ""}); // an empty design name + ai.aiHivJ = 3; + ai.sdFlT = 7; + ai.newAlliances.push_back({11, {}}); // framed element, not a bare int + ai.newAlliances.push_back({12, {}}); + ai.lnat = 4; + ai.lat = 5; + shapes::StrategyAIAgent::AISysEntry sys; + sys.id = 48; + ai.systems.push_back(sys); + shapes::AICombatReport rep; + rep.crTrn = 2; + rep.crPce = true; + rep.crSys.value = 272; + rep.crPlSv2.rpBon = 1.25f; + rep.crPlSv2.rpBonT = 3; + rep.crPlSv2.savBonus = 4; + rep.crPlSv2.maintHF = true; + rep.crPlSv2.tacReports.push_back(Node::int32("TRid", 9)); // carried, not typed + ai.combatReports.push_back(rep); + ai.colonyLosses.push_back({2, 272, 16}); + ai.provocations.push_back({16, 450.0f}); + ai.techScores = {2, 4, 6}; + ai.fct = 1; + ai.autoPeaceRuns.push_back({16, 2, 5, {}}); + + Writer w; + WriteArchive wa(w); + wa.obj(A("CD"), ai); + Node root = read_tree(w.bytes()); + std::vector issues; + shapes::StrategyAIAgent back; + ReadArchive ra(root.children, issues, ""); + ra.obj(A("CD"), back); + CHECK_EQ(count(issues, Issue::Error), size_t(0)); + CHECK_EQ(count(issues, Issue::Warn), size_t(0)); + + CHECK(back.attrib.entries.size() == 2); + CHECK(back.attrib.entries[0].key == "aggression" && back.attrib.entries[0].value == "high"); + CHECK(back.attrib.entries[1].key.empty() && back.attrib.entries[1].value.empty()); + CHECK(back.turnPriorities.entries.size() == 1 && back.turnPriorities.entries[0].key.value == 7); + CHECK(back.turnPriorities.entries[0].weight == 0.5f); + CHECK(back.situation.sections.entries.size() == 1 && back.situation.sections.entries[0].key.a == 2 && + back.situation.sections.entries[0].key.b == 44); + CHECK(back.situation.weaponFamilies.entries[0].weight == -2.0f); + CHECK(back.requestStamps.size() == 1 && back.requestStamps[0].pid == 16 && back.requestStamps[0].trn == 4); + CHECK(back.designShares.size() == 2 && back.designShares[0].turns.size() == 3 && + back.designShares[0].turns[2] == 3 && back.designShares[1].turns.empty()); + CHECK(back.borderStability.size() == 1 && back.borderStability[0].b == 5); + CHECK(back.menaceBlacklist.size() == 1 && back.menaceBlacklist[0].a == 272); + CHECK(back.designNames.names.size() == 2 && back.designNames.names[0].name == "Ravenous" && + back.designNames.names[0].section.b == 37 && back.designNames.names[1].name.empty()); + CHECK(back.newAlliances.size() == 2 && back.newAlliances[1].value == 12); + CHECK(back.lnat == 4 && back.lat == 5); + CHECK(back.systems.size() == 1 && back.systems[0].id == 48); + CHECK(back.combatReports.size() == 1 && back.combatReports[0].crPce && + back.combatReports[0].crSys.value == 272 && back.combatReports[0].crPlSv2.maintHF && + back.combatReports[0].crPlSv2.tacReports.size() == 1); + CHECK(back.colonyLosses.size() == 1 && back.colonyLosses[0].sysID == 272); + CHECK(back.provocations.size() == 1 && back.provocations[0].value == 450.0f); + CHECK(back.techScores.size() == 3 && back.techScores[1] == 4); + CHECK(back.autoPeaceRuns.size() == 1 && back.autoPeaceRuns[0].tn1 == 5); + + Writer w2; + WriteArchive wa2(w2); + wa2.obj(A("CD"), back); + CHECK(w2.bytes() == w.bytes()); + + // A StreamableEnum element is a frame, not a scalar: `nalat`'s two elements must + // come back as complex nodes, which is what a bare-int typing would get wrong. + const Node* cd = &root.children[0]; + const Node* nalat = nullptr; + for (const Node& n : cd->children) + if (n.name == "nalat") nalat = &n; + CHECK(nalat != nullptr); + if (nalat) { + CHECK(nalat->children.size() == 3); // the "." count plus two elements + CHECK(!nalat->children[0].is_complex()); + CHECK(nalat->children[1].is_complex() && nalat->children[2].is_complex()); + } +} + +// CDT plus the CD frames, exactly as SaveGame drives them. +struct CdOnly { + static constexpr const char* kStreamName = ""; + shapes::CdTable cdTable; + std::vector customData; + template + void io(Ar& ar) { + ar.obj(A("CDT"), cdTable); + size_t k = 0; + ar.repeat("CD", customData, [&](Ar& a, shapes::CustomDataBlock& e) { + e.select(k < cdTable.ids.size() ? cdTable.ids[k] : std::string()); + ++k; + a.obj(A("CD"), e); + }); + } +}; + +// The CD frames carry no key: the id at the same ordinal in CDT selects the body. +static void test_customdata_dispatch() { + CdOnly doc; + doc.cdTable.ids = {"Player.00000016.TurnCommands_v5", "Player.00000032.AIAgent"}; + shapes::CustomDataBlock tc; // not modelled: carried verbatim + tc.select(doc.cdTable.ids[0]); + tc.unknown.push_back(Node::int32(".", 42)); + doc.customData.push_back(tc); + shapes::CustomDataBlock agent; + agent.select(doc.cdTable.ids[1]); + agent.aiAgent.fct = 9; + agent.aiAgent.techScores = {2, 4}; + doc.customData.push_back(agent); + + Writer w; + WriteArchive wa(w); + doc.io(wa); + Node root = read_tree(w.bytes()); + std::vector issues; + CdOnly back; + ReadArchive ra(root.children, issues, ""); + back.io(ra); + CHECK_EQ(count(issues, Issue::Error), size_t(0)); + CHECK(back.customData.size() == 2); + CHECK(back.customData[0].which == shapes::CustomDataBlock::Which::Unknown); + CHECK(back.customData[0].unknown.size() == 1); + CHECK(back.customData[1].which == shapes::CustomDataBlock::Which::AIAgent); + CHECK(back.customData[1].aiAgent.fct == 9 && back.customData[1].aiAgent.techScores.size() == 2); + Writer w2; + WriteArchive wa2(w2); + back.io(wa2); + CHECK(w2.bytes() == w.bytes()); + + // The same bytes with the ids swapped select the other way round: nothing in the + // CD frame itself says which body it holds. + shapes::CustomDataBlock probe; + probe.select("Player.00000032.AIAgentX"); + CHECK(probe.which == shapes::CustomDataBlock::Which::Unknown); + probe.select(".AIAgent"); + CHECK(probe.which == shapes::CustomDataBlock::Which::AIAgent); +} + // --- 9. gzip container --------------------------------------------------------------- static void test_gzip() { Bytes data; @@ -640,6 +807,8 @@ int main() { test_typed_conditionals(); test_typed_string_array(); test_svsctob_variants(); + test_aiagent_block(); + test_customdata_dispatch(); test_gzip(); test_dump_format(); std::printf("test_stream: %s (%d failures)\n", fails ? "FAILED" : "ok", fails); diff --git a/tests/mars_stream/test_wire_schema.cpp b/tests/mars_stream/test_wire_schema.cpp index 9c0a0ce..586697a 100644 --- a/tests/mars_stream/test_wire_schema.cpp +++ b/tests/mars_stream/test_wire_schema.cpp @@ -258,6 +258,28 @@ int main() { check("SVSOCrowDefenders", "Game::SVSOCrowDefenders"); check("SVSOGrandMenaceTrigger", "Game::SVSOGrandMenaceTrigger"); + // --- the AIAgent custom-data block ------------------------------------------ + // The block SELECTION (a CDT id ending ".AIAgent") is not a wire item, so it is + // not checkable here; what is checked is that each body agrees with its class's + // serializer. Game::AIPlayerRequestStamp has no entry: it is a POD reached only + // through a specialised helper, so it has no RTTI class for the recovery to find. + check("StrategyAIAgent", "Game::StrategyAIAgent::Streamable"); + check("AttribMap", "Game::AttribMap"); + check("AISituation", "Game::AISituation"); + check("AISystem", "Game::AISystem"); + check("DesignNameGen", "Game::StrategyAIAgent::DesignNameGen"); + check("AICombatReport", "Game::AICombatReport"); + check("CombatPlayerStats", "Game::CombatPlayerStats"); + check("AIAutoPeaceRun", "Game::AIAutoPeaceRun"); + // One shape covers all four Game::AIWeightMap instantiations: they differ + // only in the key body, which is its own frame on the wire. + check("AIWeightMap", "Game::Game::VShipSectionID::?$AIWeightMap"); + check("AIWeightMap", + "Game::Mars::Game::W4AIPurposeID::U?$StreamableEnum::?$AIWeightMap"); + check("AIWeightMap", + "Game::Mars::Game::W4WeaponFamilyID::U?$StreamableEnum::?$AIWeightMap"); + check("AIWeightMap", "Game::Mars::I::U?$StreamableEnum::?$AIWeightMap"); + std::printf( "\ntotals: %d shapes bound, %d items matched, %d MISMATCH, %d wire-only, " "%d opaque item(s) in bound shapes\n",