sots-engine/docs/game-sim.md

124 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 |
| `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)`; 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 on the items, signs and both running-total readers; medium on the meaning of the secondary-manager slot |
| `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 || p >= 1 || rand01() <= p))`; one draw only when 0 < p < 1 in Normal mode | high |
| `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 |
| `ResearchSpendFloor` / `ResearchSpendCeiling` | `lo = cost x 50 / 100`, `hi = cost x 150 / 100` — a **32-bit** multiply (it wraps near `INT_MAX`) and a truncating divide; then `lo = max(lo, 0)`, `hi = max(lo, hi)` | high — read off the instruction sequence |
| `ResearchCompletionOdds` | `(progress − lo) / hi` evaluated in double and **narrowed to float32**: the original stores it in a 4-byte float slot before comparing | high |
| `ApplyResearchPoints` | `spend = min(points, hi − progress)` — a signed min with **no floor at zero**; `progress += spend`; below `hi`: odds as above, `roll = rand01()` (also narrowed to float32), Zuul keep the lower of two rolls, zero spend → odds 0 / roll 1; at `hi`: odds 1 / roll 0, no draw; complete iff `!(odds < roll)` compared as float32; crossing 100 % without completing → over-budget event (flag 2); completing below `(double)0.8f` of cost, where the ratio is itself a float32, → "completed early" (flag 0). Only the words the original function writes itself: the unlock cascade is `SetResearched`'s | high |
| `DecayResearchProgress` | `max(0, progress − ftol(cost x (double)0.05f))` — the image holds the widened float literal `0.05000000074505806`, and there is no special case for a node whose cost is still `INT_MAX` | high |
| `DecayAllResearch` | applies to every Available node whose progress is **non-zero** (not "positive"), 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 |
| `ProcessResearchTurn` | the whole per-turn pass: every allocation entry in order, then the decay sweep. This is the shape the shim hooks; see `docs/B3.md` | high |
| `RollLabAccident` | `randint(100) < odds`, where `randint` is uniform on `[0, 100]` **inclusive** (see `docs/mars-rng.md`) | 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| / (SuitTol + 0.1))` — linear, no exponent; SuitTol is the species start value plus the adaptation techs (+0.75 atmospheric, +1.5 gravitational); the caller passes 1 when it holds the accommodate xenotech for the species or is the rebel AI | high — read with its 0.1 constant |
| `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| / (1.5 x 1.2 / 20000)`; `points x 1.5 x 1.2 x TerraMod x sign / 20000` toward the ideal | high |
| `SlaveDeathRate` | `(SRs x BYOUTPUT + |ideal − suit| x BYHAZARD + DEATH_RATE) x ((translation1 ? 0.8 : 1) − 0.2 translation2 − 0.2 translation3)` | high |
| `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|, SuitTol)`; 0 for the rebel AI; 20 when unowned — the tolerance techs cap the money cost as well as widening the habitable band | high |
| `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.