sots-engine/tests/game_sim/test_player_turn.cpp
alex 26b041106f PL: decompose the /Sim/players residual; S00 PvSav snapshot; T31 recovers its difficulty column from the save; the bankruptcy protection factor is a widened float
Closed 2 on the reference pair and 4 on pair 2, regressed 0, with no operator
input. Measured, both pairs, never netted.

- S00 stamps PvSav from Sav on every live player, before any phase can move it.
  0 closed on pair 1 (a no-op there), 2 on pair 2.
- T31 recovers the per-player difficulty column by recomputing BnkEl from the
  colony state the input save was written from and comparing against the BnkEl
  the save carries. That removes the --ai-player flag as a blocker and turns the
  phase's self-check into a real one: it used to compare its POST-turn result
  against the PRE-turn stored value, so its 6-of-8 only ever covered the six
  players whose limit does not move. The load-time check passes for every live
  player of all eleven corpus saves. T31 Blocked -> Partial; BnkPr still needs
  the tuning constant.
- BANKRUPTCY_PROTECTION_LIMIT_FACTOR is multiplied in as fmul dword ptr, so it
  is a float32 in the image; the engine narrows it now. Zero leaves move on this
  corpus -- all seven of its BnkPr records land where the two constants agree --
  and three hand-written test expectations moved (rule 23).

docs/PL-players-residual.md carries the decomposition, the predictions written
before the build, where they were wrong, and the ranked remainder.
2026-09-08 16:34:40 -04:00

88 lines
3.6 KiB
C++

#include "game/sim/player_turn.h"
#include "check.h"
using namespace sots::sim;
static TuningTable tuning() {
TuningTable t;
t.BANKRUPTCY_PROTECTION_LIMIT_FACTOR = 3.3;
return t;
}
static void test_snapshot() {
// S00 is a plain copy, and the point of the test is that it is a copy of the value
// BEFORE the turn, so the identity has to hold for the negative and the saturated cases
// too rather than only for the corpus' positive treasuries.
CHECK_EQ(SnapshotPreviousTurn(50000), 50000);
CHECK_EQ(SnapshotPreviousTurn(0), 0);
CHECK_EQ(SnapshotPreviousTurn(-1234), -1234);
CHECK_EQ(SnapshotPreviousTurn(2000000000), 2000000000);
}
// The corpus' two real empires, with the max incomes recovered by inverting the `BnkEl` each
// save carries. Player 0 is not AI; player 1 is, and its income therefore carries the x1.1.
static void test_column_identified_from_the_stored_limit() {
const TuningTable t = tuning();
// turn1-state, player 0 ("re"): stored BnkEl -1,590,613, max income 238,592.
DifficultyColumnEvidence e =
IdentifyDifficultyColumn(-1590613, 238592, 262451, t);
CHECK(e.column == DifficultyColumn::NonAI);
CHECK(!e.IsAI());
CHECK(e.Reproduced());
// turn1-state, player 1 ("Fane Lao"): stored -1,811,273, max income 271,691 -- which is
// what the AI column produces; the non-AI column would produce 271691 / 1.1.
e = IdentifyDifficultyColumn(-1811273, 246992, 271691, t);
CHECK(e.column == DifficultyColumn::AI);
CHECK(e.IsAI());
CHECK(e.Reproduced());
}
static void test_zero_income_is_ambiguous_not_identified() {
const TuningTable t = tuning();
// Five of the corpus' eight players own nothing. Both columns reproduce the stored zero,
// and the honest answer is "the column is unknown and cannot matter" -- not "non-AI".
const DifficultyColumnEvidence e = IdentifyDifficultyColumn(0, 0, 0, t);
CHECK(e.column == DifficultyColumn::Ambiguous);
CHECK(!e.IsAI());
CHECK(e.Reproduced());
CHECK_EQ(e.nonAiLimit, 0);
CHECK_EQ(e.aiLimit, 0);
}
static void test_neither_column_fits_is_unidentified() {
const TuningTable t = tuning();
// A stored limit no column reproduces means the max-income chain is wrong for this
// player, and the caller must abstain rather than pick the nearer of two wrong answers.
const DifficultyColumnEvidence e = IdentifyDifficultyColumn(-1000000, 238592, 262451, t);
CHECK(e.column == DifficultyColumn::Unidentified);
CHECK(!e.IsAI());
CHECK(!e.Reproduced());
}
// The identification only works because the two columns are far apart. A tenth of the income
// is thousands of units of BnkEl at any empire size that matters, so a truncation can never
// make the wrong column fit -- but at a max income of a handful of units they collapse, and
// the answer there is Ambiguous, which is correct and is worth pinning.
static void test_the_two_columns_collapse_only_at_a_trivial_income() {
const TuningTable t = tuning();
DifficultyColumnEvidence e = IdentifyDifficultyColumn(-6, 1, 1, t);
CHECK(e.column == DifficultyColumn::Ambiguous);
// one unit apart, and already separable
e = IdentifyDifficultyColumn(-133, 20, 22, t);
CHECK(e.column == DifficultyColumn::NonAI);
e = IdentifyDifficultyColumn(-146, 20, 22, t);
CHECK(e.column == DifficultyColumn::AI);
}
int main() {
test_snapshot();
test_column_identified_from_the_stored_limit();
test_zero_income_is_ambiguous_not_identified();
test_neither_column_fits_is_unidentified();
test_the_two_columns_collapse_only_at_a_trivial_income();
return simtest::finish("player_turn");
}