diff --git a/docs/game-effects.md b/docs/game-effects.md new file mode 100644 index 0000000..164d9c0 --- /dev/null +++ b/docs/game-effects.md @@ -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. diff --git a/docs/game-sim.md b/docs/game-sim.md index eb4f589..185b28d 100644 --- a/docs/game-sim.md +++ b/docs/game-sim.md @@ -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. diff --git a/src/game/effects/CMakeLists.txt b/src/game/effects/CMakeLists.txt new file mode 100644 index 0000000..d9f9071 --- /dev/null +++ b/src/game/effects/CMakeLists.txt @@ -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() diff --git a/src/game/effects/tech_effects.cpp b/src/game/effects/tech_effects.cpp new file mode 100644 index 0000000..724b439 --- /dev/null +++ b/src/game/effects/tech_effects.cpp @@ -0,0 +1,283 @@ +#include "game/effects/tech_effects.h" + +#include +#include + +namespace sots::effects { + +namespace { + +using sots::sim::Species; + +constexpr int F(PlayerFlag f) { return static_cast(f); } +constexpr int Sp(Species s) { return static_cast(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 effects; +}; + +const std::vector& Table() { + using K = EffectKind; + static const std::vector 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(AiBonusSlot::Research)}}}, + {TechId::CCC_AIAdmin, {{K::AiTechBonus, static_cast(AiBonusSlot::Admin)}}}, + {TechId::CCC_AIFac, {{K::AiTechBonus, static_cast(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(static_cast(TechId::IND_BrdPod))}}}, + {TechId::CCC_SpyBm, {{K::CaptureDesignsWith, static_cast(TechId::IND_SlvgTech)}}}, + {TechId::IND_SlvgTech, {{K::CaptureDesignsWith, static_cast(TechId::CCC_SpyBm)}}}, + {TechId::BIO_PLGVAC, {{K::CurePlague, static_cast(kCurePlague)}}}, + {TechId::BIO_RTPLGVAC, {{K::CurePlague, static_cast(kCureRetroPlague)}}}, + {TechId::BIO_BSTVAC, {{K::CurePlague, static_cast(kCureBeastPlague)}}}, + {TechId::BIO_ASPLGVAC, {{K::CurePlague, static_cast(kCureAssimilationPlague)}}}, + {TechId::BIO_CONNAN, {{K::CurePlague, static_cast(kCureNaniteVirus)}}}, + {TechId::BIO_UNIANTI, {{K::CurePlague, static_cast(kCureUniversal)}}}, + {TechId::DRV_RIP, {{K::NodeBoreParams, 0}}}, + {TechId::DRV_REND, {{K::NodeBoreParams, 1}}}, + {TechId::DRV_RAD, {{K::NodeBoreParams, 2}}}, + }; + return table; +} + +const std::vector& NoEffects() { + static const std::vector 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(e.index), AiBonusFor(static_cast(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(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(static_cast(e.value)); + break; + case K::CaptureDesignsWith: + if (s.HasResearched(static_cast(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& 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(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(k), static_cast(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(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(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(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(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(t[i])) == std::tolower(static_cast(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& 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 diff --git a/src/game/effects/tech_effects.h b/src/game/effects/tech_effects.h new file mode 100644 index 0000000..875eb29 --- /dev/null +++ b/src/game/effects/tech_effects.h @@ -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 +#include +#include +#include +#include + +#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(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& 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 researched; + + bool Flag(PlayerFlag f) const { return flags[static_cast(f)]; } + bool HasResearched(TechId id) const { return IsValidTechId(id) && researched.test(static_cast(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& hasResearchedByName); + +} // namespace sots::effects diff --git a/src/game/effects/tech_id.cpp b/src/game/effects/tech_id.cpp new file mode 100644 index 0000000..de6d5fe --- /dev/null +++ b/src/game/effects/tech_id.cpp @@ -0,0 +1,80 @@ +#include "game/effects/tech_id.h" + +#include + +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(a[i])) != std::tolower(static_cast(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(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 diff --git a/src/game/effects/tech_id.h b/src/game/effects/tech_id.h new file mode 100644 index 0000000..5a4ef23 --- /dev/null +++ b/src/game/effects/tech_id.h @@ -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 + +#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(id)); } +constexpr int TechIdIndex(TechId id) { return static_cast(id) - kTechIdBase; } +constexpr TechId TechIdFromIndex(int index) { return static_cast(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 diff --git a/src/game/sim/colony.cpp b/src/game/sim/colony.cpp index bd70c67..d423ab7 100644 --- a/src/game/sim/colony.cpp +++ b/src/game/sim/colony.cpp @@ -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(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(in.popIncomeImperial); + t += static_cast(in.popIncomeCivilian); + t += static_cast(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(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(popInc, 0), std::max(popTarget - popBonus, 0)); - const std::int64_t popInc = Ftoi64(static_cast(in.capacity) * t.SYSTEMBONUS_POPBONUS_INC); - const std::int64_t popRoom = Ftoi64(static_cast(in.capacity) * popCapMult) - popBonus; - popBonus += std::max(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& queue, int points) { diff --git a/src/game/sim/colony.h b/src/game/sim/colony.h index a22684d..6b506f1 100644 --- a/src/game/sim/colony.h +++ b/src/game/sim/colony.h @@ -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); diff --git a/src/game/sim/economy.cpp b/src/game/sim/economy.cpp index cb1f8cf..e649112 100644 --- a/src/game/sim/economy.cpp +++ b/src/game/sim/economy.cpp @@ -24,15 +24,27 @@ int MaintenanceCost(int maintenance, double difficultyDivisor) { } int ExpenseTotal(const std::vector& 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(static_cast(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(maxC) - minC; + const std::int64_t request = + static_cast(Ftol(static_cast(s.fraction) * availAsFloat)) - minC; + const std::int64_t take = std::min(std::max(request, 0), room); + minimums += minC; + takes += take; } - const std::int64_t headroom = static_cast(availableBeforeExpenses) - minimums; - const std::int64_t total = minimums + std::min(extras, headroom); + const std::int64_t headroom = static_cast(availPre) - minimums; + const std::int64_t total = minimums + std::min(std::max(takes, 0), headroom); return static_cast(ClampT(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(running())); - b.savingsGiven = static_cast(std::min(std::max(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(netBeforeBonus))); + } + // Savings aid: capped by the projected treasury after this turn (bonus included). + if (in.aidSavings != 0) { + const int netWithBonus = static_cast(ClampT(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(ClampT(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(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(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(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 diff --git a/src/game/sim/economy.h b/src/game/sim/economy.h index 5bbcb14..5c5b681 100644 --- a/src/game/sim/economy.h +++ b/src/game/sim/economy.h @@ -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& 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 diff --git a/src/game/sim/movement.cpp b/src/game/sim/movement.cpp index ad0fffe..1e1fca1 100644 --- a/src/game/sim/movement.cpp +++ b/src/game/sim/movement.cpp @@ -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 BuildStutterSegments(const Vec3& from, const Vec3& to, + const std::vector& systems, + const TuningTable& t) { + std::vector 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(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 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(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& 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; diff --git a/src/game/sim/movement.h b/src/game/sim/movement.h index e799bb7..58be1fb 100644 --- a/src/game/sim/movement.h +++ b/src/game/sim/movement.h @@ -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 BuildStutterSegments(const Vec3& from, const Vec3& to, + const std::vector& 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& segments); + struct MoveStepResult { double moved = 0; // distance actually covered this pass double fraction = 1.0; // moved / step (1 when the step was zero) diff --git a/src/game/sim/species.h b/src/game/sim/species.h index 99f25c3..4be4e71 100644 --- a/src/game/sim/species.h +++ b/src/game/sim/species.h @@ -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 diff --git a/src/game/sim/tuning.h b/src/game/sim/tuning.h index d5c59d1..c71dfb3 100644 --- a/src/game/sim/tuning.h +++ b/src/game/sim/tuning.h @@ -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 diff --git a/tests/game_effects/CMakeLists.txt b/tests/game_effects/CMakeLists.txt new file mode 100644 index 0000000..64c6833 --- /dev/null +++ b/tests/game_effects/CMakeLists.txt @@ -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) diff --git a/tests/game_effects/build_and_run.sh b/tests/game_effects/build_and_run.sh new file mode 100755 index 0000000..1c42190 --- /dev/null +++ b/tests/game_effects/build_and_run.sh @@ -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 diff --git a/tests/game_effects/test_effects.cpp b/tests/game_effects/test_effects.cpp new file mode 100644 index 0000000..41b4d19 --- /dev/null +++ b/tests/game_effects/test_effects.cpp @@ -0,0 +1,377 @@ +#include "game/effects/tech_effects.h" +#include "game/effects/tech_id.h" + +#include +#include + +#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(TechId::CCC_AdvSens), 10000); + CHECK_EQ(static_cast(TechId::IND_HrdStrct), 10030); + CHECK_EQ(static_cast(TechId::WEP_ACCAMP), 10192); + CHECK_EQ(static_cast(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(XenoTechId(XenoLevel::Translation2, Species::Morrigi)), 10125); + CHECK_EQ(static_cast(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(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(Species::Liir)], 0x001u); + CHECK_EQ(s.speciesFlags[static_cast(Species::Human)], 0u); + CHECK_EQ(o.temperanceSpeciesMask, 0u); + ApplyTechEffect(s, TechId::XNC_Translation3_Liir, ctx); + CHECK_EQ(s.speciesFlags[static_cast(Species::Liir)], 0x005u); + o = ApplyTechEffect(s, TechId::XNC_Temperance_Hiver, ctx); + CHECK_EQ(s.speciesFlags[static_cast(Species::Hiver)], 0x020u); + CHECK_EQ(o.temperanceSpeciesMask, 1u << static_cast(Species::Hiver)); + o = ApplyTechEffect(s, TechId::XNC_Accommodate_Morrigi, ctx); // every completion reports temperance + CHECK_EQ(s.speciesFlags[static_cast(Species::Morrigi)], 0x080u); + CHECK_EQ(o.temperanceSpeciesMask, 1u << static_cast(Species::Hiver)); + ApplyTechEffect(s, TechId::XNC_Proliferate_Zuul, ctx); + CHECK_EQ(s.speciesFlags[static_cast(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(Species::Human)); + o = ApplyTechEffectByName(s, "ccc_ndtrkzul", ctx); + CHECK_EQ(s.nodeTrackMask, (1u << static_cast(Species::Human)) | (1u << static_cast(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 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"); +} diff --git a/tests/game_sim/test_colony.cpp b/tests/game_sim/test_colony.cpp index 4b7fcb0..86958ec 100644 --- a/tests/game_sim/test_colony.cpp +++ b/tests/game_sim/test_colony.cpp @@ -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"); diff --git a/tests/game_sim/test_economy.cpp b/tests/game_sim/test_economy.cpp index 5ef429e..764b1ca 100644 --- a/tests/game_sim/test_economy.cpp +++ b/tests/game_sim/test_economy.cpp @@ -41,12 +41,28 @@ static void test_research_points() { } static void test_expenses() { - std::vector 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 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() { diff --git a/tests/game_sim/test_movement.cpp b/tests/game_sim/test_movement.cpp index 32ead9e..9b2fd38 100644 --- a/tests/game_sim/test_movement.cpp +++ b/tests/game_sim/test_movement.cpp @@ -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 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 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();