sots-engine/docs/game-sim.md

22 KiB
Raw Blame History

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
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
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)
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
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)
SlaveDeathRate `(( ideal − suit
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
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
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
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.