14 KiB
game/sim — strategic-layer formula catalog
Module: src/game/sim/. Pure functions over plain structs — no game state, no I/O.
Every RNG-consuming roll takes an IRandom& (rng.h); the engine injects its
MT19937-compatible server generator, tests inject a scripted sequence. Tuning constants
come in through a TuningTable (tuning.h) whose fields are the data-file keys verbatim;
nothing in the module hard-codes a shipped value.
Numeric conventions (numeric.h): Ftol/Ftoi64 truncate toward zero like the original
float-to-int helper; RoundToInt rounds half away from zero; the treasury is a saturating
32-bit int clamped to +/-2,000,000,000.
Build/test: tests/game_sim/build_and_run.sh (plain g++, -Wall -Wextra -Werror), or
-DSOTS_GAME_SIM_TESTS=ON once src/game/sim is added to the root CMake. The real-data
smoke test runs only when SOTS_SAVES_JSON points at a save_reader.py --json dump and
asserts nothing (compare-mode is future work).
Confidence legend — high: formula verified in the RE notes against the code path;
medium: shape and inputs established, one term or the truncation order inferred;
low: shape only, marked CONFIDENCE: low — see sots-re open questions in the header.
Species (species.h)
| item | formula / rule | confidence |
|---|---|---|
| enum order | Human 0, Hiver 1, Tarkas 2, Liir 3, NPC 4, Zuul 5, Morrigi 6 — index of every per-species table |
high |
ConstantsOf |
engine-carried species constants: incomeFactor (Zuul 1.1, Morrigi 0.8, else 1), hazardCostFactor (Zuul 0.7, else 1), systemBonusEligible (Zuul false) |
high on the three fields; the rest of the engine's species table is not modelled |
Economy (economy.h)
| function | formula | confidence |
|---|---|---|
SavingsInterest |
Sav >= 0 && ownsSystems ? ftol(Sav x 0.01) : 0 |
high |
DebtInterest |
Sav < 0 ? ftol(-Sav x 0.15) : 0 |
high |
MaintenanceCost |
Maint / ftol(difficultyDivisor) |
high — matched live at divisors 1 and 3 |
ExpenseTotal |
per entry minC = max(min, 0), maxC = clamp(max, 0, 2e9) with 0 → 2e9, req = ftol(fraction x float(availPre)) − minC, take = min(max(req, 0), maxC − minC); total Σ minC + min(max(Σ take, 0), availPre − Σ minC) |
high — the request is the slider fraction of the pre-expense available income (single-precision product) minus the mandatory minimum |
ResearchPointsFromMoney |
ftol(difficulty x (money/50 x 1.15 x 0.5 x 0.85) x (ResMod + shrm + TRM) x techMult x srv.ResMod x ResScl) ≈ money x 0.009775 x multipliers |
high |
ComputeBudget |
line items: +system income (positive part), +trade, +ship-carried population, +secondary manager, +savings interest, +tech bonus; −negative system income, −maintenance, −research kept, −debt interest, −construction, −expenses, −research aid, −savings aid. avail = max(0, running); construction = min(demand, avail) for humans; researchMoney = max(0, ftol((avail − construction) x ResRate)) (0 when projected); totalRP = max(0, RP + TRA + TRP); researchMoneyKept (the money the turn actually spends) is charged only when the player has a research target — the slot and the research allocation are written in the same branch; aid: given = x pct/100; bonus = net > 0 ? max(0, ftol((techIncomeMult − 1) x net)) : 0 where net is the full net so far (all income incl. interest and trade, minus maintenance, research money, construction, expenses, research aid); savingsGiven = min(max(SatAdd(Sav, net + bonus), 0), max(aid, 0)) — capped by the projected treasury, not the turn net |
high — old-vs-new on the live game (docs/B1.md): 4,437 compare calls, 0 divergences, and replace mode reproduced the End-Turn oracle. The research-target gate on researchMoneyKept was found by that run. Untested by the reference save: expense sliders, research/savings aid, debt interest, the tech income bonus. The secondary-manager slot is still only a name |
TradeRoutesSupported |
max(1, ceil(civ/REQ_CIV) + ceil(imp/REQ_IMP)) |
high |
TradeRouteGrossIncome |
age < STARTUP_TURNS → STARTUP_INCOME; else MIN_INCOME + Σ_class min(n, capLeft) x PERFREIGHTER[class] (CRQ, CR, DE; capLeft from MAX_FREIGHTERS) x (1 + STATION_BONUS_TRADE_INCOME x stations) x ADDICTION_TRADE_MOD if addicted |
high on the sum; medium on truncation order of the multipliers |
TradeRouteIncome |
owner x OWNERS_SHARE (clamped 0..1), partner x (1 − share), x AI difficulty trade multiplier |
high |
ComputeBankruptcyLimits |
eliminationFloor (BnkEl) = max(ftol(maxIncome / −0.15), −2e9) — the debt whose 15 % interest equals the maximum income; protectionLimit (BnkPr) = max(−ftol(PROTECTION_LIMIT_FACTOR x maxIncome), BnkEl). The limits a turn's check reads are the ones computed at the end of the previous turn (and on load) |
high — the 3.3 factor is on the protection limit; both expressions read with their constants |
BankruptcyLevel |
2 if Sav < BnkEl, 1 if Sav < BnkPr, else 0 |
high |
BankruptcyStep |
state first: any level change (0↔1, 1↔2 alike) stamps startTurn = level ? turn : −1; decisions on the old state: costCutting = level != 0 && old.level != 0, eliminate = old.level == 2 && turn − old.startTurn >= BANKRUPTCY_ELIMINATION_TURNS — both actions begin the turn after the level is reached |
high — stamp-on-transition and act-on-old-state read from the code |
SaturatingAdd |
clamp to ±2e9 | high |
Research (research.h)
| function | formula | confidence |
|---|---|---|
EdgeAvailability |
per-species chance parsed as pct/100; unlisted species default 1.0; explicit 0 excludes | high |
RollEdgeAvailable |
include iff mode == Everything or `(p > 0 && (mode == NoRoll |
|
TechCostMultiplier |
max(0.25, 1 − 0.25 x n) |
medium — which three species techs count is unresolved |
TechCost |
INT_MAX stays; else max(1, ftol(base x mult)) |
high |
ApplyResearchPoints |
lo = cost x 50/100, hi = cost x 150/100 (integer); spend = min(points, hi − progress); below hi: odds = (progress − lo)/hi (0 at 50 %, 1/3 at 100 %, 2/3 at 150 %), roll = rand01(), Zuul keep the lower of two rolls, zero spend → odds 0/roll 1; at hi: guaranteed; complete iff odds >= roll; crossing 100 % without completing → over-budget event (flag 2); completing below 80 % → "completed early" (flag 0) |
high |
DecayResearchProgress |
max(0, progress − ftol(cost x 0.05)) |
high |
DecayAllResearch |
applies to every Available node with progress, after the target was processed; the just-funded target is not excluded and only escapes by completing in the same pass (net gain of the current tech = spend − 5 % of cost) | high — confirmed against the loop |
RollLabAccident |
randint(100) < odds |
medium — odds-from-boost function unresolved (caller supplies odds) |
LabAccidentLossPercent |
ceil(clamp01(rand01() x (max − min) + min) x 100) |
high |
Colonies (colony.h)
| function | formula | confidence |
|---|---|---|
SpeciesTechFlags |
per-species xenotech bits in family order: translation 1/2/3 (bits 0–2), incorporate 3, addict 4, temperance 5, subjugate 6, accommodate 7, proliferate 8; FromBits unpacks a flag word |
high on the order |
HazardModifier |
`clamp01(1 − | suit − ideal |
CarryingCapacity |
ftoi64(Size x 1e8 x groupMult x speciesFactor x crossSpecies x hazard) + arcology (1e8 imperial / 2e8 civilian); clamp to group max; x INDSYS_IMPERIAL_POPULATION_MOD for NPC owners; NPC species or uninhabitable → 0 |
high |
PopulationGrowthDelta |
g = clamp01((1 − clamp01(pop/cap))^EXP); if g > 0: x MOD x PopMod x hazard/species x groupMult (if > 0); delta = ftoi64(pop x g), min 1 when g > 0, max 50,000,000; blockade → 0 |
high |
ApplyImperialGrowth |
over cap: shrink by min(5e7, pop − cap) but not below min(pop, 100); else min(cap, pop + delta) |
medium — shrink floor read from a terse note |
InfrastructurePointsNeeded / InfrastructureGain |
ceil((1 − infra)/3.3e-5); points x (1/500) x 0.01 x 1.65 = points x 3.3e-5 (≈30,300 points for 0→1) |
high |
DecayUnownedInfrastructure |
max(0, infra − 0.02) |
high |
TerraformPointsNeeded / TerraformDelta |
` | ideal − suit |
SlaveDeathRate |
`(SRs x BYOUTPUT + | ideal − suit |
SlaveDeaths |
clamp(ftoi64(slaves x rate), MIN_DEATHS, MAX_DEATHS), MAX −1 = uncapped, never more than present |
high |
NormaliseOutputRates |
negatives → 0; terraform → 0 at ideal; infra → 0 when full; rescale to Σ 1, equal split when all zero | high (the tiny positive threshold is treated as 0) |
MoraleOutputMultiplier |
>= INCREASE_OUTPUT → x INCREASE_MOD; <= DECREASE_OUTPUT → x DECREASE_MOD |
high |
TotalSystemOutput |
round(base x morale x (1 + STATION_BONUS_IMPERIAL_OUTPUT x stations) x addiction x ScOutMod x RebOutMod x techOut x sys.OutMod x OutMod) |
high on the chain; the base-from-population term is an input (unresolved) |
SplitOutput |
round(total x rate) per channel |
high |
ConstructionPoints |
round(cons x (1 + STATION_BONUS_SHIPCON x stations)) |
high |
SplitLeftover |
unspent construction over trade/terraform/infra by their rates, or 1 / (suit != ideal) / (infra != 1) when construction was the only slider |
medium |
SuitabilityCostMod |
`min( | ideal − suit |
SystemMoneyIncome |
t = (trade − fmod(trade, 5)) x 5 (whole five-point blocks, five money each); t += imperial + civilian + slave population income; t *= speciesIncomeFactor; t *= IncMod; t *= serverIncomeMod x difficultyIncomeMult; cost = speciesCostFactor x suitCostMod x 10000 x 1.5; money = ftol(t − cost) |
high on the chain and constants; the three population-income terms are inputs (their group-income tables, and the addiction factor inside them, are not modelled) |
ApplyPopulationBonus / ApplyInfrastructureBonus |
pop += min(bonus, cap − pop); infra += min(bonus, 1 − infra); bonus reduced by the same |
high |
AccrueSystemBonus |
gated on stable, owned > MINTURNS, no rebellion > MINTURNS; target = eligible ? ftol(max(POPBONUS, 0) x cap) : 0; pbon += min(max(ftol(POPBONUS_INC x cap), 0), max(target − pbon, 0)); ibon += min(max(INFRABONUS_INC, 0), max((eligible ? INFRABONUS : 0) − ibon, 0)); Zuul are never eligible. The *_HOME keys are only read when a home system's bonus is initialised (not modelled) |
high — increment, target and gating read with constants |
ProcessBuildQueue |
FIFO: points < conleft → conleft −= points, stop; else complete, points −= conleft, charge money cost, continue |
high |
Movement (movement.h)
| function | formula | confidence |
|---|---|---|
| pass schedule | departing/in-transit sets: two dt = 0.5 passes; everything else one dt = 1.0 pass |
medium — constants established, bucketing semantics not fully |
StraightStep |
speed x dt |
high |
NodeLineSpeed |
speed x ((STUTTER_MAX − STUTTER_MIN) x (dist / INFLUENCE_RADIUS) + STUTTER_MIN) — no clamp; only evaluated for chords inside a sphere, so dist <= radius by construction |
high |
DistPointToSegment |
closest-point distance with the projection parameter clamped to [0, 1] | high |
BuildStutterSegments |
the travel line is intersected with every system's influence sphere; each chord (clipped to the line) becomes a segment, chords shorter than 0.01 are dropped, segments are sorted by start and overlaps are split at the midpoint of the overlap; the speed factor of a segment is NodeLineSpeed(1, closest approach of the whole chord) — per segment, not per position |
high on chord/clip/drop/sort and per-chord speed; medium on the overlap rule (midpoint split; a chord swallowed by an earlier one is dropped) |
AdvanceAlongNodeLine |
piecewise integration: segment speed inside a sphere, plain nodeSpeed in the gaps, never past the line end |
medium — written from the per-segment description |
ResolveMoveStep |
range = minShipRange − 0.05; no range at all and range < distance → step 0 (stranded); move = min(step, range, distance) ≥ 0; arrival when move == distance |
high |
ConsumeShipRange |
max(0, range − moved) unless exempt |
high |
RemainingPassTime |
fraction < 0.9999 ? (1 − fraction) x dt : 0 (multi-waypoint recursion) |
high |
RollProbabilisticJump |
roll = rand01() x castEfficiency; roll > castThreshold → stop at fraction roll; else arrive |
medium — identity of the two player fields inferred |
Low-confidence list (flagged in headers)
None. The five formulas that were low (bankruptcy limits, bankruptcy stamping, hazard
curve, money tail, system-bonus increment) are pinned; each header carries a one-line
rationale. Items still at medium: TechCostMultiplier (which three species techs
count), RollLabAccident (odds-from-boost function), ApplyImperialGrowth shrink floor,
SplitLeftover, TradeRouteGrossIncome multiplier truncation order, the movement pass
bucketing, the probabilistic-jump field identities, the stutter overlap rule and
AdvanceAlongNodeLine, and the meaning of the budget's secondary-manager slot.
Tech effects on the player's modifiers (OutMod, PopMod, TerraMod, SuitTol, ...)
live in game/effects — see game-effects.md.
Not modelled here (out of scope for pure formulas, or unresolved): base output from
population, the per-group population income tables (inputs to SystemMoneyIncome),
civilian seeding rules, morale event deltas, rebellion rolls, plague, the
research-boost → accident-odds function, the fleet speed (FPsp2) derivation from
engine ftlspeed/nodespeed, gate traffic capacity, the home-system bonus
initialisation, and the game-setup handicap multipliers (output / income / research)
that feed techIncomeMult and friends.