140 lines
22 KiB
Markdown
140 lines
22 KiB
Markdown
# 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.
|