game/sim: pin all five low-confidence formulas; game/effects: TechId table + 44 typed strategic effects
This commit is contained in:
parent
ba9ec951d2
commit
f83f6af62d
21 changed files with 2119 additions and 140 deletions
161
docs/game-effects.md
Normal file
161
docs/game-effects.md
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
# game/effects — code-defined tech effects
|
||||
|
||||
Module: `src/game/effects/`. Two data tables and a small apply layer over a plain
|
||||
`PlayerEconomyState`; no game state, no I/O. Depends on `game/sim` for `Species` and
|
||||
`TuningTable`.
|
||||
|
||||
Build/test: `tests/game_effects/build_and_run.sh` (plain g++, `-Wall -Wextra -Werror`), or
|
||||
`-DSOTS_GAME_EFFECTS_TESTS=ON` once `src/game/effects` is added to the root CMake after
|
||||
`src/game/sim`.
|
||||
|
||||
Confidence legend as in `game-sim.md`.
|
||||
|
||||
## The key space (`tech_id.h`)
|
||||
|
||||
The game does not key effects on the `.tech` files. It carries a fixed list of 196 tech
|
||||
names; when the master tree is built, every name is matched case-insensitively against
|
||||
the loaded techs and its position becomes the tech's id, `10000 + index` (`10197` is the
|
||||
"none" sentinel). All hard-coded effects, runtime gates and design-option bitmasks read
|
||||
those ids. A tech absent from the list has no code effect beyond what its data file says
|
||||
(prerequisites, section/weapon availability).
|
||||
|
||||
`TechId` reproduces that list as an enum with the same numeric values, generated from one
|
||||
X-macro so the enum and the name table cannot drift:
|
||||
|
||||
| entry kind | count | example |
|
||||
|---|---|---|
|
||||
| position and data-file name known | 89 | `IND_Waldo = 10001` |
|
||||
| position known, name inferred or role-named | ~55 | `BIO_RetroPlague` (from its vaccine), `WEP_NUKMINE` (prefix inferred), `XNC_Temperance_Hiver` |
|
||||
| position known, nothing else | ~52 | `Unresolved_052` |
|
||||
|
||||
`TechIdFromName` resolves only the 89 confirmed names; everything else comes back as
|
||||
`TechId::None` and is treated as "no code effect". `TechIdName` is `nullptr` for the
|
||||
unconfirmed slots so a loader can tell them apart.
|
||||
|
||||
### Xenotech block
|
||||
|
||||
Ids 10114–10163 are the per-species xenotechs, nine families in flag-bit order, each a
|
||||
block of one tech per target species (`XenoTechId(level, species)`):
|
||||
|
||||
| bit | family | block base | targets |
|
||||
|---|---|---|---|
|
||||
| 0 | Translation 1 | 10114 | Human, Hiver, Tarkas, Liir, Zuul, Morrigi |
|
||||
| 1 | Translation 2 | 10120 | same six |
|
||||
| 2 | Translation 3 | 10126 | same six |
|
||||
| 3 | Incorporate | 10132 | five: no Zuul |
|
||||
| 4 | Addict | 10137 | five |
|
||||
| 5 | Temperance | 10142 | five |
|
||||
| 6 | Subjugate | 10147 | six |
|
||||
| 7 | Accommodate | 10153 | five |
|
||||
| 8 | Proliferate | 10158 | six |
|
||||
|
||||
The NPC race is never a target. Confidence: **high** on the family order and the block
|
||||
bases; **medium** on the compact species order inside a block (enum order minus NPC);
|
||||
**low** on which species the 5-entry blocks other than Incorporate omit — Zuul is assumed
|
||||
for all four. Only `CCC_TRNSHUM` (10114) and `CCC_TRNSLIR` (10117) have confirmed data-file
|
||||
names; the rest of the block is named by role.
|
||||
|
||||
### Node-track techs
|
||||
|
||||
Seeing a species' node-space traffic is granted by a tech keyed by *name* in the species
|
||||
table, not by id: `CCC_NDTRKHUM` for Human traffic, `CCC_NDTRKZUL` for Zuul traffic, none
|
||||
for the others. `NodeTrackTechName(species)` exposes that; `ApplyTechEffectByName` handles
|
||||
it. Confidence: high on the names, medium on the reader semantics.
|
||||
|
||||
## The effects table (`tech_effects.h`)
|
||||
|
||||
`EffectsOf(id)` returns the list of typed effects (`{kind, index, value}`) applied when the
|
||||
tech completes. Effects are additive per research event and permanent. 44 ids carry an
|
||||
entry; every other id returns an empty list.
|
||||
|
||||
| tech | effects |
|
||||
|---|---|
|
||||
| CCC_AdvSens | flag AdvancedSensors |
|
||||
| IND_Waldo, IND_ExpSys | ConMod[0..2] −0.10; OutMod +0.15 |
|
||||
| IND_CyberInt | ConMod[0..2] −0.05; OutMod +0.20 |
|
||||
| IND_OrbFound | SavMod[0..2] −0.05 |
|
||||
| IND_OrbDry | ConMod[1] −0.05; ConMod[2] −0.05 (index 0 untouched — ConMod is per hull class, medium) |
|
||||
| IND_GravCon | OutMod +0.30 |
|
||||
| IND_HvyPlat | OutMod +0.10 |
|
||||
| IND_AstMine | flag AsteroidMining |
|
||||
| IND_MsMine | MaxOH = max(MaxOH, 0.1); MinRate +1.0 |
|
||||
| BIO_GnMod | PopMod +0.10 |
|
||||
| BIO_AtmoAd | SuitTol +0.75; PopMod +0.06; TerraMod +0.35 |
|
||||
| BIO_EnvTail | PopMod +0.20; TerraMod +0.45 |
|
||||
| BIO_GrvAdpt | SuitTol +1.50; PopMod +0.10; TerraMod +0.35 |
|
||||
| IND_ArcCon | flag Arcology; PopMod +0.15; re-evaluate systems whose civilians sit at the cap (outcome) |
|
||||
| IND_EleNans | TerraMod +0.60 |
|
||||
| BIO_TerBac | TerraMod +0.45 |
|
||||
| IND_AtProc | TerraMod +0.50 |
|
||||
| DRV_TpGate / DRV_GatAmp | PrGtTrf = max(PrGtTrf, `PERGATETRAFFIC_DRV_TpGate` / `_GatAmp`) from the tuning table |
|
||||
| DRV_FarCast | CstR 10, CstE 2, CstT 1 |
|
||||
| CCC_AI / CCC_AIAdmin / CCC_AIFac | ResMod / IncMod / OutMod += the AI-bonus value for that slot, only while the AI benefit is on |
|
||||
| CCC_AIVrus | every owned system gets its AI flag (outcome) |
|
||||
| CCC_AISlv | same, plus the AI benefit is switched back on; `AiRebellionPossible` becomes false |
|
||||
| CCC_FtlEcon | flag TradeAllowed, unless the player is the rebel AI |
|
||||
| CCC_ComRaid | flag CommerceRaiding |
|
||||
| DRV_GrvSyn | flag GravSynth (client sync only; the drive modifier is design-side) |
|
||||
| CCC_DatCor | flag ViewIntel |
|
||||
| IND_HrdStrct | pddm ×0.25; OutMod ×0.90 — **multiplicative**, so order against the additive OutMod techs matters |
|
||||
| DRN_AdvRob | ConMod[0..2] −0.05 |
|
||||
| IND_CruisCon | Zuul only: IND_BrdPod is granted (outcome; the caller researches it) |
|
||||
| CCC_SpyBm, IND_SlvgTech | flag CaptureDesigns once both are researched |
|
||||
| BIO_PLGVAC / RTPLGVAC / BSTVAC / ASPLGVAC / CONNAN | HasVac, HasImm |= bit 0 / 1 / 2 / 3 / 4; cure that plague type on owned systems and ships (outcome) |
|
||||
| BIO_UNIANTI | same with mask 0x0f |
|
||||
| DRV_RIP / REND / RAD | Zuul node-bore parameters {45,15,3} / {65,35,4} / {95,60,5}, highest wins |
|
||||
|
||||
Confidence: **high** on every constant above (each was read with its literal); **medium**
|
||||
on the AI-benefit re-application in `SetAiBenefit` and on the node-bore "highest wins"
|
||||
rule; the three AI-bonus values themselves are **not recovered** — `ApplyContext::aiBonus`
|
||||
carries them and defaults to 0.
|
||||
|
||||
Every completion also rebuilds `speciesFlags[]` (bit k of species sp = the level-k
|
||||
xenotech for sp is researched) and reports, in the outcome, every species whose
|
||||
temperance bit is held so the caller can cure addiction to it on owned systems.
|
||||
|
||||
### Where the modifiers are consumed
|
||||
|
||||
`OutMod` → `TotalSystemOutput` (game/sim); `PopMod` → `PopulationGrowthDelta`; `TerraMod` →
|
||||
`TerraformDelta`; `SuitTol` → `HazardModifier` **and** `SuitabilityCostMod`; `ConMod[i]` →
|
||||
per-hull-class construction cost (medium); `SavMod[i]` → maintenance side (low); `pddm` →
|
||||
planetary-defence damage (low); `MaxOH` / `MinRate` → mining sliders; `PrGtTrf` → gate
|
||||
traffic capacity; the flags → the gates listed in the RE catalog. `hazardMod` is 1 (skipped)
|
||||
when `speciesFlags[sp]` has the accommodate bit or the player is the rebel AI.
|
||||
|
||||
### Design-option masks
|
||||
|
||||
Two 32-bit words of "named tech researched" bits feed the design side; `kDesignOptionNamesA`
|
||||
(32 names) and `kDesignOptionNamesB` (29 names) are the tables and
|
||||
`ComputeDesignOptionMasks(hasResearchedByName)` builds the words from a by-name predicate,
|
||||
because not every name's id is recovered. Consumers are not modelled. Confidence: high on
|
||||
the tables.
|
||||
|
||||
## Apply layer
|
||||
|
||||
```
|
||||
PlayerEconomyState s; // seeded by the caller (species, SuitTol start, ...)
|
||||
ApplyContext ctx{&tuning, aiBonus};
|
||||
TechApplyOutcome o = ApplyTechEffect(s, TechId::IND_HrdStrct, ctx);
|
||||
```
|
||||
|
||||
`ApplyTechEffect` marks the id researched, applies its effects, rebuilds the species
|
||||
flags, and returns what the caller must do with real game state: `grantedTech`,
|
||||
`plagueCuredMask`, `flagSystemsAI`, `reevaluateCivilianCaps`, `temperanceSpeciesMask`,
|
||||
`nodeBoreParamsChanged`. Applying an invalid or already-researched id is a no-op
|
||||
(`applied == false`). `ApplyTechEffectByName` resolves a data-file name first and also
|
||||
handles the node-track names. `SetAiBenefit(s, on, ctx)` adds or withdraws every
|
||||
researched AI tech's bonus (AI rebellion / AI slave tech). `RebuildSpeciesTechFlags` is
|
||||
also the load path.
|
||||
|
||||
## Not modelled / open
|
||||
|
||||
- Values of the three AI-benefit bonuses (a 6-entry table in the executable; not dumped).
|
||||
- Data-file names for ~107 of the 196 slots (no strategic effect on any of them; the
|
||||
gaps matter only for `TechIdFromName` on those names).
|
||||
- The events raised on completion (research complete / under budget), the plague-cure
|
||||
roll, the Zuul starting immunity/temperance flags, and the home-system bonus
|
||||
initialisation that reads the `*_HOME` tuning keys.
|
||||
- Runtime gates that read ids at use sites (advanced-sensor contact rules, spy-beam
|
||||
intel, tunnel sensors, hyper-com retargeting, hull regeneration, missile/beam/cannon
|
||||
variants, plague-type maps): documented in the RE catalog, to be modelled where those
|
||||
systems are built.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
21
src/game/effects/CMakeLists.txt
Normal file
21
src/game/effects/CMakeLists.txt
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# Code-defined tech effects: the TechId key space, the per-tech effect table, and the
|
||||
# apply layer over a player's economy modifiers. Depends on game/sim for Species and
|
||||
# TuningTable. Not yet wired into the root CMakeLists; add_subdirectory(src/game/effects)
|
||||
# after src/game/sim. tests/game_effects/build_and_run.sh builds the same sources with
|
||||
# plain g++ in the meantime.
|
||||
add_library(sots_game_effects STATIC
|
||||
tech_id.cpp
|
||||
tech_effects.cpp)
|
||||
target_include_directories(sots_game_effects PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/../..)
|
||||
target_link_libraries(sots_game_effects PUBLIC sots_game_sim)
|
||||
target_compile_features(sots_game_effects PUBLIC cxx_std_17)
|
||||
if(NOT MSVC)
|
||||
target_compile_options(sots_game_effects PRIVATE -Wall -Wextra)
|
||||
endif()
|
||||
|
||||
option(SOTS_GAME_EFFECTS_TESTS "Build the game/effects unit tests" OFF)
|
||||
if(SOTS_GAME_EFFECTS_TESTS)
|
||||
enable_testing()
|
||||
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/../../../tests/game_effects
|
||||
${CMAKE_CURRENT_BINARY_DIR}/tests_game_effects)
|
||||
endif()
|
||||
283
src/game/effects/tech_effects.cpp
Normal file
283
src/game/effects/tech_effects.cpp
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
#include "game/effects/tech_effects.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
|
||||
namespace sots::effects {
|
||||
|
||||
namespace {
|
||||
|
||||
using sots::sim::Species;
|
||||
|
||||
constexpr int F(PlayerFlag f) { return static_cast<int>(f); }
|
||||
constexpr int Sp(Species s) { return static_cast<int>(s); }
|
||||
|
||||
// Plague-cure bits: one per plague type in vaccine order; the universal antidote covers
|
||||
// the first four.
|
||||
constexpr unsigned kCurePlague = 0x01;
|
||||
constexpr unsigned kCureRetroPlague = 0x02;
|
||||
constexpr unsigned kCureBeastPlague = 0x04;
|
||||
constexpr unsigned kCureAssimilationPlague = 0x08;
|
||||
constexpr unsigned kCureNaniteVirus = 0x10;
|
||||
constexpr unsigned kCureUniversal = 0x0f;
|
||||
|
||||
// Zuul node-bore parameter rows, lowest to highest drive.
|
||||
constexpr int kNodeBoreRows[3][3] = {{45, 15, 3}, {65, 35, 4}, {95, 60, 5}};
|
||||
|
||||
struct TableEntry {
|
||||
TechId id;
|
||||
std::vector<TechEffect> effects;
|
||||
};
|
||||
|
||||
const std::vector<TableEntry>& Table() {
|
||||
using K = EffectKind;
|
||||
static const std::vector<TableEntry> table = {
|
||||
{TechId::CCC_AdvSens, {{K::SetFlag, F(PlayerFlag::AdvancedSensors)}}},
|
||||
{TechId::IND_Waldo, {{K::AddConstructionMod, 0, -0.10}, {K::AddOutputMod, 0, 0.15}}},
|
||||
{TechId::IND_CyberInt, {{K::AddConstructionMod, 0, -0.05}, {K::AddOutputMod, 0, 0.20}}},
|
||||
{TechId::IND_ExpSys, {{K::AddConstructionMod, 0, -0.10}, {K::AddOutputMod, 0, 0.15}}},
|
||||
{TechId::IND_OrbFound, {{K::AddSavingsMod, 0, -0.05}}},
|
||||
{TechId::IND_OrbDry, {{K::AddConstructionModClass, 1, -0.05}, {K::AddConstructionModClass, 2, -0.05}}},
|
||||
{TechId::IND_GravCon, {{K::AddOutputMod, 0, 0.30}}},
|
||||
{TechId::IND_HvyPlat, {{K::AddOutputMod, 0, 0.10}}},
|
||||
{TechId::IND_AstMine, {{K::SetFlag, F(PlayerFlag::AsteroidMining)}}},
|
||||
{TechId::IND_MsMine, {{K::RaiseMaxOverharvest, 0, 0.1}, {K::AddMiningRate, 0, 1.0}}},
|
||||
{TechId::BIO_GnMod, {{K::AddPopulationMod, 0, 0.10}}},
|
||||
{TechId::BIO_AtmoAd, {{K::AddSuitTolerance, 0, 0.75}, {K::AddPopulationMod, 0, 0.06}, {K::AddTerraformMod, 0, 0.35}}},
|
||||
{TechId::BIO_EnvTail, {{K::AddPopulationMod, 0, 0.20}, {K::AddTerraformMod, 0, 0.45}}},
|
||||
{TechId::BIO_GrvAdpt, {{K::AddSuitTolerance, 0, 1.50}, {K::AddPopulationMod, 0, 0.10}, {K::AddTerraformMod, 0, 0.35}}},
|
||||
{TechId::IND_ArcCon, {{K::SetFlag, F(PlayerFlag::Arcology)}, {K::AddPopulationMod, 0, 0.15}, {K::ReevaluateCivilianCaps}}},
|
||||
{TechId::IND_EleNans, {{K::AddTerraformMod, 0, 0.60}}},
|
||||
{TechId::BIO_TerBac, {{K::AddTerraformMod, 0, 0.45}}},
|
||||
{TechId::IND_AtProc, {{K::AddTerraformMod, 0, 0.50}}},
|
||||
{TechId::DRV_TpGate, {{K::RaiseGateTraffic, 0}}},
|
||||
{TechId::DRV_GatAmp, {{K::RaiseGateTraffic, 1}}},
|
||||
{TechId::DRV_FarCast, {{K::SetFarCasting}}},
|
||||
{TechId::CCC_AI, {{K::AiTechBonus, static_cast<int>(AiBonusSlot::Research)}}},
|
||||
{TechId::CCC_AIAdmin, {{K::AiTechBonus, static_cast<int>(AiBonusSlot::Admin)}}},
|
||||
{TechId::CCC_AIFac, {{K::AiTechBonus, static_cast<int>(AiBonusSlot::Factory)}}},
|
||||
{TechId::CCC_AIVrus, {{K::FlagSystemsAI}}},
|
||||
{TechId::CCC_AISlv, {{K::FlagSystemsAI}, {K::EnableAiBenefit}}},
|
||||
{TechId::CCC_FtlEcon, {{K::SetFlagUnlessRebel, F(PlayerFlag::TradeAllowed)}}},
|
||||
{TechId::CCC_ComRaid, {{K::SetFlag, F(PlayerFlag::CommerceRaiding)}}},
|
||||
{TechId::DRV_GrvSyn, {{K::SetFlag, F(PlayerFlag::GravSynth)}}},
|
||||
{TechId::CCC_DatCor, {{K::SetFlag, F(PlayerFlag::ViewIntel)}}},
|
||||
{TechId::IND_HrdStrct, {{K::MulDefenceDamageMod, 0, 0.25}, {K::MulOutputMod, 0, 0.90}}},
|
||||
{TechId::DRN_AdvRob, {{K::AddConstructionMod, 0, -0.05}}},
|
||||
{TechId::IND_CruisCon, {{K::GrantTechIfSpecies, Sp(Species::Zuul), static_cast<double>(static_cast<int>(TechId::IND_BrdPod))}}},
|
||||
{TechId::CCC_SpyBm, {{K::CaptureDesignsWith, static_cast<int>(TechId::IND_SlvgTech)}}},
|
||||
{TechId::IND_SlvgTech, {{K::CaptureDesignsWith, static_cast<int>(TechId::CCC_SpyBm)}}},
|
||||
{TechId::BIO_PLGVAC, {{K::CurePlague, static_cast<int>(kCurePlague)}}},
|
||||
{TechId::BIO_RTPLGVAC, {{K::CurePlague, static_cast<int>(kCureRetroPlague)}}},
|
||||
{TechId::BIO_BSTVAC, {{K::CurePlague, static_cast<int>(kCureBeastPlague)}}},
|
||||
{TechId::BIO_ASPLGVAC, {{K::CurePlague, static_cast<int>(kCureAssimilationPlague)}}},
|
||||
{TechId::BIO_CONNAN, {{K::CurePlague, static_cast<int>(kCureNaniteVirus)}}},
|
||||
{TechId::BIO_UNIANTI, {{K::CurePlague, static_cast<int>(kCureUniversal)}}},
|
||||
{TechId::DRV_RIP, {{K::NodeBoreParams, 0}}},
|
||||
{TechId::DRV_REND, {{K::NodeBoreParams, 1}}},
|
||||
{TechId::DRV_RAD, {{K::NodeBoreParams, 2}}},
|
||||
};
|
||||
return table;
|
||||
}
|
||||
|
||||
const std::vector<TechEffect>& NoEffects() {
|
||||
static const std::vector<TechEffect> none;
|
||||
return none;
|
||||
}
|
||||
|
||||
double AiBonusFor(AiBonusSlot slot, const AiBonusValues& v) {
|
||||
switch (slot) {
|
||||
case AiBonusSlot::Research: return v.research;
|
||||
case AiBonusSlot::Admin: return v.admin;
|
||||
case AiBonusSlot::Factory: return v.factory;
|
||||
}
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
void AddAiBonus(PlayerEconomyState& s, AiBonusSlot slot, double amount) {
|
||||
switch (slot) {
|
||||
case AiBonusSlot::Research: s.resMod += amount; break;
|
||||
case AiBonusSlot::Admin: s.incMod += amount; break;
|
||||
case AiBonusSlot::Factory: s.outMod += amount; break;
|
||||
}
|
||||
}
|
||||
|
||||
void ApplyOne(PlayerEconomyState& s, const TechEffect& e, const ApplyContext& ctx, TechApplyOutcome& out) {
|
||||
using K = EffectKind;
|
||||
switch (e.kind) {
|
||||
case K::AddConstructionMod:
|
||||
for (double& c : s.conMod) c += e.value;
|
||||
break;
|
||||
case K::AddConstructionModClass:
|
||||
if (e.index >= 0 && e.index < 3) s.conMod[e.index] += e.value;
|
||||
break;
|
||||
case K::AddSavingsMod:
|
||||
for (double& m : s.savMod) m += e.value;
|
||||
break;
|
||||
case K::AddOutputMod: s.outMod += e.value; break;
|
||||
case K::MulOutputMod: s.outMod *= e.value; break;
|
||||
case K::AddPopulationMod: s.popMod += e.value; break;
|
||||
case K::AddTerraformMod: s.terraMod += e.value; break;
|
||||
case K::AddSuitTolerance: s.suitTol += e.value; break;
|
||||
case K::RaiseMaxOverharvest: s.maxOverharvest = std::max(s.maxOverharvest, e.value); break;
|
||||
case K::AddMiningRate: s.miningRate += e.value; break;
|
||||
case K::MulDefenceDamageMod: s.defenceDamageMod *= e.value; break;
|
||||
case K::SetFlag:
|
||||
if (e.index >= 0 && e.index < kPlayerFlagCount) s.flags[e.index] = true;
|
||||
break;
|
||||
case K::SetFlagUnlessRebel:
|
||||
if (!s.rebelAI && e.index >= 0 && e.index < kPlayerFlagCount) s.flags[e.index] = true;
|
||||
break;
|
||||
case K::RaiseGateTraffic: {
|
||||
double v = 0.0;
|
||||
if (ctx.tuning) v = e.index == 0 ? ctx.tuning->PERGATETRAFFIC_DRV_TpGate : ctx.tuning->PERGATETRAFFIC_DRV_GatAmp;
|
||||
s.perGateTraffic = std::max(s.perGateTraffic, v);
|
||||
break;
|
||||
}
|
||||
case K::SetFarCasting:
|
||||
s.castRange = 10.0;
|
||||
s.castEfficiency = 2.0;
|
||||
s.castThreshold = 1.0;
|
||||
break;
|
||||
case K::AiTechBonus:
|
||||
if (s.aiBenefit) AddAiBonus(s, static_cast<AiBonusSlot>(e.index), AiBonusFor(static_cast<AiBonusSlot>(e.index), ctx.aiBonus));
|
||||
break;
|
||||
case K::FlagSystemsAI: out.flagSystemsAI = true; break;
|
||||
case K::EnableAiBenefit: SetAiBenefit(s, true, ctx); break;
|
||||
case K::CurePlague: {
|
||||
const unsigned mask = static_cast<unsigned>(e.index);
|
||||
s.hasVaccine |= mask;
|
||||
s.hasImmunity |= mask;
|
||||
out.plagueCuredMask |= mask;
|
||||
break;
|
||||
}
|
||||
case K::GrantTechIfSpecies:
|
||||
if (Sp(s.species) == e.index) out.grantedTech = static_cast<TechId>(static_cast<int>(e.value));
|
||||
break;
|
||||
case K::CaptureDesignsWith:
|
||||
if (s.HasResearched(static_cast<TechId>(e.index))) s.flags[F(PlayerFlag::CaptureDesigns)] = true;
|
||||
break;
|
||||
case K::NodeBoreParams:
|
||||
if (e.index >= 0 && e.index < 3 && kNodeBoreRows[e.index][0] > s.nodeBoreParams[0]) {
|
||||
for (int i = 0; i < 3; ++i) s.nodeBoreParams[i] = kNodeBoreRows[e.index][i];
|
||||
out.nodeBoreParamsChanged = true;
|
||||
}
|
||||
break;
|
||||
case K::ReevaluateCivilianCaps: out.reevaluateCivilianCaps = true; break;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
const std::vector<TechEffect>& EffectsOf(TechId id) {
|
||||
for (const TableEntry& t : Table()) {
|
||||
if (t.id == id) return t.effects;
|
||||
}
|
||||
return NoEffects();
|
||||
}
|
||||
|
||||
unsigned PlagueCureMask(TechId id) {
|
||||
for (const TechEffect& e : EffectsOf(id)) {
|
||||
if (e.kind == EffectKind::CurePlague) return static_cast<unsigned>(e.index);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
void RebuildSpeciesTechFlags(PlayerEconomyState& s) {
|
||||
for (int sp = 0; sp < sots::sim::kSpeciesCount; ++sp) {
|
||||
unsigned bits = 0;
|
||||
for (int k = 0; k < kXenoLevelCount; ++k) {
|
||||
const TechId id = XenoTechId(static_cast<XenoLevel>(k), static_cast<Species>(sp));
|
||||
if (s.HasResearched(id)) bits |= 1u << k;
|
||||
}
|
||||
s.speciesFlags[sp] = bits;
|
||||
}
|
||||
}
|
||||
|
||||
bool AiRebellionPossible(const PlayerEconomyState& s) {
|
||||
return !s.HasResearched(TechId::CCC_AISlv) && !sots::sim::IsNpcSpecies(s.species);
|
||||
}
|
||||
|
||||
void SetAiBenefit(PlayerEconomyState& s, bool on, const ApplyContext& ctx) {
|
||||
if (s.aiBenefit == on) return;
|
||||
s.aiBenefit = on;
|
||||
const double sign = on ? 1.0 : -1.0;
|
||||
const TechId aiTechs[3] = {TechId::CCC_AI, TechId::CCC_AIAdmin, TechId::CCC_AIFac};
|
||||
for (TechId id : aiTechs) {
|
||||
if (!s.HasResearched(id)) continue;
|
||||
for (const TechEffect& e : EffectsOf(id)) {
|
||||
if (e.kind != EffectKind::AiTechBonus) continue;
|
||||
const AiBonusSlot slot = static_cast<AiBonusSlot>(e.index);
|
||||
AddAiBonus(s, slot, sign * AiBonusFor(slot, ctx.aiBonus));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TechApplyOutcome ApplyTechEffect(PlayerEconomyState& s, TechId id, const ApplyContext& ctx) {
|
||||
TechApplyOutcome out;
|
||||
if (!IsValidTechId(id) || s.HasResearched(id)) return out;
|
||||
s.researched.set(static_cast<std::size_t>(TechIdIndex(id)));
|
||||
out.applied = true;
|
||||
|
||||
for (const TechEffect& e : EffectsOf(id)) ApplyOne(s, e, ctx, out);
|
||||
|
||||
// Every completion: re-derive the per-species xenotech bits and report the species
|
||||
// whose temperance is held (their addiction is cured on owned systems).
|
||||
RebuildSpeciesTechFlags(s);
|
||||
for (int sp = 0; sp < sots::sim::kSpeciesCount; ++sp) {
|
||||
if (s.speciesFlags[sp] & (1u << static_cast<int>(XenoLevel::Temperance))) out.temperanceSpeciesMask |= 1u << sp;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
TechApplyOutcome ApplyTechEffectByName(PlayerEconomyState& s, std::string_view name, const ApplyContext& ctx) {
|
||||
// Node-track techs are keyed by name in the species table, not by id.
|
||||
for (int sp = 0; sp < sots::sim::kSpeciesCount; ++sp) {
|
||||
const char* track = NodeTrackTechName(static_cast<Species>(sp));
|
||||
if (track == nullptr) continue;
|
||||
std::string_view t(track);
|
||||
if (t.size() != name.size()) continue;
|
||||
bool same = true;
|
||||
for (std::size_t i = 0; i < t.size() && same; ++i) {
|
||||
same = std::tolower(static_cast<unsigned char>(t[i])) == std::tolower(static_cast<unsigned char>(name[i]));
|
||||
}
|
||||
if (same) {
|
||||
s.nodeTrackMask |= 1u << sp;
|
||||
TechApplyOutcome out;
|
||||
out.applied = true;
|
||||
return out;
|
||||
}
|
||||
}
|
||||
const TechId id = TechIdFromName(name);
|
||||
if (id == TechId::None) return TechApplyOutcome{};
|
||||
return ApplyTechEffect(s, id, ctx);
|
||||
}
|
||||
|
||||
const char* const kDesignOptionNamesA[kDesignOptionCountA] = {
|
||||
"IND_REFCOAT", "IND_IMPRFCT", "IND_PLYALLOY", "IND_MAGLAT", "IND_QRKRES", "IND_ADMALY",
|
||||
"IND_PREDGUN", "SLD_DEF", "SLD_ERGAB", "SLD_MKONE", "SLD_MKTWO", "SLD_MKTHREE", "SLD_MKFOUR",
|
||||
"SLD_CLK", "SLD_IMPCLK", "SLD_INTANG", "WEP_VRFTECH", "WEP_NUKEWHD", "WEP_GMAWHD", "WEP_FUSWHD",
|
||||
"WEP_AMWHD", "WEP_NEUTRND", "CCC_INTSENS", "CCC_SNSJAM", "CCC_QNTCHAF", "CCC_CMBTALG",
|
||||
"CCC_HOLOTAC", "CCC_ADVCNC", "CCC_AdvSens", "DRV_MCROFUS", "DRV_INCTHRST", "DRV_SMLFUS",
|
||||
};
|
||||
|
||||
const char* const kDesignOptionNamesB[kDesignOptionCountB] = {
|
||||
"DRV_NODE", "DRV_NODFOC", "DRV_NODPATH", "DRV_STRWRP", "DRV_IMPSTWRP", "DRV_FLICKER", "DRV_HYPER",
|
||||
"DRV_HYPRFLD", "DRV_WARP", "SLD_MESSHLD", "SLD_GRVSHLD", "CCC_AIFRCON", "DRV_RIP", "DRV_REND",
|
||||
"DRV_RAD", "BIO_CONNAN", "SLD_DISR", "SLD_MAGNI", "DRV_QNTCAP", "CCC_FCCOM", "IND_HRDELEC",
|
||||
"IND_TRKSTL", "DRV_VDCTR", "DRV_VDCRV", "DRV_VDMSTR", "BIO_SMRTNAN", "WEP_ACCAMP", "WEP_MwMsl",
|
||||
"WEP_HvyPmsl",
|
||||
};
|
||||
|
||||
DesignOptionMasks ComputeDesignOptionMasks(const std::function<bool(std::string_view)>& hasResearchedByName) {
|
||||
DesignOptionMasks m;
|
||||
for (int i = 0; i < kDesignOptionCountA; ++i) {
|
||||
if (hasResearchedByName(kDesignOptionNamesA[i])) m.a |= 1u << i;
|
||||
}
|
||||
for (int i = 0; i < kDesignOptionCountB; ++i) {
|
||||
if (hasResearchedByName(kDesignOptionNamesB[i])) m.b |= 1u << i;
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
} // namespace sots::effects
|
||||
179
src/game/effects/tech_effects.h
Normal file
179
src/game/effects/tech_effects.h
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
// Code-defined tech effects: what researching a tech does to a player's strategic
|
||||
// modifiers, as a data table plus a small apply layer.
|
||||
//
|
||||
// Effects are additive per research event and permanent (there is no un-research).
|
||||
// The state below carries the player fields the effects touch, named after the save
|
||||
// tags where one exists; the apply function only ever reads and writes this struct and
|
||||
// reports the things it cannot do itself (touch systems, grant another tech) in an
|
||||
// outcome the caller acts on.
|
||||
#pragma once
|
||||
|
||||
#include <bitset>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
#include "game/effects/tech_id.h"
|
||||
#include "game/sim/species.h"
|
||||
#include "game/sim/tuning.h"
|
||||
|
||||
namespace sots::effects {
|
||||
|
||||
// Boolean abilities a tech switches on.
|
||||
enum class PlayerFlag : int {
|
||||
AdvancedSensors = 0, // hadvs: sensor ranges x ADVSENS_SENSORS_MOD; partial contacts count as known
|
||||
AsteroidMining, // AMine: asteroid resources counted in output
|
||||
Arcology, // harcc: +1e8 imperial / +2e8 civilian carrying capacity
|
||||
GravSynth, // hgs: synced to clients; the drive modifier is applied design-side
|
||||
TradeAllowed, // CnTrd: trade routes may be registered (not for the rebel AI)
|
||||
CommerceRaiding, // CnRad
|
||||
ViewIntel, // CnVItl: intel view on other empires (client side)
|
||||
CaptureDesigns, // cdp: observed/captured designs (needs both spy beam and salvage)
|
||||
Count
|
||||
};
|
||||
constexpr int kPlayerFlagCount = static_cast<int>(PlayerFlag::Count);
|
||||
|
||||
// Slots of the AI-benefit tech bonus.
|
||||
enum class AiBonusSlot : int { Research = 0, Admin = 1, Factory = 2 };
|
||||
|
||||
enum class EffectKind : int {
|
||||
AddConstructionMod, // ConMod[0..2] += value
|
||||
AddConstructionModClass, // ConMod[index] += value
|
||||
AddSavingsMod, // SavMod[0..2] += value
|
||||
AddOutputMod, // OutMod += value
|
||||
MulOutputMod, // OutMod *= value
|
||||
AddPopulationMod, // PopMod += value
|
||||
AddTerraformMod, // TerraMod += value
|
||||
AddSuitTolerance, // SuitTol += value
|
||||
RaiseMaxOverharvest, // MaxOH = max(MaxOH, value)
|
||||
AddMiningRate, // MinRate += value
|
||||
MulDefenceDamageMod, // pddm *= value
|
||||
SetFlag, // flag(index) = true
|
||||
SetFlagUnlessRebel, // flag(index) = true unless the player is the rebel AI
|
||||
RaiseGateTraffic, // PrGtTrf = max(PrGtTrf, tuning key: index 0 = TpGate, 1 = GatAmp)
|
||||
SetFarCasting, // CstR = 10, CstE = 2, CstT = 1
|
||||
AiTechBonus, // slot(index) += supplied bonus while the AI benefit is on
|
||||
FlagSystemsAI, // every owned system gets its AI flag (reported to the caller)
|
||||
EnableAiBenefit, // the AI benefit is switched (back) on
|
||||
CurePlague, // HasVac |= mask, HasImm |= mask (index = mask); cured on systems/ships
|
||||
GrantTechIfSpecies, // index = species: the tech in `value` is granted for free
|
||||
CaptureDesignsWith, // CaptureDesigns flag when this and tech(index) are both researched
|
||||
NodeBoreParams, // index = parameter row; the highest row wins
|
||||
ReevaluateCivilianCaps, // systems whose civilians sit at the cap are re-evaluated
|
||||
};
|
||||
|
||||
struct TechEffect {
|
||||
EffectKind kind;
|
||||
int index = 0;
|
||||
double value = 0;
|
||||
};
|
||||
|
||||
// The effects of one tech (empty for techs without a strategic effect).
|
||||
const std::vector<TechEffect>& EffectsOf(TechId id);
|
||||
|
||||
// Everything a tech touches on the player. Defaults are the "no tech yet" values; the
|
||||
// caller seeds species-dependent starts (SuitTol) from its own tables.
|
||||
struct PlayerEconomyState {
|
||||
sots::sim::Species species = sots::sim::Species::Human;
|
||||
bool rebelAI = false;
|
||||
|
||||
double conMod[3] = {1.0, 1.0, 1.0}; // ConMod: construction cost per hull class
|
||||
double savMod[3] = {1.0, 1.0, 1.0}; // SavMod
|
||||
double outMod = 1.0; // OutMod
|
||||
double popMod = 1.0; // PopMod
|
||||
double terraMod = 1.0; // TerraMod
|
||||
double suitTol = 0.0; // SuitTol: species start value + adaptation techs
|
||||
double maxOverharvest = 0.0; // MaxOH
|
||||
double miningRate = 0.0; // MinRate
|
||||
double defenceDamageMod = 1.0; // pddm
|
||||
double resMod = 1.0; // ResMod (AI research bonus lands here)
|
||||
double incMod = 1.0; // IncMod
|
||||
double castRange = 0.0; // CstR
|
||||
double castEfficiency = 0.0; // CstE
|
||||
double castThreshold = 0.0; // CstT
|
||||
double perGateTraffic = 0.0; // PrGtTrf
|
||||
int nodeBoreParams[3] = {0, 0, 0}; // Zuul node-bore parameters (meaning of the three not resolved)
|
||||
|
||||
bool flags[kPlayerFlagCount] = {};
|
||||
bool aiBenefit = true; // AIBn: false after an AI rebellion
|
||||
unsigned nodeTrackMask = 0; // NPTrk: bit per Species whose traffic is visible
|
||||
unsigned hasVaccine = 0; // HasVac
|
||||
unsigned hasImmunity = 0; // HasImm
|
||||
unsigned speciesFlags[sots::sim::kSpeciesCount] = {}; // xenotech bits per target species
|
||||
std::bitset<kTechIdCount> researched;
|
||||
|
||||
bool Flag(PlayerFlag f) const { return flags[static_cast<int>(f)]; }
|
||||
bool HasResearched(TechId id) const { return IsValidTechId(id) && researched.test(static_cast<std::size_t>(TechIdIndex(id))); }
|
||||
};
|
||||
|
||||
// Values of the three AI-benefit bonuses. The game reads them from a small table whose
|
||||
// numbers are not recovered; the caller supplies them (0 = no bonus).
|
||||
struct AiBonusValues {
|
||||
double research = 0; // added to ResMod
|
||||
double admin = 0; // added to IncMod
|
||||
double factory = 0; // added to OutMod
|
||||
};
|
||||
|
||||
struct ApplyContext {
|
||||
const sots::sim::TuningTable* tuning = nullptr; // gate traffic keys
|
||||
AiBonusValues aiBonus;
|
||||
};
|
||||
|
||||
// What the caller has to do after an apply because it needs game state this layer has
|
||||
// no access to.
|
||||
struct TechApplyOutcome {
|
||||
bool applied = false; // false when the id is invalid or already researched
|
||||
TechId grantedTech = TechId::None; // research this one too (Zuul boarding pods)
|
||||
unsigned plagueCuredMask = 0; // clear these plague types on owned systems and ships
|
||||
bool flagSystemsAI = false; // mark every owned system's AI flag
|
||||
bool reevaluateCivilianCaps = false; // re-evaluate systems whose civilians are at the cap
|
||||
unsigned temperanceSpeciesMask = 0; // bit per Species: cure addiction to it on owned systems
|
||||
bool nodeBoreParamsChanged = false;
|
||||
};
|
||||
|
||||
// Mark `id` researched and apply its effects. Every completion also rebuilds the
|
||||
// per-species xenotech flags and reports the temperance species. Applying an already
|
||||
// researched or invalid id does nothing. CONFIDENCE: high on the tabled effects (their
|
||||
// constants were read directly); medium on the AI-benefit re-application and the
|
||||
// node-bore "highest wins" rule.
|
||||
TechApplyOutcome ApplyTechEffect(PlayerEconomyState& s, TechId id, const ApplyContext& ctx);
|
||||
|
||||
// Apply a completion by data-file name: resolves the id (case-insensitively), and also
|
||||
// handles the node-track techs, which are keyed by name in the species table rather than
|
||||
// by id. Returns the outcome; `applied` is false for names without a code effect.
|
||||
TechApplyOutcome ApplyTechEffectByName(PlayerEconomyState& s, std::string_view name, const ApplyContext& ctx);
|
||||
|
||||
// Switch the AI benefit on or off, adding or removing the bonus of every researched AI
|
||||
// tech accordingly (off = AI rebellion; on again = the AI slave tech). CONFIDENCE: medium.
|
||||
void SetAiBenefit(PlayerEconomyState& s, bool on, const ApplyContext& ctx);
|
||||
|
||||
// Recompute speciesFlags[sp] from the researched set: bit k is set when the level-k
|
||||
// xenotech aimed at species sp is researched. Also run on load. CONFIDENCE: high.
|
||||
void RebuildSpeciesTechFlags(PlayerEconomyState& s);
|
||||
|
||||
// Whether an AI rebellion can still happen to this player: only while the AI slave tech
|
||||
// is not researched (and the player is not the NPC race). CONFIDENCE: high.
|
||||
bool AiRebellionPossible(const PlayerEconomyState& s);
|
||||
|
||||
// Plague-cure bit of a vaccine tech (0 for anything else); the universal antidote
|
||||
// covers the first four plague types. CONFIDENCE: high.
|
||||
unsigned PlagueCureMask(TechId id);
|
||||
|
||||
// Design-option availability masks: two words whose bits mean "the named tech is
|
||||
// researched". The names are game vocabulary; their ids are not all recovered, so the
|
||||
// masks are computed from a by-name predicate. Bit i of A is kDesignOptionNamesA[i], of
|
||||
// B kDesignOptionNamesB[i]. Consumers are design-side and not modelled. CONFIDENCE: high
|
||||
// on the tables.
|
||||
constexpr int kDesignOptionCountA = 32;
|
||||
constexpr int kDesignOptionCountB = 29;
|
||||
extern const char* const kDesignOptionNamesA[kDesignOptionCountA];
|
||||
extern const char* const kDesignOptionNamesB[kDesignOptionCountB];
|
||||
|
||||
struct DesignOptionMasks {
|
||||
std::uint32_t a = 0;
|
||||
std::uint32_t b = 0;
|
||||
};
|
||||
DesignOptionMasks ComputeDesignOptionMasks(const std::function<bool(std::string_view)>& hasResearchedByName);
|
||||
|
||||
} // namespace sots::effects
|
||||
80
src/game/effects/tech_id.cpp
Normal file
80
src/game/effects/tech_id.cpp
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
#include "game/effects/tech_id.h"
|
||||
|
||||
#include <cctype>
|
||||
|
||||
namespace sots::effects {
|
||||
|
||||
namespace {
|
||||
|
||||
const char* const kTechIdNames[kTechIdCount] = {
|
||||
#define SOTS_TECH_ID_NAME(idx, name, str) str,
|
||||
SOTS_TECH_ID_LIST(SOTS_TECH_ID_NAME)
|
||||
#undef SOTS_TECH_ID_NAME
|
||||
};
|
||||
|
||||
bool EqualsIgnoreCase(std::string_view a, std::string_view b) {
|
||||
if (a.size() != b.size()) return false;
|
||||
for (std::size_t i = 0; i < a.size(); ++i) {
|
||||
if (std::tolower(static_cast<unsigned char>(a[i])) != std::tolower(static_cast<unsigned char>(b[i]))) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Species order inside a xenotech block: the NPC race is never a target, and the
|
||||
// 5-entry blocks also skip the Zuul.
|
||||
int BlockSlot(sots::sim::Species target, bool includesZuul) {
|
||||
using sots::sim::Species;
|
||||
switch (target) {
|
||||
case Species::Human: return 0;
|
||||
case Species::Hiver: return 1;
|
||||
case Species::Tarkas: return 2;
|
||||
case Species::Liir: return 3;
|
||||
case Species::Zuul: return includesZuul ? 4 : -1;
|
||||
case Species::Morrigi: return includesZuul ? 5 : 4;
|
||||
default: return -1;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
const char* TechIdName(TechId id) {
|
||||
if (!IsValidTechId(id)) return nullptr;
|
||||
return kTechIdNames[TechIdIndex(id)];
|
||||
}
|
||||
|
||||
TechId TechIdFromName(std::string_view name) {
|
||||
for (int i = 0; i < kTechIdCount; ++i) {
|
||||
if (kTechIdNames[i] != nullptr && EqualsIgnoreCase(name, kTechIdNames[i])) return TechIdFromIndex(i);
|
||||
}
|
||||
return TechId::None;
|
||||
}
|
||||
|
||||
TechId XenoTechId(XenoLevel level, sots::sim::Species target) {
|
||||
struct Block { int base; bool includesZuul; };
|
||||
static constexpr Block kBlocks[kXenoLevelCount] = {
|
||||
{114, true}, // Translation1
|
||||
{120, true}, // Translation2
|
||||
{126, true}, // Translation3
|
||||
{132, false}, // Incorporate
|
||||
{137, false}, // Addict
|
||||
{142, false}, // Temperance
|
||||
{147, true}, // Subjugate
|
||||
{153, false}, // Accommodate
|
||||
{158, true}, // Proliferate
|
||||
};
|
||||
const int k = static_cast<int>(level);
|
||||
if (k < 0 || k >= kXenoLevelCount) return TechId::None;
|
||||
const int slot = BlockSlot(target, kBlocks[k].includesZuul);
|
||||
if (slot < 0) return TechId::None;
|
||||
return TechIdFromIndex(kBlocks[k].base + slot);
|
||||
}
|
||||
|
||||
const char* NodeTrackTechName(sots::sim::Species tracked) {
|
||||
switch (tracked) {
|
||||
case sots::sim::Species::Human: return "CCC_NDTRKHUM";
|
||||
case sots::sim::Species::Zuul: return "CCC_NDTRKZUL";
|
||||
default: return nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace sots::effects
|
||||
269
src/game/effects/tech_id.h
Normal file
269
src/game/effects/tech_id.h
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
// TechId: the engine's own key space for techs that have a code-defined effect.
|
||||
//
|
||||
// The game keeps a fixed list of 196 tech names; at master-tree construction each name
|
||||
// is matched (case-insensitively) against the loaded `.tech` files and the position in
|
||||
// that list becomes the tech's numeric id, 10000 + index. Every hard-coded effect,
|
||||
// gate and bitmask is keyed on those ids; techs that are not in the list have no code
|
||||
// effect beyond what the data files say (prerequisites, section/weapon availability).
|
||||
//
|
||||
// The list below is the part of that table recovered so far. Entries whose data-file
|
||||
// name is known carry it; entries whose position is known but whose name is not are
|
||||
// `Unresolved_NNN` (no code reads them, or their readers are combat/design side). The
|
||||
// xenotech (XNC) block is named by role because the per-species data-file names are
|
||||
// only partially recovered -- see docs/game-effects.md.
|
||||
#pragma once
|
||||
|
||||
#include <string_view>
|
||||
|
||||
#include "game/sim/species.h"
|
||||
|
||||
namespace sots::effects {
|
||||
|
||||
constexpr int kTechIdBase = 10000;
|
||||
constexpr int kTechIdCount = 196;
|
||||
|
||||
// X(index, EnumName, "data-file name" or nullptr)
|
||||
#define SOTS_TECH_ID_LIST(X) \
|
||||
X(0, CCC_AdvSens, "CCC_AdvSens") \
|
||||
X(1, IND_Waldo, "IND_Waldo") \
|
||||
X(2, IND_CyberInt, "IND_CyberInt") \
|
||||
X(3, IND_ExpSys, "IND_ExpSys") \
|
||||
X(4, IND_OrbFound, "IND_OrbFound") \
|
||||
X(5, IND_OrbDry, "IND_OrbDry") \
|
||||
X(6, IND_GravCon, "IND_GravCon") \
|
||||
X(7, IND_HvyPlat, "IND_HvyPlat") \
|
||||
X(8, IND_AstMine, "IND_AstMine") \
|
||||
X(9, IND_MsMine, "IND_MsMine") \
|
||||
X(10, BIO_GnMod, "BIO_GnMod") \
|
||||
X(11, BIO_AtmoAd, "BIO_AtmoAd") \
|
||||
X(12, BIO_EnvTail, "BIO_EnvTail") \
|
||||
X(13, BIO_GrvAdpt, "BIO_GrvAdpt") \
|
||||
X(14, IND_ArcCon, "IND_ArcCon") \
|
||||
X(15, IND_EleNans, "IND_EleNans") \
|
||||
X(16, BIO_TerBac, "BIO_TerBac") \
|
||||
X(17, IND_AtProc, "IND_AtProc") \
|
||||
X(18, DRV_TpGate, "DRV_TpGate") \
|
||||
X(19, DRV_GatAmp, "DRV_GatAmp") \
|
||||
X(20, DRV_FarCast, "DRV_FarCast") \
|
||||
X(21, CCC_AI, "CCC_AI") \
|
||||
X(22, CCC_AIAdmin, "CCC_AIAdmin") \
|
||||
X(23, CCC_AIFac, "CCC_AIFac") \
|
||||
X(24, CCC_AIVrus, "CCC_AIVrus") \
|
||||
X(25, CCC_AISlv, "CCC_AISlv") \
|
||||
X(26, CCC_FtlEcon, "CCC_FtlEcon") \
|
||||
X(27, CCC_ComRaid, "CCC_ComRaid") \
|
||||
X(28, DRV_GrvSyn, "DRV_GrvSyn") \
|
||||
X(29, CCC_DatCor, "CCC_DatCor") \
|
||||
X(30, IND_HrdStrct, "IND_HrdStrct") \
|
||||
X(31, DRN_AdvRob, "DRN_AdvRob") \
|
||||
X(32, IND_CruisCon, "IND_CruisCon") \
|
||||
X(33, IND_BrdPod, "IND_BrdPod") \
|
||||
X(34, CCC_SpyBm, "CCC_SpyBm") \
|
||||
X(35, IND_SlvgTech, "IND_SlvgTech") \
|
||||
X(36, WEP_MwMsl, "WEP_MwMsl") \
|
||||
X(37, WEP_HvyPmsl, "WEP_HvyPmsl") \
|
||||
X(38, BIO_PLGVAC, "BIO_PLGVAC") \
|
||||
X(39, BIO_RTPLGVAC, "BIO_RTPLGVAC") \
|
||||
X(40, BIO_BSTVAC, "BIO_BSTVAC") \
|
||||
X(41, BIO_ASPLGVAC, "BIO_ASPLGVAC") \
|
||||
X(42, BIO_CONNAN, "BIO_CONNAN") \
|
||||
X(43, BIO_UNIANTI, "BIO_UNIANTI") \
|
||||
X(44, BIO_PLG, "BIO_PLG") \
|
||||
X(45, BIO_RetroPlague, nullptr) /* name inferred from its vaccine */ \
|
||||
X(46, BIO_BeastPlague, nullptr) \
|
||||
X(47, BIO_AssimilationPlague, nullptr) \
|
||||
X(48, BIO_NANVIR, "BIO_NANVIR") \
|
||||
X(49, DRN_CMBT, "DRN_CMBT") \
|
||||
X(50, IND_STLTHARM, "IND_STLTHARM") \
|
||||
X(51, DRV_PLSMFOC, "DRV_PLSMFOC") \
|
||||
X(52, Unresolved_052, nullptr) \
|
||||
X(53, Unresolved_053, nullptr) \
|
||||
X(54, Unresolved_054, nullptr) \
|
||||
X(55, Unresolved_055, nullptr) \
|
||||
X(56, Unresolved_056, nullptr) \
|
||||
X(57, Unresolved_057, nullptr) \
|
||||
X(58, Unresolved_058, nullptr) \
|
||||
X(59, Unresolved_059, nullptr) \
|
||||
X(60, Unresolved_060, nullptr) \
|
||||
X(61, Unresolved_061, nullptr) \
|
||||
X(62, Unresolved_062, nullptr) \
|
||||
X(63, Unresolved_063, nullptr) \
|
||||
X(64, DRV_RIP, "DRV_RIP") \
|
||||
X(65, DRV_REND, "DRV_REND") \
|
||||
X(66, DRV_RAD, "DRV_RAD") \
|
||||
X(67, Unresolved_067, nullptr) \
|
||||
X(68, Unresolved_068, nullptr) \
|
||||
X(69, Unresolved_069, nullptr) \
|
||||
X(70, Unresolved_070, nullptr) \
|
||||
X(71, SLD_INTANG, "SLD_INTANG") \
|
||||
X(72, DRV_RECFISS, "DRV_RECFISS") \
|
||||
X(73, Unresolved_073, nullptr) \
|
||||
X(74, CCC_ARMCOM, "CCC_ARMCOM") \
|
||||
X(75, CCC_DATSYN, "CCC_DATSYN") \
|
||||
X(76, CCC_BTLCMP, "CCC_BTLCMP") \
|
||||
X(77, WEP_BeamVariant_077, nullptr) /* combat beam variant */ \
|
||||
X(78, WEP_BeamVariant_078, nullptr) \
|
||||
X(79, WEP_CannonVariant_079, nullptr) /* combat cannon variant */ \
|
||||
X(80, WEP_CannonVariant_080, nullptr) \
|
||||
X(81, WEP_CannonVariant_081, nullptr) \
|
||||
X(82, WEP_BeamVariant_082, nullptr) \
|
||||
X(83, Unresolved_083, nullptr) \
|
||||
X(84, Unresolved_084, nullptr) \
|
||||
X(85, Unresolved_085, nullptr) \
|
||||
X(86, Unresolved_086, nullptr) \
|
||||
X(87, Unresolved_087, nullptr) \
|
||||
X(88, Unresolved_088, nullptr) \
|
||||
X(89, Unresolved_089, nullptr) \
|
||||
X(90, Unresolved_090, nullptr) \
|
||||
X(91, Unresolved_091, nullptr) \
|
||||
X(92, Unresolved_092, nullptr) \
|
||||
X(93, CCC_HYPCOM, "CCC_HYPCOM") \
|
||||
X(94, IND_TRKSTL, "IND_TRKSTL") \
|
||||
X(95, IND_SPNLMNT, "IND_SPNLMNT") \
|
||||
X(96, WEP_HCLAS, "WEP_HCLAS") \
|
||||
X(97, WEP_PRTBM, "WEP_PRTBM") \
|
||||
X(98, WEP_DSRPTR, "WEP_DSRPTR") \
|
||||
X(99, Unresolved_099, nullptr) \
|
||||
X(100, WEP_NUKMINE, nullptr) /* stem known, prefix inferred */ \
|
||||
X(101, WEP_FUSMINE, nullptr) \
|
||||
X(102, WEP_DFMSL, nullptr) \
|
||||
X(103, WEP_GSDRVR, nullptr) \
|
||||
X(104, WEP_MASDRVR, nullptr) \
|
||||
X(105, WEP_HVYDRVR, nullptr) \
|
||||
X(106, WEP_VRFTECH, "WEP_VRFTECH") \
|
||||
X(107, WEP_PDTECH, nullptr) \
|
||||
X(108, CCC_FTLBRDB, "CCC_FTLBRDB") \
|
||||
X(109, Unresolved_109, nullptr) \
|
||||
X(110, CCC_INTSENS, "CCC_INTSENS") \
|
||||
X(111, Unresolved_111, nullptr) \
|
||||
X(112, CCC_SPJAM, "CCC_SPJAM") \
|
||||
X(113, CCC_TUNSENS, "CCC_TUNSENS") \
|
||||
X(114, XNC_Translation1_Human, "CCC_TRNSHUM") \
|
||||
X(115, XNC_Translation1_Hiver, nullptr) \
|
||||
X(116, XNC_Translation1_Tarkas, nullptr) \
|
||||
X(117, XNC_Translation1_Liir, "CCC_TRNSLIR") \
|
||||
X(118, XNC_Translation1_Zuul, nullptr) \
|
||||
X(119, XNC_Translation1_Morrigi, nullptr) \
|
||||
X(120, XNC_Translation2_Human, nullptr) \
|
||||
X(121, XNC_Translation2_Hiver, nullptr) \
|
||||
X(122, XNC_Translation2_Tarkas, nullptr) \
|
||||
X(123, XNC_Translation2_Liir, nullptr) \
|
||||
X(124, XNC_Translation2_Zuul, nullptr) \
|
||||
X(125, XNC_Translation2_Morrigi, nullptr) \
|
||||
X(126, XNC_Translation3_Human, nullptr) \
|
||||
X(127, XNC_Translation3_Hiver, nullptr) \
|
||||
X(128, XNC_Translation3_Tarkas, nullptr) \
|
||||
X(129, XNC_Translation3_Liir, nullptr) \
|
||||
X(130, XNC_Translation3_Zuul, nullptr) \
|
||||
X(131, XNC_Translation3_Morrigi, nullptr) \
|
||||
X(132, XNC_Incorporate_Human, nullptr) \
|
||||
X(133, XNC_Incorporate_Hiver, nullptr) \
|
||||
X(134, XNC_Incorporate_Tarkas, nullptr) \
|
||||
X(135, XNC_Incorporate_Liir, nullptr) \
|
||||
X(136, XNC_Incorporate_Morrigi, nullptr) \
|
||||
X(137, XNC_Addict_Human, nullptr) \
|
||||
X(138, XNC_Addict_Hiver, nullptr) \
|
||||
X(139, XNC_Addict_Tarkas, nullptr) \
|
||||
X(140, XNC_Addict_Liir, nullptr) \
|
||||
X(141, XNC_Addict_Morrigi, nullptr) \
|
||||
X(142, XNC_Temperance_Human, nullptr) \
|
||||
X(143, XNC_Temperance_Hiver, nullptr) \
|
||||
X(144, XNC_Temperance_Tarkas, nullptr) \
|
||||
X(145, XNC_Temperance_Liir, nullptr) \
|
||||
X(146, XNC_Temperance_Morrigi, nullptr) \
|
||||
X(147, XNC_Subjugate_Human, nullptr) \
|
||||
X(148, XNC_Subjugate_Hiver, nullptr) \
|
||||
X(149, XNC_Subjugate_Tarkas, nullptr) \
|
||||
X(150, XNC_Subjugate_Liir, nullptr) \
|
||||
X(151, XNC_Subjugate_Zuul, nullptr) \
|
||||
X(152, XNC_Subjugate_Morrigi, nullptr) \
|
||||
X(153, XNC_Accommodate_Human, nullptr) \
|
||||
X(154, XNC_Accommodate_Hiver, nullptr) \
|
||||
X(155, XNC_Accommodate_Tarkas, nullptr) \
|
||||
X(156, XNC_Accommodate_Liir, nullptr) \
|
||||
X(157, XNC_Accommodate_Morrigi, nullptr) \
|
||||
X(158, XNC_Proliferate_Human, nullptr) \
|
||||
X(159, XNC_Proliferate_Hiver, nullptr) \
|
||||
X(160, XNC_Proliferate_Tarkas, nullptr) \
|
||||
X(161, XNC_Proliferate_Liir, nullptr) \
|
||||
X(162, XNC_Proliferate_Zuul, nullptr) \
|
||||
X(163, XNC_Proliferate_Morrigi, nullptr) \
|
||||
X(164, Unresolved_164, nullptr) \
|
||||
X(165, Unresolved_165, nullptr) \
|
||||
X(166, Unresolved_166, nullptr) \
|
||||
X(167, Unresolved_167, nullptr) \
|
||||
X(168, Unresolved_168, nullptr) \
|
||||
X(169, Unresolved_169, nullptr) \
|
||||
X(170, Unresolved_170, nullptr) \
|
||||
X(171, IND_QRKRES, "IND_QRKRES") \
|
||||
X(172, Unresolved_172, nullptr) \
|
||||
X(173, SLD_MKONE, "SLD_MKONE") \
|
||||
X(174, SLD_MKTWO, "SLD_MKTWO") \
|
||||
X(175, SLD_MKTHREE, "SLD_MKTHREE") \
|
||||
X(176, Unresolved_176, nullptr) \
|
||||
X(177, WEP_NEUTRND, "WEP_NEUTRND") \
|
||||
X(178, Unresolved_178, nullptr) \
|
||||
X(179, CCC_ADVCNC, "CCC_ADVCNC") \
|
||||
X(180, DRV_MCROFUS, "DRV_MCROFUS") \
|
||||
X(181, DRV_INCTHRST, "DRV_INCTHRST") \
|
||||
X(182, DRV_SMLFUS, "DRV_SMLFUS") \
|
||||
X(183, DRV_HYPRFLD, "DRV_HYPRFLD") \
|
||||
X(184, SLD_MESSHLD, "SLD_MESSHLD") \
|
||||
X(185, SLD_GRVSHLD, "SLD_GRVSHLD") \
|
||||
X(186, SLD_DISR, "SLD_DISR") \
|
||||
X(187, SLD_MAGNI, "SLD_MAGNI") \
|
||||
X(188, DRV_QNTCAP, "DRV_QNTCAP") \
|
||||
X(189, CCC_FCCOM, "CCC_FCCOM") \
|
||||
X(190, IND_HRDELEC, "IND_HRDELEC") \
|
||||
X(191, BIO_SMRTNAN, "BIO_SMRTNAN") \
|
||||
X(192, WEP_ACCAMP, "WEP_ACCAMP") \
|
||||
X(193, Unresolved_193, nullptr) \
|
||||
X(194, Unresolved_194, nullptr) \
|
||||
X(195, Unresolved_195, nullptr)
|
||||
|
||||
enum class TechId : int {
|
||||
#define SOTS_TECH_ID_ENUM(idx, name, str) name = kTechIdBase + idx,
|
||||
SOTS_TECH_ID_LIST(SOTS_TECH_ID_ENUM)
|
||||
#undef SOTS_TECH_ID_ENUM
|
||||
None = kTechIdBase + 197, // the game's "no tech" sentinel
|
||||
};
|
||||
|
||||
constexpr bool IsValidTechId(int id) { return id >= kTechIdBase && id < kTechIdBase + kTechIdCount; }
|
||||
constexpr bool IsValidTechId(TechId id) { return IsValidTechId(static_cast<int>(id)); }
|
||||
constexpr int TechIdIndex(TechId id) { return static_cast<int>(id) - kTechIdBase; }
|
||||
constexpr TechId TechIdFromIndex(int index) { return static_cast<TechId>(kTechIdBase + index); }
|
||||
|
||||
// Data-file name of a tech id, or nullptr when the position is known but the name is not
|
||||
// (or the id is invalid).
|
||||
const char* TechIdName(TechId id);
|
||||
|
||||
// Resolve a data-file name to its id, case-insensitively as the game does. Names not in
|
||||
// the recovered table resolve to TechId::None -- such techs have no code effect here.
|
||||
TechId TechIdFromName(std::string_view name);
|
||||
|
||||
// Xenotech families, in flag-bit order (bit k of a player's per-species flag word).
|
||||
enum class XenoLevel : int {
|
||||
Translation1 = 0,
|
||||
Translation2 = 1,
|
||||
Translation3 = 2,
|
||||
Incorporate = 3,
|
||||
Addict = 4,
|
||||
Temperance = 5,
|
||||
Subjugate = 6,
|
||||
Accommodate = 7,
|
||||
Proliferate = 8,
|
||||
};
|
||||
constexpr int kXenoLevelCount = 9;
|
||||
|
||||
// The xenotech of one family aimed at one species, or None where the game has no such
|
||||
// tech (the NPC race for every family; the Zuul for Incorporate, Addict, Temperance and
|
||||
// Accommodate). CONFIDENCE: high on the family order and block bases; medium on the
|
||||
// compact species order inside a block; low on which species the four 5-entry blocks
|
||||
// other than Incorporate omit (Zuul assumed).
|
||||
TechId XenoTechId(XenoLevel level, sots::sim::Species target);
|
||||
|
||||
// Data-file name of the tech that lets a player see one species' node-space traffic
|
||||
// (only the Humans' and the Zuul's drives leave trackable traffic), or nullptr.
|
||||
const char* NodeTrackTechName(sots::sim::Species tracked);
|
||||
|
||||
} // namespace sots::effects
|
||||
|
|
@ -11,9 +11,10 @@ namespace {
|
|||
constexpr std::int64_t kMaxPopStep = 50000000;
|
||||
}
|
||||
|
||||
double HazardModifierShape(double suitability, double idealSuitability, double tolerance) {
|
||||
if (tolerance <= 0) return suitability == idealSuitability ? 1.0 : 0.0;
|
||||
return Clamp01(1.0 - std::fabs(suitability - idealSuitability) / tolerance);
|
||||
double HazardModifier(double suitability, double idealSuitability, double suitTolerance) {
|
||||
const double band = suitTolerance + 0.1;
|
||||
if (band <= 0) return suitability == idealSuitability ? 1.0 : 0.0;
|
||||
return Clamp01(1.0 - std::fabs(suitability - idealSuitability) / band);
|
||||
}
|
||||
|
||||
std::int64_t CarryingCapacity(const CapacityInputs& in, const TuningTable& t) {
|
||||
|
|
@ -92,9 +93,9 @@ double SlaveDeathRate(double slaveOutputRate, double suit, double ideal,
|
|||
const double base = slaveOutputRate * t.SLAVES_DEATH_RATE_BYOUTPUT +
|
||||
std::fabs(ideal - suit) * t.SLAVES_DEATH_RATE_BYHAZARD +
|
||||
t.SLAVES_DEATH_RATE;
|
||||
double mod = flags.slaveDeathTech0 ? 0.8 : 1.0;
|
||||
if (flags.slaveDeathTech1) mod -= 0.2;
|
||||
if (flags.slaveDeathTech2) mod -= 0.2;
|
||||
double mod = flags.translation1 ? 0.8 : 1.0;
|
||||
if (flags.translation2) mod -= 0.2;
|
||||
if (flags.translation3) mod -= 0.2;
|
||||
return base * mod;
|
||||
}
|
||||
|
||||
|
|
@ -174,9 +175,24 @@ OutputSplit SplitLeftover(int leftover, const OutputRates& rates, bool suitAtIde
|
|||
return s;
|
||||
}
|
||||
|
||||
int SystemMoneyIncomeShape(int tradePoints, double speciesIncomeFactor, double playerIncomeMult,
|
||||
double playerCostTerm) {
|
||||
return Ftol(static_cast<double>(tradePoints) * speciesIncomeFactor * playerIncomeMult - playerCostTerm);
|
||||
double SuitabilityCostMod(double suitability, double idealSuitability, double suitTolerance,
|
||||
bool rebelAI, bool owned) {
|
||||
if (rebelAI) return 0.0;
|
||||
if (!owned) return 20.0;
|
||||
return std::min(std::fabs(idealSuitability - suitability), suitTolerance);
|
||||
}
|
||||
|
||||
int SystemMoneyIncome(const SystemMoneyInputs& in) {
|
||||
// Whole blocks of five trade points, worth five money each.
|
||||
double t = (in.tradePoints - std::fmod(in.tradePoints, 5.0)) * 5.0;
|
||||
t += static_cast<double>(in.popIncomeImperial);
|
||||
t += static_cast<double>(in.popIncomeCivilian);
|
||||
t += static_cast<double>(in.slaveIncome);
|
||||
t *= in.speciesIncomeFactor;
|
||||
t *= in.playerIncMod;
|
||||
t *= in.serverIncomeMod * in.difficultyIncomeMult;
|
||||
const double cost = in.speciesCostFactor * in.suitCostMod * 10000.0 * 1.5;
|
||||
return Ftol(t - cost);
|
||||
}
|
||||
|
||||
void ApplyPopulationBonus(std::int64_t& pop, std::int64_t capacity, std::int64_t& pendingBonus) {
|
||||
|
|
@ -198,15 +214,14 @@ void AccrueSystemBonus(const SystemBonusInputs& in, std::int64_t& popBonus, doub
|
|||
if (in.turnsOwned <= t.SYSTEMBONUS_MINTURNS) return;
|
||||
if (in.turnsSinceRebellion <= t.SYSTEMBONUS_MINTURNS) return;
|
||||
|
||||
const double popCapMult = in.homeSystem ? t.SYSTEMBONUS_POPBONUS_HOME : t.SYSTEMBONUS_POPBONUS;
|
||||
const double infraCap = in.homeSystem ? t.SYSTEMBONUS_INFRABONUS_HOME : t.SYSTEMBONUS_INFRABONUS;
|
||||
const double cap = static_cast<double>(in.capacity);
|
||||
const std::int64_t popTarget =
|
||||
in.ownerSpeciesEligible ? Ftol(std::max(t.SYSTEMBONUS_POPBONUS, 0.0) * cap) : 0;
|
||||
const std::int64_t popInc = Ftol(t.SYSTEMBONUS_POPBONUS_INC * cap);
|
||||
popBonus += std::min(std::max<std::int64_t>(popInc, 0), std::max<std::int64_t>(popTarget - popBonus, 0));
|
||||
|
||||
const std::int64_t popInc = Ftoi64(static_cast<double>(in.capacity) * t.SYSTEMBONUS_POPBONUS_INC);
|
||||
const std::int64_t popRoom = Ftoi64(static_cast<double>(in.capacity) * popCapMult) - popBonus;
|
||||
popBonus += std::max<std::int64_t>(0, std::min(popInc, popRoom));
|
||||
|
||||
const double infraRoom = infraCap - infraBonus;
|
||||
infraBonus += std::max(0.0, std::min(t.SYSTEMBONUS_INFRABONUS_INC, infraRoom));
|
||||
const double infraTarget = in.ownerSpeciesEligible ? t.SYSTEMBONUS_INFRABONUS : 0.0;
|
||||
infraBonus += std::min(std::max(t.SYSTEMBONUS_INFRABONUS_INC, 0.0), std::max(infraTarget - infraBonus, 0.0));
|
||||
}
|
||||
|
||||
BuildQueueResult ProcessBuildQueue(std::vector<BuildOrder>& queue, int points) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -24,15 +24,27 @@ int MaintenanceCost(int maintenance, double difficultyDivisor) {
|
|||
}
|
||||
|
||||
int ExpenseTotal(const std::vector<ExpenseSlider>& sliders, int availableBeforeExpenses) {
|
||||
constexpr int kUnlimited = 2000000000;
|
||||
const int availPre = availableBeforeExpenses;
|
||||
// The game multiplies the slider fraction by the available income in single
|
||||
// precision, so the income is rounded to a float before the product.
|
||||
const double availAsFloat = static_cast<double>(static_cast<float>(availPre));
|
||||
|
||||
std::int64_t minimums = 0;
|
||||
std::int64_t extras = 0;
|
||||
std::int64_t takes = 0;
|
||||
for (const ExpenseSlider& s : sliders) {
|
||||
minimums += s.minimum;
|
||||
const int span = std::max(0, s.maximum - s.minimum);
|
||||
extras += ClampT(s.requested - s.minimum, 0, span);
|
||||
const int minC = std::max(s.minimum, 0);
|
||||
int maxC = ClampT(s.maximum, 0, kUnlimited);
|
||||
if (maxC == 0) maxC = kUnlimited;
|
||||
const std::int64_t room = static_cast<std::int64_t>(maxC) - minC;
|
||||
const std::int64_t request =
|
||||
static_cast<std::int64_t>(Ftol(static_cast<double>(s.fraction) * availAsFloat)) - minC;
|
||||
const std::int64_t take = std::min(std::max<std::int64_t>(request, 0), room);
|
||||
minimums += minC;
|
||||
takes += take;
|
||||
}
|
||||
const std::int64_t headroom = static_cast<std::int64_t>(availableBeforeExpenses) - minimums;
|
||||
const std::int64_t total = minimums + std::min(extras, headroom);
|
||||
const std::int64_t headroom = static_cast<std::int64_t>(availPre) - minimums;
|
||||
const std::int64_t total = minimums + std::min(std::max<std::int64_t>(takes, 0), headroom);
|
||||
return static_cast<int>(ClampT<std::int64_t>(total, -2147483648LL, 2147483647LL));
|
||||
}
|
||||
|
||||
|
|
@ -90,9 +102,17 @@ Budget ComputeBudget(const BudgetInputs& in, bool projected) {
|
|||
b.hasResearchAllocation = in.hasResearchTarget;
|
||||
b.researchMoneyKept = b.researchMoney - b.researchMoneyGiven;
|
||||
|
||||
b.bonusIncome = Ftol((in.techIncomeMult - 1.0) * static_cast<double>(running()));
|
||||
b.savingsGiven = static_cast<int>(std::min<std::int64_t>(std::max<std::int64_t>(running(), 0),
|
||||
std::max(0, in.aidSavings)));
|
||||
// Tech income bonus: a share of the full net so far, only when that net is positive.
|
||||
const std::int64_t netBeforeBonus = running();
|
||||
if (netBeforeBonus > 0) {
|
||||
b.bonusIncome = std::max(0, Ftol((in.techIncomeMult - 1.0) * static_cast<double>(netBeforeBonus)));
|
||||
}
|
||||
// Savings aid: capped by the projected treasury after this turn (bonus included).
|
||||
if (in.aidSavings != 0) {
|
||||
const int netWithBonus = static_cast<int>(ClampT<std::int64_t>(running(), -2147483648LL, 2147483647LL));
|
||||
const int projectedSavings = SaturatingAdd(in.savings, netWithBonus);
|
||||
b.savingsGiven = std::min(std::max(projectedSavings, 0), std::max(in.aidSavings, 0));
|
||||
}
|
||||
|
||||
b.net = static_cast<int>(ClampT<std::int64_t>(running(), -2147483648LL, 2147483647LL));
|
||||
return b;
|
||||
|
|
@ -136,9 +156,13 @@ int TradeRouteIncome(const TradeRouteState& route, bool asOwner, double difficul
|
|||
// ---- bankruptcy -----------------------------------------------------------------------
|
||||
|
||||
BankruptcyLimits ComputeBankruptcyLimits(int maxIncome, const TuningTable& t) {
|
||||
constexpr int kTreasuryLimit = 2000000000;
|
||||
BankruptcyLimits l;
|
||||
l.eliminationFloor = -Ftol(t.BANKRUPTCY_PROTECTION_LIMIT_FACTOR * static_cast<double>(maxIncome));
|
||||
l.protectionLimit = std::max(l.eliminationFloor, -maxIncome);
|
||||
// Elimination: the debt whose 15 %/turn interest equals the maximum income.
|
||||
l.eliminationFloor = std::max(Ftol(static_cast<double>(maxIncome) / -0.15), -kTreasuryLimit);
|
||||
// Protection: the tuned factor times the maximum income, never below the floor.
|
||||
l.protectionLimit = std::max(-Ftol(t.BANKRUPTCY_PROTECTION_LIMIT_FACTOR * static_cast<double>(maxIncome)),
|
||||
l.eliminationFloor);
|
||||
return l;
|
||||
}
|
||||
|
||||
|
|
@ -148,17 +172,18 @@ int BankruptcyLevel(int savings, const BankruptcyLimits& limits) {
|
|||
return 0;
|
||||
}
|
||||
|
||||
bool BankruptcyStep(BankruptcyState& state, int level, int currentTurn, const TuningTable& t) {
|
||||
if (level == 0) {
|
||||
state.warningLevel = 0;
|
||||
state.startTurn = 0;
|
||||
return false;
|
||||
}
|
||||
if (state.warningLevel != level) {
|
||||
BankruptcyDecision BankruptcyStep(BankruptcyState& state, int level, int currentTurn,
|
||||
const TuningTable& t) {
|
||||
const BankruptcyState old = state;
|
||||
if (level != state.warningLevel) {
|
||||
state.warningLevel = level;
|
||||
state.startTurn = currentTurn;
|
||||
state.startTurn = level != 0 ? currentTurn : -1;
|
||||
}
|
||||
return level == 2 && (currentTurn - state.startTurn) >= t.BANKRUPTCY_ELIMINATION_TURNS;
|
||||
BankruptcyDecision d;
|
||||
d.costCutting = level != 0 && old.warningLevel != 0;
|
||||
d.eliminate = old.warningLevel == 2 &&
|
||||
(currentTurn - old.startTurn) >= t.BANKRUPTCY_ELIMINATION_TURNS;
|
||||
return d;
|
||||
}
|
||||
|
||||
} // namespace sots::sim
|
||||
|
|
|
|||
|
|
@ -15,13 +15,14 @@ namespace sots::sim {
|
|||
// Budget
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
// One per-category expense slider (the player's expense entries). `requested` is the
|
||||
// amount the slider asks for this turn; it is honoured between min and max, and the
|
||||
// total of the above-minimum parts is capped by what is left after minimums.
|
||||
// One per-category expense slider (the player's expense entries {xmin, xmax, xper}).
|
||||
// `fraction` is the share of the pre-expense available income the slider asks for; the
|
||||
// request is honoured between min and max, and the total of the above-minimum parts is
|
||||
// capped by what is left after every minimum is paid. A maximum of 0 means unlimited.
|
||||
struct ExpenseSlider {
|
||||
int minimum = 0;
|
||||
int maximum = 0;
|
||||
int requested = 0;
|
||||
int maximum = 0; // 0 = no upper bound
|
||||
float fraction = 0.f; // xper: stored as a single-precision value by the game
|
||||
};
|
||||
|
||||
// Everything the budget roll-up reads from the player and the server. Names follow the
|
||||
|
|
@ -92,9 +93,13 @@ int DebtInterest(int savings);
|
|||
// Fleet upkeep after the difficulty divisor. CONFIDENCE: high.
|
||||
int MaintenanceCost(int maintenance, double difficultyDivisor);
|
||||
|
||||
// Total of the expense sliders: every minimum is paid; the above-minimum requests are
|
||||
// honoured up to what is left of `availableBeforeExpenses` after the minimums.
|
||||
// CONFIDENCE: medium (the per-entry request amount is derived from an unresolved term).
|
||||
// Total of the expense sliders. Per entry, with `availPre` = the non-negative net before
|
||||
// expenses:
|
||||
// minC = max(min, 0); maxC = clamp(max, 0, 2e9), 0 meaning 2e9; room = maxC - minC
|
||||
// request = ftol(fraction x float(availPre)) - minC; take = min(max(request, 0), room)
|
||||
// total = sum(minC) + min(max(sum(take), 0), availPre - sum(minC)).
|
||||
// CONFIDENCE: high -- the request term is a fraction of the pre-expense available
|
||||
// income, multiplied in single precision, minus the mandatory minimum.
|
||||
int ExpenseTotal(const std::vector<ExpenseSlider>& sliders, int availableBeforeExpenses);
|
||||
|
||||
// Research points bought with `researchMoney`:
|
||||
|
|
@ -108,8 +113,13 @@ int ResearchPointsFromMoney(int researchMoney, double difficultyMult, double res
|
|||
// The full per-turn budget. The order of evaluation matters because later slots read
|
||||
// the running totals: interest -> system income -> trade/other income -> maintenance ->
|
||||
// expenses -> available -> construction -> research money/points -> aid -> bonus ->
|
||||
// savings aid -> net. CONFIDENCE: high on the line items and their signs; medium on
|
||||
// which running total the tech income bonus and the savings aid read.
|
||||
// savings aid -> net.
|
||||
// The tech income bonus reads the full net (every income line including interest and
|
||||
// trade, minus maintenance, research money, construction, expenses and research aid)
|
||||
// and is only granted when that net is positive: bonus = max(0, ftol((mult - 1) x net)).
|
||||
// Savings aid is capped by the projected treasury after this turn, not by the turn net:
|
||||
// given = min(max(Sav + net, 0), max(aid, 0)), evaluated with the bonus already added.
|
||||
// CONFIDENCE: high (line items, signs, and both running-total readers).
|
||||
Budget ComputeBudget(const BudgetInputs& in, bool projected);
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
|
@ -153,11 +163,13 @@ struct BankruptcyLimits {
|
|||
};
|
||||
|
||||
// Limits from the sum of every owned system's maximum money output:
|
||||
// eliminationFloor = -ftol(BANKRUPTCY_PROTECTION_LIMIT_FACTOR x maxIncome)
|
||||
// protectionLimit = max(eliminationFloor, -maxIncome)
|
||||
// CONFIDENCE: low -- see sots-re open questions. The shape "debt floor is -3.3 x maximum
|
||||
// income" is established; which of the two limits carries the factor, and the exact form
|
||||
// of the other, is not. The protection limit here is the simplest reading.
|
||||
// eliminationFloor = max(ftol(maxIncome / -0.15), -2e9)
|
||||
// (the debt at which 15 %/turn interest eats the whole maximum income)
|
||||
// protectionLimit = max(-ftol(BANKRUPTCY_PROTECTION_LIMIT_FACTOR x maxIncome), eliminationFloor)
|
||||
// The limits a turn's check uses are the ones computed at the end of the previous turn
|
||||
// (and on load); the caller keeps them on the player.
|
||||
// CONFIDENCE: high -- the factor is on the protection limit, the elimination limit is
|
||||
// the interest break-even, both read with their constants.
|
||||
BankruptcyLimits ComputeBankruptcyLimits(int maxIncome, const TuningTable& t);
|
||||
|
||||
// 2 = elimination pending, 1 = protection (cost cutting), 0 = solvent. CONFIDENCE: high.
|
||||
|
|
@ -165,14 +177,22 @@ int BankruptcyLevel(int savings, const BankruptcyLimits& limits);
|
|||
|
||||
struct BankruptcyState {
|
||||
int warningLevel = 0; // BnkWrn
|
||||
int startTurn = 0; // BnkTrn: turn the current level began
|
||||
int startTurn = -1; // BnkTrn: turn the current level began; -1 when solvent
|
||||
};
|
||||
|
||||
// Per-turn bankruptcy bookkeeping. Returns true when the player is to be eliminated:
|
||||
// level 2 held for at least BANKRUPTCY_ELIMINATION_TURNS turns. A non-zero level that
|
||||
// differs from the stored one restarts the clock; level 0 clears it.
|
||||
// CONFIDENCE: low -- see sots-re open questions (elimination condition is established;
|
||||
// when exactly the start turn is stamped is inferred).
|
||||
bool BankruptcyStep(BankruptcyState& state, int level, int currentTurn, const TuningTable& t);
|
||||
struct BankruptcyDecision {
|
||||
bool costCutting = false; // run the cost-cutting pass this turn
|
||||
bool eliminate = false; // the player is eliminated this turn
|
||||
};
|
||||
|
||||
// Per-turn bankruptcy bookkeeping. The stored state is updated first -- any change of
|
||||
// level (0<->1, 1<->2 alike) restamps the start turn with the current turn, level 0
|
||||
// stamps -1 -- and the decisions are then taken on the *previous* state:
|
||||
// costCutting = level != 0 && old.level != 0
|
||||
// eliminate = old.level == 2 && currentTurn - old.startTurn >= BANKRUPTCY_ELIMINATION_TURNS
|
||||
// so both actions begin the turn after the level was reached.
|
||||
// CONFIDENCE: high -- stamp-on-transition and act-on-old-state read from the code.
|
||||
BankruptcyDecision BankruptcyStep(BankruptcyState& state, int level, int currentTurn,
|
||||
const TuningTable& t);
|
||||
|
||||
} // namespace sots::sim
|
||||
|
|
|
|||
|
|
@ -7,6 +7,16 @@
|
|||
|
||||
namespace sots::sim {
|
||||
|
||||
namespace {
|
||||
constexpr double kMinChordLength = 0.01;
|
||||
|
||||
double Dot(const Vec3& a, const Vec3& b) { return a.x * b.x + a.y * b.y + a.z * b.z; }
|
||||
Vec3 Sub(const Vec3& a, const Vec3& b) { return Vec3{a.x - b.x, a.y - b.y, a.z - b.z}; }
|
||||
Vec3 Lerp(const Vec3& a, const Vec3& b, double f) {
|
||||
return Vec3{a.x + (b.x - a.x) * f, a.y + (b.y - a.y) * f, a.z + (b.z - a.z) * f};
|
||||
}
|
||||
} // namespace
|
||||
|
||||
double Distance(const Vec3& a, const Vec3& b) {
|
||||
const double dx = b.x - a.x, dy = b.y - a.y, dz = b.z - a.z;
|
||||
return std::sqrt(dx * dx + dy * dy + dz * dz);
|
||||
|
|
@ -15,8 +25,15 @@ double Distance(const Vec3& a, const Vec3& b) {
|
|||
Vec3 AdvanceToward(const Vec3& pos, const Vec3& dest, double amount) {
|
||||
const double d = Distance(pos, dest);
|
||||
if (amount >= d || d <= 0) return dest;
|
||||
const double f = amount / d;
|
||||
return Vec3{pos.x + (dest.x - pos.x) * f, pos.y + (dest.y - pos.y) * f, pos.z + (dest.z - pos.z) * f};
|
||||
return Lerp(pos, dest, amount / d);
|
||||
}
|
||||
|
||||
double DistPointToSegment(const Vec3& p, const Vec3& a, const Vec3& b) {
|
||||
const Vec3 ab = Sub(b, a);
|
||||
const double len2 = Dot(ab, ab);
|
||||
double u = 0.0;
|
||||
if (len2 > 0) u = Clamp01(Dot(Sub(p, a), ab) / len2);
|
||||
return Distance(p, Lerp(a, b, u));
|
||||
}
|
||||
|
||||
double StraightStep(double speed, double dt) { return speed * dt; }
|
||||
|
|
@ -24,11 +41,103 @@ double StraightStep(double speed, double dt) { return speed * dt; }
|
|||
double NodeLineSpeed(double nodeSpeed, double distToLineSystem, const TuningTable& t) {
|
||||
double ratio = 0.0;
|
||||
if (t.STUTTER_SYSTEM_INFLUENCE_RADIUS > 0) {
|
||||
ratio = Clamp01(distToLineSystem / t.STUTTER_SYSTEM_INFLUENCE_RADIUS);
|
||||
ratio = distToLineSystem / t.STUTTER_SYSTEM_INFLUENCE_RADIUS;
|
||||
}
|
||||
return nodeSpeed * ((t.STUTTER_MAX_SPEED - t.STUTTER_MIN_SPEED) * ratio + t.STUTTER_MIN_SPEED);
|
||||
}
|
||||
|
||||
std::vector<StutterSegment> BuildStutterSegments(const Vec3& from, const Vec3& to,
|
||||
const std::vector<Vec3>& systems,
|
||||
const TuningTable& t) {
|
||||
std::vector<StutterSegment> segs;
|
||||
const double radius = t.STUTTER_SYSTEM_INFLUENCE_RADIUS;
|
||||
const Vec3 d = Sub(to, from);
|
||||
const double len2 = Dot(d, d);
|
||||
if (radius <= 0 || len2 <= 0) return segs;
|
||||
const double len = std::sqrt(len2);
|
||||
|
||||
// Chord of the line inside each sphere, as [start, end] distances along the line.
|
||||
for (std::size_t i = 0; i < systems.size(); ++i) {
|
||||
const Vec3 fc = Sub(from, systems[i]);
|
||||
const double b = 2.0 * Dot(d, fc);
|
||||
const double c = Dot(fc, fc) - radius * radius;
|
||||
const double disc = b * b - 4.0 * len2 * c;
|
||||
if (disc < 0) continue;
|
||||
const double root = std::sqrt(disc);
|
||||
const double u0 = Clamp01((-b - root) / (2.0 * len2));
|
||||
const double u1 = Clamp01((-b + root) / (2.0 * len2));
|
||||
StutterSegment s;
|
||||
s.start = u0 * len;
|
||||
s.end = u1 * len;
|
||||
s.systemIndex = static_cast<int>(i);
|
||||
if (s.end - s.start < kMinChordLength) continue;
|
||||
segs.push_back(s);
|
||||
}
|
||||
|
||||
std::sort(segs.begin(), segs.end(),
|
||||
[](const StutterSegment& a, const StutterSegment& b) { return a.start < b.start; });
|
||||
|
||||
// Split overlaps at the midpoint of the overlap; a chord entirely inside the
|
||||
// previous one disappears.
|
||||
std::vector<StutterSegment> merged;
|
||||
for (const StutterSegment& s : segs) {
|
||||
if (!merged.empty() && s.start < merged.back().end) {
|
||||
const double mid = 0.5 * (s.start + merged.back().end);
|
||||
if (s.end <= mid) continue;
|
||||
merged.back().end = mid;
|
||||
StutterSegment cut = s;
|
||||
cut.start = mid;
|
||||
merged.push_back(cut);
|
||||
} else {
|
||||
merged.push_back(s);
|
||||
}
|
||||
}
|
||||
|
||||
// Per-segment profile: closest approach of the whole chord to its system.
|
||||
for (StutterSegment& s : merged) {
|
||||
const Vec3 a = Lerp(from, to, s.start / len);
|
||||
const Vec3 b = Lerp(from, to, s.end / len);
|
||||
const double dist = DistPointToSegment(systems[static_cast<std::size_t>(s.systemIndex)], a, b);
|
||||
s.speedFactor = NodeLineSpeed(1.0, dist, t);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
double AdvanceAlongNodeLine(double along, double dt, double nodeSpeed, double lineLength,
|
||||
const std::vector<StutterSegment>& segments) {
|
||||
if (nodeSpeed <= 0 || dt <= 0) return std::min(along, lineLength);
|
||||
double s = along;
|
||||
double timeLeft = dt;
|
||||
while (timeLeft > 0 && s < lineLength) {
|
||||
// Speed at s, and the distance until it may change.
|
||||
double factor = 1.0;
|
||||
double until = lineLength;
|
||||
for (const StutterSegment& seg : segments) {
|
||||
if (s >= seg.start && s < seg.end) {
|
||||
factor = seg.speedFactor;
|
||||
until = seg.end;
|
||||
break;
|
||||
}
|
||||
if (seg.start > s) {
|
||||
until = std::min(until, seg.start);
|
||||
break;
|
||||
}
|
||||
}
|
||||
const double v = nodeSpeed * factor;
|
||||
if (v <= 0) break;
|
||||
const double reach = v * timeLeft;
|
||||
const double gap = until - s;
|
||||
if (reach < gap) {
|
||||
s += reach;
|
||||
timeLeft = 0;
|
||||
} else {
|
||||
s = until;
|
||||
timeLeft -= gap / v;
|
||||
}
|
||||
}
|
||||
return std::min(s, lineLength);
|
||||
}
|
||||
|
||||
MoveStepResult ResolveMoveStep(double step, double minShipRange, double distance) {
|
||||
MoveStepResult r;
|
||||
const double range = minShipRange - 0.05;
|
||||
|
|
|
|||
|
|
@ -18,6 +18,10 @@ double Distance(const Vec3& a, const Vec3& b);
|
|||
// the remaining distance. CONFIDENCE: high.
|
||||
Vec3 AdvanceToward(const Vec3& pos, const Vec3& dest, double amount);
|
||||
|
||||
// Distance from a point to the closest point of the segment [a, b] (the projection
|
||||
// parameter is clamped to [0, 1]). CONFIDENCE: high.
|
||||
double DistPointToSegment(const Vec3& p, const Vec3& a, const Vec3& b);
|
||||
|
||||
// Fractions of a turn each movement pass advances. The server runs the departing/
|
||||
// in-transit sets in two half-steps and the remaining fleets in one full step.
|
||||
// CONFIDENCE: medium (the bucketing semantics are not fully resolved; the constants are).
|
||||
|
|
@ -34,14 +38,42 @@ enum class WaypointKind : int {
|
|||
// Straight-line step for one pass: speed x dt. CONFIDENCE: high.
|
||||
double StraightStep(double speed, double dt);
|
||||
|
||||
// Node-line speed at a point along the line:
|
||||
// Node-line speed inside a system's influence sphere:
|
||||
// speed x ((STUTTER_MAX_SPEED - STUTTER_MIN_SPEED) x (dist / INFLUENCE_RADIUS) + MIN_SPEED)
|
||||
// where dist is the distance to the nearest system on the line. The ratio is clamped
|
||||
// to [0, 1] here so a fleet beyond the influence radius travels at the max profile
|
||||
// speed; the notes give the formula without stating the clamp. CONFIDENCE: high on the
|
||||
// formula, medium on the clamp.
|
||||
// where dist is the closest approach of the travelled chord to the system. There is no
|
||||
// clamp: the caller only evaluates this for chords that lie inside the sphere (see
|
||||
// BuildStutterSegments), so dist <= radius holds by construction and the result is in
|
||||
// [MIN, MAX] x speed. Outside every sphere the fleet moves at plain `speed`.
|
||||
// CONFIDENCE: high.
|
||||
double NodeLineSpeed(double nodeSpeed, double distToLineSystem, const TuningTable& t);
|
||||
|
||||
// One stretch of a node line that lies inside a system's influence sphere, measured as
|
||||
// distances along the line from its start. `speedFactor` is the profile multiplier for
|
||||
// the whole stretch (closest approach of the chord, not re-evaluated per position).
|
||||
struct StutterSegment {
|
||||
double start = 0;
|
||||
double end = 0;
|
||||
int systemIndex = -1;
|
||||
double speedFactor = 1.0;
|
||||
};
|
||||
|
||||
// Intersect the travel line with every system's sphere of radius
|
||||
// STUTTER_SYSTEM_INFLUENCE_RADIUS: each chord becomes a segment (clipped to the line),
|
||||
// chords shorter than 0.01 are dropped, the segments are sorted by start, and where two
|
||||
// overlap the boundary is placed at the midpoint of the overlap. CONFIDENCE: high on the
|
||||
// chord/clip/drop/sort steps; medium on the exact overlap rule (midpoint split assumed;
|
||||
// a chord swallowed whole by an earlier one is dropped).
|
||||
std::vector<StutterSegment> BuildStutterSegments(const Vec3& from, const Vec3& to,
|
||||
const std::vector<Vec3>& systems,
|
||||
const TuningTable& t);
|
||||
|
||||
// Advance a distance `along` the line by `dt` turns at `nodeSpeed`, using the segment
|
||||
// speeds inside influence spheres and plain nodeSpeed between them. Returns the new
|
||||
// distance along the line, never past `lineLength`. CONFIDENCE: medium (piecewise
|
||||
// integration written from the per-segment description).
|
||||
double AdvanceAlongNodeLine(double along, double dt, double nodeSpeed, double lineLength,
|
||||
const std::vector<StutterSegment>& segments);
|
||||
|
||||
struct MoveStepResult {
|
||||
double moved = 0; // distance actually covered this pass
|
||||
double fraction = 1.0; // moved / step (1 when the step was zero)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
8
tests/game_effects/CMakeLists.txt
Normal file
8
tests/game_effects/CMakeLists.txt
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# game/effects tests: hand-computed cases over the tech-effect table and apply layer.
|
||||
# The canonical runner is build_and_run.sh (plain g++); include this from the root with
|
||||
# add_subdirectory(tests/game_effects) after add_subdirectory(src/game/effects).
|
||||
add_executable(game_effects_test test_effects.cpp)
|
||||
target_link_libraries(game_effects_test PRIVATE sots_game_effects)
|
||||
target_include_directories(game_effects_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} ${CMAKE_CURRENT_SOURCE_DIR}/../game_sim)
|
||||
target_compile_options(game_effects_test PRIVATE -Wall -Wextra -pedantic)
|
||||
add_test(NAME game_effects COMMAND game_effects_test)
|
||||
27
tests/game_effects/build_and_run.sh
Executable file
27
tests/game_effects/build_and_run.sh
Executable file
|
|
@ -0,0 +1,27 @@
|
|||
#!/usr/bin/env bash
|
||||
# Build and run the game/effects unit tests with plain g++ (no CMake needed).
|
||||
# tests/game_effects/build_and_run.sh
|
||||
# BUILD_DIR=/some/dir tests/game_effects/build_and_run.sh
|
||||
set -euo pipefail
|
||||
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
root="$(cd "$here/../.." && pwd)"
|
||||
build="${BUILD_DIR:-$(mktemp -d "${TMPDIR:-/tmp}/sots-game-effects.XXXXXX")}"
|
||||
mkdir -p "$build"
|
||||
|
||||
CXX="${CXX:-g++}"
|
||||
CXXFLAGS="${CXXFLAGS:--std=c++17 -O1 -g -Wall -Wextra -Werror -pedantic}"
|
||||
srcs=("$root"/src/game/sim/economy.cpp "$root"/src/game/sim/research.cpp \
|
||||
"$root"/src/game/sim/colony.cpp "$root"/src/game/sim/movement.cpp \
|
||||
"$root"/src/game/effects/tech_id.cpp "$root"/src/game/effects/tech_effects.cpp)
|
||||
|
||||
objs=()
|
||||
for s in "${srcs[@]}"; do
|
||||
o="$build/$(basename "${s%.cpp}").o"
|
||||
$CXX $CXXFLAGS -I"$root/src" -c "$s" -o "$o"
|
||||
objs+=("$o")
|
||||
done
|
||||
|
||||
exe="$build/test_effects"
|
||||
$CXX $CXXFLAGS -I"$root/src" -I"$here" -I"$root/tests/game_sim" "$here/test_effects.cpp" "${objs[@]}" -o "$exe"
|
||||
if "$exe"; then echo "game_effects: all tests passed (build dir $build)"; else echo "game_effects: FAILURES (build dir $build)"; exit 1; fi
|
||||
377
tests/game_effects/test_effects.cpp
Normal file
377
tests/game_effects/test_effects.cpp
Normal file
|
|
@ -0,0 +1,377 @@
|
|||
#include "game/effects/tech_effects.h"
|
||||
#include "game/effects/tech_id.h"
|
||||
|
||||
#include <set>
|
||||
#include <string>
|
||||
|
||||
#include "check.h" // shared with tests/game_sim
|
||||
|
||||
using namespace sots::effects;
|
||||
using sots::sim::Species;
|
||||
|
||||
static ApplyContext ctx_with_tuning(sots::sim::TuningTable& t) {
|
||||
ApplyContext c;
|
||||
c.tuning = &t;
|
||||
return c;
|
||||
}
|
||||
|
||||
static void test_ids() {
|
||||
CHECK_EQ(static_cast<int>(TechId::CCC_AdvSens), 10000);
|
||||
CHECK_EQ(static_cast<int>(TechId::IND_HrdStrct), 10030);
|
||||
CHECK_EQ(static_cast<int>(TechId::WEP_ACCAMP), 10192);
|
||||
CHECK_EQ(static_cast<int>(TechId::None), 10197);
|
||||
CHECK(IsValidTechId(10000) && IsValidTechId(10195));
|
||||
CHECK(!IsValidTechId(9999) && !IsValidTechId(10196) && !IsValidTechId(TechId::None));
|
||||
CHECK_EQ(TechIdIndex(TechId::DRV_RAD), 66);
|
||||
CHECK(TechIdFromIndex(64) == TechId::DRV_RIP);
|
||||
|
||||
CHECK(std::string(TechIdName(TechId::IND_Waldo)) == "IND_Waldo");
|
||||
CHECK(TechIdName(TechId::Unresolved_052) == nullptr);
|
||||
CHECK(TechIdName(TechId::None) == nullptr);
|
||||
CHECK(TechIdFromName("IND_Waldo") == TechId::IND_Waldo);
|
||||
CHECK(TechIdFromName("ind_waldo") == TechId::IND_Waldo); // case-insensitive like the game
|
||||
CHECK(TechIdFromName("CCC_TRNSLIR") == TechId::XNC_Translation1_Liir);
|
||||
CHECK(TechIdFromName("not_a_tech") == TechId::None);
|
||||
CHECK(TechIdFromName("") == TechId::None);
|
||||
|
||||
// every resolved name round-trips, and the table has exactly 196 slots
|
||||
int named = 0;
|
||||
for (int i = 0; i < kTechIdCount; ++i) {
|
||||
const char* n = TechIdName(TechIdFromIndex(i));
|
||||
if (n == nullptr) continue;
|
||||
++named;
|
||||
CHECK(TechIdFromName(n) == TechIdFromIndex(i));
|
||||
}
|
||||
CHECK_EQ(named, 89); // recovered data-file names so far; bump when more are resolved
|
||||
|
||||
// xenotech blocks
|
||||
CHECK(XenoTechId(XenoLevel::Translation1, Species::Human) == TechId::XNC_Translation1_Human);
|
||||
CHECK(XenoTechId(XenoLevel::Translation1, Species::Morrigi) == TechId::XNC_Translation1_Morrigi);
|
||||
CHECK_EQ(static_cast<int>(XenoTechId(XenoLevel::Translation2, Species::Morrigi)), 10125);
|
||||
CHECK_EQ(static_cast<int>(XenoTechId(XenoLevel::Incorporate, Species::Morrigi)), 10136);
|
||||
CHECK(XenoTechId(XenoLevel::Incorporate, Species::Zuul) == TechId::None);
|
||||
CHECK(XenoTechId(XenoLevel::Subjugate, Species::Zuul) == TechId::XNC_Subjugate_Zuul);
|
||||
CHECK_EQ(static_cast<int>(XenoTechId(XenoLevel::Proliferate, Species::Morrigi)), 10163);
|
||||
CHECK(XenoTechId(XenoLevel::Temperance, Species::NPC) == TechId::None);
|
||||
CHECK(XenoTechId(XenoLevel::Translation3, Species::NPC) == TechId::None);
|
||||
|
||||
CHECK(std::string(NodeTrackTechName(Species::Human)) == "CCC_NDTRKHUM");
|
||||
CHECK(std::string(NodeTrackTechName(Species::Zuul)) == "CCC_NDTRKZUL");
|
||||
CHECK(NodeTrackTechName(Species::Hiver) == nullptr);
|
||||
}
|
||||
|
||||
static void test_industrial() {
|
||||
PlayerEconomyState s;
|
||||
ApplyContext ctx;
|
||||
TechApplyOutcome o = ApplyTechEffect(s, TechId::IND_Waldo, ctx);
|
||||
CHECK(o.applied);
|
||||
CHECK_NEAR(s.conMod[0], 0.90, 1e-12);
|
||||
CHECK_NEAR(s.conMod[1], 0.90, 1e-12);
|
||||
CHECK_NEAR(s.conMod[2], 0.90, 1e-12);
|
||||
CHECK_NEAR(s.outMod, 1.15, 1e-12);
|
||||
CHECK(s.HasResearched(TechId::IND_Waldo));
|
||||
|
||||
o = ApplyTechEffect(s, TechId::IND_Waldo, ctx); // no double application
|
||||
CHECK(!o.applied);
|
||||
CHECK_NEAR(s.outMod, 1.15, 1e-12);
|
||||
|
||||
ApplyTechEffect(s, TechId::IND_CyberInt, ctx); // -0.05 / +0.20
|
||||
CHECK_NEAR(s.conMod[0], 0.85, 1e-12);
|
||||
CHECK_NEAR(s.outMod, 1.35, 1e-12);
|
||||
ApplyTechEffect(s, TechId::IND_ExpSys, ctx); // -0.10 / +0.15
|
||||
CHECK_NEAR(s.conMod[2], 0.75, 1e-12);
|
||||
CHECK_NEAR(s.outMod, 1.50, 1e-12);
|
||||
ApplyTechEffect(s, TechId::IND_OrbDry, ctx); // classes 1 and 2 only
|
||||
CHECK_NEAR(s.conMod[0], 0.75, 1e-12);
|
||||
CHECK_NEAR(s.conMod[1], 0.70, 1e-12);
|
||||
CHECK_NEAR(s.conMod[2], 0.70, 1e-12);
|
||||
ApplyTechEffect(s, TechId::DRN_AdvRob, ctx);
|
||||
CHECK_NEAR(s.conMod[0], 0.70, 1e-12);
|
||||
CHECK_NEAR(s.conMod[1], 0.65, 1e-12);
|
||||
|
||||
ApplyTechEffect(s, TechId::IND_OrbFound, ctx);
|
||||
CHECK_NEAR(s.savMod[0], 0.95, 1e-12);
|
||||
CHECK_NEAR(s.savMod[2], 0.95, 1e-12);
|
||||
|
||||
ApplyTechEffect(s, TechId::IND_GravCon, ctx); // 1.80
|
||||
ApplyTechEffect(s, TechId::IND_HvyPlat, ctx); // 1.90
|
||||
CHECK_NEAR(s.outMod, 1.90, 1e-12);
|
||||
ApplyTechEffect(s, TechId::IND_HrdStrct, ctx); // multiplicative
|
||||
CHECK_NEAR(s.outMod, 1.71, 1e-12);
|
||||
CHECK_NEAR(s.defenceDamageMod, 0.25, 1e-12);
|
||||
|
||||
// the order of additive and multiplicative effects matters
|
||||
PlayerEconomyState r;
|
||||
ApplyTechEffect(r, TechId::IND_HrdStrct, ctx);
|
||||
ApplyTechEffect(r, TechId::IND_GravCon, ctx);
|
||||
CHECK_NEAR(r.outMod, 1.20, 1e-12); // 0.9 + 0.3, not 1.3 x 0.9
|
||||
|
||||
ApplyTechEffect(s, TechId::IND_AstMine, ctx);
|
||||
CHECK(s.Flag(PlayerFlag::AsteroidMining));
|
||||
ApplyTechEffect(s, TechId::IND_MsMine, ctx);
|
||||
CHECK_NEAR(s.maxOverharvest, 0.1, 0.0);
|
||||
CHECK_NEAR(s.miningRate, 1.0, 0.0);
|
||||
PlayerEconomyState q;
|
||||
q.maxOverharvest = 0.3;
|
||||
ApplyTechEffect(q, TechId::IND_MsMine, ctx);
|
||||
CHECK_NEAR(q.maxOverharvest, 0.3, 0.0); // max(), not assignment
|
||||
|
||||
ApplyTechEffect(s, TechId::CCC_AdvSens, ctx);
|
||||
CHECK(s.Flag(PlayerFlag::AdvancedSensors));
|
||||
}
|
||||
|
||||
static void test_biology() {
|
||||
PlayerEconomyState s;
|
||||
s.suitTol = 0.07;
|
||||
ApplyContext ctx;
|
||||
ApplyTechEffect(s, TechId::BIO_GnMod, ctx);
|
||||
CHECK_NEAR(s.popMod, 1.10, 1e-12);
|
||||
ApplyTechEffect(s, TechId::BIO_AtmoAd, ctx);
|
||||
CHECK_NEAR(s.suitTol, 0.82, 1e-12);
|
||||
CHECK_NEAR(s.popMod, 1.16, 1e-12);
|
||||
CHECK_NEAR(s.terraMod, 1.35, 1e-12);
|
||||
ApplyTechEffect(s, TechId::BIO_GrvAdpt, ctx);
|
||||
CHECK_NEAR(s.suitTol, 2.32, 1e-12);
|
||||
CHECK_NEAR(s.popMod, 1.26, 1e-12);
|
||||
CHECK_NEAR(s.terraMod, 1.70, 1e-12);
|
||||
ApplyTechEffect(s, TechId::BIO_EnvTail, ctx);
|
||||
CHECK_NEAR(s.popMod, 1.46, 1e-12);
|
||||
CHECK_NEAR(s.terraMod, 2.15, 1e-12);
|
||||
ApplyTechEffect(s, TechId::IND_EleNans, ctx); // +0.60
|
||||
ApplyTechEffect(s, TechId::BIO_TerBac, ctx); // +0.45
|
||||
ApplyTechEffect(s, TechId::IND_AtProc, ctx); // +0.50
|
||||
CHECK_NEAR(s.terraMod, 3.70, 1e-12);
|
||||
|
||||
TechApplyOutcome o = ApplyTechEffect(s, TechId::IND_ArcCon, ctx);
|
||||
CHECK(s.Flag(PlayerFlag::Arcology));
|
||||
CHECK_NEAR(s.popMod, 1.61, 1e-12);
|
||||
CHECK(o.reevaluateCivilianCaps);
|
||||
}
|
||||
|
||||
static void test_gates_and_casting() {
|
||||
sots::sim::TuningTable t;
|
||||
t.PERGATETRAFFIC_DRV_TpGate = 5;
|
||||
t.PERGATETRAFFIC_DRV_GatAmp = 12;
|
||||
ApplyContext ctx = ctx_with_tuning(t);
|
||||
|
||||
PlayerEconomyState s;
|
||||
ApplyTechEffect(s, TechId::DRV_TpGate, ctx);
|
||||
CHECK_NEAR(s.perGateTraffic, 5.0, 0.0);
|
||||
ApplyTechEffect(s, TechId::DRV_GatAmp, ctx);
|
||||
CHECK_NEAR(s.perGateTraffic, 12.0, 0.0);
|
||||
|
||||
PlayerEconomyState r; // reverse order: max wins
|
||||
ApplyTechEffect(r, TechId::DRV_GatAmp, ctx);
|
||||
ApplyTechEffect(r, TechId::DRV_TpGate, ctx);
|
||||
CHECK_NEAR(r.perGateTraffic, 12.0, 0.0);
|
||||
|
||||
PlayerEconomyState n; // no tuning: nothing to raise
|
||||
ApplyTechEffect(n, TechId::DRV_TpGate, ApplyContext{});
|
||||
CHECK_NEAR(n.perGateTraffic, 0.0, 0.0);
|
||||
|
||||
ApplyTechEffect(s, TechId::DRV_FarCast, ctx);
|
||||
CHECK_NEAR(s.castRange, 10.0, 0.0);
|
||||
CHECK_NEAR(s.castEfficiency, 2.0, 0.0);
|
||||
CHECK_NEAR(s.castThreshold, 1.0, 0.0);
|
||||
ApplyTechEffect(s, TechId::DRV_GrvSyn, ctx);
|
||||
CHECK(s.Flag(PlayerFlag::GravSynth));
|
||||
}
|
||||
|
||||
static void test_ai() {
|
||||
ApplyContext ctx;
|
||||
ctx.aiBonus = {0.2, 0.3, 0.4};
|
||||
|
||||
PlayerEconomyState s;
|
||||
CHECK(s.aiBenefit);
|
||||
ApplyTechEffect(s, TechId::CCC_AI, ctx);
|
||||
CHECK_NEAR(s.resMod, 1.2, 1e-12);
|
||||
ApplyTechEffect(s, TechId::CCC_AIAdmin, ctx);
|
||||
CHECK_NEAR(s.incMod, 1.3, 1e-12);
|
||||
ApplyTechEffect(s, TechId::CCC_AIFac, ctx);
|
||||
CHECK_NEAR(s.outMod, 1.4, 1e-12);
|
||||
|
||||
SetAiBenefit(s, false, ctx); // rebellion: bonuses withdrawn
|
||||
CHECK_NEAR(s.resMod, 1.0, 1e-12);
|
||||
CHECK_NEAR(s.incMod, 1.0, 1e-12);
|
||||
CHECK_NEAR(s.outMod, 1.0, 1e-12);
|
||||
SetAiBenefit(s, false, ctx); // idempotent
|
||||
CHECK_NEAR(s.outMod, 1.0, 1e-12);
|
||||
SetAiBenefit(s, true, ctx);
|
||||
CHECK_NEAR(s.resMod, 1.2, 1e-12);
|
||||
CHECK_NEAR(s.outMod, 1.4, 1e-12);
|
||||
|
||||
PlayerEconomyState off; // researched while off: nothing
|
||||
off.aiBenefit = false;
|
||||
ApplyTechEffect(off, TechId::CCC_AIFac, ctx);
|
||||
CHECK_NEAR(off.outMod, 1.0, 1e-12);
|
||||
TechApplyOutcome o = ApplyTechEffect(off, TechId::CCC_AISlv, ctx); // slave AI: benefit back on
|
||||
CHECK(off.aiBenefit);
|
||||
CHECK_NEAR(off.outMod, 1.4, 1e-12);
|
||||
CHECK(o.flagSystemsAI);
|
||||
CHECK(!AiRebellionPossible(off));
|
||||
|
||||
PlayerEconomyState v;
|
||||
CHECK(AiRebellionPossible(v));
|
||||
o = ApplyTechEffect(v, TechId::CCC_AIVrus, ctx);
|
||||
CHECK(o.flagSystemsAI);
|
||||
CHECK(AiRebellionPossible(v));
|
||||
v.species = Species::NPC;
|
||||
CHECK(!AiRebellionPossible(v));
|
||||
|
||||
PlayerEconomyState none; // unknown table values: 0
|
||||
ApplyTechEffect(none, TechId::CCC_AI, ApplyContext{});
|
||||
CHECK_NEAR(none.resMod, 1.0, 0.0);
|
||||
}
|
||||
|
||||
static void test_flags_and_species() {
|
||||
ApplyContext ctx;
|
||||
PlayerEconomyState s;
|
||||
ApplyTechEffect(s, TechId::CCC_FtlEcon, ctx);
|
||||
CHECK(s.Flag(PlayerFlag::TradeAllowed));
|
||||
PlayerEconomyState rebel;
|
||||
rebel.rebelAI = true;
|
||||
ApplyTechEffect(rebel, TechId::CCC_FtlEcon, ctx);
|
||||
CHECK(!rebel.Flag(PlayerFlag::TradeAllowed));
|
||||
ApplyTechEffect(s, TechId::CCC_ComRaid, ctx);
|
||||
CHECK(s.Flag(PlayerFlag::CommerceRaiding));
|
||||
ApplyTechEffect(s, TechId::CCC_DatCor, ctx);
|
||||
CHECK(s.Flag(PlayerFlag::ViewIntel));
|
||||
|
||||
// design capture needs both techs, in either order
|
||||
ApplyTechEffect(s, TechId::CCC_SpyBm, ctx);
|
||||
CHECK(!s.Flag(PlayerFlag::CaptureDesigns));
|
||||
ApplyTechEffect(s, TechId::IND_SlvgTech, ctx);
|
||||
CHECK(s.Flag(PlayerFlag::CaptureDesigns));
|
||||
PlayerEconomyState r;
|
||||
ApplyTechEffect(r, TechId::IND_SlvgTech, ctx);
|
||||
CHECK(!r.Flag(PlayerFlag::CaptureDesigns));
|
||||
ApplyTechEffect(r, TechId::CCC_SpyBm, ctx);
|
||||
CHECK(r.Flag(PlayerFlag::CaptureDesigns));
|
||||
|
||||
// Zuul get boarding pods with cruiser construction; nobody else does
|
||||
TechApplyOutcome o = ApplyTechEffect(s, TechId::IND_CruisCon, ctx);
|
||||
CHECK(o.grantedTech == TechId::None);
|
||||
PlayerEconomyState z;
|
||||
z.species = Species::Zuul;
|
||||
o = ApplyTechEffect(z, TechId::IND_CruisCon, ctx);
|
||||
CHECK(o.grantedTech == TechId::IND_BrdPod);
|
||||
CHECK(!z.HasResearched(TechId::IND_BrdPod)); // the caller researches it
|
||||
o = ApplyTechEffect(z, o.grantedTech, ctx);
|
||||
CHECK(o.applied && z.HasResearched(TechId::IND_BrdPod));
|
||||
|
||||
// node-bore parameters: highest drive wins regardless of order
|
||||
ApplyTechEffect(z, TechId::DRV_RIP, ctx);
|
||||
CHECK_EQ(z.nodeBoreParams[0], 45);
|
||||
CHECK_EQ(z.nodeBoreParams[2], 3);
|
||||
o = ApplyTechEffect(z, TechId::DRV_RAD, ctx);
|
||||
CHECK(o.nodeBoreParamsChanged);
|
||||
CHECK_EQ(z.nodeBoreParams[0], 95);
|
||||
CHECK_EQ(z.nodeBoreParams[1], 60);
|
||||
o = ApplyTechEffect(z, TechId::DRV_REND, ctx);
|
||||
CHECK(!o.nodeBoreParamsChanged);
|
||||
CHECK_EQ(z.nodeBoreParams[0], 95);
|
||||
}
|
||||
|
||||
static void test_plague() {
|
||||
ApplyContext ctx;
|
||||
PlayerEconomyState s;
|
||||
CHECK_EQ(PlagueCureMask(TechId::BIO_PLGVAC), 1u);
|
||||
CHECK_EQ(PlagueCureMask(TechId::BIO_CONNAN), 16u);
|
||||
CHECK_EQ(PlagueCureMask(TechId::BIO_UNIANTI), 15u);
|
||||
CHECK_EQ(PlagueCureMask(TechId::IND_Waldo), 0u);
|
||||
|
||||
TechApplyOutcome o = ApplyTechEffect(s, TechId::BIO_RTPLGVAC, ctx);
|
||||
CHECK_EQ(s.hasVaccine, 2u);
|
||||
CHECK_EQ(s.hasImmunity, 2u);
|
||||
CHECK_EQ(o.plagueCuredMask, 2u);
|
||||
o = ApplyTechEffect(s, TechId::BIO_UNIANTI, ctx);
|
||||
CHECK_EQ(s.hasVaccine, 15u);
|
||||
CHECK_EQ(o.plagueCuredMask, 15u);
|
||||
ApplyTechEffect(s, TechId::BIO_CONNAN, ctx);
|
||||
CHECK_EQ(s.hasImmunity, 31u);
|
||||
}
|
||||
|
||||
static void test_xenotech() {
|
||||
ApplyContext ctx;
|
||||
PlayerEconomyState s;
|
||||
TechApplyOutcome o = ApplyTechEffect(s, TechId::XNC_Translation1_Liir, ctx);
|
||||
CHECK_EQ(s.speciesFlags[static_cast<int>(Species::Liir)], 0x001u);
|
||||
CHECK_EQ(s.speciesFlags[static_cast<int>(Species::Human)], 0u);
|
||||
CHECK_EQ(o.temperanceSpeciesMask, 0u);
|
||||
ApplyTechEffect(s, TechId::XNC_Translation3_Liir, ctx);
|
||||
CHECK_EQ(s.speciesFlags[static_cast<int>(Species::Liir)], 0x005u);
|
||||
o = ApplyTechEffect(s, TechId::XNC_Temperance_Hiver, ctx);
|
||||
CHECK_EQ(s.speciesFlags[static_cast<int>(Species::Hiver)], 0x020u);
|
||||
CHECK_EQ(o.temperanceSpeciesMask, 1u << static_cast<int>(Species::Hiver));
|
||||
o = ApplyTechEffect(s, TechId::XNC_Accommodate_Morrigi, ctx); // every completion reports temperance
|
||||
CHECK_EQ(s.speciesFlags[static_cast<int>(Species::Morrigi)], 0x080u);
|
||||
CHECK_EQ(o.temperanceSpeciesMask, 1u << static_cast<int>(Species::Hiver));
|
||||
ApplyTechEffect(s, TechId::XNC_Proliferate_Zuul, ctx);
|
||||
CHECK_EQ(s.speciesFlags[static_cast<int>(Species::Zuul)], 0x100u);
|
||||
|
||||
// rebuild from the researched set alone (load path)
|
||||
PlayerEconomyState l;
|
||||
l.researched = s.researched;
|
||||
RebuildSpeciesTechFlags(l);
|
||||
for (int sp = 0; sp < sots::sim::kSpeciesCount; ++sp) CHECK_EQ(l.speciesFlags[sp], s.speciesFlags[sp]);
|
||||
}
|
||||
|
||||
static void test_by_name() {
|
||||
ApplyContext ctx;
|
||||
PlayerEconomyState s;
|
||||
TechApplyOutcome o = ApplyTechEffectByName(s, "ind_gravcon", ctx);
|
||||
CHECK(o.applied);
|
||||
CHECK_NEAR(s.outMod, 1.30, 1e-12);
|
||||
o = ApplyTechEffectByName(s, "CCC_NDTRKHUM", ctx);
|
||||
CHECK(o.applied);
|
||||
CHECK_EQ(s.nodeTrackMask, 1u << static_cast<int>(Species::Human));
|
||||
o = ApplyTechEffectByName(s, "ccc_ndtrkzul", ctx);
|
||||
CHECK_EQ(s.nodeTrackMask, (1u << static_cast<int>(Species::Human)) | (1u << static_cast<int>(Species::Zuul)));
|
||||
o = ApplyTechEffectByName(s, "WEP_SOMETHING_DATA_ONLY", ctx);
|
||||
CHECK(!o.applied);
|
||||
CHECK_NEAR(s.outMod, 1.30, 1e-12);
|
||||
o = ApplyTechEffectByName(s, "IND_SPNLMNT", ctx); // in the table, no effect
|
||||
CHECK(o.applied);
|
||||
CHECK(s.HasResearched(TechId::IND_SPNLMNT));
|
||||
CHECK_NEAR(s.outMod, 1.30, 1e-12);
|
||||
}
|
||||
|
||||
static void test_table_shape() {
|
||||
// Techs with a strategic effect have entries; unresolved and gate-only ids do not.
|
||||
CHECK(!EffectsOf(TechId::IND_Waldo).empty());
|
||||
CHECK_EQ(EffectsOf(TechId::IND_Waldo).size(), std::size_t{2});
|
||||
CHECK(EffectsOf(TechId::Unresolved_052).empty());
|
||||
CHECK(EffectsOf(TechId::CCC_HYPCOM).empty());
|
||||
CHECK(EffectsOf(TechId::None).empty());
|
||||
int withEffects = 0;
|
||||
for (int i = 0; i < kTechIdCount; ++i) {
|
||||
if (!EffectsOf(TechIdFromIndex(i)).empty()) ++withEffects;
|
||||
}
|
||||
CHECK_EQ(withEffects, 44);
|
||||
|
||||
// design-option masks
|
||||
std::set<std::string> have = {"IND_REFCOAT", "SLD_INTANG", "DRV_NODE", "WEP_HvyPmsl"};
|
||||
DesignOptionMasks m = ComputeDesignOptionMasks([&](std::string_view n) { return have.count(std::string(n)) > 0; });
|
||||
CHECK_EQ(m.a, (1u << 0) | (1u << 15));
|
||||
CHECK_EQ(m.b, (1u << 0) | (1u << 28));
|
||||
m = ComputeDesignOptionMasks([](std::string_view) { return false; });
|
||||
CHECK_EQ(m.a, 0u);
|
||||
CHECK_EQ(m.b, 0u);
|
||||
m = ComputeDesignOptionMasks([](std::string_view) { return true; });
|
||||
CHECK_EQ(m.a, 0xffffffffu);
|
||||
CHECK_EQ(m.b, 0x1fffffffu);
|
||||
}
|
||||
|
||||
int main() {
|
||||
test_ids();
|
||||
test_industrial();
|
||||
test_biology();
|
||||
test_gates_and_casting();
|
||||
test_ai();
|
||||
test_flags_and_species();
|
||||
test_plague();
|
||||
test_xenotech();
|
||||
test_by_name();
|
||||
test_table_shape();
|
||||
return simtest::finish("test_effects");
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -41,12 +41,28 @@ static void test_research_points() {
|
|||
}
|
||||
|
||||
static void test_expenses() {
|
||||
std::vector<ExpenseSlider> s = {{100, 300, 250}, {50, 60, 100}};
|
||||
// minimums 150; extras 150 + 10 = 160; headroom 1000-150 = 850 -> 310
|
||||
CHECK_EQ(ExpenseTotal(s, 1000), 310);
|
||||
// headroom 200-150 = 50 -> 200
|
||||
CHECK_EQ(ExpenseTotal(s, 200), 200);
|
||||
std::vector<ExpenseSlider> s = {{100, 300, 0.5f}, {50, 60, 0.1f}};
|
||||
// s1: request ftol(0.5 x 1000) - 100 = 400, room 200 -> 200
|
||||
// s2: request 100 - 50 = 50, room 10 -> 10; minimums 150 + takes 210 = 360
|
||||
CHECK_EQ(ExpenseTotal(s, 1000), 360);
|
||||
// avail 200: s1 request 0, s2 request -30 -> 0; only the minimums
|
||||
CHECK_EQ(ExpenseTotal(s, 200), 150);
|
||||
// avail below the minimums: total is capped at avail
|
||||
CHECK_EQ(ExpenseTotal(s, 100), 100);
|
||||
CHECK_EQ(ExpenseTotal({}, 1000), 0);
|
||||
|
||||
// max 0 = unlimited
|
||||
CHECK_EQ(ExpenseTotal({{0, 0, 0.25f}}, 1000), 250);
|
||||
// a negative minimum counts as 0
|
||||
CHECK_EQ(ExpenseTotal({{-50, 100, 0.1f}}, 1000), 100);
|
||||
// min above max: the entry contributes its minimum, its (negative) take is absorbed
|
||||
CHECK_EQ(ExpenseTotal({{100, 50, 1.0f}}, 1000), 100);
|
||||
// takes are capped by what is left after the minimums
|
||||
CHECK_EQ(ExpenseTotal({{100, 0, 1.0f}, {200, 0, 1.0f}}, 1000), 1000);
|
||||
// the product is taken in single precision: 0.7f x 1000 = 699.99998 -> 699
|
||||
CHECK_EQ(ExpenseTotal({{0, 0, 0.7f}}, 1000), 699);
|
||||
// ... and the income itself is rounded to a float first
|
||||
CHECK_EQ(ExpenseTotal({{0, 0, 1.0f}}, 16777217), 16777216);
|
||||
}
|
||||
|
||||
static BudgetInputs base_inputs() {
|
||||
|
|
@ -134,9 +150,58 @@ static void test_budget_aid_and_bonus() {
|
|||
in = base_inputs();
|
||||
in.techIncomeMult = 1.1;
|
||||
b = ComputeBudget(in, false);
|
||||
// remaining before bonus = 3200 -> ftol(0.1 x 3200) = 320
|
||||
// full net before the bonus = 3200 -> ftol(0.1 x 3200) = 320
|
||||
CHECK_EQ(b.bonusIncome, 320);
|
||||
CHECK_EQ(b.net, 3520);
|
||||
|
||||
// the bonus reads the net *after* research aid was deducted
|
||||
in.aidResearchPercent = 50;
|
||||
b = ComputeBudget(in, false);
|
||||
CHECK_EQ(b.bonusIncome, 320); // 1600 kept + 1600 given: net unchanged
|
||||
in.aidResearchPercent = 0;
|
||||
|
||||
// a multiplier below 1 never takes money away
|
||||
in.techIncomeMult = 0.5;
|
||||
b = ComputeBudget(in, false);
|
||||
CHECK_EQ(b.bonusIncome, 0);
|
||||
CHECK_EQ(b.net, 3200);
|
||||
|
||||
// no bonus on a negative net
|
||||
in.techIncomeMult = 1.1;
|
||||
in.maintenance = 20000;
|
||||
b = ComputeBudget(in, false);
|
||||
CHECK_EQ(b.available, 0);
|
||||
CHECK_EQ(b.bonusIncome, 0);
|
||||
CHECK_EQ(b.net, -11100);
|
||||
|
||||
// savings aid is capped by the projected treasury, not by the turn net
|
||||
in = base_inputs();
|
||||
in.savings = -5000; // debt interest 750
|
||||
in.aidSavings = 100;
|
||||
b = ComputeBudget(in, false);
|
||||
// available 8000+1000-200-2000-750 = 6050; construction 500; research 2775; net 2775
|
||||
CHECK_EQ(b.researchMoney, 2775);
|
||||
CHECK_EQ(b.savingsGiven, 0); // -5000 + 2775 < 0: nothing to give
|
||||
CHECK_EQ(b.net, 2775);
|
||||
|
||||
in.savings = -2000; // debt interest 300
|
||||
in.aidSavings = 5000;
|
||||
b = ComputeBudget(in, false);
|
||||
// available 6500; construction 500; research 3000; net 3000; projected 1000
|
||||
CHECK_EQ(b.savingsGiven, 1000);
|
||||
CHECK_EQ(b.net, 2000);
|
||||
|
||||
in.savings = 10000;
|
||||
in.techIncomeMult = 1.1; // bonus 320 counts toward the projection
|
||||
in.aidSavings = 20000;
|
||||
b = ComputeBudget(in, false);
|
||||
CHECK_EQ(b.bonusIncome, 320);
|
||||
CHECK_EQ(b.savingsGiven, 13520); // 10000 + 3200 + 320: the whole projected treasury
|
||||
CHECK_EQ(b.net, -10000); // ... so the treasury ends the turn at 0
|
||||
|
||||
in.aidSavings = -5; // negative aid gives nothing
|
||||
b = ComputeBudget(in, false);
|
||||
CHECK_EQ(b.savingsGiven, 0);
|
||||
}
|
||||
|
||||
static void test_budget_edges() {
|
||||
|
|
@ -163,10 +228,11 @@ static void test_budget_edges() {
|
|||
CHECK_EQ(b.researchMoney, 0);
|
||||
|
||||
in = base_inputs();
|
||||
in.expenses = {{1000, 2000, 1500}};
|
||||
in.expenses = {{1000, 2000, 0.5f}};
|
||||
b = ComputeBudget(in, false);
|
||||
CHECK_EQ(b.expenses, 1500);
|
||||
CHECK_EQ(b.available, 5400);
|
||||
// pre-expense avail 6900: request 3450 - 1000 = 2450, room 1000 -> 2000 total
|
||||
CHECK_EQ(b.expenses, 2000);
|
||||
CHECK_EQ(b.available, 4900);
|
||||
}
|
||||
|
||||
static void test_trade() {
|
||||
|
|
@ -218,29 +284,85 @@ static void test_bankruptcy() {
|
|||
TuningTable t;
|
||||
t.BANKRUPTCY_PROTECTION_LIMIT_FACTOR = 3.3;
|
||||
t.BANKRUPTCY_ELIMINATION_TURNS = 5;
|
||||
// max income 1000: elimination at 1000 / -0.15 = -6666.67 -> -6666; protection -3300
|
||||
BankruptcyLimits l = ComputeBankruptcyLimits(1000, t);
|
||||
CHECK_EQ(l.eliminationFloor, -3300);
|
||||
CHECK_EQ(l.protectionLimit, -1000);
|
||||
CHECK_EQ(BankruptcyLevel(-3301, l), 2);
|
||||
CHECK_EQ(BankruptcyLevel(-3300, l), 1);
|
||||
CHECK_EQ(BankruptcyLevel(-1500, l), 1);
|
||||
CHECK_EQ(BankruptcyLevel(-1000, l), 0);
|
||||
CHECK_EQ(l.eliminationFloor, -6666);
|
||||
CHECK_EQ(l.protectionLimit, -3300);
|
||||
CHECK_EQ(BankruptcyLevel(-6667, l), 2);
|
||||
CHECK_EQ(BankruptcyLevel(-6666, l), 1);
|
||||
CHECK_EQ(BankruptcyLevel(-3301, l), 1);
|
||||
CHECK_EQ(BankruptcyLevel(-3300, l), 0);
|
||||
CHECK_EQ(BankruptcyLevel(0, l), 0);
|
||||
|
||||
l = ComputeBankruptcyLimits(100, t);
|
||||
CHECK_EQ(l.eliminationFloor, -666);
|
||||
CHECK_EQ(l.protectionLimit, -330);
|
||||
|
||||
// a factor beyond the interest break-even is pinned to the elimination floor
|
||||
TuningTable big = t;
|
||||
big.BANKRUPTCY_PROTECTION_LIMIT_FACTOR = 10.0;
|
||||
l = ComputeBankruptcyLimits(1000, big);
|
||||
CHECK_EQ(l.eliminationFloor, -6666);
|
||||
CHECK_EQ(l.protectionLimit, -6666);
|
||||
|
||||
// huge income: the floor saturates at the treasury limit
|
||||
l = ComputeBankruptcyLimits(400000000, t);
|
||||
CHECK_EQ(l.eliminationFloor, -2000000000);
|
||||
CHECK_EQ(l.protectionLimit, -1320000000);
|
||||
|
||||
BankruptcyLimits none = ComputeBankruptcyLimits(0, t);
|
||||
CHECK_EQ(none.eliminationFloor, 0);
|
||||
CHECK_EQ(none.protectionLimit, 0);
|
||||
CHECK_EQ(BankruptcyLevel(-1, none), 2); // no income at all: any debt is terminal
|
||||
|
||||
// Stamping happens on the transition; actions run on the previous state.
|
||||
BankruptcyState s;
|
||||
CHECK(!BankruptcyStep(s, 1, 10, t));
|
||||
CHECK_EQ(s.startTurn, -1);
|
||||
BankruptcyDecision d = BankruptcyStep(s, 1, 10, t);
|
||||
CHECK(!d.costCutting); // first turn at level 1: not yet
|
||||
CHECK(!d.eliminate);
|
||||
CHECK_EQ(s.warningLevel, 1);
|
||||
CHECK_EQ(s.startTurn, 10);
|
||||
CHECK(!BankruptcyStep(s, 2, 12, t)); // level changed: clock restarts at 12
|
||||
d = BankruptcyStep(s, 1, 11, t);
|
||||
CHECK(d.costCutting); // second turn: cost cutting
|
||||
CHECK_EQ(s.startTurn, 10); // no restamp while the level holds
|
||||
d = BankruptcyStep(s, 2, 12, t); // 1 -> 2 restamps the clock at 12
|
||||
CHECK(d.costCutting);
|
||||
CHECK(!d.eliminate);
|
||||
CHECK_EQ(s.startTurn, 12);
|
||||
CHECK(!BankruptcyStep(s, 2, 16, t)); // 4 turns < 5
|
||||
CHECK(BankruptcyStep(s, 2, 17, t)); // 5 turns -> eliminated
|
||||
CHECK(!BankruptcyStep(s, 0, 18, t)); // recovered
|
||||
d = BankruptcyStep(s, 2, 16, t);
|
||||
CHECK(!d.eliminate); // 4 turns < 5
|
||||
d = BankruptcyStep(s, 2, 17, t);
|
||||
CHECK(d.eliminate); // 5 turns -> eliminated
|
||||
d = BankruptcyStep(s, 0, 18, t); // recovering on the turn after the clock
|
||||
CHECK(!d.costCutting);
|
||||
CHECK(d.eliminate); // ... still acts on the old level-2 state
|
||||
CHECK_EQ(s.warningLevel, 0);
|
||||
CHECK_EQ(s.startTurn, -1);
|
||||
|
||||
// 2 -> 1 -> 2 restarts the clock each time
|
||||
BankruptcyState r;
|
||||
BankruptcyStep(r, 2, 20, t);
|
||||
CHECK_EQ(r.startTurn, 20);
|
||||
d = BankruptcyStep(r, 1, 22, t);
|
||||
CHECK(d.costCutting);
|
||||
CHECK_EQ(r.startTurn, 22);
|
||||
d = BankruptcyStep(r, 2, 25, t);
|
||||
CHECK(!d.eliminate); // old level was 1
|
||||
CHECK_EQ(r.startTurn, 25);
|
||||
d = BankruptcyStep(r, 2, 29, t);
|
||||
CHECK(!d.eliminate);
|
||||
d = BankruptcyStep(r, 2, 30, t);
|
||||
CHECK(d.eliminate);
|
||||
|
||||
// even with a zero-turn limit, elimination happens the turn after level 2 is reached
|
||||
TuningTable instant = t;
|
||||
instant.BANKRUPTCY_ELIMINATION_TURNS = 0;
|
||||
BankruptcyState q;
|
||||
d = BankruptcyStep(q, 2, 5, instant);
|
||||
CHECK(!d.eliminate);
|
||||
d = BankruptcyStep(q, 2, 6, instant);
|
||||
CHECK(d.eliminate);
|
||||
}
|
||||
|
||||
int main() {
|
||||
|
|
|
|||
|
|
@ -30,11 +30,76 @@ static void test_steps() {
|
|||
CHECK_NEAR(NodeLineSpeed(10, 50, t), 6.0, 1e-12); // 10 x (0.8 x 0.5 + 0.2)
|
||||
CHECK_NEAR(NodeLineSpeed(10, 0, t), 2.0, 1e-12); // at a system: min profile
|
||||
CHECK_NEAR(NodeLineSpeed(10, 100, t), 10.0, 1e-12); // at the radius: max profile
|
||||
CHECK_NEAR(NodeLineSpeed(10, 200, t), 10.0, 1e-12); // beyond: clamped
|
||||
CHECK_NEAR(NodeLineSpeed(10, 200, t), 18.0, 1e-12); // no clamp (never reached in practice)
|
||||
TuningTable zero;
|
||||
CHECK_NEAR(NodeLineSpeed(10, 50, zero), 0.0, 0.0); // no tuning -> no speed
|
||||
}
|
||||
|
||||
static void test_stutter_segments() {
|
||||
CHECK_NEAR(DistPointToSegment({5, 3, 0}, {0, 0, 0}, {10, 0, 0}), 3.0, 1e-12);
|
||||
CHECK_NEAR(DistPointToSegment({-5, 3, 0}, {0, 0, 0}, {10, 0, 0}), std::sqrt(34.0), 1e-12);
|
||||
CHECK_NEAR(DistPointToSegment({15, 0, 0}, {0, 0, 0}, {10, 0, 0}), 5.0, 1e-12);
|
||||
CHECK_NEAR(DistPointToSegment({3, 4, 0}, {1, 1, 1}, {1, 1, 1}), std::sqrt(4 + 9 + 1), 1e-12);
|
||||
|
||||
TuningTable t;
|
||||
t.STUTTER_SYSTEM_INFLUENCE_RADIUS = 50;
|
||||
t.STUTTER_MIN_SPEED = 0.2;
|
||||
t.STUTTER_MAX_SPEED = 1.0;
|
||||
const Vec3 from{0, 0, 0}, to{100, 0, 0};
|
||||
|
||||
// One system 30 off the line: chord where (x-50)^2 + 900 <= 2500 -> x in [10, 90]
|
||||
std::vector<StutterSegment> s = BuildStutterSegments(from, to, {{50, 30, 0}}, t);
|
||||
CHECK_EQ(s.size(), std::size_t{1});
|
||||
CHECK_NEAR(s[0].start, 10.0, 1e-9);
|
||||
CHECK_NEAR(s[0].end, 90.0, 1e-9);
|
||||
CHECK_EQ(s[0].systemIndex, 0);
|
||||
CHECK_NEAR(s[0].speedFactor, 0.8 * 30 / 50 + 0.2, 1e-12); // 0.68 for the whole chord
|
||||
|
||||
// Out of reach, and exactly tangent (zero-length chord): no segments
|
||||
CHECK(BuildStutterSegments(from, to, {{50, 200, 0}}, t).empty());
|
||||
CHECK(BuildStutterSegments(from, to, {{50, 50, 0}}, t).empty());
|
||||
|
||||
// Second system on the line near the end: chord [35, 135] clipped to [35, 100];
|
||||
// the overlap with [10, 90] is split at 62.5.
|
||||
s = BuildStutterSegments(from, to, {{50, 30, 0}, {85, 0, 0}}, t);
|
||||
CHECK_EQ(s.size(), std::size_t{2});
|
||||
CHECK_NEAR(s[0].start, 10.0, 1e-9);
|
||||
CHECK_NEAR(s[0].end, 62.5, 1e-9);
|
||||
CHECK_EQ(s[0].systemIndex, 0);
|
||||
CHECK_NEAR(s[0].speedFactor, 0.68, 1e-12);
|
||||
CHECK_NEAR(s[1].start, 62.5, 1e-9);
|
||||
CHECK_NEAR(s[1].end, 100.0, 1e-9);
|
||||
CHECK_EQ(s[1].systemIndex, 1);
|
||||
CHECK_NEAR(s[1].speedFactor, 0.2, 1e-12); // system on the line: min
|
||||
|
||||
// Input order does not matter: segments come back sorted
|
||||
std::vector<StutterSegment> r = BuildStutterSegments(from, to, {{85, 0, 0}, {50, 30, 0}}, t);
|
||||
CHECK_EQ(r.size(), std::size_t{2});
|
||||
CHECK_EQ(r[0].systemIndex, 1);
|
||||
CHECK_EQ(r[1].systemIndex, 0);
|
||||
|
||||
// A chord contained in an earlier one is split at the midpoint of the overlap
|
||||
s = BuildStutterSegments(from, to, {{50, 0, 0}, {50, 40, 0}}, t); // [0,100] and [20,80]
|
||||
CHECK_EQ(s.size(), std::size_t{2});
|
||||
CHECK_NEAR(s[0].end, 60.0, 1e-9);
|
||||
CHECK_NEAR(s[1].start, 60.0, 1e-9);
|
||||
CHECK_NEAR(s[1].end, 80.0, 1e-9);
|
||||
|
||||
TuningTable zero;
|
||||
CHECK(BuildStutterSegments(from, to, {{50, 0, 0}}, zero).empty()); // no radius: plain line
|
||||
|
||||
// Advance along the [10, 90] x0.68 profile at node speed 20
|
||||
s = BuildStutterSegments(from, to, {{50, 30, 0}}, t);
|
||||
// half a turn to reach 10 at speed 20, then 0.5 x 13.6
|
||||
CHECK_NEAR(AdvanceAlongNodeLine(0, 1.0, 20, 100, s), 16.8, 1e-9);
|
||||
// from 85: 5 units at 13.6 take 0.36765 turns; the rest at 20
|
||||
CHECK_NEAR(AdvanceAlongNodeLine(85, 0.5, 20, 100, s), 90.0 + (0.5 - 5.0 / 13.6) * 20.0, 1e-9);
|
||||
CHECK_NEAR(AdvanceAlongNodeLine(0, 10.0, 20, 100, s), 100.0, 0.0); // never past the end
|
||||
CHECK_NEAR(AdvanceAlongNodeLine(0, 1.0, 20, 100, {}), 20.0, 1e-12); // no spheres: plain speed
|
||||
CHECK_NEAR(AdvanceAlongNodeLine(30, 1.0, 0, 100, s), 30.0, 0.0); // no speed: no movement
|
||||
CHECK_NEAR(AdvanceAlongNodeLine(30, 0.0, 20, 100, s), 30.0, 0.0);
|
||||
}
|
||||
|
||||
static void test_resolve() {
|
||||
MoveStepResult r = ResolveMoveStep(5, 10, 20);
|
||||
CHECK_NEAR(r.moved, 5.0, 0.0);
|
||||
|
|
@ -132,6 +197,7 @@ static void test_jump() {
|
|||
int main() {
|
||||
test_vectors();
|
||||
test_steps();
|
||||
test_stutter_segments();
|
||||
test_resolve();
|
||||
test_multi_waypoint_turn();
|
||||
test_jump();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue