merge sim-pin (pinned formulas + tech effects)

This commit is contained in:
alex 2026-09-07 22:32:16 -04:00
commit 16ce9ab6ee
21 changed files with 2119 additions and 140 deletions

161
docs/game-effects.md Normal file
View file

@ -0,0 +1,161 @@
# game/effects — code-defined tech effects
Module: `src/game/effects/`. Two data tables and a small apply layer over a plain
`PlayerEconomyState`; no game state, no I/O. Depends on `game/sim` for `Species` and
`TuningTable`.
Build/test: `tests/game_effects/build_and_run.sh` (plain g++, `-Wall -Wextra -Werror`), or
`-DSOTS_GAME_EFFECTS_TESTS=ON` once `src/game/effects` is added to the root CMake after
`src/game/sim`.
Confidence legend as in `game-sim.md`.
## The key space (`tech_id.h`)
The game does not key effects on the `.tech` files. It carries a fixed list of 196 tech
names; when the master tree is built, every name is matched case-insensitively against
the loaded techs and its position becomes the tech's id, `10000 + index` (`10197` is the
"none" sentinel). All hard-coded effects, runtime gates and design-option bitmasks read
those ids. A tech absent from the list has no code effect beyond what its data file says
(prerequisites, section/weapon availability).
`TechId` reproduces that list as an enum with the same numeric values, generated from one
X-macro so the enum and the name table cannot drift:
| entry kind | count | example |
|---|---|---|
| position and data-file name known | 89 | `IND_Waldo = 10001` |
| position known, name inferred or role-named | ~55 | `BIO_RetroPlague` (from its vaccine), `WEP_NUKMINE` (prefix inferred), `XNC_Temperance_Hiver` |
| position known, nothing else | ~52 | `Unresolved_052` |
`TechIdFromName` resolves only the 89 confirmed names; everything else comes back as
`TechId::None` and is treated as "no code effect". `TechIdName` is `nullptr` for the
unconfirmed slots so a loader can tell them apart.
### Xenotech block
Ids 10114–10163 are the per-species xenotechs, nine families in flag-bit order, each a
block of one tech per target species (`XenoTechId(level, species)`):
| bit | family | block base | targets |
|---|---|---|---|
| 0 | Translation 1 | 10114 | Human, Hiver, Tarkas, Liir, Zuul, Morrigi |
| 1 | Translation 2 | 10120 | same six |
| 2 | Translation 3 | 10126 | same six |
| 3 | Incorporate | 10132 | five: no Zuul |
| 4 | Addict | 10137 | five |
| 5 | Temperance | 10142 | five |
| 6 | Subjugate | 10147 | six |
| 7 | Accommodate | 10153 | five |
| 8 | Proliferate | 10158 | six |
The NPC race is never a target. Confidence: **high** on the family order and the block
bases; **medium** on the compact species order inside a block (enum order minus NPC);
**low** on which species the 5-entry blocks other than Incorporate omit — Zuul is assumed
for all four. Only `CCC_TRNSHUM` (10114) and `CCC_TRNSLIR` (10117) have confirmed data-file
names; the rest of the block is named by role.
### Node-track techs
Seeing a species' node-space traffic is granted by a tech keyed by *name* in the species
table, not by id: `CCC_NDTRKHUM` for Human traffic, `CCC_NDTRKZUL` for Zuul traffic, none
for the others. `NodeTrackTechName(species)` exposes that; `ApplyTechEffectByName` handles
it. Confidence: high on the names, medium on the reader semantics.
## The effects table (`tech_effects.h`)
`EffectsOf(id)` returns the list of typed effects (`{kind, index, value}`) applied when the
tech completes. Effects are additive per research event and permanent. 44 ids carry an
entry; every other id returns an empty list.
| tech | effects |
|---|---|
| CCC_AdvSens | flag AdvancedSensors |
| IND_Waldo, IND_ExpSys | ConMod[0..2] −0.10; OutMod +0.15 |
| IND_CyberInt | ConMod[0..2] −0.05; OutMod +0.20 |
| IND_OrbFound | SavMod[0..2] −0.05 |
| IND_OrbDry | ConMod[1] −0.05; ConMod[2] −0.05 (index 0 untouched — ConMod is per hull class, medium) |
| IND_GravCon | OutMod +0.30 |
| IND_HvyPlat | OutMod +0.10 |
| IND_AstMine | flag AsteroidMining |
| IND_MsMine | MaxOH = max(MaxOH, 0.1); MinRate +1.0 |
| BIO_GnMod | PopMod +0.10 |
| BIO_AtmoAd | SuitTol +0.75; PopMod +0.06; TerraMod +0.35 |
| BIO_EnvTail | PopMod +0.20; TerraMod +0.45 |
| BIO_GrvAdpt | SuitTol +1.50; PopMod +0.10; TerraMod +0.35 |
| IND_ArcCon | flag Arcology; PopMod +0.15; re-evaluate systems whose civilians sit at the cap (outcome) |
| IND_EleNans | TerraMod +0.60 |
| BIO_TerBac | TerraMod +0.45 |
| IND_AtProc | TerraMod +0.50 |
| DRV_TpGate / DRV_GatAmp | PrGtTrf = max(PrGtTrf, `PERGATETRAFFIC_DRV_TpGate` / `_GatAmp`) from the tuning table |
| DRV_FarCast | CstR 10, CstE 2, CstT 1 |
| CCC_AI / CCC_AIAdmin / CCC_AIFac | ResMod / IncMod / OutMod += the AI-bonus value for that slot, only while the AI benefit is on |
| CCC_AIVrus | every owned system gets its AI flag (outcome) |
| CCC_AISlv | same, plus the AI benefit is switched back on; `AiRebellionPossible` becomes false |
| CCC_FtlEcon | flag TradeAllowed, unless the player is the rebel AI |
| CCC_ComRaid | flag CommerceRaiding |
| DRV_GrvSyn | flag GravSynth (client sync only; the drive modifier is design-side) |
| CCC_DatCor | flag ViewIntel |
| IND_HrdStrct | pddm ×0.25; OutMod ×0.90 — **multiplicative**, so order against the additive OutMod techs matters |
| DRN_AdvRob | ConMod[0..2] −0.05 |
| IND_CruisCon | Zuul only: IND_BrdPod is granted (outcome; the caller researches it) |
| CCC_SpyBm, IND_SlvgTech | flag CaptureDesigns once both are researched |
| BIO_PLGVAC / RTPLGVAC / BSTVAC / ASPLGVAC / CONNAN | HasVac, HasImm |= bit 0 / 1 / 2 / 3 / 4; cure that plague type on owned systems and ships (outcome) |
| BIO_UNIANTI | same with mask 0x0f |
| DRV_RIP / REND / RAD | Zuul node-bore parameters {45,15,3} / {65,35,4} / {95,60,5}, highest wins |
Confidence: **high** on every constant above (each was read with its literal); **medium**
on the AI-benefit re-application in `SetAiBenefit` and on the node-bore "highest wins"
rule; the three AI-bonus values themselves are **not recovered** — `ApplyContext::aiBonus`
carries them and defaults to 0.
Every completion also rebuilds `speciesFlags[]` (bit k of species sp = the level-k
xenotech for sp is researched) and reports, in the outcome, every species whose
temperance bit is held so the caller can cure addiction to it on owned systems.
### Where the modifiers are consumed
`OutMod` → `TotalSystemOutput` (game/sim); `PopMod` → `PopulationGrowthDelta`; `TerraMod` →
`TerraformDelta`; `SuitTol` → `HazardModifier` **and** `SuitabilityCostMod`; `ConMod[i]` →
per-hull-class construction cost (medium); `SavMod[i]` → maintenance side (low); `pddm` →
planetary-defence damage (low); `MaxOH` / `MinRate` → mining sliders; `PrGtTrf` → gate
traffic capacity; the flags → the gates listed in the RE catalog. `hazardMod` is 1 (skipped)
when `speciesFlags[sp]` has the accommodate bit or the player is the rebel AI.
### Design-option masks
Two 32-bit words of "named tech researched" bits feed the design side; `kDesignOptionNamesA`
(32 names) and `kDesignOptionNamesB` (29 names) are the tables and
`ComputeDesignOptionMasks(hasResearchedByName)` builds the words from a by-name predicate,
because not every name's id is recovered. Consumers are not modelled. Confidence: high on
the tables.
## Apply layer
```
PlayerEconomyState s; // seeded by the caller (species, SuitTol start, ...)
ApplyContext ctx{&tuning, aiBonus};
TechApplyOutcome o = ApplyTechEffect(s, TechId::IND_HrdStrct, ctx);
```
`ApplyTechEffect` marks the id researched, applies its effects, rebuilds the species
flags, and returns what the caller must do with real game state: `grantedTech`,
`plagueCuredMask`, `flagSystemsAI`, `reevaluateCivilianCaps`, `temperanceSpeciesMask`,
`nodeBoreParamsChanged`. Applying an invalid or already-researched id is a no-op
(`applied == false`). `ApplyTechEffectByName` resolves a data-file name first and also
handles the node-track names. `SetAiBenefit(s, on, ctx)` adds or withdraws every
researched AI tech's bonus (AI rebellion / AI slave tech). `RebuildSpeciesTechFlags` is
also the load path.
## Not modelled / open
- Values of the three AI-benefit bonuses (a 6-entry table in the executable; not dumped).
- Data-file names for ~107 of the 196 slots (no strategic effect on any of them; the
gaps matter only for `TechIdFromName` on those names).
- The events raised on completion (research complete / under budget), the plague-cure
roll, the Zuul starting immunity/temperance flags, and the home-system bonus
initialisation that reads the `*_HOME` tuning keys.
- Runtime gates that read ids at use sites (advanced-sensor contact rules, spy-beam
intel, tunnel sensors, hyper-com retargeting, hull regeneration, missile/beam/cannon
variants, plague-type maps): documented in the RE catalog, to be modelled where those
systems are built.

View file

@ -24,6 +24,7 @@ Confidence legend — **high**: formula verified in the RE notes against the cod
| 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`)
@ -32,15 +33,15 @@ Confidence legend — **high**: formula verified in the RE notes against the cod
| `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` | `sum(min) + min(sum(clamp(req - min, 0, max - min)), availBefore - sum(min))` | medium — the per-entry request term is unresolved |
| `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 = ftol((techIncomeMult − 1) x running)`; `savingsGiven = min(max(running, 0), aid)` | high on the items and signs; medium on which running total the bonus and savings aid read and on the meaning of the secondary-manager slot |
| `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 = −ftol(PROTECTION_LIMIT_FACTOR x maxIncome)`; `protectionLimit = max(floor, −maxIncome)` | **low** — "debt floor ≈ −3.3 x max income" is established; which limit carries the factor and the other limit's exact form are not |
| `BankruptcyLevel` | 2 if `Sav < floor`, 1 if `Sav < protection`, else 0 | high |
| `BankruptcyStep` | non-zero level differing from the stored one restamps the start turn; eliminate when level 2 and `turn − start >= BANKRUPTCY_ELIMINATION_TURNS`; level 0 clears | **low** — elimination condition established; restamp rule inferred |
| `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`)
@ -53,7 +54,7 @@ Confidence legend — **high**: formula verified in the RE notes against the cod
| `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 target decays too: net gain = spend − 5 %) | 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 |
@ -61,14 +62,15 @@ Confidence legend — **high**: formula verified in the RE notes against the cod
| function | formula | confidence |
|---|---|---|
| `HazardModifierShape` | placeholder: `clamp01(1 − |suit − ideal| / tolerance)` | **low** — inputs known, curve shape not |
| `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 ((tech0 ? 0.8 : 1) − 0.2 tech1 − 0.2 tech2)` | 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 |
@ -76,9 +78,10 @@ Confidence legend — **high**: formula verified in the RE notes against the cod
| `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 |
| `SystemMoneyIncomeShape` | `ftol(trade x speciesIncomeFactor x playerIncomeMult − costTerm)` | **low** — the income tail's FP chain is unresolved |
| `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; `pbon += min(ftoi64(cap x POPBONUS_INC), cap x POPBONUS(_HOME) − pbon)`; `ibon += min(INFRABONUS_INC, INFRABONUS(_HOME) − ibon)` | **low** — the POPBONUS_INC-derived increment is not fully resolved; caps and gating are |
| `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`)
@ -87,7 +90,10 @@ Confidence legend — **high**: formula verified in the RE notes against the cod
|---|---|---|
| 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)`, ratio clamped to [0, 1] here | high on the formula; medium on the clamp (assumed) |
| `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 |
@ -95,13 +101,21 @@ Confidence legend — **high**: formula verified in the RE notes against the cod
## Low-confidence list (flagged in headers)
1. `ComputeBankruptcyLimits` — protection-limit expression.
2. `BankruptcyStep` — when the bankruptcy start turn is stamped.
3. `HazardModifierShape` — suitability-to-capacity curve.
4. `SystemMoneyIncomeShape` — trade points → money tail.
5. `AccrueSystemBonus` — the population-bonus increment.
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, civilian seeding rules, morale event deltas, rebellion rolls, plague, the
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.
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.

View file

@ -0,0 +1,21 @@
# Code-defined tech effects: the TechId key space, the per-tech effect table, and the
# apply layer over a player's economy modifiers. Depends on game/sim for Species and
# TuningTable. Not yet wired into the root CMakeLists; add_subdirectory(src/game/effects)
# after src/game/sim. tests/game_effects/build_and_run.sh builds the same sources with
# plain g++ in the meantime.
add_library(sots_game_effects STATIC
tech_id.cpp
tech_effects.cpp)
target_include_directories(sots_game_effects PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/../..)
target_link_libraries(sots_game_effects PUBLIC sots_game_sim)
target_compile_features(sots_game_effects PUBLIC cxx_std_17)
if(NOT MSVC)
target_compile_options(sots_game_effects PRIVATE -Wall -Wextra)
endif()
option(SOTS_GAME_EFFECTS_TESTS "Build the game/effects unit tests" OFF)
if(SOTS_GAME_EFFECTS_TESTS)
enable_testing()
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/../../../tests/game_effects
${CMAKE_CURRENT_BINARY_DIR}/tests_game_effects)
endif()

View file

@ -0,0 +1,283 @@
#include "game/effects/tech_effects.h"
#include <algorithm>
#include <array>
namespace sots::effects {
namespace {
using sots::sim::Species;
constexpr int F(PlayerFlag f) { return static_cast<int>(f); }
constexpr int Sp(Species s) { return static_cast<int>(s); }
// Plague-cure bits: one per plague type in vaccine order; the universal antidote covers
// the first four.
constexpr unsigned kCurePlague = 0x01;
constexpr unsigned kCureRetroPlague = 0x02;
constexpr unsigned kCureBeastPlague = 0x04;
constexpr unsigned kCureAssimilationPlague = 0x08;
constexpr unsigned kCureNaniteVirus = 0x10;
constexpr unsigned kCureUniversal = 0x0f;
// Zuul node-bore parameter rows, lowest to highest drive.
constexpr int kNodeBoreRows[3][3] = {{45, 15, 3}, {65, 35, 4}, {95, 60, 5}};
struct TableEntry {
TechId id;
std::vector<TechEffect> effects;
};
const std::vector<TableEntry>& Table() {
using K = EffectKind;
static const std::vector<TableEntry> table = {
{TechId::CCC_AdvSens, {{K::SetFlag, F(PlayerFlag::AdvancedSensors)}}},
{TechId::IND_Waldo, {{K::AddConstructionMod, 0, -0.10}, {K::AddOutputMod, 0, 0.15}}},
{TechId::IND_CyberInt, {{K::AddConstructionMod, 0, -0.05}, {K::AddOutputMod, 0, 0.20}}},
{TechId::IND_ExpSys, {{K::AddConstructionMod, 0, -0.10}, {K::AddOutputMod, 0, 0.15}}},
{TechId::IND_OrbFound, {{K::AddSavingsMod, 0, -0.05}}},
{TechId::IND_OrbDry, {{K::AddConstructionModClass, 1, -0.05}, {K::AddConstructionModClass, 2, -0.05}}},
{TechId::IND_GravCon, {{K::AddOutputMod, 0, 0.30}}},
{TechId::IND_HvyPlat, {{K::AddOutputMod, 0, 0.10}}},
{TechId::IND_AstMine, {{K::SetFlag, F(PlayerFlag::AsteroidMining)}}},
{TechId::IND_MsMine, {{K::RaiseMaxOverharvest, 0, 0.1}, {K::AddMiningRate, 0, 1.0}}},
{TechId::BIO_GnMod, {{K::AddPopulationMod, 0, 0.10}}},
{TechId::BIO_AtmoAd, {{K::AddSuitTolerance, 0, 0.75}, {K::AddPopulationMod, 0, 0.06}, {K::AddTerraformMod, 0, 0.35}}},
{TechId::BIO_EnvTail, {{K::AddPopulationMod, 0, 0.20}, {K::AddTerraformMod, 0, 0.45}}},
{TechId::BIO_GrvAdpt, {{K::AddSuitTolerance, 0, 1.50}, {K::AddPopulationMod, 0, 0.10}, {K::AddTerraformMod, 0, 0.35}}},
{TechId::IND_ArcCon, {{K::SetFlag, F(PlayerFlag::Arcology)}, {K::AddPopulationMod, 0, 0.15}, {K::ReevaluateCivilianCaps}}},
{TechId::IND_EleNans, {{K::AddTerraformMod, 0, 0.60}}},
{TechId::BIO_TerBac, {{K::AddTerraformMod, 0, 0.45}}},
{TechId::IND_AtProc, {{K::AddTerraformMod, 0, 0.50}}},
{TechId::DRV_TpGate, {{K::RaiseGateTraffic, 0}}},
{TechId::DRV_GatAmp, {{K::RaiseGateTraffic, 1}}},
{TechId::DRV_FarCast, {{K::SetFarCasting}}},
{TechId::CCC_AI, {{K::AiTechBonus, static_cast<int>(AiBonusSlot::Research)}}},
{TechId::CCC_AIAdmin, {{K::AiTechBonus, static_cast<int>(AiBonusSlot::Admin)}}},
{TechId::CCC_AIFac, {{K::AiTechBonus, static_cast<int>(AiBonusSlot::Factory)}}},
{TechId::CCC_AIVrus, {{K::FlagSystemsAI}}},
{TechId::CCC_AISlv, {{K::FlagSystemsAI}, {K::EnableAiBenefit}}},
{TechId::CCC_FtlEcon, {{K::SetFlagUnlessRebel, F(PlayerFlag::TradeAllowed)}}},
{TechId::CCC_ComRaid, {{K::SetFlag, F(PlayerFlag::CommerceRaiding)}}},
{TechId::DRV_GrvSyn, {{K::SetFlag, F(PlayerFlag::GravSynth)}}},
{TechId::CCC_DatCor, {{K::SetFlag, F(PlayerFlag::ViewIntel)}}},
{TechId::IND_HrdStrct, {{K::MulDefenceDamageMod, 0, 0.25}, {K::MulOutputMod, 0, 0.90}}},
{TechId::DRN_AdvRob, {{K::AddConstructionMod, 0, -0.05}}},
{TechId::IND_CruisCon, {{K::GrantTechIfSpecies, Sp(Species::Zuul), static_cast<double>(static_cast<int>(TechId::IND_BrdPod))}}},
{TechId::CCC_SpyBm, {{K::CaptureDesignsWith, static_cast<int>(TechId::IND_SlvgTech)}}},
{TechId::IND_SlvgTech, {{K::CaptureDesignsWith, static_cast<int>(TechId::CCC_SpyBm)}}},
{TechId::BIO_PLGVAC, {{K::CurePlague, static_cast<int>(kCurePlague)}}},
{TechId::BIO_RTPLGVAC, {{K::CurePlague, static_cast<int>(kCureRetroPlague)}}},
{TechId::BIO_BSTVAC, {{K::CurePlague, static_cast<int>(kCureBeastPlague)}}},
{TechId::BIO_ASPLGVAC, {{K::CurePlague, static_cast<int>(kCureAssimilationPlague)}}},
{TechId::BIO_CONNAN, {{K::CurePlague, static_cast<int>(kCureNaniteVirus)}}},
{TechId::BIO_UNIANTI, {{K::CurePlague, static_cast<int>(kCureUniversal)}}},
{TechId::DRV_RIP, {{K::NodeBoreParams, 0}}},
{TechId::DRV_REND, {{K::NodeBoreParams, 1}}},
{TechId::DRV_RAD, {{K::NodeBoreParams, 2}}},
};
return table;
}
const std::vector<TechEffect>& NoEffects() {
static const std::vector<TechEffect> none;
return none;
}
double AiBonusFor(AiBonusSlot slot, const AiBonusValues& v) {
switch (slot) {
case AiBonusSlot::Research: return v.research;
case AiBonusSlot::Admin: return v.admin;
case AiBonusSlot::Factory: return v.factory;
}
return 0.0;
}
void AddAiBonus(PlayerEconomyState& s, AiBonusSlot slot, double amount) {
switch (slot) {
case AiBonusSlot::Research: s.resMod += amount; break;
case AiBonusSlot::Admin: s.incMod += amount; break;
case AiBonusSlot::Factory: s.outMod += amount; break;
}
}
void ApplyOne(PlayerEconomyState& s, const TechEffect& e, const ApplyContext& ctx, TechApplyOutcome& out) {
using K = EffectKind;
switch (e.kind) {
case K::AddConstructionMod:
for (double& c : s.conMod) c += e.value;
break;
case K::AddConstructionModClass:
if (e.index >= 0 && e.index < 3) s.conMod[e.index] += e.value;
break;
case K::AddSavingsMod:
for (double& m : s.savMod) m += e.value;
break;
case K::AddOutputMod: s.outMod += e.value; break;
case K::MulOutputMod: s.outMod *= e.value; break;
case K::AddPopulationMod: s.popMod += e.value; break;
case K::AddTerraformMod: s.terraMod += e.value; break;
case K::AddSuitTolerance: s.suitTol += e.value; break;
case K::RaiseMaxOverharvest: s.maxOverharvest = std::max(s.maxOverharvest, e.value); break;
case K::AddMiningRate: s.miningRate += e.value; break;
case K::MulDefenceDamageMod: s.defenceDamageMod *= e.value; break;
case K::SetFlag:
if (e.index >= 0 && e.index < kPlayerFlagCount) s.flags[e.index] = true;
break;
case K::SetFlagUnlessRebel:
if (!s.rebelAI && e.index >= 0 && e.index < kPlayerFlagCount) s.flags[e.index] = true;
break;
case K::RaiseGateTraffic: {
double v = 0.0;
if (ctx.tuning) v = e.index == 0 ? ctx.tuning->PERGATETRAFFIC_DRV_TpGate : ctx.tuning->PERGATETRAFFIC_DRV_GatAmp;
s.perGateTraffic = std::max(s.perGateTraffic, v);
break;
}
case K::SetFarCasting:
s.castRange = 10.0;
s.castEfficiency = 2.0;
s.castThreshold = 1.0;
break;
case K::AiTechBonus:
if (s.aiBenefit) AddAiBonus(s, static_cast<AiBonusSlot>(e.index), AiBonusFor(static_cast<AiBonusSlot>(e.index), ctx.aiBonus));
break;
case K::FlagSystemsAI: out.flagSystemsAI = true; break;
case K::EnableAiBenefit: SetAiBenefit(s, true, ctx); break;
case K::CurePlague: {
const unsigned mask = static_cast<unsigned>(e.index);
s.hasVaccine |= mask;
s.hasImmunity |= mask;
out.plagueCuredMask |= mask;
break;
}
case K::GrantTechIfSpecies:
if (Sp(s.species) == e.index) out.grantedTech = static_cast<TechId>(static_cast<int>(e.value));
break;
case K::CaptureDesignsWith:
if (s.HasResearched(static_cast<TechId>(e.index))) s.flags[F(PlayerFlag::CaptureDesigns)] = true;
break;
case K::NodeBoreParams:
if (e.index >= 0 && e.index < 3 && kNodeBoreRows[e.index][0] > s.nodeBoreParams[0]) {
for (int i = 0; i < 3; ++i) s.nodeBoreParams[i] = kNodeBoreRows[e.index][i];
out.nodeBoreParamsChanged = true;
}
break;
case K::ReevaluateCivilianCaps: out.reevaluateCivilianCaps = true; break;
}
}
} // namespace
const std::vector<TechEffect>& EffectsOf(TechId id) {
for (const TableEntry& t : Table()) {
if (t.id == id) return t.effects;
}
return NoEffects();
}
unsigned PlagueCureMask(TechId id) {
for (const TechEffect& e : EffectsOf(id)) {
if (e.kind == EffectKind::CurePlague) return static_cast<unsigned>(e.index);
}
return 0;
}
void RebuildSpeciesTechFlags(PlayerEconomyState& s) {
for (int sp = 0; sp < sots::sim::kSpeciesCount; ++sp) {
unsigned bits = 0;
for (int k = 0; k < kXenoLevelCount; ++k) {
const TechId id = XenoTechId(static_cast<XenoLevel>(k), static_cast<Species>(sp));
if (s.HasResearched(id)) bits |= 1u << k;
}
s.speciesFlags[sp] = bits;
}
}
bool AiRebellionPossible(const PlayerEconomyState& s) {
return !s.HasResearched(TechId::CCC_AISlv) && !sots::sim::IsNpcSpecies(s.species);
}
void SetAiBenefit(PlayerEconomyState& s, bool on, const ApplyContext& ctx) {
if (s.aiBenefit == on) return;
s.aiBenefit = on;
const double sign = on ? 1.0 : -1.0;
const TechId aiTechs[3] = {TechId::CCC_AI, TechId::CCC_AIAdmin, TechId::CCC_AIFac};
for (TechId id : aiTechs) {
if (!s.HasResearched(id)) continue;
for (const TechEffect& e : EffectsOf(id)) {
if (e.kind != EffectKind::AiTechBonus) continue;
const AiBonusSlot slot = static_cast<AiBonusSlot>(e.index);
AddAiBonus(s, slot, sign * AiBonusFor(slot, ctx.aiBonus));
}
}
}
TechApplyOutcome ApplyTechEffect(PlayerEconomyState& s, TechId id, const ApplyContext& ctx) {
TechApplyOutcome out;
if (!IsValidTechId(id) || s.HasResearched(id)) return out;
s.researched.set(static_cast<std::size_t>(TechIdIndex(id)));
out.applied = true;
for (const TechEffect& e : EffectsOf(id)) ApplyOne(s, e, ctx, out);
// Every completion: re-derive the per-species xenotech bits and report the species
// whose temperance is held (their addiction is cured on owned systems).
RebuildSpeciesTechFlags(s);
for (int sp = 0; sp < sots::sim::kSpeciesCount; ++sp) {
if (s.speciesFlags[sp] & (1u << static_cast<int>(XenoLevel::Temperance))) out.temperanceSpeciesMask |= 1u << sp;
}
return out;
}
TechApplyOutcome ApplyTechEffectByName(PlayerEconomyState& s, std::string_view name, const ApplyContext& ctx) {
// Node-track techs are keyed by name in the species table, not by id.
for (int sp = 0; sp < sots::sim::kSpeciesCount; ++sp) {
const char* track = NodeTrackTechName(static_cast<Species>(sp));
if (track == nullptr) continue;
std::string_view t(track);
if (t.size() != name.size()) continue;
bool same = true;
for (std::size_t i = 0; i < t.size() && same; ++i) {
same = std::tolower(static_cast<unsigned char>(t[i])) == std::tolower(static_cast<unsigned char>(name[i]));
}
if (same) {
s.nodeTrackMask |= 1u << sp;
TechApplyOutcome out;
out.applied = true;
return out;
}
}
const TechId id = TechIdFromName(name);
if (id == TechId::None) return TechApplyOutcome{};
return ApplyTechEffect(s, id, ctx);
}
const char* const kDesignOptionNamesA[kDesignOptionCountA] = {
"IND_REFCOAT", "IND_IMPRFCT", "IND_PLYALLOY", "IND_MAGLAT", "IND_QRKRES", "IND_ADMALY",
"IND_PREDGUN", "SLD_DEF", "SLD_ERGAB", "SLD_MKONE", "SLD_MKTWO", "SLD_MKTHREE", "SLD_MKFOUR",
"SLD_CLK", "SLD_IMPCLK", "SLD_INTANG", "WEP_VRFTECH", "WEP_NUKEWHD", "WEP_GMAWHD", "WEP_FUSWHD",
"WEP_AMWHD", "WEP_NEUTRND", "CCC_INTSENS", "CCC_SNSJAM", "CCC_QNTCHAF", "CCC_CMBTALG",
"CCC_HOLOTAC", "CCC_ADVCNC", "CCC_AdvSens", "DRV_MCROFUS", "DRV_INCTHRST", "DRV_SMLFUS",
};
const char* const kDesignOptionNamesB[kDesignOptionCountB] = {
"DRV_NODE", "DRV_NODFOC", "DRV_NODPATH", "DRV_STRWRP", "DRV_IMPSTWRP", "DRV_FLICKER", "DRV_HYPER",
"DRV_HYPRFLD", "DRV_WARP", "SLD_MESSHLD", "SLD_GRVSHLD", "CCC_AIFRCON", "DRV_RIP", "DRV_REND",
"DRV_RAD", "BIO_CONNAN", "SLD_DISR", "SLD_MAGNI", "DRV_QNTCAP", "CCC_FCCOM", "IND_HRDELEC",
"IND_TRKSTL", "DRV_VDCTR", "DRV_VDCRV", "DRV_VDMSTR", "BIO_SMRTNAN", "WEP_ACCAMP", "WEP_MwMsl",
"WEP_HvyPmsl",
};
DesignOptionMasks ComputeDesignOptionMasks(const std::function<bool(std::string_view)>& hasResearchedByName) {
DesignOptionMasks m;
for (int i = 0; i < kDesignOptionCountA; ++i) {
if (hasResearchedByName(kDesignOptionNamesA[i])) m.a |= 1u << i;
}
for (int i = 0; i < kDesignOptionCountB; ++i) {
if (hasResearchedByName(kDesignOptionNamesB[i])) m.b |= 1u << i;
}
return m;
}
} // namespace sots::effects

View file

@ -0,0 +1,179 @@
// Code-defined tech effects: what researching a tech does to a player's strategic
// modifiers, as a data table plus a small apply layer.
//
// Effects are additive per research event and permanent (there is no un-research).
// The state below carries the player fields the effects touch, named after the save
// tags where one exists; the apply function only ever reads and writes this struct and
// reports the things it cannot do itself (touch systems, grant another tech) in an
// outcome the caller acts on.
#pragma once
#include <bitset>
#include <cstdint>
#include <functional>
#include <string_view>
#include <vector>
#include "game/effects/tech_id.h"
#include "game/sim/species.h"
#include "game/sim/tuning.h"
namespace sots::effects {
// Boolean abilities a tech switches on.
enum class PlayerFlag : int {
AdvancedSensors = 0, // hadvs: sensor ranges x ADVSENS_SENSORS_MOD; partial contacts count as known
AsteroidMining, // AMine: asteroid resources counted in output
Arcology, // harcc: +1e8 imperial / +2e8 civilian carrying capacity
GravSynth, // hgs: synced to clients; the drive modifier is applied design-side
TradeAllowed, // CnTrd: trade routes may be registered (not for the rebel AI)
CommerceRaiding, // CnRad
ViewIntel, // CnVItl: intel view on other empires (client side)
CaptureDesigns, // cdp: observed/captured designs (needs both spy beam and salvage)
Count
};
constexpr int kPlayerFlagCount = static_cast<int>(PlayerFlag::Count);
// Slots of the AI-benefit tech bonus.
enum class AiBonusSlot : int { Research = 0, Admin = 1, Factory = 2 };
enum class EffectKind : int {
AddConstructionMod, // ConMod[0..2] += value
AddConstructionModClass, // ConMod[index] += value
AddSavingsMod, // SavMod[0..2] += value
AddOutputMod, // OutMod += value
MulOutputMod, // OutMod *= value
AddPopulationMod, // PopMod += value
AddTerraformMod, // TerraMod += value
AddSuitTolerance, // SuitTol += value
RaiseMaxOverharvest, // MaxOH = max(MaxOH, value)
AddMiningRate, // MinRate += value
MulDefenceDamageMod, // pddm *= value
SetFlag, // flag(index) = true
SetFlagUnlessRebel, // flag(index) = true unless the player is the rebel AI
RaiseGateTraffic, // PrGtTrf = max(PrGtTrf, tuning key: index 0 = TpGate, 1 = GatAmp)
SetFarCasting, // CstR = 10, CstE = 2, CstT = 1
AiTechBonus, // slot(index) += supplied bonus while the AI benefit is on
FlagSystemsAI, // every owned system gets its AI flag (reported to the caller)
EnableAiBenefit, // the AI benefit is switched (back) on
CurePlague, // HasVac |= mask, HasImm |= mask (index = mask); cured on systems/ships
GrantTechIfSpecies, // index = species: the tech in `value` is granted for free
CaptureDesignsWith, // CaptureDesigns flag when this and tech(index) are both researched
NodeBoreParams, // index = parameter row; the highest row wins
ReevaluateCivilianCaps, // systems whose civilians sit at the cap are re-evaluated
};
struct TechEffect {
EffectKind kind;
int index = 0;
double value = 0;
};
// The effects of one tech (empty for techs without a strategic effect).
const std::vector<TechEffect>& EffectsOf(TechId id);
// Everything a tech touches on the player. Defaults are the "no tech yet" values; the
// caller seeds species-dependent starts (SuitTol) from its own tables.
struct PlayerEconomyState {
sots::sim::Species species = sots::sim::Species::Human;
bool rebelAI = false;
double conMod[3] = {1.0, 1.0, 1.0}; // ConMod: construction cost per hull class
double savMod[3] = {1.0, 1.0, 1.0}; // SavMod
double outMod = 1.0; // OutMod
double popMod = 1.0; // PopMod
double terraMod = 1.0; // TerraMod
double suitTol = 0.0; // SuitTol: species start value + adaptation techs
double maxOverharvest = 0.0; // MaxOH
double miningRate = 0.0; // MinRate
double defenceDamageMod = 1.0; // pddm
double resMod = 1.0; // ResMod (AI research bonus lands here)
double incMod = 1.0; // IncMod
double castRange = 0.0; // CstR
double castEfficiency = 0.0; // CstE
double castThreshold = 0.0; // CstT
double perGateTraffic = 0.0; // PrGtTrf
int nodeBoreParams[3] = {0, 0, 0}; // Zuul node-bore parameters (meaning of the three not resolved)
bool flags[kPlayerFlagCount] = {};
bool aiBenefit = true; // AIBn: false after an AI rebellion
unsigned nodeTrackMask = 0; // NPTrk: bit per Species whose traffic is visible
unsigned hasVaccine = 0; // HasVac
unsigned hasImmunity = 0; // HasImm
unsigned speciesFlags[sots::sim::kSpeciesCount] = {}; // xenotech bits per target species
std::bitset<kTechIdCount> researched;
bool Flag(PlayerFlag f) const { return flags[static_cast<int>(f)]; }
bool HasResearched(TechId id) const { return IsValidTechId(id) && researched.test(static_cast<std::size_t>(TechIdIndex(id))); }
};
// Values of the three AI-benefit bonuses. The game reads them from a small table whose
// numbers are not recovered; the caller supplies them (0 = no bonus).
struct AiBonusValues {
double research = 0; // added to ResMod
double admin = 0; // added to IncMod
double factory = 0; // added to OutMod
};
struct ApplyContext {
const sots::sim::TuningTable* tuning = nullptr; // gate traffic keys
AiBonusValues aiBonus;
};
// What the caller has to do after an apply because it needs game state this layer has
// no access to.
struct TechApplyOutcome {
bool applied = false; // false when the id is invalid or already researched
TechId grantedTech = TechId::None; // research this one too (Zuul boarding pods)
unsigned plagueCuredMask = 0; // clear these plague types on owned systems and ships
bool flagSystemsAI = false; // mark every owned system's AI flag
bool reevaluateCivilianCaps = false; // re-evaluate systems whose civilians are at the cap
unsigned temperanceSpeciesMask = 0; // bit per Species: cure addiction to it on owned systems
bool nodeBoreParamsChanged = false;
};
// Mark `id` researched and apply its effects. Every completion also rebuilds the
// per-species xenotech flags and reports the temperance species. Applying an already
// researched or invalid id does nothing. CONFIDENCE: high on the tabled effects (their
// constants were read directly); medium on the AI-benefit re-application and the
// node-bore "highest wins" rule.
TechApplyOutcome ApplyTechEffect(PlayerEconomyState& s, TechId id, const ApplyContext& ctx);
// Apply a completion by data-file name: resolves the id (case-insensitively), and also
// handles the node-track techs, which are keyed by name in the species table rather than
// by id. Returns the outcome; `applied` is false for names without a code effect.
TechApplyOutcome ApplyTechEffectByName(PlayerEconomyState& s, std::string_view name, const ApplyContext& ctx);
// Switch the AI benefit on or off, adding or removing the bonus of every researched AI
// tech accordingly (off = AI rebellion; on again = the AI slave tech). CONFIDENCE: medium.
void SetAiBenefit(PlayerEconomyState& s, bool on, const ApplyContext& ctx);
// Recompute speciesFlags[sp] from the researched set: bit k is set when the level-k
// xenotech aimed at species sp is researched. Also run on load. CONFIDENCE: high.
void RebuildSpeciesTechFlags(PlayerEconomyState& s);
// Whether an AI rebellion can still happen to this player: only while the AI slave tech
// is not researched (and the player is not the NPC race). CONFIDENCE: high.
bool AiRebellionPossible(const PlayerEconomyState& s);
// Plague-cure bit of a vaccine tech (0 for anything else); the universal antidote
// covers the first four plague types. CONFIDENCE: high.
unsigned PlagueCureMask(TechId id);
// Design-option availability masks: two words whose bits mean "the named tech is
// researched". The names are game vocabulary; their ids are not all recovered, so the
// masks are computed from a by-name predicate. Bit i of A is kDesignOptionNamesA[i], of
// B kDesignOptionNamesB[i]. Consumers are design-side and not modelled. CONFIDENCE: high
// on the tables.
constexpr int kDesignOptionCountA = 32;
constexpr int kDesignOptionCountB = 29;
extern const char* const kDesignOptionNamesA[kDesignOptionCountA];
extern const char* const kDesignOptionNamesB[kDesignOptionCountB];
struct DesignOptionMasks {
std::uint32_t a = 0;
std::uint32_t b = 0;
};
DesignOptionMasks ComputeDesignOptionMasks(const std::function<bool(std::string_view)>& hasResearchedByName);
} // namespace sots::effects

View file

@ -0,0 +1,80 @@
#include "game/effects/tech_id.h"
#include <cctype>
namespace sots::effects {
namespace {
const char* const kTechIdNames[kTechIdCount] = {
#define SOTS_TECH_ID_NAME(idx, name, str) str,
SOTS_TECH_ID_LIST(SOTS_TECH_ID_NAME)
#undef SOTS_TECH_ID_NAME
};
bool EqualsIgnoreCase(std::string_view a, std::string_view b) {
if (a.size() != b.size()) return false;
for (std::size_t i = 0; i < a.size(); ++i) {
if (std::tolower(static_cast<unsigned char>(a[i])) != std::tolower(static_cast<unsigned char>(b[i]))) return false;
}
return true;
}
// Species order inside a xenotech block: the NPC race is never a target, and the
// 5-entry blocks also skip the Zuul.
int BlockSlot(sots::sim::Species target, bool includesZuul) {
using sots::sim::Species;
switch (target) {
case Species::Human: return 0;
case Species::Hiver: return 1;
case Species::Tarkas: return 2;
case Species::Liir: return 3;
case Species::Zuul: return includesZuul ? 4 : -1;
case Species::Morrigi: return includesZuul ? 5 : 4;
default: return -1;
}
}
} // namespace
const char* TechIdName(TechId id) {
if (!IsValidTechId(id)) return nullptr;
return kTechIdNames[TechIdIndex(id)];
}
TechId TechIdFromName(std::string_view name) {
for (int i = 0; i < kTechIdCount; ++i) {
if (kTechIdNames[i] != nullptr && EqualsIgnoreCase(name, kTechIdNames[i])) return TechIdFromIndex(i);
}
return TechId::None;
}
TechId XenoTechId(XenoLevel level, sots::sim::Species target) {
struct Block { int base; bool includesZuul; };
static constexpr Block kBlocks[kXenoLevelCount] = {
{114, true}, // Translation1
{120, true}, // Translation2
{126, true}, // Translation3
{132, false}, // Incorporate
{137, false}, // Addict
{142, false}, // Temperance
{147, true}, // Subjugate
{153, false}, // Accommodate
{158, true}, // Proliferate
};
const int k = static_cast<int>(level);
if (k < 0 || k >= kXenoLevelCount) return TechId::None;
const int slot = BlockSlot(target, kBlocks[k].includesZuul);
if (slot < 0) return TechId::None;
return TechIdFromIndex(kBlocks[k].base + slot);
}
const char* NodeTrackTechName(sots::sim::Species tracked) {
switch (tracked) {
case sots::sim::Species::Human: return "CCC_NDTRKHUM";
case sots::sim::Species::Zuul: return "CCC_NDTRKZUL";
default: return nullptr;
}
}
} // namespace sots::effects

269
src/game/effects/tech_id.h Normal file
View file

@ -0,0 +1,269 @@
// TechId: the engine's own key space for techs that have a code-defined effect.
//
// The game keeps a fixed list of 196 tech names; at master-tree construction each name
// is matched (case-insensitively) against the loaded `.tech` files and the position in
// that list becomes the tech's numeric id, 10000 + index. Every hard-coded effect,
// gate and bitmask is keyed on those ids; techs that are not in the list have no code
// effect beyond what the data files say (prerequisites, section/weapon availability).
//
// The list below is the part of that table recovered so far. Entries whose data-file
// name is known carry it; entries whose position is known but whose name is not are
// `Unresolved_NNN` (no code reads them, or their readers are combat/design side). The
// xenotech (XNC) block is named by role because the per-species data-file names are
// only partially recovered -- see docs/game-effects.md.
#pragma once
#include <string_view>
#include "game/sim/species.h"
namespace sots::effects {
constexpr int kTechIdBase = 10000;
constexpr int kTechIdCount = 196;
// X(index, EnumName, "data-file name" or nullptr)
#define SOTS_TECH_ID_LIST(X) \
X(0, CCC_AdvSens, "CCC_AdvSens") \
X(1, IND_Waldo, "IND_Waldo") \
X(2, IND_CyberInt, "IND_CyberInt") \
X(3, IND_ExpSys, "IND_ExpSys") \
X(4, IND_OrbFound, "IND_OrbFound") \
X(5, IND_OrbDry, "IND_OrbDry") \
X(6, IND_GravCon, "IND_GravCon") \
X(7, IND_HvyPlat, "IND_HvyPlat") \
X(8, IND_AstMine, "IND_AstMine") \
X(9, IND_MsMine, "IND_MsMine") \
X(10, BIO_GnMod, "BIO_GnMod") \
X(11, BIO_AtmoAd, "BIO_AtmoAd") \
X(12, BIO_EnvTail, "BIO_EnvTail") \
X(13, BIO_GrvAdpt, "BIO_GrvAdpt") \
X(14, IND_ArcCon, "IND_ArcCon") \
X(15, IND_EleNans, "IND_EleNans") \
X(16, BIO_TerBac, "BIO_TerBac") \
X(17, IND_AtProc, "IND_AtProc") \
X(18, DRV_TpGate, "DRV_TpGate") \
X(19, DRV_GatAmp, "DRV_GatAmp") \
X(20, DRV_FarCast, "DRV_FarCast") \
X(21, CCC_AI, "CCC_AI") \
X(22, CCC_AIAdmin, "CCC_AIAdmin") \
X(23, CCC_AIFac, "CCC_AIFac") \
X(24, CCC_AIVrus, "CCC_AIVrus") \
X(25, CCC_AISlv, "CCC_AISlv") \
X(26, CCC_FtlEcon, "CCC_FtlEcon") \
X(27, CCC_ComRaid, "CCC_ComRaid") \
X(28, DRV_GrvSyn, "DRV_GrvSyn") \
X(29, CCC_DatCor, "CCC_DatCor") \
X(30, IND_HrdStrct, "IND_HrdStrct") \
X(31, DRN_AdvRob, "DRN_AdvRob") \
X(32, IND_CruisCon, "IND_CruisCon") \
X(33, IND_BrdPod, "IND_BrdPod") \
X(34, CCC_SpyBm, "CCC_SpyBm") \
X(35, IND_SlvgTech, "IND_SlvgTech") \
X(36, WEP_MwMsl, "WEP_MwMsl") \
X(37, WEP_HvyPmsl, "WEP_HvyPmsl") \
X(38, BIO_PLGVAC, "BIO_PLGVAC") \
X(39, BIO_RTPLGVAC, "BIO_RTPLGVAC") \
X(40, BIO_BSTVAC, "BIO_BSTVAC") \
X(41, BIO_ASPLGVAC, "BIO_ASPLGVAC") \
X(42, BIO_CONNAN, "BIO_CONNAN") \
X(43, BIO_UNIANTI, "BIO_UNIANTI") \
X(44, BIO_PLG, "BIO_PLG") \
X(45, BIO_RetroPlague, nullptr) /* name inferred from its vaccine */ \
X(46, BIO_BeastPlague, nullptr) \
X(47, BIO_AssimilationPlague, nullptr) \
X(48, BIO_NANVIR, "BIO_NANVIR") \
X(49, DRN_CMBT, "DRN_CMBT") \
X(50, IND_STLTHARM, "IND_STLTHARM") \
X(51, DRV_PLSMFOC, "DRV_PLSMFOC") \
X(52, Unresolved_052, nullptr) \
X(53, Unresolved_053, nullptr) \
X(54, Unresolved_054, nullptr) \
X(55, Unresolved_055, nullptr) \
X(56, Unresolved_056, nullptr) \
X(57, Unresolved_057, nullptr) \
X(58, Unresolved_058, nullptr) \
X(59, Unresolved_059, nullptr) \
X(60, Unresolved_060, nullptr) \
X(61, Unresolved_061, nullptr) \
X(62, Unresolved_062, nullptr) \
X(63, Unresolved_063, nullptr) \
X(64, DRV_RIP, "DRV_RIP") \
X(65, DRV_REND, "DRV_REND") \
X(66, DRV_RAD, "DRV_RAD") \
X(67, Unresolved_067, nullptr) \
X(68, Unresolved_068, nullptr) \
X(69, Unresolved_069, nullptr) \
X(70, Unresolved_070, nullptr) \
X(71, SLD_INTANG, "SLD_INTANG") \
X(72, DRV_RECFISS, "DRV_RECFISS") \
X(73, Unresolved_073, nullptr) \
X(74, CCC_ARMCOM, "CCC_ARMCOM") \
X(75, CCC_DATSYN, "CCC_DATSYN") \
X(76, CCC_BTLCMP, "CCC_BTLCMP") \
X(77, WEP_BeamVariant_077, nullptr) /* combat beam variant */ \
X(78, WEP_BeamVariant_078, nullptr) \
X(79, WEP_CannonVariant_079, nullptr) /* combat cannon variant */ \
X(80, WEP_CannonVariant_080, nullptr) \
X(81, WEP_CannonVariant_081, nullptr) \
X(82, WEP_BeamVariant_082, nullptr) \
X(83, Unresolved_083, nullptr) \
X(84, Unresolved_084, nullptr) \
X(85, Unresolved_085, nullptr) \
X(86, Unresolved_086, nullptr) \
X(87, Unresolved_087, nullptr) \
X(88, Unresolved_088, nullptr) \
X(89, Unresolved_089, nullptr) \
X(90, Unresolved_090, nullptr) \
X(91, Unresolved_091, nullptr) \
X(92, Unresolved_092, nullptr) \
X(93, CCC_HYPCOM, "CCC_HYPCOM") \
X(94, IND_TRKSTL, "IND_TRKSTL") \
X(95, IND_SPNLMNT, "IND_SPNLMNT") \
X(96, WEP_HCLAS, "WEP_HCLAS") \
X(97, WEP_PRTBM, "WEP_PRTBM") \
X(98, WEP_DSRPTR, "WEP_DSRPTR") \
X(99, Unresolved_099, nullptr) \
X(100, WEP_NUKMINE, nullptr) /* stem known, prefix inferred */ \
X(101, WEP_FUSMINE, nullptr) \
X(102, WEP_DFMSL, nullptr) \
X(103, WEP_GSDRVR, nullptr) \
X(104, WEP_MASDRVR, nullptr) \
X(105, WEP_HVYDRVR, nullptr) \
X(106, WEP_VRFTECH, "WEP_VRFTECH") \
X(107, WEP_PDTECH, nullptr) \
X(108, CCC_FTLBRDB, "CCC_FTLBRDB") \
X(109, Unresolved_109, nullptr) \
X(110, CCC_INTSENS, "CCC_INTSENS") \
X(111, Unresolved_111, nullptr) \
X(112, CCC_SPJAM, "CCC_SPJAM") \
X(113, CCC_TUNSENS, "CCC_TUNSENS") \
X(114, XNC_Translation1_Human, "CCC_TRNSHUM") \
X(115, XNC_Translation1_Hiver, nullptr) \
X(116, XNC_Translation1_Tarkas, nullptr) \
X(117, XNC_Translation1_Liir, "CCC_TRNSLIR") \
X(118, XNC_Translation1_Zuul, nullptr) \
X(119, XNC_Translation1_Morrigi, nullptr) \
X(120, XNC_Translation2_Human, nullptr) \
X(121, XNC_Translation2_Hiver, nullptr) \
X(122, XNC_Translation2_Tarkas, nullptr) \
X(123, XNC_Translation2_Liir, nullptr) \
X(124, XNC_Translation2_Zuul, nullptr) \
X(125, XNC_Translation2_Morrigi, nullptr) \
X(126, XNC_Translation3_Human, nullptr) \
X(127, XNC_Translation3_Hiver, nullptr) \
X(128, XNC_Translation3_Tarkas, nullptr) \
X(129, XNC_Translation3_Liir, nullptr) \
X(130, XNC_Translation3_Zuul, nullptr) \
X(131, XNC_Translation3_Morrigi, nullptr) \
X(132, XNC_Incorporate_Human, nullptr) \
X(133, XNC_Incorporate_Hiver, nullptr) \
X(134, XNC_Incorporate_Tarkas, nullptr) \
X(135, XNC_Incorporate_Liir, nullptr) \
X(136, XNC_Incorporate_Morrigi, nullptr) \
X(137, XNC_Addict_Human, nullptr) \
X(138, XNC_Addict_Hiver, nullptr) \
X(139, XNC_Addict_Tarkas, nullptr) \
X(140, XNC_Addict_Liir, nullptr) \
X(141, XNC_Addict_Morrigi, nullptr) \
X(142, XNC_Temperance_Human, nullptr) \
X(143, XNC_Temperance_Hiver, nullptr) \
X(144, XNC_Temperance_Tarkas, nullptr) \
X(145, XNC_Temperance_Liir, nullptr) \
X(146, XNC_Temperance_Morrigi, nullptr) \
X(147, XNC_Subjugate_Human, nullptr) \
X(148, XNC_Subjugate_Hiver, nullptr) \
X(149, XNC_Subjugate_Tarkas, nullptr) \
X(150, XNC_Subjugate_Liir, nullptr) \
X(151, XNC_Subjugate_Zuul, nullptr) \
X(152, XNC_Subjugate_Morrigi, nullptr) \
X(153, XNC_Accommodate_Human, nullptr) \
X(154, XNC_Accommodate_Hiver, nullptr) \
X(155, XNC_Accommodate_Tarkas, nullptr) \
X(156, XNC_Accommodate_Liir, nullptr) \
X(157, XNC_Accommodate_Morrigi, nullptr) \
X(158, XNC_Proliferate_Human, nullptr) \
X(159, XNC_Proliferate_Hiver, nullptr) \
X(160, XNC_Proliferate_Tarkas, nullptr) \
X(161, XNC_Proliferate_Liir, nullptr) \
X(162, XNC_Proliferate_Zuul, nullptr) \
X(163, XNC_Proliferate_Morrigi, nullptr) \
X(164, Unresolved_164, nullptr) \
X(165, Unresolved_165, nullptr) \
X(166, Unresolved_166, nullptr) \
X(167, Unresolved_167, nullptr) \
X(168, Unresolved_168, nullptr) \
X(169, Unresolved_169, nullptr) \
X(170, Unresolved_170, nullptr) \
X(171, IND_QRKRES, "IND_QRKRES") \
X(172, Unresolved_172, nullptr) \
X(173, SLD_MKONE, "SLD_MKONE") \
X(174, SLD_MKTWO, "SLD_MKTWO") \
X(175, SLD_MKTHREE, "SLD_MKTHREE") \
X(176, Unresolved_176, nullptr) \
X(177, WEP_NEUTRND, "WEP_NEUTRND") \
X(178, Unresolved_178, nullptr) \
X(179, CCC_ADVCNC, "CCC_ADVCNC") \
X(180, DRV_MCROFUS, "DRV_MCROFUS") \
X(181, DRV_INCTHRST, "DRV_INCTHRST") \
X(182, DRV_SMLFUS, "DRV_SMLFUS") \
X(183, DRV_HYPRFLD, "DRV_HYPRFLD") \
X(184, SLD_MESSHLD, "SLD_MESSHLD") \
X(185, SLD_GRVSHLD, "SLD_GRVSHLD") \
X(186, SLD_DISR, "SLD_DISR") \
X(187, SLD_MAGNI, "SLD_MAGNI") \
X(188, DRV_QNTCAP, "DRV_QNTCAP") \
X(189, CCC_FCCOM, "CCC_FCCOM") \
X(190, IND_HRDELEC, "IND_HRDELEC") \
X(191, BIO_SMRTNAN, "BIO_SMRTNAN") \
X(192, WEP_ACCAMP, "WEP_ACCAMP") \
X(193, Unresolved_193, nullptr) \
X(194, Unresolved_194, nullptr) \
X(195, Unresolved_195, nullptr)
enum class TechId : int {
#define SOTS_TECH_ID_ENUM(idx, name, str) name = kTechIdBase + idx,
SOTS_TECH_ID_LIST(SOTS_TECH_ID_ENUM)
#undef SOTS_TECH_ID_ENUM
None = kTechIdBase + 197, // the game's "no tech" sentinel
};
constexpr bool IsValidTechId(int id) { return id >= kTechIdBase && id < kTechIdBase + kTechIdCount; }
constexpr bool IsValidTechId(TechId id) { return IsValidTechId(static_cast<int>(id)); }
constexpr int TechIdIndex(TechId id) { return static_cast<int>(id) - kTechIdBase; }
constexpr TechId TechIdFromIndex(int index) { return static_cast<TechId>(kTechIdBase + index); }
// Data-file name of a tech id, or nullptr when the position is known but the name is not
// (or the id is invalid).
const char* TechIdName(TechId id);
// Resolve a data-file name to its id, case-insensitively as the game does. Names not in
// the recovered table resolve to TechId::None -- such techs have no code effect here.
TechId TechIdFromName(std::string_view name);
// Xenotech families, in flag-bit order (bit k of a player's per-species flag word).
enum class XenoLevel : int {
Translation1 = 0,
Translation2 = 1,
Translation3 = 2,
Incorporate = 3,
Addict = 4,
Temperance = 5,
Subjugate = 6,
Accommodate = 7,
Proliferate = 8,
};
constexpr int kXenoLevelCount = 9;
// The xenotech of one family aimed at one species, or None where the game has no such
// tech (the NPC race for every family; the Zuul for Incorporate, Addict, Temperance and
// Accommodate). CONFIDENCE: high on the family order and block bases; medium on the
// compact species order inside a block; low on which species the four 5-entry blocks
// other than Incorporate omit (Zuul assumed).
TechId XenoTechId(XenoLevel level, sots::sim::Species target);
// Data-file name of the tech that lets a player see one species' node-space traffic
// (only the Humans' and the Zuul's drives leave trackable traffic), or nullptr.
const char* NodeTrackTechName(sots::sim::Species tracked);
} // namespace sots::effects

View file

@ -11,9 +11,10 @@ namespace {
constexpr std::int64_t kMaxPopStep = 50000000;
}
double HazardModifierShape(double suitability, double idealSuitability, double tolerance) {
if (tolerance <= 0) return suitability == idealSuitability ? 1.0 : 0.0;
return Clamp01(1.0 - std::fabs(suitability - idealSuitability) / tolerance);
double HazardModifier(double suitability, double idealSuitability, double suitTolerance) {
const double band = suitTolerance + 0.1;
if (band <= 0) return suitability == idealSuitability ? 1.0 : 0.0;
return Clamp01(1.0 - std::fabs(suitability - idealSuitability) / band);
}
std::int64_t CarryingCapacity(const CapacityInputs& in, const TuningTable& t) {
@ -92,9 +93,9 @@ double SlaveDeathRate(double slaveOutputRate, double suit, double ideal,
const double base = slaveOutputRate * t.SLAVES_DEATH_RATE_BYOUTPUT +
std::fabs(ideal - suit) * t.SLAVES_DEATH_RATE_BYHAZARD +
t.SLAVES_DEATH_RATE;
double mod = flags.slaveDeathTech0 ? 0.8 : 1.0;
if (flags.slaveDeathTech1) mod -= 0.2;
if (flags.slaveDeathTech2) mod -= 0.2;
double mod = flags.translation1 ? 0.8 : 1.0;
if (flags.translation2) mod -= 0.2;
if (flags.translation3) mod -= 0.2;
return base * mod;
}
@ -174,9 +175,24 @@ OutputSplit SplitLeftover(int leftover, const OutputRates& rates, bool suitAtIde
return s;
}
int SystemMoneyIncomeShape(int tradePoints, double speciesIncomeFactor, double playerIncomeMult,
double playerCostTerm) {
return Ftol(static_cast<double>(tradePoints) * speciesIncomeFactor * playerIncomeMult - playerCostTerm);
double SuitabilityCostMod(double suitability, double idealSuitability, double suitTolerance,
bool rebelAI, bool owned) {
if (rebelAI) return 0.0;
if (!owned) return 20.0;
return std::min(std::fabs(idealSuitability - suitability), suitTolerance);
}
int SystemMoneyIncome(const SystemMoneyInputs& in) {
// Whole blocks of five trade points, worth five money each.
double t = (in.tradePoints - std::fmod(in.tradePoints, 5.0)) * 5.0;
t += static_cast<double>(in.popIncomeImperial);
t += static_cast<double>(in.popIncomeCivilian);
t += static_cast<double>(in.slaveIncome);
t *= in.speciesIncomeFactor;
t *= in.playerIncMod;
t *= in.serverIncomeMod * in.difficultyIncomeMult;
const double cost = in.speciesCostFactor * in.suitCostMod * 10000.0 * 1.5;
return Ftol(t - cost);
}
void ApplyPopulationBonus(std::int64_t& pop, std::int64_t capacity, std::int64_t& pendingBonus) {
@ -198,15 +214,14 @@ void AccrueSystemBonus(const SystemBonusInputs& in, std::int64_t& popBonus, doub
if (in.turnsOwned <= t.SYSTEMBONUS_MINTURNS) return;
if (in.turnsSinceRebellion <= t.SYSTEMBONUS_MINTURNS) return;
const double popCapMult = in.homeSystem ? t.SYSTEMBONUS_POPBONUS_HOME : t.SYSTEMBONUS_POPBONUS;
const double infraCap = in.homeSystem ? t.SYSTEMBONUS_INFRABONUS_HOME : t.SYSTEMBONUS_INFRABONUS;
const double cap = static_cast<double>(in.capacity);
const std::int64_t popTarget =
in.ownerSpeciesEligible ? Ftol(std::max(t.SYSTEMBONUS_POPBONUS, 0.0) * cap) : 0;
const std::int64_t popInc = Ftol(t.SYSTEMBONUS_POPBONUS_INC * cap);
popBonus += std::min(std::max<std::int64_t>(popInc, 0), std::max<std::int64_t>(popTarget - popBonus, 0));
const std::int64_t popInc = Ftoi64(static_cast<double>(in.capacity) * t.SYSTEMBONUS_POPBONUS_INC);
const std::int64_t popRoom = Ftoi64(static_cast<double>(in.capacity) * popCapMult) - popBonus;
popBonus += std::max<std::int64_t>(0, std::min(popInc, popRoom));
const double infraRoom = infraCap - infraBonus;
infraBonus += std::max(0.0, std::min(t.SYSTEMBONUS_INFRABONUS_INC, infraRoom));
const double infraTarget = in.ownerSpeciesEligible ? t.SYSTEMBONUS_INFRABONUS : 0.0;
infraBonus += std::min(std::max(t.SYSTEMBONUS_INFRABONUS_INC, 0.0), std::max(infraTarget - infraBonus, 0.0));
}
BuildQueueResult ProcessBuildQueue(std::vector<BuildOrder>& queue, int points) {

View file

@ -13,12 +13,34 @@ namespace sots::sim {
// Population group kinds (rows of the per-type population table).
enum class PopGroup : int { Imperial = 0, Civilian = 1, Slaves = 2 };
// Growth-suppression / immunity bits a player holds per species (tech effects).
// Xenotech level a player has reached against one species: bit k of the per-species
// flag word is set when the k-th xenotech for that species is researched. The bit order
// is the order of the xenotech families (translation 1/2/3, incorporate, addict,
// temperance, subjugate, accommodate, proliferate). CONFIDENCE: high on the order.
struct SpeciesTechFlags {
bool slaveDeathTech0 = false; // bit 0: slave death rate x0.8
bool slaveDeathTech1 = false; // bit 1: slave death rate -0.2
bool slaveDeathTech2 = false; // bit 2: slave death rate -0.2
bool hazardImmune = false; // bit 7: no suitability penalty on capacity
bool translation1 = false; // bit 0: slave death rate x0.8
bool translation2 = false; // bit 1: slave death rate -0.2
bool translation3 = false; // bit 2: slave death rate -0.2
bool incorporate = false; // bit 3
bool addict = false; // bit 4
bool temperance = false; // bit 5: addiction to this species is cured
bool subjugate = false; // bit 6
bool accommodate = false; // bit 7: no suitability penalty on capacity (hazard = 1)
bool proliferate = false; // bit 8
static SpeciesTechFlags FromBits(unsigned bits) {
SpeciesTechFlags f;
f.translation1 = bits & 0x001;
f.translation2 = bits & 0x002;
f.translation3 = bits & 0x004;
f.incorporate = bits & 0x008;
f.addict = bits & 0x010;
f.temperance = bits & 0x020;
f.subjugate = bits & 0x040;
f.accommodate = bits & 0x080;
f.proliferate = bits & 0x100;
return f;
}
};
// ---------------------------------------------------------------------------------------
@ -26,10 +48,13 @@ struct SpeciesTechFlags {
// ---------------------------------------------------------------------------------------
// Hazard modifier on carrying capacity from planet suitability vs the species' ideal:
// 1 at the ideal, falling linearly to 0 at |suit - ideal| == tolerance.
// CONFIDENCE: low -- see sots-re open questions (only the inputs of this function are
// established; the curve shape is a placeholder).
double HazardModifierShape(double suitability, double idealSuitability, double tolerance);
// clamp01(1 - |suit - ideal| / (SuitTol + 0.1))
// Linear, no exponent; the +0.1 gives every species a habitable band even at zero
// tolerance. SuitTol starts at the species value and is raised by the atmospheric and
// gravitational adaptation techs. Skipped (treated as 1) when the player has the
// accommodate xenotech for the species or is the rebel AI -- the caller decides that.
// CONFIDENCE: high -- read with its constant.
double HazardModifier(double suitability, double idealSuitability, double suitTolerance);
struct CapacityInputs {
int planetSize = 0; // Size
@ -40,7 +65,7 @@ struct CapacityInputs {
double speciesGrowthFactor = 1.0; // per-species factor
bool ownerIsDifferentSpecies = false;
double crossSpeciesMod = 1.0; // applied when the owner is another species
double hazardMod = 1.0; // from HazardModifierShape (or 1 when immune)
double hazardMod = 1.0; // from HazardModifier (or 1 when accommodated)
bool arcologyTech = false; // adds a flat 1e8 (imperial) / 2e8 (civilian)
bool groupMaxEnabled = false; // per-group hard cap present
std::int64_t groupMax = 0;
@ -115,7 +140,7 @@ double TerraformDelta(int points, double terraMod, double suit, double ideal);
// Per-turn slave death rate:
// (slaveOutputRate x BYOUTPUT + |ideal - suit| x BYHAZARD + DEATH_RATE) x mod
// mod = (tech0 ? 0.8 : 1) - 0.2 x tech1 - 0.2 x tech2
// mod = (translation1 ? 0.8 : 1) - 0.2 x translation2 - 0.2 x translation3
// CONFIDENCE: high.
double SlaveDeathRate(double slaveOutputRate, double suit, double ideal,
const SpeciesTechFlags& flags, const TuningTable& t);
@ -181,10 +206,36 @@ int ConstructionPoints(int constructionShare, int stations, const TuningTable& t
// construction was the only slider. CONFIDENCE: medium.
OutputSplit SplitLeftover(int leftover, const OutputRates& rates, bool suitAtIdeal, bool infraFull);
// Money from trade points: ftol(points x speciesIncomeFactor x playerIncomeMult - costTerm).
// CONFIDENCE: low -- see sots-re open questions (the income tail lost its FP chain).
int SystemMoneyIncomeShape(int tradePoints, double speciesIncomeFactor, double playerIncomeMult,
double playerCostTerm);
// Suitability term of the money cost: how far the planet is from the owner species'
// ideal, capped by the owner's SuitTol (so the tolerance techs also cap the cost).
// The rebel AI pays nothing; an unowned system is charged as if 20 away.
// CONFIDENCE: high.
double SuitabilityCostMod(double suitability, double idealSuitability, double suitTolerance,
bool rebelAI, bool owned);
struct SystemMoneyInputs {
double tradePoints = 0; // the system's trade-channel output this turn
int popIncomeImperial = 0; // income of the imperial population groups
int popIncomeCivilian = 0; // income of the civilian groups (incl. surplus term)
int slaveIncome = 0; // income of the slave groups
double speciesIncomeFactor = 1.0; // ConstantsOf(owner).incomeFactor (1 if unowned)
double playerIncMod = 1.0; // IncMod (1 if unowned)
double serverIncomeMod = 1.0; // game-option income modifier
double difficultyIncomeMult = 1.0; // AI difficulty trade/income multiplier
double speciesCostFactor = 1.0; // ConstantsOf(owner).hazardCostFactor
double suitCostMod = 0; // from SuitabilityCostMod
};
// Money a system contributes this turn:
// t = (trade - fmod(trade, 5)) x 5 whole 5-point blocks of trade, five each
// t += imperial + civilian + slave income (the per-group income tables are inputs)
// t *= speciesIncomeFactor; t *= IncMod; t *= serverIncomeMod x difficultyIncomeMult
// cost = speciesCostFactor x suitCostMod x 10000 x 1.5
// money = ftol(t - cost)
// CONFIDENCE: high on the chain and constants; the per-group population income terms
// (and the addiction factor inside them) are inputs because their own tables are not
// modelled here.
int SystemMoneyIncome(const SystemMoneyInputs& in);
// ---------------------------------------------------------------------------------------
// System bonus and build queue
@ -200,18 +251,20 @@ void ApplyInfrastructureBonus(double& infra, double& pendingBonus);
struct SystemBonusInputs {
bool stable = false;
int turnsOwned = 0; // current turn - acquisition turn
int turnsOwned = 0; // current turn - acquisition turn
int turnsSinceRebellion = 0;
bool homeSystem = false;
std::int64_t capacity = 0;
bool ownerSpeciesEligible = true; // ConstantsOf(owner).systemBonusEligible (Zuul: false)
std::int64_t capacity = 0; // imperial carrying capacity
};
// Accrue the long-stability bonus when stable, owned for more than SYSTEMBONUS_MINTURNS
// and free of rebellion for as long:
// popBonus += min(ftoi64(cap x POPBONUS_INC), cap x POPBONUS(_HOME) - popBonus)
// infraBonus += min(INFRABONUS_INC, INFRABONUS(_HOME) - infraBonus)
// CONFIDENCE: low -- see sots-re open questions (the increment derived from
// POPBONUS_INC is not fully resolved; the caps and gating are).
// target = eligible ? ftol(max(POPBONUS, 0) x cap) : 0
// popBonus += min(max(ftol(POPBONUS_INC x cap), 0), max(target - popBonus, 0))
// infraBonus += min(max(INFRABONUS_INC, 0), max((eligible ? INFRABONUS : 0) - infraBonus, 0))
// The *_HOME keys are not used by the per-turn accrual (only when a home system's
// bonus is initialised, which is not modelled here). Applied next turn by the Apply*
// functions above. CONFIDENCE: high -- increment, target and gating read with constants.
void AccrueSystemBonus(const SystemBonusInputs& in, std::int64_t& popBonus, double& infraBonus,
const TuningTable& t);

View file

@ -24,15 +24,27 @@ int MaintenanceCost(int maintenance, double difficultyDivisor) {
}
int ExpenseTotal(const std::vector<ExpenseSlider>& sliders, int availableBeforeExpenses) {
constexpr int kUnlimited = 2000000000;
const int availPre = availableBeforeExpenses;
// The game multiplies the slider fraction by the available income in single
// precision, so the income is rounded to a float before the product.
const double availAsFloat = static_cast<double>(static_cast<float>(availPre));
std::int64_t minimums = 0;
std::int64_t extras = 0;
std::int64_t takes = 0;
for (const ExpenseSlider& s : sliders) {
minimums += s.minimum;
const int span = std::max(0, s.maximum - s.minimum);
extras += ClampT(s.requested - s.minimum, 0, span);
const int minC = std::max(s.minimum, 0);
int maxC = ClampT(s.maximum, 0, kUnlimited);
if (maxC == 0) maxC = kUnlimited;
const std::int64_t room = static_cast<std::int64_t>(maxC) - minC;
const std::int64_t request =
static_cast<std::int64_t>(Ftol(static_cast<double>(s.fraction) * availAsFloat)) - minC;
const std::int64_t take = std::min(std::max<std::int64_t>(request, 0), room);
minimums += minC;
takes += take;
}
const std::int64_t headroom = static_cast<std::int64_t>(availableBeforeExpenses) - minimums;
const std::int64_t total = minimums + std::min(extras, headroom);
const std::int64_t headroom = static_cast<std::int64_t>(availPre) - minimums;
const std::int64_t total = minimums + std::min(std::max<std::int64_t>(takes, 0), headroom);
return static_cast<int>(ClampT<std::int64_t>(total, -2147483648LL, 2147483647LL));
}
@ -90,9 +102,17 @@ Budget ComputeBudget(const BudgetInputs& in, bool projected) {
b.hasResearchAllocation = in.hasResearchTarget;
b.researchMoneyKept = b.researchMoney - b.researchMoneyGiven;
b.bonusIncome = Ftol((in.techIncomeMult - 1.0) * static_cast<double>(running()));
b.savingsGiven = static_cast<int>(std::min<std::int64_t>(std::max<std::int64_t>(running(), 0),
std::max(0, in.aidSavings)));
// Tech income bonus: a share of the full net so far, only when that net is positive.
const std::int64_t netBeforeBonus = running();
if (netBeforeBonus > 0) {
b.bonusIncome = std::max(0, Ftol((in.techIncomeMult - 1.0) * static_cast<double>(netBeforeBonus)));
}
// Savings aid: capped by the projected treasury after this turn (bonus included).
if (in.aidSavings != 0) {
const int netWithBonus = static_cast<int>(ClampT<std::int64_t>(running(), -2147483648LL, 2147483647LL));
const int projectedSavings = SaturatingAdd(in.savings, netWithBonus);
b.savingsGiven = std::min(std::max(projectedSavings, 0), std::max(in.aidSavings, 0));
}
b.net = static_cast<int>(ClampT<std::int64_t>(running(), -2147483648LL, 2147483647LL));
return b;
@ -136,9 +156,13 @@ int TradeRouteIncome(const TradeRouteState& route, bool asOwner, double difficul
// ---- bankruptcy -----------------------------------------------------------------------
BankruptcyLimits ComputeBankruptcyLimits(int maxIncome, const TuningTable& t) {
constexpr int kTreasuryLimit = 2000000000;
BankruptcyLimits l;
l.eliminationFloor = -Ftol(t.BANKRUPTCY_PROTECTION_LIMIT_FACTOR * static_cast<double>(maxIncome));
l.protectionLimit = std::max(l.eliminationFloor, -maxIncome);
// Elimination: the debt whose 15 %/turn interest equals the maximum income.
l.eliminationFloor = std::max(Ftol(static_cast<double>(maxIncome) / -0.15), -kTreasuryLimit);
// Protection: the tuned factor times the maximum income, never below the floor.
l.protectionLimit = std::max(-Ftol(t.BANKRUPTCY_PROTECTION_LIMIT_FACTOR * static_cast<double>(maxIncome)),
l.eliminationFloor);
return l;
}
@ -148,17 +172,18 @@ int BankruptcyLevel(int savings, const BankruptcyLimits& limits) {
return 0;
}
bool BankruptcyStep(BankruptcyState& state, int level, int currentTurn, const TuningTable& t) {
if (level == 0) {
state.warningLevel = 0;
state.startTurn = 0;
return false;
}
if (state.warningLevel != level) {
BankruptcyDecision BankruptcyStep(BankruptcyState& state, int level, int currentTurn,
const TuningTable& t) {
const BankruptcyState old = state;
if (level != state.warningLevel) {
state.warningLevel = level;
state.startTurn = currentTurn;
state.startTurn = level != 0 ? currentTurn : -1;
}
return level == 2 && (currentTurn - state.startTurn) >= t.BANKRUPTCY_ELIMINATION_TURNS;
BankruptcyDecision d;
d.costCutting = level != 0 && old.warningLevel != 0;
d.eliminate = old.warningLevel == 2 &&
(currentTurn - old.startTurn) >= t.BANKRUPTCY_ELIMINATION_TURNS;
return d;
}
} // namespace sots::sim

View file

@ -15,13 +15,14 @@ namespace sots::sim {
// Budget
// ---------------------------------------------------------------------------------------
// One per-category expense slider (the player's expense entries). `requested` is the
// amount the slider asks for this turn; it is honoured between min and max, and the
// total of the above-minimum parts is capped by what is left after minimums.
// One per-category expense slider (the player's expense entries {xmin, xmax, xper}).
// `fraction` is the share of the pre-expense available income the slider asks for; the
// request is honoured between min and max, and the total of the above-minimum parts is
// capped by what is left after every minimum is paid. A maximum of 0 means unlimited.
struct ExpenseSlider {
int minimum = 0;
int maximum = 0;
int requested = 0;
int maximum = 0; // 0 = no upper bound
float fraction = 0.f; // xper: stored as a single-precision value by the game
};
// Everything the budget roll-up reads from the player and the server. Names follow the
@ -92,9 +93,13 @@ int DebtInterest(int savings);
// Fleet upkeep after the difficulty divisor. CONFIDENCE: high.
int MaintenanceCost(int maintenance, double difficultyDivisor);
// Total of the expense sliders: every minimum is paid; the above-minimum requests are
// honoured up to what is left of `availableBeforeExpenses` after the minimums.
// CONFIDENCE: medium (the per-entry request amount is derived from an unresolved term).
// Total of the expense sliders. Per entry, with `availPre` = the non-negative net before
// expenses:
// minC = max(min, 0); maxC = clamp(max, 0, 2e9), 0 meaning 2e9; room = maxC - minC
// request = ftol(fraction x float(availPre)) - minC; take = min(max(request, 0), room)
// total = sum(minC) + min(max(sum(take), 0), availPre - sum(minC)).
// CONFIDENCE: high -- the request term is a fraction of the pre-expense available
// income, multiplied in single precision, minus the mandatory minimum.
int ExpenseTotal(const std::vector<ExpenseSlider>& sliders, int availableBeforeExpenses);
// Research points bought with `researchMoney`:
@ -108,8 +113,13 @@ int ResearchPointsFromMoney(int researchMoney, double difficultyMult, double res
// The full per-turn budget. The order of evaluation matters because later slots read
// the running totals: interest -> system income -> trade/other income -> maintenance ->
// expenses -> available -> construction -> research money/points -> aid -> bonus ->
// savings aid -> net. CONFIDENCE: high on the line items and their signs; medium on
// which running total the tech income bonus and the savings aid read.
// savings aid -> net.
// The tech income bonus reads the full net (every income line including interest and
// trade, minus maintenance, research money, construction, expenses and research aid)
// and is only granted when that net is positive: bonus = max(0, ftol((mult - 1) x net)).
// Savings aid is capped by the projected treasury after this turn, not by the turn net:
// given = min(max(Sav + net, 0), max(aid, 0)), evaluated with the bonus already added.
// CONFIDENCE: high (line items, signs, and both running-total readers).
Budget ComputeBudget(const BudgetInputs& in, bool projected);
// ---------------------------------------------------------------------------------------
@ -153,11 +163,13 @@ struct BankruptcyLimits {
};
// Limits from the sum of every owned system's maximum money output:
// eliminationFloor = -ftol(BANKRUPTCY_PROTECTION_LIMIT_FACTOR x maxIncome)
// protectionLimit = max(eliminationFloor, -maxIncome)
// CONFIDENCE: low -- see sots-re open questions. The shape "debt floor is -3.3 x maximum
// income" is established; which of the two limits carries the factor, and the exact form
// of the other, is not. The protection limit here is the simplest reading.
// eliminationFloor = max(ftol(maxIncome / -0.15), -2e9)
// (the debt at which 15 %/turn interest eats the whole maximum income)
// protectionLimit = max(-ftol(BANKRUPTCY_PROTECTION_LIMIT_FACTOR x maxIncome), eliminationFloor)
// The limits a turn's check uses are the ones computed at the end of the previous turn
// (and on load); the caller keeps them on the player.
// CONFIDENCE: high -- the factor is on the protection limit, the elimination limit is
// the interest break-even, both read with their constants.
BankruptcyLimits ComputeBankruptcyLimits(int maxIncome, const TuningTable& t);
// 2 = elimination pending, 1 = protection (cost cutting), 0 = solvent. CONFIDENCE: high.
@ -165,14 +177,22 @@ int BankruptcyLevel(int savings, const BankruptcyLimits& limits);
struct BankruptcyState {
int warningLevel = 0; // BnkWrn
int startTurn = 0; // BnkTrn: turn the current level began
int startTurn = -1; // BnkTrn: turn the current level began; -1 when solvent
};
// Per-turn bankruptcy bookkeeping. Returns true when the player is to be eliminated:
// level 2 held for at least BANKRUPTCY_ELIMINATION_TURNS turns. A non-zero level that
// differs from the stored one restarts the clock; level 0 clears it.
// CONFIDENCE: low -- see sots-re open questions (elimination condition is established;
// when exactly the start turn is stamped is inferred).
bool BankruptcyStep(BankruptcyState& state, int level, int currentTurn, const TuningTable& t);
struct BankruptcyDecision {
bool costCutting = false; // run the cost-cutting pass this turn
bool eliminate = false; // the player is eliminated this turn
};
// Per-turn bankruptcy bookkeeping. The stored state is updated first -- any change of
// level (0<->1, 1<->2 alike) restamps the start turn with the current turn, level 0
// stamps -1 -- and the decisions are then taken on the *previous* state:
// costCutting = level != 0 && old.level != 0
// eliminate = old.level == 2 && currentTurn - old.startTurn >= BANKRUPTCY_ELIMINATION_TURNS
// so both actions begin the turn after the level was reached.
// CONFIDENCE: high -- stamp-on-transition and act-on-old-state read from the code.
BankruptcyDecision BankruptcyStep(BankruptcyState& state, int level, int currentTurn,
const TuningTable& t);
} // namespace sots::sim

View file

@ -7,6 +7,16 @@
namespace sots::sim {
namespace {
constexpr double kMinChordLength = 0.01;
double Dot(const Vec3& a, const Vec3& b) { return a.x * b.x + a.y * b.y + a.z * b.z; }
Vec3 Sub(const Vec3& a, const Vec3& b) { return Vec3{a.x - b.x, a.y - b.y, a.z - b.z}; }
Vec3 Lerp(const Vec3& a, const Vec3& b, double f) {
return Vec3{a.x + (b.x - a.x) * f, a.y + (b.y - a.y) * f, a.z + (b.z - a.z) * f};
}
} // namespace
double Distance(const Vec3& a, const Vec3& b) {
const double dx = b.x - a.x, dy = b.y - a.y, dz = b.z - a.z;
return std::sqrt(dx * dx + dy * dy + dz * dz);
@ -15,8 +25,15 @@ double Distance(const Vec3& a, const Vec3& b) {
Vec3 AdvanceToward(const Vec3& pos, const Vec3& dest, double amount) {
const double d = Distance(pos, dest);
if (amount >= d || d <= 0) return dest;
const double f = amount / d;
return Vec3{pos.x + (dest.x - pos.x) * f, pos.y + (dest.y - pos.y) * f, pos.z + (dest.z - pos.z) * f};
return Lerp(pos, dest, amount / d);
}
double DistPointToSegment(const Vec3& p, const Vec3& a, const Vec3& b) {
const Vec3 ab = Sub(b, a);
const double len2 = Dot(ab, ab);
double u = 0.0;
if (len2 > 0) u = Clamp01(Dot(Sub(p, a), ab) / len2);
return Distance(p, Lerp(a, b, u));
}
double StraightStep(double speed, double dt) { return speed * dt; }
@ -24,11 +41,103 @@ double StraightStep(double speed, double dt) { return speed * dt; }
double NodeLineSpeed(double nodeSpeed, double distToLineSystem, const TuningTable& t) {
double ratio = 0.0;
if (t.STUTTER_SYSTEM_INFLUENCE_RADIUS > 0) {
ratio = Clamp01(distToLineSystem / t.STUTTER_SYSTEM_INFLUENCE_RADIUS);
ratio = distToLineSystem / t.STUTTER_SYSTEM_INFLUENCE_RADIUS;
}
return nodeSpeed * ((t.STUTTER_MAX_SPEED - t.STUTTER_MIN_SPEED) * ratio + t.STUTTER_MIN_SPEED);
}
std::vector<StutterSegment> BuildStutterSegments(const Vec3& from, const Vec3& to,
const std::vector<Vec3>& systems,
const TuningTable& t) {
std::vector<StutterSegment> segs;
const double radius = t.STUTTER_SYSTEM_INFLUENCE_RADIUS;
const Vec3 d = Sub(to, from);
const double len2 = Dot(d, d);
if (radius <= 0 || len2 <= 0) return segs;
const double len = std::sqrt(len2);
// Chord of the line inside each sphere, as [start, end] distances along the line.
for (std::size_t i = 0; i < systems.size(); ++i) {
const Vec3 fc = Sub(from, systems[i]);
const double b = 2.0 * Dot(d, fc);
const double c = Dot(fc, fc) - radius * radius;
const double disc = b * b - 4.0 * len2 * c;
if (disc < 0) continue;
const double root = std::sqrt(disc);
const double u0 = Clamp01((-b - root) / (2.0 * len2));
const double u1 = Clamp01((-b + root) / (2.0 * len2));
StutterSegment s;
s.start = u0 * len;
s.end = u1 * len;
s.systemIndex = static_cast<int>(i);
if (s.end - s.start < kMinChordLength) continue;
segs.push_back(s);
}
std::sort(segs.begin(), segs.end(),
[](const StutterSegment& a, const StutterSegment& b) { return a.start < b.start; });
// Split overlaps at the midpoint of the overlap; a chord entirely inside the
// previous one disappears.
std::vector<StutterSegment> merged;
for (const StutterSegment& s : segs) {
if (!merged.empty() && s.start < merged.back().end) {
const double mid = 0.5 * (s.start + merged.back().end);
if (s.end <= mid) continue;
merged.back().end = mid;
StutterSegment cut = s;
cut.start = mid;
merged.push_back(cut);
} else {
merged.push_back(s);
}
}
// Per-segment profile: closest approach of the whole chord to its system.
for (StutterSegment& s : merged) {
const Vec3 a = Lerp(from, to, s.start / len);
const Vec3 b = Lerp(from, to, s.end / len);
const double dist = DistPointToSegment(systems[static_cast<std::size_t>(s.systemIndex)], a, b);
s.speedFactor = NodeLineSpeed(1.0, dist, t);
}
return merged;
}
double AdvanceAlongNodeLine(double along, double dt, double nodeSpeed, double lineLength,
const std::vector<StutterSegment>& segments) {
if (nodeSpeed <= 0 || dt <= 0) return std::min(along, lineLength);
double s = along;
double timeLeft = dt;
while (timeLeft > 0 && s < lineLength) {
// Speed at s, and the distance until it may change.
double factor = 1.0;
double until = lineLength;
for (const StutterSegment& seg : segments) {
if (s >= seg.start && s < seg.end) {
factor = seg.speedFactor;
until = seg.end;
break;
}
if (seg.start > s) {
until = std::min(until, seg.start);
break;
}
}
const double v = nodeSpeed * factor;
if (v <= 0) break;
const double reach = v * timeLeft;
const double gap = until - s;
if (reach < gap) {
s += reach;
timeLeft = 0;
} else {
s = until;
timeLeft -= gap / v;
}
}
return std::min(s, lineLength);
}
MoveStepResult ResolveMoveStep(double step, double minShipRange, double distance) {
MoveStepResult r;
const double range = minShipRange - 0.05;

View file

@ -18,6 +18,10 @@ double Distance(const Vec3& a, const Vec3& b);
// the remaining distance. CONFIDENCE: high.
Vec3 AdvanceToward(const Vec3& pos, const Vec3& dest, double amount);
// Distance from a point to the closest point of the segment [a, b] (the projection
// parameter is clamped to [0, 1]). CONFIDENCE: high.
double DistPointToSegment(const Vec3& p, const Vec3& a, const Vec3& b);
// Fractions of a turn each movement pass advances. The server runs the departing/
// in-transit sets in two half-steps and the remaining fleets in one full step.
// CONFIDENCE: medium (the bucketing semantics are not fully resolved; the constants are).
@ -34,14 +38,42 @@ enum class WaypointKind : int {
// Straight-line step for one pass: speed x dt. CONFIDENCE: high.
double StraightStep(double speed, double dt);
// Node-line speed at a point along the line:
// Node-line speed inside a system's influence sphere:
// speed x ((STUTTER_MAX_SPEED - STUTTER_MIN_SPEED) x (dist / INFLUENCE_RADIUS) + MIN_SPEED)
// where dist is the distance to the nearest system on the line. The ratio is clamped
// to [0, 1] here so a fleet beyond the influence radius travels at the max profile
// speed; the notes give the formula without stating the clamp. CONFIDENCE: high on the
// formula, medium on the clamp.
// where dist is the closest approach of the travelled chord to the system. There is no
// clamp: the caller only evaluates this for chords that lie inside the sphere (see
// BuildStutterSegments), so dist <= radius holds by construction and the result is in
// [MIN, MAX] x speed. Outside every sphere the fleet moves at plain `speed`.
// CONFIDENCE: high.
double NodeLineSpeed(double nodeSpeed, double distToLineSystem, const TuningTable& t);
// One stretch of a node line that lies inside a system's influence sphere, measured as
// distances along the line from its start. `speedFactor` is the profile multiplier for
// the whole stretch (closest approach of the chord, not re-evaluated per position).
struct StutterSegment {
double start = 0;
double end = 0;
int systemIndex = -1;
double speedFactor = 1.0;
};
// Intersect the travel line with every system's sphere of radius
// STUTTER_SYSTEM_INFLUENCE_RADIUS: each chord becomes a segment (clipped to the line),
// chords shorter than 0.01 are dropped, the segments are sorted by start, and where two
// overlap the boundary is placed at the midpoint of the overlap. CONFIDENCE: high on the
// chord/clip/drop/sort steps; medium on the exact overlap rule (midpoint split assumed;
// a chord swallowed whole by an earlier one is dropped).
std::vector<StutterSegment> BuildStutterSegments(const Vec3& from, const Vec3& to,
const std::vector<Vec3>& systems,
const TuningTable& t);
// Advance a distance `along` the line by `dt` turns at `nodeSpeed`, using the segment
// speeds inside influence spheres and plain nodeSpeed between them. Returns the new
// distance along the line, never past `lineLength`. CONFIDENCE: medium (piecewise
// integration written from the per-segment description).
double AdvanceAlongNodeLine(double along, double dt, double nodeSpeed, double lineLength,
const std::vector<StutterSegment>& segments);
struct MoveStepResult {
double moved = 0; // distance actually covered this pass
double fraction = 1.0; // moved / step (1 when the step was zero)

View file

@ -1,4 +1,5 @@
// Species enumeration used by every per-species table in the strategic sim.
// Species enumeration used by every per-species table in the strategic sim, plus the
// handful of per-species constants the executable carries itself (not the data files).
//
// The index order is the one the save format and the per-species arrays use; note that
// index 4 is the independent/NPC race, which the growth and capacity formulas treat
@ -22,4 +23,21 @@ constexpr int kSpeciesCount = 7;
constexpr bool IsNpcSpecies(Species s) { return s == Species::NPC; }
// Per-species constants that live in the engine's own species table rather than in the
// data files. Only the fields whose readers are known are exposed here.
struct SpeciesConstants {
double incomeFactor = 1.0; // multiplies a system's money income (Zuul 1.1, Morrigi 0.8)
double hazardCostFactor = 1.0; // multiplies the suitability money cost (Zuul 0.7)
bool systemBonusEligible = true; // long-stability system bonus accrues (Zuul never)
};
// CONFIDENCE: high on the three fields (read with their constants and their consumers).
constexpr SpeciesConstants ConstantsOf(Species s) {
switch (s) {
case Species::Zuul: return SpeciesConstants{1.1, 0.7, false};
case Species::Morrigi: return SpeciesConstants{0.8, 1.0, true};
default: return SpeciesConstants{1.0, 1.0, true};
}
}
} // namespace sots::sim

View file

@ -66,6 +66,10 @@ struct TuningTable {
double STUTTER_SYSTEM_INFLUENCE_RADIUS = 0;
double STUTTER_MIN_SPEED = 0;
double STUTTER_MAX_SPEED = 0;
// ---- gates (read by the tech-effect layer) ----
double PERGATETRAFFIC_DRV_TpGate = 0; // per-gate traffic capacity granted by the gate tech
double PERGATETRAFFIC_DRV_GatAmp = 0; // ... and by the amplifier tech (the higher wins)
};
} // namespace sots::sim

View file

@ -0,0 +1,8 @@
# game/effects tests: hand-computed cases over the tech-effect table and apply layer.
# The canonical runner is build_and_run.sh (plain g++); include this from the root with
# add_subdirectory(tests/game_effects) after add_subdirectory(src/game/effects).
add_executable(game_effects_test test_effects.cpp)
target_link_libraries(game_effects_test PRIVATE sots_game_effects)
target_include_directories(game_effects_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} ${CMAKE_CURRENT_SOURCE_DIR}/../game_sim)
target_compile_options(game_effects_test PRIVATE -Wall -Wextra -pedantic)
add_test(NAME game_effects COMMAND game_effects_test)

View file

@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Build and run the game/effects unit tests with plain g++ (no CMake needed).
# tests/game_effects/build_and_run.sh
# BUILD_DIR=/some/dir tests/game_effects/build_and_run.sh
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
root="$(cd "$here/../.." && pwd)"
build="${BUILD_DIR:-$(mktemp -d "${TMPDIR:-/tmp}/sots-game-effects.XXXXXX")}"
mkdir -p "$build"
CXX="${CXX:-g++}"
CXXFLAGS="${CXXFLAGS:--std=c++17 -O1 -g -Wall -Wextra -Werror -pedantic}"
srcs=("$root"/src/game/sim/economy.cpp "$root"/src/game/sim/research.cpp \
"$root"/src/game/sim/colony.cpp "$root"/src/game/sim/movement.cpp \
"$root"/src/game/effects/tech_id.cpp "$root"/src/game/effects/tech_effects.cpp)
objs=()
for s in "${srcs[@]}"; do
o="$build/$(basename "${s%.cpp}").o"
$CXX $CXXFLAGS -I"$root/src" -c "$s" -o "$o"
objs+=("$o")
done
exe="$build/test_effects"
$CXX $CXXFLAGS -I"$root/src" -I"$here" -I"$root/tests/game_sim" "$here/test_effects.cpp" "${objs[@]}" -o "$exe"
if "$exe"; then echo "game_effects: all tests passed (build dir $build)"; else echo "game_effects: FAILURES (build dir $build)"; exit 1; fi

View file

@ -0,0 +1,377 @@
#include "game/effects/tech_effects.h"
#include "game/effects/tech_id.h"
#include <set>
#include <string>
#include "check.h" // shared with tests/game_sim
using namespace sots::effects;
using sots::sim::Species;
static ApplyContext ctx_with_tuning(sots::sim::TuningTable& t) {
ApplyContext c;
c.tuning = &t;
return c;
}
static void test_ids() {
CHECK_EQ(static_cast<int>(TechId::CCC_AdvSens), 10000);
CHECK_EQ(static_cast<int>(TechId::IND_HrdStrct), 10030);
CHECK_EQ(static_cast<int>(TechId::WEP_ACCAMP), 10192);
CHECK_EQ(static_cast<int>(TechId::None), 10197);
CHECK(IsValidTechId(10000) && IsValidTechId(10195));
CHECK(!IsValidTechId(9999) && !IsValidTechId(10196) && !IsValidTechId(TechId::None));
CHECK_EQ(TechIdIndex(TechId::DRV_RAD), 66);
CHECK(TechIdFromIndex(64) == TechId::DRV_RIP);
CHECK(std::string(TechIdName(TechId::IND_Waldo)) == "IND_Waldo");
CHECK(TechIdName(TechId::Unresolved_052) == nullptr);
CHECK(TechIdName(TechId::None) == nullptr);
CHECK(TechIdFromName("IND_Waldo") == TechId::IND_Waldo);
CHECK(TechIdFromName("ind_waldo") == TechId::IND_Waldo); // case-insensitive like the game
CHECK(TechIdFromName("CCC_TRNSLIR") == TechId::XNC_Translation1_Liir);
CHECK(TechIdFromName("not_a_tech") == TechId::None);
CHECK(TechIdFromName("") == TechId::None);
// every resolved name round-trips, and the table has exactly 196 slots
int named = 0;
for (int i = 0; i < kTechIdCount; ++i) {
const char* n = TechIdName(TechIdFromIndex(i));
if (n == nullptr) continue;
++named;
CHECK(TechIdFromName(n) == TechIdFromIndex(i));
}
CHECK_EQ(named, 89); // recovered data-file names so far; bump when more are resolved
// xenotech blocks
CHECK(XenoTechId(XenoLevel::Translation1, Species::Human) == TechId::XNC_Translation1_Human);
CHECK(XenoTechId(XenoLevel::Translation1, Species::Morrigi) == TechId::XNC_Translation1_Morrigi);
CHECK_EQ(static_cast<int>(XenoTechId(XenoLevel::Translation2, Species::Morrigi)), 10125);
CHECK_EQ(static_cast<int>(XenoTechId(XenoLevel::Incorporate, Species::Morrigi)), 10136);
CHECK(XenoTechId(XenoLevel::Incorporate, Species::Zuul) == TechId::None);
CHECK(XenoTechId(XenoLevel::Subjugate, Species::Zuul) == TechId::XNC_Subjugate_Zuul);
CHECK_EQ(static_cast<int>(XenoTechId(XenoLevel::Proliferate, Species::Morrigi)), 10163);
CHECK(XenoTechId(XenoLevel::Temperance, Species::NPC) == TechId::None);
CHECK(XenoTechId(XenoLevel::Translation3, Species::NPC) == TechId::None);
CHECK(std::string(NodeTrackTechName(Species::Human)) == "CCC_NDTRKHUM");
CHECK(std::string(NodeTrackTechName(Species::Zuul)) == "CCC_NDTRKZUL");
CHECK(NodeTrackTechName(Species::Hiver) == nullptr);
}
static void test_industrial() {
PlayerEconomyState s;
ApplyContext ctx;
TechApplyOutcome o = ApplyTechEffect(s, TechId::IND_Waldo, ctx);
CHECK(o.applied);
CHECK_NEAR(s.conMod[0], 0.90, 1e-12);
CHECK_NEAR(s.conMod[1], 0.90, 1e-12);
CHECK_NEAR(s.conMod[2], 0.90, 1e-12);
CHECK_NEAR(s.outMod, 1.15, 1e-12);
CHECK(s.HasResearched(TechId::IND_Waldo));
o = ApplyTechEffect(s, TechId::IND_Waldo, ctx); // no double application
CHECK(!o.applied);
CHECK_NEAR(s.outMod, 1.15, 1e-12);
ApplyTechEffect(s, TechId::IND_CyberInt, ctx); // -0.05 / +0.20
CHECK_NEAR(s.conMod[0], 0.85, 1e-12);
CHECK_NEAR(s.outMod, 1.35, 1e-12);
ApplyTechEffect(s, TechId::IND_ExpSys, ctx); // -0.10 / +0.15
CHECK_NEAR(s.conMod[2], 0.75, 1e-12);
CHECK_NEAR(s.outMod, 1.50, 1e-12);
ApplyTechEffect(s, TechId::IND_OrbDry, ctx); // classes 1 and 2 only
CHECK_NEAR(s.conMod[0], 0.75, 1e-12);
CHECK_NEAR(s.conMod[1], 0.70, 1e-12);
CHECK_NEAR(s.conMod[2], 0.70, 1e-12);
ApplyTechEffect(s, TechId::DRN_AdvRob, ctx);
CHECK_NEAR(s.conMod[0], 0.70, 1e-12);
CHECK_NEAR(s.conMod[1], 0.65, 1e-12);
ApplyTechEffect(s, TechId::IND_OrbFound, ctx);
CHECK_NEAR(s.savMod[0], 0.95, 1e-12);
CHECK_NEAR(s.savMod[2], 0.95, 1e-12);
ApplyTechEffect(s, TechId::IND_GravCon, ctx); // 1.80
ApplyTechEffect(s, TechId::IND_HvyPlat, ctx); // 1.90
CHECK_NEAR(s.outMod, 1.90, 1e-12);
ApplyTechEffect(s, TechId::IND_HrdStrct, ctx); // multiplicative
CHECK_NEAR(s.outMod, 1.71, 1e-12);
CHECK_NEAR(s.defenceDamageMod, 0.25, 1e-12);
// the order of additive and multiplicative effects matters
PlayerEconomyState r;
ApplyTechEffect(r, TechId::IND_HrdStrct, ctx);
ApplyTechEffect(r, TechId::IND_GravCon, ctx);
CHECK_NEAR(r.outMod, 1.20, 1e-12); // 0.9 + 0.3, not 1.3 x 0.9
ApplyTechEffect(s, TechId::IND_AstMine, ctx);
CHECK(s.Flag(PlayerFlag::AsteroidMining));
ApplyTechEffect(s, TechId::IND_MsMine, ctx);
CHECK_NEAR(s.maxOverharvest, 0.1, 0.0);
CHECK_NEAR(s.miningRate, 1.0, 0.0);
PlayerEconomyState q;
q.maxOverharvest = 0.3;
ApplyTechEffect(q, TechId::IND_MsMine, ctx);
CHECK_NEAR(q.maxOverharvest, 0.3, 0.0); // max(), not assignment
ApplyTechEffect(s, TechId::CCC_AdvSens, ctx);
CHECK(s.Flag(PlayerFlag::AdvancedSensors));
}
static void test_biology() {
PlayerEconomyState s;
s.suitTol = 0.07;
ApplyContext ctx;
ApplyTechEffect(s, TechId::BIO_GnMod, ctx);
CHECK_NEAR(s.popMod, 1.10, 1e-12);
ApplyTechEffect(s, TechId::BIO_AtmoAd, ctx);
CHECK_NEAR(s.suitTol, 0.82, 1e-12);
CHECK_NEAR(s.popMod, 1.16, 1e-12);
CHECK_NEAR(s.terraMod, 1.35, 1e-12);
ApplyTechEffect(s, TechId::BIO_GrvAdpt, ctx);
CHECK_NEAR(s.suitTol, 2.32, 1e-12);
CHECK_NEAR(s.popMod, 1.26, 1e-12);
CHECK_NEAR(s.terraMod, 1.70, 1e-12);
ApplyTechEffect(s, TechId::BIO_EnvTail, ctx);
CHECK_NEAR(s.popMod, 1.46, 1e-12);
CHECK_NEAR(s.terraMod, 2.15, 1e-12);
ApplyTechEffect(s, TechId::IND_EleNans, ctx); // +0.60
ApplyTechEffect(s, TechId::BIO_TerBac, ctx); // +0.45
ApplyTechEffect(s, TechId::IND_AtProc, ctx); // +0.50
CHECK_NEAR(s.terraMod, 3.70, 1e-12);
TechApplyOutcome o = ApplyTechEffect(s, TechId::IND_ArcCon, ctx);
CHECK(s.Flag(PlayerFlag::Arcology));
CHECK_NEAR(s.popMod, 1.61, 1e-12);
CHECK(o.reevaluateCivilianCaps);
}
static void test_gates_and_casting() {
sots::sim::TuningTable t;
t.PERGATETRAFFIC_DRV_TpGate = 5;
t.PERGATETRAFFIC_DRV_GatAmp = 12;
ApplyContext ctx = ctx_with_tuning(t);
PlayerEconomyState s;
ApplyTechEffect(s, TechId::DRV_TpGate, ctx);
CHECK_NEAR(s.perGateTraffic, 5.0, 0.0);
ApplyTechEffect(s, TechId::DRV_GatAmp, ctx);
CHECK_NEAR(s.perGateTraffic, 12.0, 0.0);
PlayerEconomyState r; // reverse order: max wins
ApplyTechEffect(r, TechId::DRV_GatAmp, ctx);
ApplyTechEffect(r, TechId::DRV_TpGate, ctx);
CHECK_NEAR(r.perGateTraffic, 12.0, 0.0);
PlayerEconomyState n; // no tuning: nothing to raise
ApplyTechEffect(n, TechId::DRV_TpGate, ApplyContext{});
CHECK_NEAR(n.perGateTraffic, 0.0, 0.0);
ApplyTechEffect(s, TechId::DRV_FarCast, ctx);
CHECK_NEAR(s.castRange, 10.0, 0.0);
CHECK_NEAR(s.castEfficiency, 2.0, 0.0);
CHECK_NEAR(s.castThreshold, 1.0, 0.0);
ApplyTechEffect(s, TechId::DRV_GrvSyn, ctx);
CHECK(s.Flag(PlayerFlag::GravSynth));
}
static void test_ai() {
ApplyContext ctx;
ctx.aiBonus = {0.2, 0.3, 0.4};
PlayerEconomyState s;
CHECK(s.aiBenefit);
ApplyTechEffect(s, TechId::CCC_AI, ctx);
CHECK_NEAR(s.resMod, 1.2, 1e-12);
ApplyTechEffect(s, TechId::CCC_AIAdmin, ctx);
CHECK_NEAR(s.incMod, 1.3, 1e-12);
ApplyTechEffect(s, TechId::CCC_AIFac, ctx);
CHECK_NEAR(s.outMod, 1.4, 1e-12);
SetAiBenefit(s, false, ctx); // rebellion: bonuses withdrawn
CHECK_NEAR(s.resMod, 1.0, 1e-12);
CHECK_NEAR(s.incMod, 1.0, 1e-12);
CHECK_NEAR(s.outMod, 1.0, 1e-12);
SetAiBenefit(s, false, ctx); // idempotent
CHECK_NEAR(s.outMod, 1.0, 1e-12);
SetAiBenefit(s, true, ctx);
CHECK_NEAR(s.resMod, 1.2, 1e-12);
CHECK_NEAR(s.outMod, 1.4, 1e-12);
PlayerEconomyState off; // researched while off: nothing
off.aiBenefit = false;
ApplyTechEffect(off, TechId::CCC_AIFac, ctx);
CHECK_NEAR(off.outMod, 1.0, 1e-12);
TechApplyOutcome o = ApplyTechEffect(off, TechId::CCC_AISlv, ctx); // slave AI: benefit back on
CHECK(off.aiBenefit);
CHECK_NEAR(off.outMod, 1.4, 1e-12);
CHECK(o.flagSystemsAI);
CHECK(!AiRebellionPossible(off));
PlayerEconomyState v;
CHECK(AiRebellionPossible(v));
o = ApplyTechEffect(v, TechId::CCC_AIVrus, ctx);
CHECK(o.flagSystemsAI);
CHECK(AiRebellionPossible(v));
v.species = Species::NPC;
CHECK(!AiRebellionPossible(v));
PlayerEconomyState none; // unknown table values: 0
ApplyTechEffect(none, TechId::CCC_AI, ApplyContext{});
CHECK_NEAR(none.resMod, 1.0, 0.0);
}
static void test_flags_and_species() {
ApplyContext ctx;
PlayerEconomyState s;
ApplyTechEffect(s, TechId::CCC_FtlEcon, ctx);
CHECK(s.Flag(PlayerFlag::TradeAllowed));
PlayerEconomyState rebel;
rebel.rebelAI = true;
ApplyTechEffect(rebel, TechId::CCC_FtlEcon, ctx);
CHECK(!rebel.Flag(PlayerFlag::TradeAllowed));
ApplyTechEffect(s, TechId::CCC_ComRaid, ctx);
CHECK(s.Flag(PlayerFlag::CommerceRaiding));
ApplyTechEffect(s, TechId::CCC_DatCor, ctx);
CHECK(s.Flag(PlayerFlag::ViewIntel));
// design capture needs both techs, in either order
ApplyTechEffect(s, TechId::CCC_SpyBm, ctx);
CHECK(!s.Flag(PlayerFlag::CaptureDesigns));
ApplyTechEffect(s, TechId::IND_SlvgTech, ctx);
CHECK(s.Flag(PlayerFlag::CaptureDesigns));
PlayerEconomyState r;
ApplyTechEffect(r, TechId::IND_SlvgTech, ctx);
CHECK(!r.Flag(PlayerFlag::CaptureDesigns));
ApplyTechEffect(r, TechId::CCC_SpyBm, ctx);
CHECK(r.Flag(PlayerFlag::CaptureDesigns));
// Zuul get boarding pods with cruiser construction; nobody else does
TechApplyOutcome o = ApplyTechEffect(s, TechId::IND_CruisCon, ctx);
CHECK(o.grantedTech == TechId::None);
PlayerEconomyState z;
z.species = Species::Zuul;
o = ApplyTechEffect(z, TechId::IND_CruisCon, ctx);
CHECK(o.grantedTech == TechId::IND_BrdPod);
CHECK(!z.HasResearched(TechId::IND_BrdPod)); // the caller researches it
o = ApplyTechEffect(z, o.grantedTech, ctx);
CHECK(o.applied && z.HasResearched(TechId::IND_BrdPod));
// node-bore parameters: highest drive wins regardless of order
ApplyTechEffect(z, TechId::DRV_RIP, ctx);
CHECK_EQ(z.nodeBoreParams[0], 45);
CHECK_EQ(z.nodeBoreParams[2], 3);
o = ApplyTechEffect(z, TechId::DRV_RAD, ctx);
CHECK(o.nodeBoreParamsChanged);
CHECK_EQ(z.nodeBoreParams[0], 95);
CHECK_EQ(z.nodeBoreParams[1], 60);
o = ApplyTechEffect(z, TechId::DRV_REND, ctx);
CHECK(!o.nodeBoreParamsChanged);
CHECK_EQ(z.nodeBoreParams[0], 95);
}
static void test_plague() {
ApplyContext ctx;
PlayerEconomyState s;
CHECK_EQ(PlagueCureMask(TechId::BIO_PLGVAC), 1u);
CHECK_EQ(PlagueCureMask(TechId::BIO_CONNAN), 16u);
CHECK_EQ(PlagueCureMask(TechId::BIO_UNIANTI), 15u);
CHECK_EQ(PlagueCureMask(TechId::IND_Waldo), 0u);
TechApplyOutcome o = ApplyTechEffect(s, TechId::BIO_RTPLGVAC, ctx);
CHECK_EQ(s.hasVaccine, 2u);
CHECK_EQ(s.hasImmunity, 2u);
CHECK_EQ(o.plagueCuredMask, 2u);
o = ApplyTechEffect(s, TechId::BIO_UNIANTI, ctx);
CHECK_EQ(s.hasVaccine, 15u);
CHECK_EQ(o.plagueCuredMask, 15u);
ApplyTechEffect(s, TechId::BIO_CONNAN, ctx);
CHECK_EQ(s.hasImmunity, 31u);
}
static void test_xenotech() {
ApplyContext ctx;
PlayerEconomyState s;
TechApplyOutcome o = ApplyTechEffect(s, TechId::XNC_Translation1_Liir, ctx);
CHECK_EQ(s.speciesFlags[static_cast<int>(Species::Liir)], 0x001u);
CHECK_EQ(s.speciesFlags[static_cast<int>(Species::Human)], 0u);
CHECK_EQ(o.temperanceSpeciesMask, 0u);
ApplyTechEffect(s, TechId::XNC_Translation3_Liir, ctx);
CHECK_EQ(s.speciesFlags[static_cast<int>(Species::Liir)], 0x005u);
o = ApplyTechEffect(s, TechId::XNC_Temperance_Hiver, ctx);
CHECK_EQ(s.speciesFlags[static_cast<int>(Species::Hiver)], 0x020u);
CHECK_EQ(o.temperanceSpeciesMask, 1u << static_cast<int>(Species::Hiver));
o = ApplyTechEffect(s, TechId::XNC_Accommodate_Morrigi, ctx); // every completion reports temperance
CHECK_EQ(s.speciesFlags[static_cast<int>(Species::Morrigi)], 0x080u);
CHECK_EQ(o.temperanceSpeciesMask, 1u << static_cast<int>(Species::Hiver));
ApplyTechEffect(s, TechId::XNC_Proliferate_Zuul, ctx);
CHECK_EQ(s.speciesFlags[static_cast<int>(Species::Zuul)], 0x100u);
// rebuild from the researched set alone (load path)
PlayerEconomyState l;
l.researched = s.researched;
RebuildSpeciesTechFlags(l);
for (int sp = 0; sp < sots::sim::kSpeciesCount; ++sp) CHECK_EQ(l.speciesFlags[sp], s.speciesFlags[sp]);
}
static void test_by_name() {
ApplyContext ctx;
PlayerEconomyState s;
TechApplyOutcome o = ApplyTechEffectByName(s, "ind_gravcon", ctx);
CHECK(o.applied);
CHECK_NEAR(s.outMod, 1.30, 1e-12);
o = ApplyTechEffectByName(s, "CCC_NDTRKHUM", ctx);
CHECK(o.applied);
CHECK_EQ(s.nodeTrackMask, 1u << static_cast<int>(Species::Human));
o = ApplyTechEffectByName(s, "ccc_ndtrkzul", ctx);
CHECK_EQ(s.nodeTrackMask, (1u << static_cast<int>(Species::Human)) | (1u << static_cast<int>(Species::Zuul)));
o = ApplyTechEffectByName(s, "WEP_SOMETHING_DATA_ONLY", ctx);
CHECK(!o.applied);
CHECK_NEAR(s.outMod, 1.30, 1e-12);
o = ApplyTechEffectByName(s, "IND_SPNLMNT", ctx); // in the table, no effect
CHECK(o.applied);
CHECK(s.HasResearched(TechId::IND_SPNLMNT));
CHECK_NEAR(s.outMod, 1.30, 1e-12);
}
static void test_table_shape() {
// Techs with a strategic effect have entries; unresolved and gate-only ids do not.
CHECK(!EffectsOf(TechId::IND_Waldo).empty());
CHECK_EQ(EffectsOf(TechId::IND_Waldo).size(), std::size_t{2});
CHECK(EffectsOf(TechId::Unresolved_052).empty());
CHECK(EffectsOf(TechId::CCC_HYPCOM).empty());
CHECK(EffectsOf(TechId::None).empty());
int withEffects = 0;
for (int i = 0; i < kTechIdCount; ++i) {
if (!EffectsOf(TechIdFromIndex(i)).empty()) ++withEffects;
}
CHECK_EQ(withEffects, 44);
// design-option masks
std::set<std::string> have = {"IND_REFCOAT", "SLD_INTANG", "DRV_NODE", "WEP_HvyPmsl"};
DesignOptionMasks m = ComputeDesignOptionMasks([&](std::string_view n) { return have.count(std::string(n)) > 0; });
CHECK_EQ(m.a, (1u << 0) | (1u << 15));
CHECK_EQ(m.b, (1u << 0) | (1u << 28));
m = ComputeDesignOptionMasks([](std::string_view) { return false; });
CHECK_EQ(m.a, 0u);
CHECK_EQ(m.b, 0u);
m = ComputeDesignOptionMasks([](std::string_view) { return true; });
CHECK_EQ(m.a, 0xffffffffu);
CHECK_EQ(m.b, 0x1fffffffu);
}
int main() {
test_ids();
test_industrial();
test_biology();
test_gates_and_casting();
test_ai();
test_flags_and_species();
test_plague();
test_xenotech();
test_by_name();
test_table_shape();
return simtest::finish("test_effects");
}

View file

@ -63,9 +63,16 @@ static void test_capacity() {
c.planetSize = 0;
CHECK_EQ(CarryingCapacity(c, t), std::int64_t{100000000}); // arcology alone
CHECK_NEAR(HazardModifierShape(0.5, 0.5, 0.2), 1.0, 0.0);
CHECK_NEAR(HazardModifierShape(0.6, 0.5, 0.2), 0.5, 1e-12);
CHECK_NEAR(HazardModifierShape(0.9, 0.5, 0.2), 0.0, 0.0);
// hazard = clamp01(1 - |suit - ideal| / (tol + 0.1))
CHECK_NEAR(HazardModifier(0.5, 0.5, 0.2), 1.0, 0.0);
CHECK_NEAR(HazardModifier(0.6, 0.5, 0.2), 1.0 - 0.1 / 0.3, 1e-12);
CHECK_NEAR(HazardModifier(0.5, 0.65, 0.2), 0.5, 1e-12); // symmetric
CHECK_NEAR(HazardModifier(0.8, 0.5, 0.2), 0.0, 0.0); // at the band edge
CHECK_NEAR(HazardModifier(0.9, 0.5, 0.2), 0.0, 0.0);
CHECK_NEAR(HazardModifier(0.55, 0.5, 0.0), 0.5, 1e-12); // zero tolerance keeps a 0.1 band
CHECK_NEAR(HazardModifier(0.7, 0.5, 0.0), 0.0, 0.0);
// both adaptation techs: 0.2 + 0.75 + 1.5 -> band 2.55
CHECK_NEAR(HazardModifier(0.8, 0.5, 2.45), 1.0 - 0.3 / 2.55, 1e-12);
}
static void test_growth() {
@ -143,13 +150,20 @@ static void test_slaves() {
SpeciesTechFlags f;
// 0.5 x 0.1 + 0.2 x 0.5 + 0.05 = 0.2
CHECK_NEAR(SlaveDeathRate(0.5, 0.3, 0.5, f, t), 0.2, 1e-12);
f.slaveDeathTech0 = true;
f.translation1 = true;
CHECK_NEAR(SlaveDeathRate(0.5, 0.3, 0.5, f, t), 0.16, 1e-12);
f.slaveDeathTech1 = f.slaveDeathTech2 = true;
f.translation2 = f.translation3 = true;
CHECK_NEAR(SlaveDeathRate(0.5, 0.3, 0.5, f, t), 0.08, 1e-12);
SpeciesTechFlags none;
CHECK_NEAR(SlaveDeathRate(0.0, 0.5, 0.5, none, t), 0.05, 1e-12); // base rate only
SpeciesTechFlags bits = SpeciesTechFlags::FromBits(0x087); // bits 0,1,2,7
CHECK(bits.translation1 && bits.translation2 && bits.translation3 && bits.accommodate);
CHECK(!bits.incorporate && !bits.addict && !bits.temperance && !bits.subjugate && !bits.proliferate);
CHECK_NEAR(SlaveDeathRate(0.5, 0.3, 0.5, bits, t), 0.08, 1e-12);
CHECK(SpeciesTechFlags::FromBits(0x100).proliferate);
CHECK(SpeciesTechFlags::FromBits(0x020).temperance);
CHECK_EQ(SlaveDeaths(1000, 0.2, t), std::int64_t{200});
CHECK_EQ(SlaveDeaths(0, 0.2, t), std::int64_t{0});
t.SLAVES_MIN_DEATHS = 300;
@ -216,8 +230,65 @@ static void test_output() {
l = SplitLeftover(0, {0.5, 0.25, 0.125, 0.125}, false, false);
CHECK_EQ(l.trade, 0);
CHECK_EQ(SystemMoneyIncomeShape(1000, 1.5, 1.1, 10.0), 1640); // 1650 - 10
CHECK_EQ(SystemMoneyIncomeShape(0, 1.5, 1.1, 10.0), -10);
}
static void test_system_money() {
CHECK_NEAR(SuitabilityCostMod(0.3, 0.5, 0.15, false, true), 0.15, 0.0); // capped by SuitTol
CHECK_NEAR(SuitabilityCostMod(0.45, 0.5, 0.15, false, true), 0.05, 1e-12);
CHECK_NEAR(SuitabilityCostMod(0.3, 0.5, 0.15, true, true), 0.0, 0.0); // rebel AI pays nothing
CHECK_NEAR(SuitabilityCostMod(0.5, 0.5, 0.15, false, false), 20.0, 0.0); // unowned
SystemMoneyInputs m;
m.tradePoints = 20; // 4 blocks x 5 = 100
CHECK_EQ(SystemMoneyIncome(m), 100);
m.tradePoints = 4.9; // no whole block
CHECK_EQ(SystemMoneyIncome(m), 0);
m.tradePoints = 123; // 24 blocks -> 600
CHECK_EQ(SystemMoneyIncome(m), 600);
m.popIncomeImperial = 100;
m.popIncomeCivilian = 50;
m.slaveIncome = 10;
m.speciesIncomeFactor = 1.1; // Zuul
m.speciesCostFactor = 0.7;
m.suitCostMod = 0.2;
// (600 + 160) x 1.1 = 836.0000000000001; cost 0.7 x 0.2 x 15000 = 2100;
// -1263.9999999999998 truncates toward zero
CHECK_EQ(SystemMoneyIncome(m), -1263);
m.speciesIncomeFactor = 1.0; // 760 - 0.25 x 15000 = -2990 exactly
m.speciesCostFactor = 1.0;
m.suitCostMod = 0.25;
CHECK_EQ(SystemMoneyIncome(m), -2990);
SystemMoneyInputs n;
n.tradePoints = 10;
n.speciesIncomeFactor = 0.8; // Morrigi
CHECK_EQ(SystemMoneyIncome(n), 40);
n.speciesIncomeFactor = 1.0;
n.tradePoints = 100; // 500
n.playerIncMod = 1.2; // 600
n.serverIncomeMod = 0.5;
n.difficultyIncomeMult = 2.0; // x1 net
CHECK_EQ(SystemMoneyIncome(n), 600);
SystemMoneyInputs unowned;
unowned.suitCostMod = 20.0; // 20 x 15000
CHECK_EQ(SystemMoneyIncome(unowned), -300000);
// the cost is not scaled by the income multipliers
SystemMoneyInputs c;
c.tradePoints = 10; // 50
c.playerIncMod = 3.0; // 150
c.suitCostMod = 0.01; // cost 150
CHECK_EQ(SystemMoneyIncome(c), 0);
CHECK_NEAR(ConstantsOf(Species::Zuul).incomeFactor, 1.1, 0.0);
CHECK_NEAR(ConstantsOf(Species::Zuul).hazardCostFactor, 0.7, 0.0);
CHECK_NEAR(ConstantsOf(Species::Morrigi).incomeFactor, 0.8, 0.0);
CHECK_NEAR(ConstantsOf(Species::Human).incomeFactor, 1.0, 0.0);
CHECK(!ConstantsOf(Species::Zuul).systemBonusEligible);
CHECK(ConstantsOf(Species::Hiver).systemBonusEligible);
}
static void test_bonuses() {
@ -249,10 +320,30 @@ static void test_bonuses() {
for (int i = 0; i < 20; ++i) AccrueSystemBonus(in, pbon, ibonus, t);
CHECK_EQ(pbon, std::int64_t{100000}); // capped at 1e6 x 0.1
CHECK_NEAR(ibonus, 0.2, 1e-12); // capped at INFRABONUS
in.homeSystem = true;
for (int i = 0; i < 20; ++i) AccrueSystemBonus(in, pbon, ibonus, t);
CHECK_EQ(pbon, std::int64_t{200000});
CHECK_NEAR(ibonus, 0.5, 1e-12);
// the increment truncates: 12345 x 0.005 = 61.725 -> 61; target 1234.5 -> 1234
TuningTable small = t;
small.SYSTEMBONUS_POPBONUS_INC = 0.005;
in.capacity = 12345;
std::int64_t p3 = 0;
double i3 = 0;
AccrueSystemBonus(in, p3, i3, small);
CHECK_EQ(p3, std::int64_t{61});
for (int i = 0; i < 30; ++i) AccrueSystemBonus(in, p3, i3, small);
CHECK_EQ(p3, std::int64_t{1234});
p3 = 5000; // already above the target: untouched
AccrueSystemBonus(in, p3, i3, small);
CHECK_EQ(p3, std::int64_t{5000});
in.capacity = 1000000;
// Zuul never accrue either bonus
std::int64_t pz = 0;
double iz = 0;
in.ownerSpeciesEligible = false;
AccrueSystemBonus(in, pz, iz, t);
CHECK_EQ(pz, std::int64_t{0});
CHECK_NEAR(iz, 0.0, 0.0);
in.ownerSpeciesEligible = true;
std::int64_t p2 = 0;
double i2 = 0;
@ -263,6 +354,10 @@ static void test_bonuses() {
in.stable = false;
AccrueSystemBonus(in, p2, i2, t);
CHECK_EQ(p2, std::int64_t{0});
in.stable = true;
in.turnsSinceRebellion = 10;
AccrueSystemBonus(in, p2, i2, t);
CHECK_EQ(p2, std::int64_t{0});
}
static void test_build_queue() {
@ -303,6 +398,7 @@ int main() {
test_infra_terraform();
test_slaves();
test_output();
test_system_money();
test_bonuses();
test_build_queue();
return simtest::finish("test_colony");

View file

@ -41,12 +41,28 @@ static void test_research_points() {
}
static void test_expenses() {
std::vector<ExpenseSlider> s = {{100, 300, 250}, {50, 60, 100}};
// minimums 150; extras 150 + 10 = 160; headroom 1000-150 = 850 -> 310
CHECK_EQ(ExpenseTotal(s, 1000), 310);
// headroom 200-150 = 50 -> 200
CHECK_EQ(ExpenseTotal(s, 200), 200);
std::vector<ExpenseSlider> s = {{100, 300, 0.5f}, {50, 60, 0.1f}};
// s1: request ftol(0.5 x 1000) - 100 = 400, room 200 -> 200
// s2: request 100 - 50 = 50, room 10 -> 10; minimums 150 + takes 210 = 360
CHECK_EQ(ExpenseTotal(s, 1000), 360);
// avail 200: s1 request 0, s2 request -30 -> 0; only the minimums
CHECK_EQ(ExpenseTotal(s, 200), 150);
// avail below the minimums: total is capped at avail
CHECK_EQ(ExpenseTotal(s, 100), 100);
CHECK_EQ(ExpenseTotal({}, 1000), 0);
// max 0 = unlimited
CHECK_EQ(ExpenseTotal({{0, 0, 0.25f}}, 1000), 250);
// a negative minimum counts as 0
CHECK_EQ(ExpenseTotal({{-50, 100, 0.1f}}, 1000), 100);
// min above max: the entry contributes its minimum, its (negative) take is absorbed
CHECK_EQ(ExpenseTotal({{100, 50, 1.0f}}, 1000), 100);
// takes are capped by what is left after the minimums
CHECK_EQ(ExpenseTotal({{100, 0, 1.0f}, {200, 0, 1.0f}}, 1000), 1000);
// the product is taken in single precision: 0.7f x 1000 = 699.99998 -> 699
CHECK_EQ(ExpenseTotal({{0, 0, 0.7f}}, 1000), 699);
// ... and the income itself is rounded to a float first
CHECK_EQ(ExpenseTotal({{0, 0, 1.0f}}, 16777217), 16777216);
}
static BudgetInputs base_inputs() {
@ -134,9 +150,58 @@ static void test_budget_aid_and_bonus() {
in = base_inputs();
in.techIncomeMult = 1.1;
b = ComputeBudget(in, false);
// remaining before bonus = 3200 -> ftol(0.1 x 3200) = 320
// full net before the bonus = 3200 -> ftol(0.1 x 3200) = 320
CHECK_EQ(b.bonusIncome, 320);
CHECK_EQ(b.net, 3520);
// the bonus reads the net *after* research aid was deducted
in.aidResearchPercent = 50;
b = ComputeBudget(in, false);
CHECK_EQ(b.bonusIncome, 320); // 1600 kept + 1600 given: net unchanged
in.aidResearchPercent = 0;
// a multiplier below 1 never takes money away
in.techIncomeMult = 0.5;
b = ComputeBudget(in, false);
CHECK_EQ(b.bonusIncome, 0);
CHECK_EQ(b.net, 3200);
// no bonus on a negative net
in.techIncomeMult = 1.1;
in.maintenance = 20000;
b = ComputeBudget(in, false);
CHECK_EQ(b.available, 0);
CHECK_EQ(b.bonusIncome, 0);
CHECK_EQ(b.net, -11100);
// savings aid is capped by the projected treasury, not by the turn net
in = base_inputs();
in.savings = -5000; // debt interest 750
in.aidSavings = 100;
b = ComputeBudget(in, false);
// available 8000+1000-200-2000-750 = 6050; construction 500; research 2775; net 2775
CHECK_EQ(b.researchMoney, 2775);
CHECK_EQ(b.savingsGiven, 0); // -5000 + 2775 < 0: nothing to give
CHECK_EQ(b.net, 2775);
in.savings = -2000; // debt interest 300
in.aidSavings = 5000;
b = ComputeBudget(in, false);
// available 6500; construction 500; research 3000; net 3000; projected 1000
CHECK_EQ(b.savingsGiven, 1000);
CHECK_EQ(b.net, 2000);
in.savings = 10000;
in.techIncomeMult = 1.1; // bonus 320 counts toward the projection
in.aidSavings = 20000;
b = ComputeBudget(in, false);
CHECK_EQ(b.bonusIncome, 320);
CHECK_EQ(b.savingsGiven, 13520); // 10000 + 3200 + 320: the whole projected treasury
CHECK_EQ(b.net, -10000); // ... so the treasury ends the turn at 0
in.aidSavings = -5; // negative aid gives nothing
b = ComputeBudget(in, false);
CHECK_EQ(b.savingsGiven, 0);
}
static void test_budget_edges() {
@ -163,10 +228,11 @@ static void test_budget_edges() {
CHECK_EQ(b.researchMoney, 0);
in = base_inputs();
in.expenses = {{1000, 2000, 1500}};
in.expenses = {{1000, 2000, 0.5f}};
b = ComputeBudget(in, false);
CHECK_EQ(b.expenses, 1500);
CHECK_EQ(b.available, 5400);
// pre-expense avail 6900: request 3450 - 1000 = 2450, room 1000 -> 2000 total
CHECK_EQ(b.expenses, 2000);
CHECK_EQ(b.available, 4900);
}
static void test_trade() {
@ -218,29 +284,85 @@ static void test_bankruptcy() {
TuningTable t;
t.BANKRUPTCY_PROTECTION_LIMIT_FACTOR = 3.3;
t.BANKRUPTCY_ELIMINATION_TURNS = 5;
// max income 1000: elimination at 1000 / -0.15 = -6666.67 -> -6666; protection -3300
BankruptcyLimits l = ComputeBankruptcyLimits(1000, t);
CHECK_EQ(l.eliminationFloor, -3300);
CHECK_EQ(l.protectionLimit, -1000);
CHECK_EQ(BankruptcyLevel(-3301, l), 2);
CHECK_EQ(BankruptcyLevel(-3300, l), 1);
CHECK_EQ(BankruptcyLevel(-1500, l), 1);
CHECK_EQ(BankruptcyLevel(-1000, l), 0);
CHECK_EQ(l.eliminationFloor, -6666);
CHECK_EQ(l.protectionLimit, -3300);
CHECK_EQ(BankruptcyLevel(-6667, l), 2);
CHECK_EQ(BankruptcyLevel(-6666, l), 1);
CHECK_EQ(BankruptcyLevel(-3301, l), 1);
CHECK_EQ(BankruptcyLevel(-3300, l), 0);
CHECK_EQ(BankruptcyLevel(0, l), 0);
l = ComputeBankruptcyLimits(100, t);
CHECK_EQ(l.eliminationFloor, -666);
CHECK_EQ(l.protectionLimit, -330);
// a factor beyond the interest break-even is pinned to the elimination floor
TuningTable big = t;
big.BANKRUPTCY_PROTECTION_LIMIT_FACTOR = 10.0;
l = ComputeBankruptcyLimits(1000, big);
CHECK_EQ(l.eliminationFloor, -6666);
CHECK_EQ(l.protectionLimit, -6666);
// huge income: the floor saturates at the treasury limit
l = ComputeBankruptcyLimits(400000000, t);
CHECK_EQ(l.eliminationFloor, -2000000000);
CHECK_EQ(l.protectionLimit, -1320000000);
BankruptcyLimits none = ComputeBankruptcyLimits(0, t);
CHECK_EQ(none.eliminationFloor, 0);
CHECK_EQ(none.protectionLimit, 0);
CHECK_EQ(BankruptcyLevel(-1, none), 2); // no income at all: any debt is terminal
// Stamping happens on the transition; actions run on the previous state.
BankruptcyState s;
CHECK(!BankruptcyStep(s, 1, 10, t));
CHECK_EQ(s.startTurn, -1);
BankruptcyDecision d = BankruptcyStep(s, 1, 10, t);
CHECK(!d.costCutting); // first turn at level 1: not yet
CHECK(!d.eliminate);
CHECK_EQ(s.warningLevel, 1);
CHECK_EQ(s.startTurn, 10);
CHECK(!BankruptcyStep(s, 2, 12, t)); // level changed: clock restarts at 12
d = BankruptcyStep(s, 1, 11, t);
CHECK(d.costCutting); // second turn: cost cutting
CHECK_EQ(s.startTurn, 10); // no restamp while the level holds
d = BankruptcyStep(s, 2, 12, t); // 1 -> 2 restamps the clock at 12
CHECK(d.costCutting);
CHECK(!d.eliminate);
CHECK_EQ(s.startTurn, 12);
CHECK(!BankruptcyStep(s, 2, 16, t)); // 4 turns < 5
CHECK(BankruptcyStep(s, 2, 17, t)); // 5 turns -> eliminated
CHECK(!BankruptcyStep(s, 0, 18, t)); // recovered
d = BankruptcyStep(s, 2, 16, t);
CHECK(!d.eliminate); // 4 turns < 5
d = BankruptcyStep(s, 2, 17, t);
CHECK(d.eliminate); // 5 turns -> eliminated
d = BankruptcyStep(s, 0, 18, t); // recovering on the turn after the clock
CHECK(!d.costCutting);
CHECK(d.eliminate); // ... still acts on the old level-2 state
CHECK_EQ(s.warningLevel, 0);
CHECK_EQ(s.startTurn, -1);
// 2 -> 1 -> 2 restarts the clock each time
BankruptcyState r;
BankruptcyStep(r, 2, 20, t);
CHECK_EQ(r.startTurn, 20);
d = BankruptcyStep(r, 1, 22, t);
CHECK(d.costCutting);
CHECK_EQ(r.startTurn, 22);
d = BankruptcyStep(r, 2, 25, t);
CHECK(!d.eliminate); // old level was 1
CHECK_EQ(r.startTurn, 25);
d = BankruptcyStep(r, 2, 29, t);
CHECK(!d.eliminate);
d = BankruptcyStep(r, 2, 30, t);
CHECK(d.eliminate);
// even with a zero-turn limit, elimination happens the turn after level 2 is reached
TuningTable instant = t;
instant.BANKRUPTCY_ELIMINATION_TURNS = 0;
BankruptcyState q;
d = BankruptcyStep(q, 2, 5, instant);
CHECK(!d.eliminate);
d = BankruptcyStep(q, 2, 6, instant);
CHECK(d.eliminate);
}
int main() {

View file

@ -30,11 +30,76 @@ static void test_steps() {
CHECK_NEAR(NodeLineSpeed(10, 50, t), 6.0, 1e-12); // 10 x (0.8 x 0.5 + 0.2)
CHECK_NEAR(NodeLineSpeed(10, 0, t), 2.0, 1e-12); // at a system: min profile
CHECK_NEAR(NodeLineSpeed(10, 100, t), 10.0, 1e-12); // at the radius: max profile
CHECK_NEAR(NodeLineSpeed(10, 200, t), 10.0, 1e-12); // beyond: clamped
CHECK_NEAR(NodeLineSpeed(10, 200, t), 18.0, 1e-12); // no clamp (never reached in practice)
TuningTable zero;
CHECK_NEAR(NodeLineSpeed(10, 50, zero), 0.0, 0.0); // no tuning -> no speed
}
static void test_stutter_segments() {
CHECK_NEAR(DistPointToSegment({5, 3, 0}, {0, 0, 0}, {10, 0, 0}), 3.0, 1e-12);
CHECK_NEAR(DistPointToSegment({-5, 3, 0}, {0, 0, 0}, {10, 0, 0}), std::sqrt(34.0), 1e-12);
CHECK_NEAR(DistPointToSegment({15, 0, 0}, {0, 0, 0}, {10, 0, 0}), 5.0, 1e-12);
CHECK_NEAR(DistPointToSegment({3, 4, 0}, {1, 1, 1}, {1, 1, 1}), std::sqrt(4 + 9 + 1), 1e-12);
TuningTable t;
t.STUTTER_SYSTEM_INFLUENCE_RADIUS = 50;
t.STUTTER_MIN_SPEED = 0.2;
t.STUTTER_MAX_SPEED = 1.0;
const Vec3 from{0, 0, 0}, to{100, 0, 0};
// One system 30 off the line: chord where (x-50)^2 + 900 <= 2500 -> x in [10, 90]
std::vector<StutterSegment> s = BuildStutterSegments(from, to, {{50, 30, 0}}, t);
CHECK_EQ(s.size(), std::size_t{1});
CHECK_NEAR(s[0].start, 10.0, 1e-9);
CHECK_NEAR(s[0].end, 90.0, 1e-9);
CHECK_EQ(s[0].systemIndex, 0);
CHECK_NEAR(s[0].speedFactor, 0.8 * 30 / 50 + 0.2, 1e-12); // 0.68 for the whole chord
// Out of reach, and exactly tangent (zero-length chord): no segments
CHECK(BuildStutterSegments(from, to, {{50, 200, 0}}, t).empty());
CHECK(BuildStutterSegments(from, to, {{50, 50, 0}}, t).empty());
// Second system on the line near the end: chord [35, 135] clipped to [35, 100];
// the overlap with [10, 90] is split at 62.5.
s = BuildStutterSegments(from, to, {{50, 30, 0}, {85, 0, 0}}, t);
CHECK_EQ(s.size(), std::size_t{2});
CHECK_NEAR(s[0].start, 10.0, 1e-9);
CHECK_NEAR(s[0].end, 62.5, 1e-9);
CHECK_EQ(s[0].systemIndex, 0);
CHECK_NEAR(s[0].speedFactor, 0.68, 1e-12);
CHECK_NEAR(s[1].start, 62.5, 1e-9);
CHECK_NEAR(s[1].end, 100.0, 1e-9);
CHECK_EQ(s[1].systemIndex, 1);
CHECK_NEAR(s[1].speedFactor, 0.2, 1e-12); // system on the line: min
// Input order does not matter: segments come back sorted
std::vector<StutterSegment> r = BuildStutterSegments(from, to, {{85, 0, 0}, {50, 30, 0}}, t);
CHECK_EQ(r.size(), std::size_t{2});
CHECK_EQ(r[0].systemIndex, 1);
CHECK_EQ(r[1].systemIndex, 0);
// A chord contained in an earlier one is split at the midpoint of the overlap
s = BuildStutterSegments(from, to, {{50, 0, 0}, {50, 40, 0}}, t); // [0,100] and [20,80]
CHECK_EQ(s.size(), std::size_t{2});
CHECK_NEAR(s[0].end, 60.0, 1e-9);
CHECK_NEAR(s[1].start, 60.0, 1e-9);
CHECK_NEAR(s[1].end, 80.0, 1e-9);
TuningTable zero;
CHECK(BuildStutterSegments(from, to, {{50, 0, 0}}, zero).empty()); // no radius: plain line
// Advance along the [10, 90] x0.68 profile at node speed 20
s = BuildStutterSegments(from, to, {{50, 30, 0}}, t);
// half a turn to reach 10 at speed 20, then 0.5 x 13.6
CHECK_NEAR(AdvanceAlongNodeLine(0, 1.0, 20, 100, s), 16.8, 1e-9);
// from 85: 5 units at 13.6 take 0.36765 turns; the rest at 20
CHECK_NEAR(AdvanceAlongNodeLine(85, 0.5, 20, 100, s), 90.0 + (0.5 - 5.0 / 13.6) * 20.0, 1e-9);
CHECK_NEAR(AdvanceAlongNodeLine(0, 10.0, 20, 100, s), 100.0, 0.0); // never past the end
CHECK_NEAR(AdvanceAlongNodeLine(0, 1.0, 20, 100, {}), 20.0, 1e-12); // no spheres: plain speed
CHECK_NEAR(AdvanceAlongNodeLine(30, 1.0, 0, 100, s), 30.0, 0.0); // no speed: no movement
CHECK_NEAR(AdvanceAlongNodeLine(30, 0.0, 20, 100, s), 30.0, 0.0);
}
static void test_resolve() {
MoveStepResult r = ResolveMoveStep(5, 10, 20);
CHECK_NEAR(r.moved, 5.0, 0.0);
@ -132,6 +197,7 @@ static void test_jump() {
int main() {
test_vectors();
test_steps();
test_stutter_segments();
test_resolve();
test_multi_waypoint_turn();
test_jump();