diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 6fcd4da..2a1d457 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -7,6 +7,7 @@ add_library(sots_app STATIC alliance.cpp phase_catalog.cpp trade_raid.cpp + treaty.cpp turn_record.cpp construction_phase.cpp visibility_phase.cpp diff --git a/src/app/phase_catalog.cpp b/src/app/phase_catalog.cpp index e8ad319..007044a 100644 --- a/src/app/phase_catalog.cpp +++ b/src/app/phase_catalog.cpp @@ -44,7 +44,13 @@ constexpr PhaseDesc kHost[] = { {Driver::Host, 0, "H00", "BeginProcessTurn", PhaseStatus::Implemented, "advances the frame counter, which is the turn number the whole game displays; runs " "before the spine and is where the 'Begin processing turn N' line comes from"}, - {Driver::Host, 1, "H01", "SaveWriterInvariants", PhaseStatus::Implemented, + {Driver::Host, 1, "H02", "StampTreatyTurns", PhaseStatus::Implemented, + "the diplomacy ledger's 'this treaty was last in force on turn N' stamp, over every " + "ORDERED pair of players that holds one, creating the entry on demand. Runs in the " + "command-application step -- after the frame counter has advanced and before either " + "turn driver -- so it stamps the NEW turn. It is the only writer of these fields on a " + "turn with no combat and no diplomatic command"}, + {Driver::Host, 2, "H01", "SaveWriterInvariants", PhaseStatus::Implemented, "the summary's turn number is the simulation's frame counter -- an identity that holds " "across every save in the corpus and belongs to the writer, not to a turn phase"}, }; diff --git a/src/app/treaty.cpp b/src/app/treaty.cpp new file mode 100644 index 0000000..0d64902 --- /dev/null +++ b/src/app/treaty.cpp @@ -0,0 +1,89 @@ +#include "app/treaty.h" + +namespace sots::app { + +using mars::stream::shapes::DipStat; +using mars::stream::shapes::Player; +using mars::stream::shapes::PlayerEntry; + +namespace { + +// "This kind of treaty was last in force on turn N", one field per treaty kind. A fresh +// entry starts all three at -1, which is what distinguishes "never" from "on turn 0". +constexpr std::int32_t kNever = -1; + +std::int32_t* StampField(DipStat& e, Relation r) { + switch (r) { + case Relation::Allied: return &e.lastally; + case Relation::NonAggression: return &e.lastnap; + case Relation::CeaseFire: return &e.lastcf; + case Relation::War: return nullptr; + } + return nullptr; +} + +} // namespace + +Relation RelationTo(const Player& self, const Player& other) { + // Self first, and by the index field -- two players with the same index are the same + // player as far as this test is concerned, which is how the original short-circuits. + if (self.plyrIdx == other.plyrIdx) return Relation::Allied; + // x86 masks a variable shift count to five bits. Reproduced rather than corrected, for + // the same reason `alliance.cpp` reproduces it: the point is to be the original. + const std::uint32_t bit = 1u << (static_cast(other.plyrIdx) & 31u); + // The alliance mask is tested UNCONDITIONALLY here -- there is no alliance-id guard, and + // that is the difference from the shared-vision mask in alliance.cpp. + if (static_cast(self.alliances.al) & bit) return Relation::Allied; + if (static_cast(self.alliances.na) & bit) return Relation::NonAggression; + if (static_cast(self.alliances.cf) & bit) return Relation::CeaseFire; + return Relation::War; +} + +DipStat NewDipStat(std::int32_t otherPlayerId) { + DipStat e; + e.other = otherPlayerId; + e.lastnap = kNever; + e.lastally = kNever; + e.lastcf = kNever; + return e; +} + +TreatyStampResult StampTreatyTurns(std::vector& players, std::int32_t frame) { + TreatyStampResult r; + const std::size_t n = players.size(); + for (std::size_t i = 0; i < n; ++i) { + Player& a = players[i].player; + for (std::size_t j = 0; j < n; ++j) { + if (i == j) continue; // the original compares the POINTERS, not the indices + const Player& b = players[j].player; + const Relation rel = RelationTo(a, b); + if (rel == Relation::War) continue; + ++r.pairsStamped; + + const std::int32_t otherId = players[j].playerID; + DipStat* e = nullptr; + for (auto& cand : a.dipstats) // linear, first match, exactly as the original + if (cand.other == otherId) { + e = &cand; + break; + } + if (!e) { + a.dipstats.push_back(NewDipStat(otherId)); // appended at the END + e = &a.dipstats.back(); + ++r.entriesCreated; + } + std::int32_t* field = StampField(*e, rel); + if (field && *field != frame) ++r.fieldsWritten; + if (field) *field = frame; + } + } + // The phase's own accounting: one for each stamp that moved a value, plus one for each + // record that did not exist before. It is deliberately NOT an attempt to predict the + // divergence report's leaf count, which also charges two container leaves to each player + // whose ledger goes from empty to non-empty -- on the reference pair this reports 28 + // where the report closes 26. The report is the authority; this is what the phase did. + r.leafWrites = r.fieldsWritten + r.entriesCreated; + return r; +} + +} // namespace sots::app diff --git a/src/app/treaty.h b/src/app/treaty.h new file mode 100644 index 0000000..a29621e --- /dev/null +++ b/src/app/treaty.h @@ -0,0 +1,76 @@ +// The treaty-turn stamp -- a host step, not a phase of either turn driver. +// +// Once per turn, after the turn's commands have been applied and before either turn driver +// runs, the server walks every ORDERED pair of players and, for each pair that currently +// holds a treaty, stamps the current turn number into that player's diplomacy-stats entry for +// the other. The entry is created on demand. Nothing else in a normal turn writes those +// fields, so the whole per-player `dipstats` vector of a save is this step's output. +// +// It is the diplomacy ledger the AI reads. There is no attitude score in the simulation: what +// a player knows about another's diplomatic history is thirteen counters, and three of them +// are "the turn the treaty of this kind was last in force". +// +// Four things about it are worth stating here because each is a place a reimplementation +// goes quietly wrong. +// +// 1. The stamped value is the TURN, not the modification counter. The two are easy to +// confuse -- they are adjacent words on the same object and an earlier reading of this +// step named the wrong one -- but they differ by an order of magnitude in every save: on +// the corpus's turn-23 save the counter is in the hundreds and the stamp is 23. +// 2. The relation codes are 3 = allied (and self), 2 = non-aggression, 1 = cease-fire, +// 0 = war. An earlier reading had 1 and 3 the other way round, which puts the stamp in +// the cease-fire field of an allied pair. +// 3. The relation's bit is the player's own INDEX FIELD, not its position in the player +// vector. This is the exact opposite of the shared-vision mask in `alliance.h`, which +// uses the vector position and never loads the index field. Both live in the same +// subsystem and they disagree. No save in the corpus separates them. +// 4. The alliance mask is consulted unconditionally. Unlike the shared-vision mask, it is +// NOT gated on the player carrying an alliance id. +// +// Existing entries are found by a linear first-match scan on the other player's id, and a new +// entry is appended at the END -- so the vector's order is the order in which pairs were +// first stamped, which is player-vector order. +#pragma once + +#include +#include +#include + +#include "mars/stream/shapes.h" + +namespace sots::app { + +// What one player currently is to another. The values are the original's, and they are the +// values the rest of the game tests against (`== 3` gates allied resupply; `< 1` is war). +enum class Relation : int { + War = 0, + CeaseFire = 1, + NonAggression = 2, + Allied = 3, // and self +}; + +// `self` and `other` are read from the wire's player records. `other`'s INDEX FIELD supplies +// the bit; a player is always `Allied` to itself. +Relation RelationTo(const mars::stream::shapes::Player& self, + const mars::stream::shapes::Player& other); + +// The value a fresh diplomacy-stats entry starts at: the three "last in force" fields at -1 +// and every counter at 0. Held as a function rather than as a default member initialiser +// because the wire shape's own default is all-zero, and -1 is this step's, not the wire's. +mars::stream::shapes::DipStat NewDipStat(std::int32_t otherPlayerId); + +// What the step did, so the phase log can report it without the phase re-deriving it. +struct TreatyStampResult { + int pairsStamped = 0; // ordered pairs that held a treaty + int entriesCreated = 0; // entries that did not exist before this turn + int fieldsWritten = 0; // stamps applied; equals pairsStamped + int leafWrites = 0; // save leaves this moved, counted the way the report counts them +}; + +// The whole step, in place. `frame` is the turn number AFTER the host has advanced it -- +// this runs downstream of that increment, so a caller that has not advanced the frame yet +// will stamp every entry one turn short. +TreatyStampResult StampTreatyTurns(std::vector& players, + std::int32_t frame); + +} // namespace sots::app diff --git a/src/app/turn.cpp b/src/app/turn.cpp index 90595d9..00da8f9 100644 --- a/src/app/turn.cpp +++ b/src/app/turn.cpp @@ -10,6 +10,7 @@ #include "app/alliance.h" #include "app/construction_phase.h" #include "app/trade_raid.h" +#include "app/treaty.h" #include "app/turn_record.h" #include "app/visibility_phase.h" #include "game/sim/colony.h" @@ -860,6 +861,30 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) { r.records.push_back(rec); } + // H02 StampTreatyTurns: the diplomacy ledger's "last in force on turn N" stamp. It runs + // in the command-application step, which is AFTER the frame counter above and before + // either turn driver -- the ordering is load-bearing, because the value stamped is the + // new turn and a step placed before H00 would stamp every entry one turn short. + { + PhaseRecord rec; + rec.desc = &hp[1]; + const TreatyStampResult ts = StampTreatyTurns(game.sim.players, game.sim.frame); + rec.invocations = ts.pairsStamped; + rec.leafWrites = ts.leafWrites; + rec.committed = true; + rec.notes.push_back(fmt("%d ordered pair(s) hold a treaty; %d entry(s) created, " + "%d stamp(s) written at turn %d", + ts.pairsStamped, ts.entriesCreated, ts.fieldsWritten, + game.sim.frame)); + if (ts.pairsStamped == 0) + rec.notes.push_back("no player in this save holds any treaty, so this run " + "exercises the relation test only and writes nothing"); + rec.notes.push_back("the betrayal half of the same step needs the turn's diplomatic " + "commands (the alliance/NAP/cease-fire broken masks); with no " + "command stream it is provably a no-op and is not modelled"); + r.records.push_back(rec); + } + PlayerPhaseTotals pt; SystemTotals st; bool playerDriverRan = false; @@ -1100,7 +1125,7 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) { // H01 SaveWriterInvariants. { PhaseRecord rec; - rec.desc = &hp[1]; + rec.desc = &hp[2]; const int before = game.summary.turn; ApplySaveWriterInvariants(game, r); rec.invocations = 1; diff --git a/tests/app/CMakeLists.txt b/tests/app/CMakeLists.txt index 89e921e..fc565ef 100644 --- a/tests/app/CMakeLists.txt +++ b/tests/app/CMakeLists.txt @@ -22,13 +22,20 @@ add_executable(app_test_alliance test_alliance.cpp) target_link_libraries(app_test_alliance PRIVATE sots_app) add_test(NAME app_alliance COMMAND app_test_alliance) +# The treaty-turn stamp, as a rule. Pins the five things the corpus cannot separate -- the +# relation codes, the bit's source, the missing alliance-id guard, the -1 initialiser and the +# append order -- so it always runs. +add_executable(app_test_treaty test_treaty.cpp) +target_link_libraries(app_test_treaty PRIVATE sots_app) +add_test(NAME app_treaty COMMAND app_test_treaty) + # The turn-record model against the record the game itself archived; needs the owner's saves. add_executable(app_test_turn_record test_turn_record.cpp) target_link_libraries(app_test_turn_record PRIVATE sots_app) add_test(NAME app_turn_record COMMAND app_test_turn_record) foreach(_t app_test_catalog app_test_turn app_test_trade_raid app_test_alliance - app_test_turn_record) + app_test_treaty app_test_turn_record) target_include_directories(${_t} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) target_compile_options(${_t} PRIVATE -Wall -Wextra -Wpedantic) endforeach() diff --git a/tests/app/test_catalog.cpp b/tests/app/test_catalog.cpp index 1fd1aaa..68937aa 100644 --- a/tests/app/test_catalog.cpp +++ b/tests/app/test_catalog.cpp @@ -48,8 +48,10 @@ int main() { CHECK(nt == 37); CHECK(static_cast(ns + np) == kSpinePhaseCount); - // The host steps are deliberately NOT part of the milestone's denominator. - CHECK(nh == 2); + // The host steps are deliberately NOT part of the milestone's denominator. Their ids run + // H00, H02, H01 because the table is in EXECUTION order and H02 was read later than the + // step it runs before; the id is the stable name, the index is the ordinal. + CHECK(nh == 3); CheckTable(h, nh, Driver::Host, 0, ids, names); CheckTable(s, ns, Driver::Strategic, 0, ids, names); CheckTable(p, np, Driver::Player, 1, ids, names); diff --git a/tests/app/test_treaty.cpp b/tests/app/test_treaty.cpp new file mode 100644 index 0000000..61eeaca --- /dev/null +++ b/tests/app/test_treaty.cpp @@ -0,0 +1,159 @@ +// The treaty-turn stamp, pinned as a rule rather than as a table of observations. +// +// The corpus agrees with this rule on ten of eleven saves -- two games, species 0/2/4/5, +// turns 2 through 23 -- reproducing every entry, every `other` id, every entry's ORDER and +// every stamped value. (The eleventh is a turn-1 save on which no turn has been processed, +// so its ledger is empty; it is the input of the reference pair and the rule predicts what +// it becomes.) That comparison lives against the owner's saves. What is pinned HERE is what +// the corpus cannot separate: +// +// * the relation codes. Every save's treaties are either alliances or non-aggression +// pacts, so the cease-fire arm has never been exercised and an implementation that +// swapped 1 and 3 would look right on ten of the eleven if it also swapped the fields. +// * the bit is the player's INDEX FIELD, not its vector position. Every save in the corpus +// has the two equal. +// * the alliance mask is consulted with no alliance-id guard, unlike the shared-vision +// mask -- in the corpus no player holds an alliance mask without an alliance id. +// * a fresh entry's three "last in force" fields start at -1, not 0. A save cannot show +// this for a field that was then stamped; it shows only for the two that were not. +// * the entry is appended at the END and found by first match, so re-running the step is +// idempotent rather than appending duplicates. +#include + +#include "app/treaty.h" +#include "mars/stream/shapes.h" + +static int failures = 0; +#define CHECK_EQ(a, b) \ + do { \ + const long long va = (long long)(a), vb = (long long)(b); \ + if (va != vb) { \ + std::printf("FAIL %s:%d %s == %s (%lld != %lld)\n", __FILE__, \ + __LINE__, #a, #b, va, vb); \ + ++failures; \ + } \ + } while (0) + +using mars::stream::shapes::PlayerEntry; +using sots::app::Relation; +using sots::app::RelationTo; +using sots::app::StampTreatyTurns; + +static std::vector MakePlayers(std::size_t n) { + std::vector v(n); + for (std::size_t i = 0; i < n; ++i) { + v[i].playerID = static_cast(16 * (i + 1)); + v[i].player.plyrIdx = static_cast(i); + } + return v; +} + +int main() { + // 1. The relation codes, and their precedence. Alliance beats NAP beats cease-fire, and + // a player is allied to itself whatever the masks say. + { + std::vector p = MakePlayers(3); + p[0].player.alliances.al = 1 << 1; + p[0].player.alliances.na = (1 << 1) | (1 << 2); + p[0].player.alliances.cf = (1 << 1) | (1 << 2); + CHECK_EQ((int)RelationTo(p[0].player, p[1].player), (int)Relation::Allied); + CHECK_EQ((int)RelationTo(p[0].player, p[2].player), (int)Relation::NonAggression); + CHECK_EQ((int)RelationTo(p[0].player, p[0].player), (int)Relation::Allied); + CHECK_EQ((int)RelationTo(p[1].player, p[0].player), (int)Relation::War); // not symmetric + + p[0].player.alliances.na = 0; + CHECK_EQ((int)RelationTo(p[0].player, p[2].player), (int)Relation::CeaseFire); + p[0].player.alliances.cf = 0; + CHECK_EQ((int)RelationTo(p[0].player, p[2].player), (int)Relation::War); + } + + // 2. The bit is the INDEX FIELD. Give the vector's second element index 5 and the mask + // bit that reaches it is bit 5, not bit 1. + { + std::vector p = MakePlayers(2); + p[1].player.plyrIdx = 5; + p[0].player.alliances.na = 1 << 5; + CHECK_EQ((int)RelationTo(p[0].player, p[1].player), (int)Relation::NonAggression); + p[0].player.alliances.na = 1 << 1; + CHECK_EQ((int)RelationTo(p[0].player, p[1].player), (int)Relation::War); + } + + // 3. No alliance-id guard: an alliance mask with no alliance id still means allied. + { + std::vector p = MakePlayers(2); + p[0].player.alliances.alid = -1; + p[0].player.alliances.al = 1 << 1; + CHECK_EQ((int)RelationTo(p[0].player, p[1].player), (int)Relation::Allied); + } + + // 4. A created entry: the right field stamped, the other two at -1, every counter 0, + // and `other` the OTHER PLAYER'S ID -- not its index. + { + std::vector p = MakePlayers(2); + p[0].player.alliances.na = 1 << 1; + p[1].player.alliances.na = 1 << 0; + const sots::app::TreatyStampResult r = StampTreatyTurns(p, 7); + CHECK_EQ(r.pairsStamped, 2); + CHECK_EQ(r.entriesCreated, 2); + CHECK_EQ(r.fieldsWritten, 2); + CHECK_EQ(p[0].player.dipstats.size(), 1u); + const auto& e = p[0].player.dipstats[0]; + CHECK_EQ(e.other, 32); + CHECK_EQ(e.lastnap, 7); + CHECK_EQ(e.lastally, -1); + CHECK_EQ(e.lastcf, -1); + CHECK_EQ(e.lastnapbty, 0); + CHECK_EQ(e.bknnap, 0); + CHECK_EQ(e.btynap, 0); + CHECK_EQ(e.deadhome, 0); + CHECK_EQ(p[1].player.dipstats[0].other, 16); + } + + // 5. Re-running does not duplicate: the entry is found by first match on `other` and the + // stamp is overwritten in place. + { + std::vector p = MakePlayers(2); + p[0].player.alliances.na = 1 << 1; + (void)StampTreatyTurns(p, 7); + const sots::app::TreatyStampResult r = StampTreatyTurns(p, 8); + CHECK_EQ(p[0].player.dipstats.size(), 1u); + CHECK_EQ(p[0].player.dipstats[0].lastnap, 8); + CHECK_EQ(r.entriesCreated, 0); + CHECK_EQ(r.fieldsWritten, 1); + // Stamping the same turn twice writes nothing new. + const sots::app::TreatyStampResult again = StampTreatyTurns(p, 8); + CHECK_EQ(again.fieldsWritten, 0); + CHECK_EQ(again.pairsStamped, 1); // the pair still holds a treaty; it just did not move + } + + // 6. Entries are appended in the order pairs are first stamped, which is player-vector + // order -- and a later turn that adds a new treaty appends after the existing ones. + { + std::vector p = MakePlayers(4); + p[0].player.alliances.na = (1 << 2) | (1 << 3); + (void)StampTreatyTurns(p, 4); + CHECK_EQ(p[0].player.dipstats.size(), 2u); + CHECK_EQ(p[0].player.dipstats[0].other, 48); // index 2 + CHECK_EQ(p[0].player.dipstats[1].other, 64); // index 3 + p[0].player.alliances.al = 1 << 1; + (void)StampTreatyTurns(p, 5); + CHECK_EQ(p[0].player.dipstats.size(), 3u); + CHECK_EQ(p[0].player.dipstats[2].other, 32); // appended at the END, not sorted in + CHECK_EQ(p[0].player.dipstats[2].lastally, 5); + CHECK_EQ(p[0].player.dipstats[2].lastnap, -1); + CHECK_EQ(p[0].player.dipstats[0].lastnap, 5); // and the older ones were re-stamped + } + + // 7. War with everyone writes nothing at all -- the ledger of a player with no treaty + // stays empty, which is what both real empires in the reference save look like. + { + std::vector p = MakePlayers(4); + const sots::app::TreatyStampResult r = StampTreatyTurns(p, 9); + CHECK_EQ(r.pairsStamped, 0); + CHECK_EQ(r.leafWrites, 0); + for (const auto& e : p) CHECK_EQ(e.player.dipstats.size(), 0u); + } + + std::printf("app_test_treaty: %d failure(s)\n", failures); + return failures ? 1 : 0; +}