sots-engine/docs/game-sim.md

140 lines
22 KiB
Markdown
Raw Permalink 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; **`RoundHalfEven` is the engine's "round"** -- `fistp`/`fild`, i.e. round
to nearest with ties to *even*, not away from zero (B4 correction); `F32` narrows to float32
and back, which the colony and movement formulas do at every named store; 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 || 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 node in state **Available (2)** whose progress is **non-zero** (not "positive"), after the allocation pass. The *current* research target is **not** among them: the tree marks the selected target with state **3**, so the funded node keeps its full gain and only idle partially-researched techs decay (live trace, `docs/B3.md`) | high — confirmed against the loop and the live trace |
| `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, all in double; the 0.1 is a **true double**, not a widened float, the clamp is low end first then high end, and a zero band at the ideal yields NaN rather than 1 | high — read with its constant's exact bits |
| `CarryingCapacity` | `quantise(ftoi64((Size x 1e8) x (hazard x (groupMult x speciesFactor x crossSpecies))))` + arcology (1e8 imperial / 2e8 civilian, **0 for slaves**); clamp to group max; `x INDSYS_IMPERIAL_POPULATION_MOD` and re-quantise for NPC owners. `Size x 1e8` is an exact **64-bit integer** product; `quantise` rounds down to a multiple of 10 above 10, so every capacity is a multiple of ten (B4) | high |
| `PopulationGrowthFraction` / `PopulationGrowthDelta` | **B4: there is no `pop/cap` term** — the capacity never enters the growth chain. `base = 1 − clamp01(min(|ideal − clamp(suit, 0, 20)|, SuitTol) / SuitTol)`; `g = clamp01(pow(base, clamp(EXP, 0.01f, 1000)))`; then `x MOD x PopMod x extraFactor x groupMult`, each gated on a strict `> 0` and each stored back to float32. `delta = trunc(pop x g)`, forced to 1 only when that truncates to 0 with `g > 0`; blockade or empty group → 0. The 50,000,000 cap lives in the apply | high — read instruction by instruction |
| `ApplyImperialGrowth` | delta capped at 50,000,000 when non-negative; `new <= cap` → new; `new > cap` but `pop <= cap` → exactly `cap` (**no shrink**); both over cap → `max(pop − min(|cap − pop|, 5e7), min(pop, 100))`; floored at 0. The shrink is computed from the **old** population | high — read branch by branch |
| `InfrastructurePointsNeeded` / `InfrastructureGain` / `ApplyInfrastructureDelta` | `ceil((1 − infra)/3.3e-5)` with 3.3e-5 a **true double**; the gain is `points/500 x 0.01 x 1.65` evaluated in that order on the x87, clamped at 0 and narrowed to float32 once — deliberately **not** folded into one `x3.3e-5`; the apply is a no-op at 1 and clamps there otherwise | high |
| `DecayUnownedInfrastructure` | `float32(infra − (double)0.02f)`, then `result <= 0 → 0`. The image holds the **widened float literal** 0.019999999552965164, not the decimal 0.02 | high |
| `TerraformPointsNeeded` / `TerraformDelta` / `ApplyTerraformDelta` | `ceil(|float32(ideal − suit)| / |TerraMod x 1.8000000715255737 / 20000|)` — **the terraforming modifier is inside the point count**, so a better modifier needs fewer points; `float32(points x 1.5 x (double)1.2f x TerraMod x sign / 20000)` with `sign = −1` only for `suit > ideal` strictly; the apply clamps at the ideal from whichever side it approached | high |
| `SlaveDeathRate` | `((|ideal − suit| x BYHAZARD + DEATH_RATE) + SRs x BYOUTPUT) x mod` — the hazard term joins the base **first**, and every step is stored back to a float32; `mod = (translation1 ? 0.8f : 1) − 0.2 t2 − 0.2 t3` with no clamp; an unowned system reports 1.0, not 0 | high |
| `SlaveDeaths` | `ftoi64(slaves x (rate + plagueRate))` — the worst plague at the system adds an **additive** rate term — then MIN/MAX_DEATHS, each disabled by **any** negative value, then clamped into `[0, slaves]` | high |
| `NormaliseOutputRates` | **B4: the trade slider is pinned.** Terraform → 0 at the ideal (exact `==`); infra → 0 when `float32(ibon + infra) >= 1`; any channel `<= (double)1e-4f` → 0; **only trade** is clamped to `[0, 1]`; the other three are summed in float32 and rescaled to `(r/Σ) x (1 − trade)`; an exactly-zero sum seeds them with 1e-4f — an equal split over **three** channels, not four | high — read step by step |
| `MoraleOutputMultiplier` | `>= INCREASE_OUTPUT → x INCREASE_MOD`; `<= DECREASE_OUTPUT → x DECREASE_MOD` | high |
| `TotalSystemOutput` | `roundHalfEven(base x morale x (1 + STATION_BONUS_IMPERIAL_OUTPUT x stations) x addiction x ScOutMod x RebOutMod x techOut x sys.OutMod x OutMod)`, kept as a double for the channel splits | high on the chain and the rounding mode; the base-from-population term is an input (unresolved) |
| `SplitOutput` | `roundHalfEven(total x rate)` per channel, each kept as a double | high |
| `ConstructionPoints` | `trunc(cons x (1 + STATION_BONUS_SHIPCON x stations))` — **truncating**, not rounding | high |
| `SplitLeftover` | unspent construction over trade/terraform/infra by their rates, or `1 / (suit != ideal) / (infra != 1)` when the construction rate is **exactly 1**; each share is rounded half-to-even independently, so the three need not add back up to the leftover | medium on the weights, high on the rounding |
| `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(cap − pop, bonus)` with an **unowned system dropping the whole pool** and an at-or-over-cap colony short-circuiting; `infra += min(bonus, float32(1 − infra))`, snapping to **exactly 1.0** when the pool covers the remainder; bonus reduced by the same. Either firing on a colony that is not the owner's home system **resets `ntdev` to 0**, which then costs it the system-bonus gate that same turn | high |
| `AccrueSystemBonus` | gated on stable, owned > MINTURNS, and **`ntdev` > MINTURNS (not the rebellion turn — B4 correction)**; `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: `conleft > points → conleft −= points, points = 0, stop`; else charge the money cost — **a refusal skips that order and continues** rather than stopping — then `points −= conleft`, `conleft = 0`, continue. The leftover is the return value, and removal is a separate sweep that unlinks every order at or below zero | high |
| `CountdownFor` / `SetCountdown` / `TickCountdowns` | the system's `Bats2` and `rcex` words carry a 4-bit countdown per player at bits `[4i, 4i+4)`, with a companion 32-bit "someone is counting" mask. Each turn every counter ticks down by one and a player whose counter is **already** zero loses its mask bit; index >= 15 has no nibble and is skipped entirely, and the whole sweep is skipped when the counter word is zero | high |
| `AddictionPhaseOf` | `adt[species] == 0` → none; else `elapsed = turn − adt`, `elapsed > PHASE3_START` → terminal, `elapsed > PHASE2_START` → established, else onset. Both comparisons strict | high |
| `ProcessColonyTurn` | the whole per-system pass, restricted to what `ServerSystem::ProcessTurn` writes itself: unowned infra decay → both bonus pools (either resets `ntdev` on a non-home colony) → `ntdev` ++/reset → `AccrueSystemBonus` → `TRes = 0` → `haltv` cleared → both countdown sweeps → the addiction sweep, which raises morale event `0x1b` (−1) under temperance, `0x1c` (+1) at onset, `0x1d` (−2) at terminal, and **nothing at all** in the established phase. Consumes no RNG; see `docs/B4.md` for the input boundary | high on every step; the callees are not modelled |
## Movement (`movement.h`)
| function | formula | confidence |
|---|---|---|
| `PlanFleetMovement` | the turn's schedule is a **pursuit model**, not a departing/in-transit split: a fleet whose current waypoint targets another fleet is a *pursuer* when the two owners have no relation and a *follower* otherwise. Prey move `dt 0.5`; pursuers move `dt 0.5` and a pursuer that arrives retires itself and its prey; surviving prey take a second `0.5`; everything unscheduled takes `1.0` (an uncaught pursuer takes another `0.5`); followers take `1.0` | high — read pass by pass |
| `IsGateTransitWaypoint` / `IsNodeWaypoint` | type 4 or 5, and type **3 only**, respectively. The node-*line* case of the movement switch is type 2, which `IsNodeWaypoint` does not accept | high |
| `GateTrafficTotals` | per player, the sum of a **signed int16** per fleet whose front waypoint is a gate transit; assigned, not accumulated, so it resets every turn | high |
| `StraightStep` | `float32(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 |
| `SegmentSphereIntersect` / `BuildStutterSegments` | chord parameters scaled to world distance and **clamped to `[0, length]`**; a chord with `|start − end| <= 0.01f` is dropped; `std::sort` ascending by `start` alone (ties unordered); then **one forward pass over adjacent pairs** sets *both* boundaries of an overlap to `float32(end_i + 0.5 x (end_i − start_{i+1}))` — the mirror of the midpoint, pushed forward past both chords. Nothing is dropped or clipped back, so a swallowed chord comes out **inverted** and the step loop skips it. Reproduced verbatim, bug and all | high — read down to the ModRM byte |
| `NodeLineStep` | walk the segments in ascending order at plain `nodeSpeed` between influence spheres and at the segment's own constant speed inside one, until the line ends or `dt` runs out; arrival is `time < dt` or `|along − length|` under one float epsilon, and on arrival the destination is copied verbatim. **Under shipped data `STUTTER_MIN_SPEED == STUTTER_MAX_SPEED == 0.33`, so the ramp collapses to a constant 0.33x inside any sphere** | high — the loop was reconstructed instruction by instruction |
| `FleetMinShipRange` / `ResolveMoveStep` | `range = float32(minShipRange + 0.05f)` — the grace margin is **added** (B4 correction; the constant's sign bit is clear and the helper adds it). If `distance > range` and the un-biased minimum is exactly 0, the **range** is zeroed — not the step, which stays the divisor of the pass fraction. `move = min(min(range, step), distance)` in that order, with **no floor at zero**; arrival when `move == distance`, after which the position is copied from the destination verbatim. An empty fleet's minimum range is `FLT_MAX`, and no ship is excluded — a range-exempt tanker still clamps the fleet | high |
| `AdvanceAlongDirection` / `ConsumeShipRange` | `pos + unit(dest − pos) x move`, each component narrowed to float32 after the multiply and before the add; `max(0, float32(range − moved))` unless the ship carries the range-exempt flag, in which case it pays nothing | high |
| `PassFraction` / `RemainingPassTime` | a full pass unless the waypoint is type 3, which reports `clamp01(distance/step)`; a blocked move reports `clamp01(move/step)`. Recursion iff `fraction < 0.9998999834060669` **strictly** — an 8-byte constant whose value is exactly `(double)0.9999f` — with `dt' = float32((1 − fraction) x dt)` | high |
| `RollProbabilisticJump` | `v = float32(rand01() x CstE)`; **arrives iff `!(v > CstT)`** (equality arrives). On a miss the fleet is placed at `dest + randomUnitVector x v` — *scattered around* the destination by `v`, not advanced a fraction along the vector (B4 correction) — and the random direction costs a **second** draw, so a jump consumes 1 word on success and 2 on a miss. The pass fraction is 1.0 either way, so a type-5 waypoint never recurses. No fuel is charged and no distance clamp applies | high on the arithmetic and the draw count; medium on the field identities |
## 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. B4 promoted five more to **high** by reading them instruction by instruction —
`ApplyImperialGrowth`'s shrink, the movement pass schedule, the stutter overlap rule, the
node-line step and the probabilistic jump's arithmetic. Items still at **medium**:
`TechCostMultiplier` (which three species techs count), `RollLabAccident` (odds-from-boost
function), `SplitLeftover`'s weights, `TradeRouteGrossIncome` multiplier truncation order,
the identity of the two probabilistic-jump player fields, 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, the morale-event *application* (`ProcessColonyTurn` reports the
addiction events it would raise, but not what the owner does with them), 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.
`ComputeOutputFromRates` was read in full for B4 and every correction is folded into the
table above, but the function itself is **not** side-effect free — it repairs damaged ships
in orbit — so it has no compare hook; see `docs/B4.md`. The unspent terraforming points
cascade into the money channel, an edge the split functions here do not yet carry.