sots-engine/src/game/sim/economy.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

234 lines
11 KiB
C++

#include "game/sim/economy.h"
#include <algorithm>
#include <cmath>
#include "game/sim/numeric.h"
namespace sots::sim {
DifficultyMods DifficultyModsFor(int level, bool isAI, bool isNpc) {
// The six floats of each row, as the image holds them: {AI triple, non-AI triple}.
// Row 0 gives the break to the player; rows 1 and 2 give it to the AI.
struct Row { float ai[3]; float other[3]; };
static constexpr Row kTable[kDifficultyLevels] = {
{{1.0f, 1.0f, 1.0f}, {1.5f, 1.5f, 1.5f}}, // 0
{{3.0f, 1.1f, 1.5f}, {1.0f, 1.0f, 1.0f}}, // 1
{{1000000.0f, 1.7f, 2.0f}, {1.0f, 1.0f, 1.0f}}, // 2
};
// An out-of-range level keeps the all-ones default the original memcpy's in first.
if (level < 0 || level >= kDifficultyLevels) return DifficultyMods{};
const Row& r = kTable[level];
const float* m = (isAI && !isNpc) ? r.ai : r.other;
return DifficultyMods{static_cast<double>(m[0]), static_cast<double>(m[1]),
static_cast<double>(m[2])};
}
int SavingsInterest(int savings, bool ownsSystems) {
if (savings < 0 || !ownsSystems) return 0;
return Ftol(static_cast<double>(savings) * kSavingsInterestRate);
}
int DebtInterest(int savings) {
if (savings >= 0) return 0;
return Ftol(-static_cast<double>(savings) * kDebtInterestRate);
}
int MaintenanceCost(int maintenance, double difficultyDivisor) {
const int d = Ftol(difficultyDivisor);
if (d == 0) return maintenance;
return maintenance / d;
}
int ExpenseTotal(const std::vector<ExpenseSlider>& sliders, int availableBeforeExpenses) {
constexpr int kUnlimited = 2000000000;
const int availPre = availableBeforeExpenses;
// The game multiplies the slider fraction by the available income in single
// precision, so the income is rounded to a float before the product.
const double availAsFloat = static_cast<double>(static_cast<float>(availPre));
std::int64_t minimums = 0;
std::int64_t takes = 0;
for (const ExpenseSlider& s : sliders) {
const int minC = std::max(s.minimum, 0);
int maxC = ClampT(s.maximum, 0, kUnlimited);
if (maxC == 0) maxC = kUnlimited;
const std::int64_t room = static_cast<std::int64_t>(maxC) - minC;
const std::int64_t request =
static_cast<std::int64_t>(Ftol(static_cast<double>(s.fraction) * availAsFloat)) - minC;
const std::int64_t take = std::min(std::max<std::int64_t>(request, 0), room);
minimums += minC;
takes += take;
}
const std::int64_t headroom = static_cast<std::int64_t>(availPre) - minimums;
const std::int64_t total = minimums + std::min(std::max<std::int64_t>(takes, 0), headroom);
return static_cast<int>(ClampT<std::int64_t>(total, -2147483648LL, 2147483647LL));
}
int ResearchPointsFromMoney(int researchMoney, double difficultyMult, double resMod,
double shrm, double trm, double techMult, double serverResMod,
double resScl) {
const double base = (static_cast<double>(researchMoney) / 50.0) * 1.15 * 0.5 * 0.85;
const double rp = difficultyMult * base * (resMod + shrm + trm) * techMult * serverResMod * resScl;
return Ftol(rp);
}
Budget ComputeBudget(const BudgetInputs& in, bool projected) {
Budget b;
b.savingsInterest = SavingsInterest(in.savings, in.ownsSystems);
b.debtInterest = DebtInterest(in.savings);
for (int inc : in.systemIncome) {
if (inc >= 0) b.systemIncomePositive = SaturatingAdd(b.systemIncomePositive, inc);
else b.systemIncomeNegative = SaturatingAdd(b.systemIncomeNegative, -inc);
}
b.tradeIncome = in.tradeIncome;
b.secondaryManagerIncome = in.secondaryManagerIncome;
b.shipCarriedPopIncome = in.shipCarriedPopIncome;
b.maintenance = MaintenanceCost(in.maintenance, in.maintenanceDivisor);
// Running total of the fixed lines; the slots filled later start at zero.
auto running = [&]() -> std::int64_t {
return static_cast<std::int64_t>(b.systemIncomePositive) + b.tradeIncome +
b.shipCarriedPopIncome + b.secondaryManagerIncome + b.savingsInterest +
b.bonusIncome - b.systemIncomeNegative - b.maintenance - b.researchMoneyKept -
b.debtInterest - b.construction - b.expenses - b.researchMoneyGiven -
b.savingsGiven;
};
b.expenses = ExpenseTotal(in.expenses, static_cast<int>(std::max<std::int64_t>(0, running())));
b.available = static_cast<int>(std::max<std::int64_t>(0, running()));
if (!in.isAI && b.available > 0) {
b.construction = std::min(std::max(0, in.constructionDemand), b.available);
}
const int availAfterConstruction = b.available - b.construction;
const double rate = projected ? 0.0 : in.researchRate;
b.researchMoney = std::max(0, Ftol(static_cast<double>(availAfterConstruction) * rate));
b.researchPoints = ResearchPointsFromMoney(b.researchMoney, in.researchDifficultyMult,
in.resMod, in.shrm, in.trm, in.techResearchMult,
in.serverResMod, in.resScl);
b.totalResearchPoints = std::max(0, b.researchPoints + in.tra + in.trp);
const int pct = ClampT(in.aidResearchPercent, 0, 100);
b.researchMoneyGiven = static_cast<int>(static_cast<std::int64_t>(b.researchMoney) * pct / 100);
b.researchPointsGiven = static_cast<int>(static_cast<std::int64_t>(b.totalResearchPoints) * pct / 100);
b.totalResearchPoints -= b.researchPointsGiven;
b.hasResearchAllocation = in.hasResearchTarget;
// The research money is only actually charged when there is something to research: the
// "kept" line and the allocation are written in the same branch, so a player with no
// research target keeps the money in the treasury (B1 trace, docs/B1.md).
b.researchMoneyKept = in.hasResearchTarget ? b.researchMoney - b.researchMoneyGiven : 0;
// Tech income bonus: a share of the full net so far, only when that net is positive.
const std::int64_t netBeforeBonus = running();
if (netBeforeBonus > 0) {
b.bonusIncome = std::max(0, Ftol((in.techIncomeMult - 1.0) * static_cast<double>(netBeforeBonus)));
}
// Savings aid: capped by the projected treasury after this turn (bonus included).
if (in.aidSavings != 0) {
const int netWithBonus = static_cast<int>(ClampT<std::int64_t>(running(), -2147483648LL, 2147483647LL));
const int projectedSavings = SaturatingAdd(in.savings, netWithBonus);
b.savingsGiven = std::min(std::max(projectedSavings, 0), std::max(in.aidSavings, 0));
}
b.net = static_cast<int>(ClampT<std::int64_t>(running(), -2147483648LL, 2147483647LL));
return b;
}
// ---- trade ---------------------------------------------------------------------------
int TradeRoutesSupported(double civilianPop, double imperialPop, const TuningTable& t) {
double routes = 0;
if (t.TRADE_ROUTE_REQ_CIVPOPULATION > 0) routes += std::ceil(civilianPop / t.TRADE_ROUTE_REQ_CIVPOPULATION);
if (t.TRADE_ROUTE_REQ_IMPPOPULATION > 0) routes += std::ceil(imperialPop / t.TRADE_ROUTE_REQ_IMPPOPULATION);
return std::max(1, Ftol(routes));
}
int TradeRouteGrossIncome(const TradeRouteState& route, const TuningTable& t) {
if (route.ageTurns < t.TRADE_ROUTE_STARTUP_TURNS) return t.TRADE_ROUTE_STARTUP_INCOME;
const int perFreighter[3] = {t.TRADE_ROUTE_INCOME_PERFREIGHTER_CRQ,
t.TRADE_ROUTE_INCOME_PERFREIGHTER_CR,
t.TRADE_ROUTE_INCOME_PERFREIGHTER_DE};
int capLeft = t.TRADE_ROUTE_MAX_FREIGHTERS;
std::int64_t income = t.TRADE_ROUTE_MIN_INCOME;
for (int c = 0; c < 3; ++c) {
const int n = std::min(std::max(0, route.freighters[c]), std::max(0, capLeft));
income += static_cast<std::int64_t>(n) * perFreighter[c];
capLeft -= n;
}
double v = static_cast<double>(income);
v *= 1.0 + t.STATION_BONUS_TRADE_INCOME * route.tradeStationsAtSystem;
if (route.partnerAddicted) v *= t.ADDICTION_TRADE_MOD;
return Ftol(v);
}
int TradeRouteIncome(const TradeRouteState& route, bool asOwner, double difficultyTradeMult,
const TuningTable& t) {
const double share = Clamp01(t.TRADE_ROUTE_OWNERS_SHARE);
const double part = asOwner ? share : 1.0 - share;
return Ftol(static_cast<double>(TradeRouteGrossIncome(route, t)) * part * difficultyTradeMult);
}
// ---- bankruptcy -----------------------------------------------------------------------
BankruptcyLimits ComputeBankruptcyLimits(int maxIncome, const TuningTable& t) {
constexpr int kTreasuryLimit = 2000000000;
BankruptcyLimits l;
// Elimination: the debt whose 15 %/turn interest equals the maximum income.
//
// The divisor is NOT the decimal -0.15. The image's .rdata double is
// -0.15000000596046448, i.e. (double)(float)-0.15f -- the compiler widened a float
// literal once, at compile time. Lane K read it; lane N is fixing it here because the
// difference is not the "one ulp on large empires" the old note claimed. The two
// constants give a DIFFERENT truncated result for every maxIncome divisible by 3,
// starting at maxIncome = 3 (-20 against -19), and the disagreement rate rises with
// empire size to 100 % above about 3,000,000. Six of the twenty-five bankruptcy records
// recoverable from the save corpus would come out wrong with the decimal.
l.eliminationFloor =
std::max(Ftol(static_cast<double>(maxIncome) / kBankruptcyInterestDivisor),
-kTreasuryLimit);
// Protection: the tuned factor times the maximum income, never below the floor.
//
// The factor is NARROWED TO FLOAT32 first, and that is not a guess about the data file:
// the two constants in this routine are loaded by different opcodes. The divisor above is
// `DD /0` -- `fld qword`, a double in .rdata. The factor is
//
// mov ecx, [PTR_g_BANKRUPTCY_PROTECTION_LIMIT_FACTOR]
// fmul dword ptr [ecx] ; D8 /1 == m32fp
//
// so whatever decimal the data file carries is a 4-byte float by the time it multiplies.
// A `double` multiply here truncates to a different integer for roughly 1% of max-income
// values at the corpus' empire size, and for more as the empire grows. The corpus cannot
// see it -- all seven of its non-zero `BnkPr` records land where the two agree -- so this
// is an instruction-stream reading, not a measured leaf (rule 23).
l.protectionLimit = std::max(-Ftol(F32(t.BANKRUPTCY_PROTECTION_LIMIT_FACTOR) *
static_cast<double>(maxIncome)),
l.eliminationFloor);
return l;
}
int BankruptcyLevel(int savings, const BankruptcyLimits& limits) {
if (savings < limits.eliminationFloor) return 2;
if (savings < limits.protectionLimit) return 1;
return 0;
}
BankruptcyDecision BankruptcyStep(BankruptcyState& state, int level, int currentTurn,
const TuningTable& t) {
const BankruptcyState old = state;
if (level != state.warningLevel) {
state.warningLevel = level;
state.startTurn = level != 0 ? currentTurn : -1;
}
BankruptcyDecision d;
d.costCutting = level != 0 && old.warningLevel != 0;
d.eliminate = old.warningLevel == 2 &&
(currentTurn - old.startTurn) >= t.BANKRUPTCY_ELIMINATION_TURNS;
return d;
}
} // namespace sots::sim