diff --git a/docs/game-sim.md b/docs/game-sim.md new file mode 100644 index 0000000..eb4f589 --- /dev/null +++ b/docs/game-sim.md @@ -0,0 +1,107 @@ +# game/sim — strategic-layer formula catalog + +Module: `src/game/sim/`. Pure functions over plain structs — no game state, no I/O. +Every RNG-consuming roll takes an `IRandom&` (`rng.h`); the engine injects its +MT19937-compatible server generator, tests inject a scripted sequence. Tuning constants +come in through a `TuningTable` (`tuning.h`) whose fields are the data-file keys verbatim; +nothing in the module hard-codes a shipped value. + +Numeric conventions (`numeric.h`): `Ftol`/`Ftoi64` truncate toward zero like the original +float-to-int helper; `RoundToInt` rounds half away from zero; the treasury is a saturating +32-bit int clamped to +/-2,000,000,000. + +Build/test: `tests/game_sim/build_and_run.sh` (plain g++, `-Wall -Wextra -Werror`), or +`-DSOTS_GAME_SIM_TESTS=ON` once `src/game/sim` is added to the root CMake. The real-data +smoke test runs only when `SOTS_SAVES_JSON` points at a `save_reader.py --json` dump and +asserts nothing (compare-mode is future work). + +Confidence legend — **high**: formula verified in the RE notes against the code path; +**medium**: shape and inputs established, one term or the truncation order inferred; +**low**: shape only, marked `CONFIDENCE: low — see sots-re open questions` in the header. + +## Species (`species.h`) + +| item | formula / rule | confidence | +|---|---|---| +| enum order | `Human 0, Hiver 1, Tarkas 2, Liir 3, NPC 4, Zuul 5, Morrigi 6` — index of every per-species table | high | + +## Economy (`economy.h`) + +| function | formula | confidence | +|---|---|---| +| `SavingsInterest` | `Sav >= 0 && ownsSystems ? ftol(Sav x 0.01) : 0` | high | +| `DebtInterest` | `Sav < 0 ? ftol(-Sav x 0.15) : 0` | high | +| `MaintenanceCost` | `Maint / ftol(difficultyDivisor)` | high | +| `ExpenseTotal` | `sum(min) + min(sum(clamp(req - min, 0, max - min)), availBefore - sum(min))` | medium — the per-entry request term is unresolved | +| `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 | +| `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 | +| `SaturatingAdd` | clamp to ±2e9 | high | + +## Research (`research.h`) + +| function | formula | confidence | +|---|---|---| +| `EdgeAvailability` | per-species chance parsed as pct/100; unlisted species default 1.0; explicit 0 excludes | high | +| `RollEdgeAvailable` | include iff `mode == Everything` or `(p > 0 && (mode == NoRoll || p >= 1 || rand01() <= p))`; one draw only when 0 < p < 1 in Normal mode | high | +| `TechCostMultiplier` | `max(0.25, 1 − 0.25 x n)` | medium — which three species techs count is unresolved | +| `TechCost` | `INT_MAX` stays; else `max(1, ftol(base x mult))` | high | +| `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 | +| `RollLabAccident` | `randint(100) < odds` | medium — odds-from-boost function unresolved (caller supplies odds) | +| `LabAccidentLossPercent` | `ceil(clamp01(rand01() x (max − min) + min) x 100)` | high | + +## Colonies (`colony.h`) + +| function | formula | confidence | +|---|---|---| +| `HazardModifierShape` | placeholder: `clamp01(1 − |suit − ideal| / tolerance)` | **low** — inputs known, curve shape not | +| `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 | +| `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 | +| `TotalSystemOutput` | `round(base x morale x (1 + STATION_BONUS_IMPERIAL_OUTPUT x stations) x addiction x ScOutMod x RebOutMod x techOut x sys.OutMod x OutMod)` | high on the chain; the base-from-population term is an input (unresolved) | +| `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 | +| `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 | +| `ProcessBuildQueue` | FIFO: `points < conleft → conleft −= points, stop`; else complete, `points −= conleft`, charge money cost, continue | high | + +## Movement (`movement.h`) + +| function | formula | confidence | +|---|---|---| +| 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) | +| `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 | +| `RollProbabilisticJump` | `roll = rand01() x castEfficiency`; `roll > castThreshold` → stop at fraction `roll`; else arrive | medium — identity of the two player fields inferred | + +## 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. + +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 +research-boost → accident-odds function, the fleet speed (`FPsp2`) derivation from +engine `ftlspeed`/`nodespeed`, gate traffic capacity. diff --git a/src/game/sim/CMakeLists.txt b/src/game/sim/CMakeLists.txt new file mode 100644 index 0000000..e0dc2dd --- /dev/null +++ b/src/game/sim/CMakeLists.txt @@ -0,0 +1,28 @@ +# Strategic-layer formulas as pure functions (no game state, no I/O). +# Not yet wired into the root CMakeLists; add_subdirectory(src/game/sim) when the +# host build grows a game target. tests/game_sim/build_and_run.sh builds the same +# sources with plain g++ in the meantime. +add_library(sots_game_sim STATIC + economy.cpp + research.cpp + colony.cpp + movement.cpp) +target_include_directories(sots_game_sim PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/../..) +target_compile_features(sots_game_sim PUBLIC cxx_std_17) +if(NOT MSVC) + target_compile_options(sots_game_sim PRIVATE -Wall -Wextra) +endif() + +option(SOTS_GAME_SIM_TESTS "Build the game/sim unit tests" OFF) +if(SOTS_GAME_SIM_TESTS) + enable_testing() + set(_sim_tests economy research colony movement) + foreach(_t IN LISTS _sim_tests) + add_executable(game_sim_test_${_t} ${CMAKE_CURRENT_SOURCE_DIR}/../../../tests/game_sim/test_${_t}.cpp) + target_link_libraries(game_sim_test_${_t} PRIVATE sots_game_sim) + add_test(NAME game_sim_${_t} COMMAND game_sim_test_${_t}) + endforeach() + add_executable(game_sim_smoke_save ${CMAKE_CURRENT_SOURCE_DIR}/../../../tests/game_sim/smoke_real_save.cpp) + target_link_libraries(game_sim_smoke_save PRIVATE sots_game_sim) + add_test(NAME game_sim_smoke_save COMMAND game_sim_smoke_save) +endif() diff --git a/src/game/sim/colony.cpp b/src/game/sim/colony.cpp new file mode 100644 index 0000000..bd70c67 --- /dev/null +++ b/src/game/sim/colony.cpp @@ -0,0 +1,232 @@ +#include "game/sim/colony.h" + +#include +#include + +#include "game/sim/numeric.h" + +namespace sots::sim { + +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); +} + +std::int64_t CarryingCapacity(const CapacityInputs& in, const TuningTable& t) { + if (IsNpcSpecies(in.species)) return 0; + if (!in.speciesCanLive) return 0; + + double cap = static_cast(in.planetSize) * 1e8 * in.groupCapacityMult * + in.speciesGrowthFactor * + (in.ownerIsDifferentSpecies ? in.crossSpeciesMod : 1.0) * in.hazardMod; + std::int64_t result = Ftoi64(cap); + if (in.arcologyTech) { + if (in.group == PopGroup::Imperial) result += 100000000; + else if (in.group == PopGroup::Civilian) result += 200000000; + } + if (in.groupMaxEnabled) result = std::min(result, in.groupMax); + if (in.ownerIsNpc) result = Ftoi64(static_cast(result) * t.INDSYS_IMPERIAL_POPULATION_MOD); + return result; +} + +std::int64_t PopulationGrowthDelta(const GrowthInputs& in, const TuningTable& t) { + if (in.blockaded) return 0; + if (in.capacity <= 0) return 0; + const double fill = Clamp01(static_cast(in.pop) / static_cast(in.capacity)); + double g = Clamp01(std::pow(1.0 - fill, t.POPULATION_GROWTH_EXP)); + if (g > 0) { + g *= t.POPULATION_GROWTH_MOD; + g *= in.playerPopMod; + g *= in.hazardSpeciesFactor; + g *= in.flaggedByPlayer ? in.flaggedFactor : 1.0; + if (in.groupGrowthMult > 0) g *= in.groupGrowthMult; + } + std::int64_t delta = Ftoi64(static_cast(in.pop) * g); + if (g > 0) delta = std::max(1, delta); + return std::min(delta, kMaxPopStep); +} + +std::int64_t ApplyImperialGrowth(std::int64_t pop, std::int64_t capacity, std::int64_t delta) { + if (pop > capacity) { + const std::int64_t floor = std::min(pop, 100); + const std::int64_t shrunk = pop - std::min(kMaxPopStep, pop - capacity); + return std::max(shrunk, floor); + } + return std::min(capacity, pop + delta); +} + +int InfrastructurePointsNeeded(double infra) { + if (infra >= 1.0) return 0; + return static_cast(std::ceil((1.0 - infra) / kInfraPerPoint)); +} + +double InfrastructureGain(int points) { + return (static_cast(points) / 500.0) * 0.01 * 1.65; +} + +double ApplyInfrastructurePoints(double infra, int pool, int* pointsUsed) { + const int spend = std::max(0, std::min(pool, InfrastructurePointsNeeded(infra))); + if (pointsUsed) *pointsUsed = spend; + return infra + InfrastructureGain(spend); +} + +double DecayUnownedInfrastructure(double infra) { + return std::max(0.0, infra - 0.02); +} + +int TerraformPointsNeeded(double suit, double ideal) { + return Ftol(std::fabs(ideal - suit) / kTerraformPerPoint); +} + +double TerraformDelta(int points, double terraMod, double suit, double ideal) { + const double sign = suit > ideal ? -1.0 : 1.0; + return static_cast(points) * 1.5 * 1.2 * terraMod * sign / 20000.0; +} + +double SlaveDeathRate(double slaveOutputRate, double suit, double ideal, + const SpeciesTechFlags& flags, const TuningTable& t) { + 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; + return base * mod; +} + +std::int64_t SlaveDeaths(std::int64_t slaves, double rate, const TuningTable& t) { + std::int64_t d = Ftoi64(static_cast(slaves) * rate); + d = std::max(d, t.SLAVES_MIN_DEATHS); + if (t.SLAVES_MAX_DEATHS >= 0) d = std::min(d, t.SLAVES_MAX_DEATHS); + return std::max(0, std::min(d, slaves)); +} + +OutputRates NormaliseOutputRates(const OutputRates& raw, bool suitAtIdeal, bool infraFull) { + OutputRates r; + r.trade = std::max(0.0, raw.trade); + r.construction = std::max(0.0, raw.construction); + r.terraform = suitAtIdeal ? 0.0 : std::max(0.0, raw.terraform); + r.infra = infraFull ? 0.0 : std::max(0.0, raw.infra); + const double sum = r.trade + r.construction + r.terraform + r.infra; + if (sum <= 0) { + r.trade = r.construction = r.terraform = r.infra = 0.25; + return r; + } + r.trade /= sum; + r.construction /= sum; + r.terraform /= sum; + r.infra /= sum; + return r; +} + +double MoraleOutputMultiplier(int morale, const TuningTable& t) { + if (morale >= t.MORALE_INCREASE_OUTPUT) return t.MORALE_INCREASE_OUTPUT_MOD; + if (morale <= t.MORALE_DECREASE_OUTPUT) return t.MORALE_DECREASE_OUTPUT_MOD; + return 1.0; +} + +int TotalSystemOutput(const OutputModifiers& m, const TuningTable& t) { + double v = m.baseOutput; + v *= MoraleOutputMultiplier(m.morale, t); + v *= 1.0 + t.STATION_BONUS_IMPERIAL_OUTPUT * m.stations; + if (m.addictionPhase3) v *= t.ADDICTION_OUTPUT_MOD; + v *= m.scOutMod * m.rebOutMod * m.techOutMod * m.systemOutMod * m.playerOutMod; + return RoundToInt(v); +} + +OutputSplit SplitOutput(int total, const OutputRates& rates) { + OutputSplit s; + s.trade = RoundToInt(total * rates.trade); + s.construction = RoundToInt(total * rates.construction); + s.terraform = RoundToInt(total * rates.terraform); + s.infra = RoundToInt(total * rates.infra); + return s; +} + +int ConstructionPoints(int constructionShare, int stations, const TuningTable& t) { + return RoundToInt(constructionShare * (1.0 + t.STATION_BONUS_SHIPCON * stations)); +} + +OutputSplit SplitLeftover(int leftover, const OutputRates& rates, bool suitAtIdeal, bool infraFull) { + double wt, wf, wi; + if (rates.construction >= 1.0) { + wt = 1.0; + wf = suitAtIdeal ? 0.0 : 1.0; + wi = infraFull ? 0.0 : 1.0; + } else { + wt = std::max(0.0, rates.trade); + wf = std::max(0.0, rates.terraform); + wi = std::max(0.0, rates.infra); + } + const double sum = wt + wf + wi; + OutputSplit s; + if (sum <= 0 || leftover <= 0) { + s.trade = std::max(0, leftover); + return s; + } + s.trade = RoundToInt(leftover * wt / sum); + s.terraform = RoundToInt(leftover * wf / sum); + s.infra = RoundToInt(leftover * wi / sum); + return s; +} + +int SystemMoneyIncomeShape(int tradePoints, double speciesIncomeFactor, double playerIncomeMult, + double playerCostTerm) { + return Ftol(static_cast(tradePoints) * speciesIncomeFactor * playerIncomeMult - playerCostTerm); +} + +void ApplyPopulationBonus(std::int64_t& pop, std::int64_t capacity, std::int64_t& pendingBonus) { + const std::int64_t room = std::max(0, capacity - pop); + const std::int64_t applied = std::max(0, std::min(pendingBonus, room)); + pop += applied; + pendingBonus -= applied; +} + +void ApplyInfrastructureBonus(double& infra, double& pendingBonus) { + const double applied = std::max(0.0, std::min(pendingBonus, 1.0 - infra)); + infra += applied; + pendingBonus -= applied; +} + +void AccrueSystemBonus(const SystemBonusInputs& in, std::int64_t& popBonus, double& infraBonus, + const TuningTable& t) { + if (!in.stable) return; + 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 std::int64_t popInc = Ftoi64(static_cast(in.capacity) * t.SYSTEMBONUS_POPBONUS_INC); + const std::int64_t popRoom = Ftoi64(static_cast(in.capacity) * popCapMult) - popBonus; + popBonus += std::max(0, std::min(popInc, popRoom)); + + const double infraRoom = infraCap - infraBonus; + infraBonus += std::max(0.0, std::min(t.SYSTEMBONUS_INFRABONUS_INC, infraRoom)); +} + +BuildQueueResult ProcessBuildQueue(std::vector& queue, int points) { + BuildQueueResult r; + std::size_t completed = 0; + for (BuildOrder& o : queue) { + if (points < o.constructionLeft) { + o.constructionLeft -= points; + points = 0; + break; + } + points -= o.constructionLeft; + o.constructionLeft = 0; + if (o.moneyCost > 0) r.moneyCharged = SaturatingAdd(r.moneyCharged, o.moneyCost); + r.completedOrderIds.push_back(o.orderId); + ++completed; + } + queue.erase(queue.begin(), queue.begin() + static_cast(completed)); + r.pointsLeft = points; + return r; +} + +} // namespace sots::sim diff --git a/src/game/sim/colony.h b/src/game/sim/colony.h new file mode 100644 index 0000000..a22684d --- /dev/null +++ b/src/game/sim/colony.h @@ -0,0 +1,238 @@ +// Colonies: carrying capacity, population growth, infrastructure and terraforming +// point conversion, slave deaths, output split, build-queue consumption. +#pragma once + +#include +#include + +#include "game/sim/species.h" +#include "game/sim/tuning.h" + +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). +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 +}; + +// --------------------------------------------------------------------------------------- +// Capacity +// --------------------------------------------------------------------------------------- + +// 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); + +struct CapacityInputs { + int planetSize = 0; // Size + Species species = Species::Human; // species of the population group + bool speciesCanLive = true; // species can survive on this planet class + PopGroup group = PopGroup::Imperial; + double groupCapacityMult = 1.0; // per-group-type capacity column + 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) + bool arcologyTech = false; // adds a flat 1e8 (imperial) / 2e8 (civilian) + bool groupMaxEnabled = false; // per-group hard cap present + std::int64_t groupMax = 0; + bool ownerIsNpc = false; // owner species is the independent race +}; + +// cap = ftoi64(Size x 1e8 x groupMult x speciesFactor x crossSpecies x hazard) +// + arcology flat bonus; clamped to the group max; x INDSYS_IMPERIAL_POPULATION_MOD +// when the owner is the NPC race. The NPC species itself, and a species that cannot +// live there, get 0. CONFIDENCE: high on the shape and the special cases. +std::int64_t CarryingCapacity(const CapacityInputs& in, const TuningTable& t); + +// --------------------------------------------------------------------------------------- +// Growth +// --------------------------------------------------------------------------------------- + +struct GrowthInputs { + std::int64_t pop = 0; + std::int64_t capacity = 0; + bool blockaded = false; // growth halted at this system + double playerPopMod = 1.0; // PopMod + double hazardSpeciesFactor = 1.0; + double groupGrowthMult = 0.0; // per-group-type growth column; applied only if > 0 + bool flaggedByPlayer = false; // system in the player's special-growth mask + double flaggedFactor = 1.0; // replaces the implicit 1 for flagged systems +}; + +// Logistic growth fraction and the resulting delta: +// g = clamp01( (1 - clamp01(pop/cap)) ^ POPULATION_GROWTH_EXP ) +// if g > 0: g x= POPULATION_GROWTH_MOD x PopMod x hazard/species factor +// x groupGrowthMult (if > 0) +// delta = ftoi64(pop x g), at least 1 when g > 0, at most 50,000,000. +// A blockaded system yields 0. CONFIDENCE: high. +std::int64_t PopulationGrowthDelta(const GrowthInputs& in, const TuningTable& t); + +// Apply growth to an imperial population: grow toward the cap, or if already over the +// cap shrink by at most 50,000,000 per turn but never below min(previous pop, 100). +// CONFIDENCE: medium (the shrink floor is read from a terse note). +std::int64_t ApplyImperialGrowth(std::int64_t pop, std::int64_t capacity, std::int64_t delta); + +// --------------------------------------------------------------------------------------- +// Infrastructure and terraforming +// --------------------------------------------------------------------------------------- + +constexpr double kInfraPerPoint = 3.3e-5; // (1/500) x 0.01 x 1.65 +constexpr double kTerraformPerPoint = 1.5 * 1.2 / 20000.0; + +// Points needed to bring infrastructure from `infra` to 1.0. CONFIDENCE: high. +int InfrastructurePointsNeeded(double infra); + +// Infrastructure gained from spending `points`: points x 3.3e-5. CONFIDENCE: high. +double InfrastructureGain(int points); + +// Spend from a pool toward full infrastructure; returns the new value and reports the +// points used. CONFIDENCE: high. +double ApplyInfrastructurePoints(double infra, int pool, int* pointsUsed); + +// Unowned systems lose 0.02 infrastructure per turn, floored at 0. CONFIDENCE: high. +double DecayUnownedInfrastructure(double infra); + +// Points needed to terraform from `suit` to `ideal`: |ideal - suit| / (1.8 / 20000). +// CONFIDENCE: high. +int TerraformPointsNeeded(double suit, double ideal); + +// Suitability change from spending `points`: points x 1.8 x TerraMod / 20000, signed +// toward the ideal. CONFIDENCE: high. +double TerraformDelta(int points, double terraMod, double suit, double ideal); + +// --------------------------------------------------------------------------------------- +// Slaves +// --------------------------------------------------------------------------------------- + +// 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 +// CONFIDENCE: high. +double SlaveDeathRate(double slaveOutputRate, double suit, double ideal, + const SpeciesTechFlags& flags, const TuningTable& t); + +// Deaths = clamp(ftoi64(slaves x rate), MIN_DEATHS, MAX_DEATHS); MAX -1 means no cap. +// Never more than the slaves present. CONFIDENCE: high. +std::int64_t SlaveDeaths(std::int64_t slaves, double rate, const TuningTable& t); + +// --------------------------------------------------------------------------------------- +// Output split +// --------------------------------------------------------------------------------------- + +struct OutputRates { + double trade = 0; // SRt + double construction = 0; // SRsc + double terraform = 0; // SRtf + double infra = 0; // SRi +}; + +// Normalise the player's output sliders: negatives -> 0; terraform -> 0 when the planet +// is at the ideal; infra -> 0 when infra + pending bonus >= 1; rescale to sum 1, or an +// equal split when everything is zero. CONFIDENCE: high (the small positive threshold +// below which a slider counts as zero is treated as 0). +OutputRates NormaliseOutputRates(const OutputRates& raw, bool suitAtIdeal, bool infraFull); + +struct OutputModifiers { + double baseOutput = 0; // population-derived base (its own formula is unresolved) + int morale = 0; + int stations = 0; // stations at the system + bool addictionPhase3 = false; + double scOutMod = 1.0; // ScOutMod + double rebOutMod = 1.0; // RebOutMod + double techOutMod = 1.0; // tech-effect output multiplier + double systemOutMod = 1.0; // sys.OutMod + double playerOutMod = 1.0; // OutMod +}; + +// Morale effect on output: above the increase threshold x INCREASE_MOD, at or below the +// decrease threshold x DECREASE_MOD, otherwise x1. CONFIDENCE: high. +double MoraleOutputMultiplier(int morale, const TuningTable& t); + +// total = round(base x morale x (1 + STATION_BONUS_IMPERIAL_OUTPUT x stations) +// x addiction x ScOutMod x RebOutMod x techOut x sys.OutMod x OutMod) +// CONFIDENCE: high on the multiplier chain; the base-output-from-population term is an +// input here because its own formula is not resolved. +int TotalSystemOutput(const OutputModifiers& m, const TuningTable& t); + +struct OutputSplit { + int trade = 0; + int construction = 0; + int terraform = 0; + int infra = 0; +}; + +// Round(total x rate) for each channel. CONFIDENCE: high. +OutputSplit SplitOutput(int total, const OutputRates& rates); + +// Construction points after the shipyard station bonus. CONFIDENCE: high. +int ConstructionPoints(int constructionShare, int stations, const TuningTable& t); + +// Redistribute unspent construction points L over trade/terraform/infra. Weights are the +// normalised rates of those three channels, or 1 / (suit != ideal) / (infra != 1) when +// 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); + +// --------------------------------------------------------------------------------------- +// System bonus and build queue +// --------------------------------------------------------------------------------------- + +// Apply a pending population bonus: pop += min(bonus, cap - pop); bonus -= same. +// CONFIDENCE: high. +void ApplyPopulationBonus(std::int64_t& pop, std::int64_t capacity, std::int64_t& pendingBonus); + +// Apply a pending infrastructure bonus: infra += min(bonus, 1 - infra); bonus -= same. +// CONFIDENCE: high. +void ApplyInfrastructureBonus(double& infra, double& pendingBonus); + +struct SystemBonusInputs { + bool stable = false; + int turnsOwned = 0; // current turn - acquisition turn + int turnsSinceRebellion = 0; + bool homeSystem = false; + std::int64_t capacity = 0; +}; + +// 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). +void AccrueSystemBonus(const SystemBonusInputs& in, std::int64_t& popBonus, double& infraBonus, + const TuningTable& t); + +struct BuildOrder { + int designId = 0; + int orderId = 0; + int constructionCost = 0; // con + int constructionLeft = 0; // conleft + int moneyCost = 0; // charged to the owner on completion (0 = free) +}; + +struct BuildQueueResult { + std::vector completedOrderIds; + int moneyCharged = 0; + int pointsLeft = 0; // construction points not consumed by any order +}; + +// FIFO consumption of construction points: an order that needs more than what is left +// absorbs everything and stops the pass; otherwise it completes, its remaining need is +// deducted and the next order is considered. Completed orders are removed. +// CONFIDENCE: high. +BuildQueueResult ProcessBuildQueue(std::vector& queue, int points); + +} // namespace sots::sim diff --git a/src/game/sim/economy.cpp b/src/game/sim/economy.cpp new file mode 100644 index 0000000..cb1f8cf --- /dev/null +++ b/src/game/sim/economy.cpp @@ -0,0 +1,164 @@ +#include "game/sim/economy.h" + +#include +#include + +#include "game/sim/numeric.h" + +namespace sots::sim { + +int SavingsInterest(int savings, bool ownsSystems) { + if (savings < 0 || !ownsSystems) return 0; + return Ftol(static_cast(savings) * 0.01); +} + +int DebtInterest(int savings) { + if (savings >= 0) return 0; + return Ftol(-static_cast(savings) * 0.15); +} + +int MaintenanceCost(int maintenance, double difficultyDivisor) { + const int d = Ftol(difficultyDivisor); + if (d == 0) return maintenance; + return maintenance / d; +} + +int ExpenseTotal(const std::vector& sliders, int availableBeforeExpenses) { + std::int64_t minimums = 0; + std::int64_t extras = 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 std::int64_t headroom = static_cast(availableBeforeExpenses) - minimums; + const std::int64_t total = minimums + std::min(extras, headroom); + return static_cast(ClampT(total, -2147483648LL, 2147483647LL)); +} + +int ResearchPointsFromMoney(int researchMoney, double difficultyMult, double resMod, + double shrm, double trm, double techMult, double serverResMod, + double resScl) { + const double base = (static_cast(researchMoney) / 50.0) * 1.15 * 0.5 * 0.85; + const double rp = difficultyMult * base * (resMod + shrm + trm) * techMult * serverResMod * resScl; + return Ftol(rp); +} + +Budget ComputeBudget(const BudgetInputs& in, bool projected) { + Budget b; + + b.savingsInterest = SavingsInterest(in.savings, in.ownsSystems); + b.debtInterest = DebtInterest(in.savings); + + for (int inc : in.systemIncome) { + if (inc >= 0) b.systemIncomePositive = SaturatingAdd(b.systemIncomePositive, inc); + else b.systemIncomeNegative = SaturatingAdd(b.systemIncomeNegative, -inc); + } + b.tradeIncome = in.tradeIncome; + b.secondaryManagerIncome = in.secondaryManagerIncome; + b.shipCarriedPopIncome = in.shipCarriedPopIncome; + b.maintenance = MaintenanceCost(in.maintenance, in.maintenanceDivisor); + + // Running total of the fixed lines; the slots filled later start at zero. + auto running = [&]() -> std::int64_t { + return static_cast(b.systemIncomePositive) + b.tradeIncome + + b.shipCarriedPopIncome + b.secondaryManagerIncome + b.savingsInterest + + b.bonusIncome - b.systemIncomeNegative - b.maintenance - b.researchMoneyKept - + b.debtInterest - b.construction - b.expenses - b.researchMoneyGiven - + b.savingsGiven; + }; + + b.expenses = ExpenseTotal(in.expenses, static_cast(std::max(0, running()))); + b.available = static_cast(std::max(0, running())); + + if (!in.isAI && b.available > 0) { + b.construction = std::min(std::max(0, in.constructionDemand), b.available); + } + const int availAfterConstruction = b.available - b.construction; + + const double rate = projected ? 0.0 : in.researchRate; + b.researchMoney = std::max(0, Ftol(static_cast(availAfterConstruction) * rate)); + b.researchPoints = ResearchPointsFromMoney(b.researchMoney, in.researchDifficultyMult, + in.resMod, in.shrm, in.trm, in.techResearchMult, + in.serverResMod, in.resScl); + b.totalResearchPoints = std::max(0, b.researchPoints + in.tra + in.trp); + + const int pct = ClampT(in.aidResearchPercent, 0, 100); + b.researchMoneyGiven = static_cast(static_cast(b.researchMoney) * pct / 100); + b.researchPointsGiven = static_cast(static_cast(b.totalResearchPoints) * pct / 100); + b.totalResearchPoints -= b.researchPointsGiven; + b.hasResearchAllocation = in.hasResearchTarget; + b.researchMoneyKept = b.researchMoney - b.researchMoneyGiven; + + b.bonusIncome = Ftol((in.techIncomeMult - 1.0) * static_cast(running())); + b.savingsGiven = static_cast(std::min(std::max(running(), 0), + std::max(0, in.aidSavings))); + + b.net = static_cast(ClampT(running(), -2147483648LL, 2147483647LL)); + return b; +} + +// ---- trade --------------------------------------------------------------------------- + +int TradeRoutesSupported(double civilianPop, double imperialPop, const TuningTable& t) { + double routes = 0; + if (t.TRADE_ROUTE_REQ_CIVPOPULATION > 0) routes += std::ceil(civilianPop / t.TRADE_ROUTE_REQ_CIVPOPULATION); + if (t.TRADE_ROUTE_REQ_IMPPOPULATION > 0) routes += std::ceil(imperialPop / t.TRADE_ROUTE_REQ_IMPPOPULATION); + return std::max(1, Ftol(routes)); +} + +int TradeRouteGrossIncome(const TradeRouteState& route, const TuningTable& t) { + if (route.ageTurns < t.TRADE_ROUTE_STARTUP_TURNS) return t.TRADE_ROUTE_STARTUP_INCOME; + + const int perFreighter[3] = {t.TRADE_ROUTE_INCOME_PERFREIGHTER_CRQ, + t.TRADE_ROUTE_INCOME_PERFREIGHTER_CR, + t.TRADE_ROUTE_INCOME_PERFREIGHTER_DE}; + int capLeft = t.TRADE_ROUTE_MAX_FREIGHTERS; + std::int64_t income = t.TRADE_ROUTE_MIN_INCOME; + for (int c = 0; c < 3; ++c) { + const int n = std::min(std::max(0, route.freighters[c]), std::max(0, capLeft)); + income += static_cast(n) * perFreighter[c]; + capLeft -= n; + } + double v = static_cast(income); + v *= 1.0 + t.STATION_BONUS_TRADE_INCOME * route.tradeStationsAtSystem; + if (route.partnerAddicted) v *= t.ADDICTION_TRADE_MOD; + return Ftol(v); +} + +int TradeRouteIncome(const TradeRouteState& route, bool asOwner, double difficultyTradeMult, + const TuningTable& t) { + const double share = Clamp01(t.TRADE_ROUTE_OWNERS_SHARE); + const double part = asOwner ? share : 1.0 - share; + return Ftol(static_cast(TradeRouteGrossIncome(route, t)) * part * difficultyTradeMult); +} + +// ---- bankruptcy ----------------------------------------------------------------------- + +BankruptcyLimits ComputeBankruptcyLimits(int maxIncome, const TuningTable& t) { + BankruptcyLimits l; + l.eliminationFloor = -Ftol(t.BANKRUPTCY_PROTECTION_LIMIT_FACTOR * static_cast(maxIncome)); + l.protectionLimit = std::max(l.eliminationFloor, -maxIncome); + return l; +} + +int BankruptcyLevel(int savings, const BankruptcyLimits& limits) { + if (savings < limits.eliminationFloor) return 2; + if (savings < limits.protectionLimit) return 1; + 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) { + state.warningLevel = level; + state.startTurn = currentTurn; + } + return level == 2 && (currentTurn - state.startTurn) >= t.BANKRUPTCY_ELIMINATION_TURNS; +} + +} // namespace sots::sim diff --git a/src/game/sim/economy.h b/src/game/sim/economy.h new file mode 100644 index 0000000..5bbcb14 --- /dev/null +++ b/src/game/sim/economy.h @@ -0,0 +1,178 @@ +// Economy: per-player income roll-up, research points, trade income, bankruptcy. +// +// Pure functions over plain input structs. Money is int (the treasury is a 32-bit +// integer clamped to +/-2e9); rates and multipliers are double. +#pragma once + +#include +#include + +#include "game/sim/tuning.h" + +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. +struct ExpenseSlider { + int minimum = 0; + int maximum = 0; + int requested = 0; +}; + +// Everything the budget roll-up reads from the player and the server. Names follow the +// save-tag names where one exists (Sav, Maint, ResRate, ResMod, ResScl, TRM/TRA/TRP, +// shrm) so a save dump maps onto this struct directly. +struct BudgetInputs { + int savings = 0; // Sav + bool ownsSystems = false; // savings interest only accrues to landed players + std::vector systemIncome; // money output of every owned, non-abandoned system + int tradeIncome = 0; // sum of the player's trade-route incomes + int secondaryManagerIncome = 0; // income reported by a second server manager + int shipCarriedPopIncome = 0; // income from population carried in slaver/colony hulls + int maintenance = 0; // Maint (raw fleet upkeep before difficulty) + double maintenanceDivisor = 1.0; // difficulty table: upkeep is divided by ftol(this) + double researchDifficultyMult = 1.0; // difficulty table: research-point multiplier + std::vector expenses; + bool isAI = false; // AI players do not take the human construction path + int constructionDemand = 0; // what the build queues would consume this turn + double researchRate = 0.0; // ResRate: share of available money to research (0..1) + double resMod = 1.0; // ResMod + double shrm = 0.0; // shrm (shared research modifier) + double trm = 0.0; // TRM (timed research multiplier bonuses) + double techResearchMult = 1.0; // research multiplier set by tech effects + double serverResMod = 1.0; // game-option research modifier + double resScl = 1.0; // ResScl + int tra = 0; // TRA: per-turn research-point contribution + int trp = 0; // TRP: per-turn research-point contribution + int aidResearchPercent = 0; // sum of active research-aid entries (clamped 0..100) + int aidSavings = 0; // sum of active savings-aid entries + double techIncomeMult = 1.0; // income multiplier set by tech effects (1 = none) + bool hasResearchTarget = false; // ResT set +}; + +struct Budget { + // income side + int systemIncomePositive = 0; // sum of positive system money outputs + int tradeIncome = 0; + int shipCarriedPopIncome = 0; + int secondaryManagerIncome = 0; + int savingsInterest = 0; // 1 % of a non-negative treasury + int bonusIncome = 0; // tech income multiplier applied to the running net + // expense side + int systemIncomeNegative = 0; // sum of |negative| system money outputs + int maintenance = 0; + int researchMoneyKept = 0; // research money minus the part given as aid + int debtInterest = 0; // 15 % of a negative treasury + int construction = 0; + int expenses = 0; + int researchMoneyGiven = 0; + int savingsGiven = 0; + // derived + int available = 0; // money left for construction/research after fixed costs + int researchMoney = 0; // money routed to research before aid + int researchPoints = 0; // RP from the research money alone + int researchPointsGiven = 0; + int totalResearchPoints = 0; // RP allocated to the current research target + bool hasResearchAllocation = false; + int net = 0; // change in savings this turn +}; + +// Savings interest: 1 % of a non-negative treasury, only for players who own systems. +// CONFIDENCE: high. +int SavingsInterest(int savings, bool ownsSystems); + +// Debt interest: 15 % of the magnitude of a negative treasury. CONFIDENCE: high. +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). +int ExpenseTotal(const std::vector& sliders, int availableBeforeExpenses); + +// Research points bought with `researchMoney`: +// RP = ftol( difficulty x (money/50 x 1.15 x 0.5 x 0.85) x (ResMod + shrm + TRM) +// x techMult x serverResMod x ResScl ) +// i.e. about 0.009775 RP per unit of money before multipliers. CONFIDENCE: high. +int ResearchPointsFromMoney(int researchMoney, double difficultyMult, double resMod, + double shrm, double trm, double techMult, double serverResMod, + double resScl); + +// 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. +Budget ComputeBudget(const BudgetInputs& in, bool projected); + +// --------------------------------------------------------------------------------------- +// Trade +// --------------------------------------------------------------------------------------- + +// Routes a system can host: ceil(civilians / REQ_CIV) + ceil(imperials / REQ_IMP), at +// least 1. A zero requirement contributes nothing. CONFIDENCE: high. +int TradeRoutesSupported(double civilianPop, double imperialPop, const TuningTable& t); + +enum class FreighterClass : int { Cruiser = 0 /*CRQ*/, CruiserRefit = 1 /*CR*/, Destroyer = 2 /*DE*/ }; + +struct TradeRouteState { + int ageTurns = 0; // turns since the route was established + int freighters[3] = {0, 0, 0}; // by FreighterClass index + int tradeStationsAtSystem = 0; + bool partnerAddicted = false; +}; + +// Gross income of one route before the owner/partner split: +// young route (age < STARTUP_TURNS): STARTUP_INCOME flat +// else MIN_INCOME + sum over classes in order CRQ, CR, DE of +// min(n_class, capLeft) x PERFREIGHTER[class], capLeft starting at MAX_FREIGHTERS, +// then x (1 + STATION_BONUS_TRADE_INCOME x stations), x ADDICTION_TRADE_MOD if addicted. +// CONFIDENCE: high on the freighter sum; medium on where the multipliers truncate. +int TradeRouteGrossIncome(const TradeRouteState& route, const TuningTable& t); + +// The share one side of the route receives: owner gets OWNERS_SHARE (clamped 0..1), the +// partner the rest; AI players additionally scale by their difficulty trade multiplier. +// CONFIDENCE: high. +int TradeRouteIncome(const TradeRouteState& route, bool asOwner, double difficultyTradeMult, + const TuningTable& t); + +// --------------------------------------------------------------------------------------- +// Bankruptcy +// --------------------------------------------------------------------------------------- + +struct BankruptcyLimits { + int eliminationFloor = 0; // BnkEl: below this the player is on the elimination clock + int protectionLimit = 0; // BnkPr: below this cost-cutting starts +}; + +// 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. +BankruptcyLimits ComputeBankruptcyLimits(int maxIncome, const TuningTable& t); + +// 2 = elimination pending, 1 = protection (cost cutting), 0 = solvent. CONFIDENCE: high. +int BankruptcyLevel(int savings, const BankruptcyLimits& limits); + +struct BankruptcyState { + int warningLevel = 0; // BnkWrn + int startTurn = 0; // BnkTrn: turn the current level began +}; + +// 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); + +} // namespace sots::sim diff --git a/src/game/sim/movement.cpp b/src/game/sim/movement.cpp new file mode 100644 index 0000000..ad0fffe --- /dev/null +++ b/src/game/sim/movement.cpp @@ -0,0 +1,71 @@ +#include "game/sim/movement.h" + +#include +#include + +#include "game/sim/numeric.h" + +namespace sots::sim { + +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); +} + +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}; +} + +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); + } + return nodeSpeed * ((t.STUTTER_MAX_SPEED - t.STUTTER_MIN_SPEED) * ratio + t.STUTTER_MIN_SPEED); +} + +MoveStepResult ResolveMoveStep(double step, double minShipRange, double distance) { + MoveStepResult r; + const double range = minShipRange - 0.05; + if (range < distance && minShipRange == 0.0) { + step = 0.0; + r.outOfFuel = true; + } + double move = std::min(step, std::min(range, distance)); + if (move < 0) move = 0; + r.moved = move; + r.fraction = step > 0 ? move / step : 1.0; + r.arrived = distance <= 0 || move == distance; + return r; +} + +double ConsumeShipRange(double shipRange, double moved, bool exempt) { + if (exempt) return shipRange; + return std::max(0.0, shipRange - moved); +} + +double RemainingPassTime(double fraction, double dt) { + if (fraction < 0.9999) return (1.0 - fraction) * dt; + return 0.0; +} + +JumpResult RollProbabilisticJump(double castEfficiency, double castThreshold, IRandom& rng) { + JumpResult r; + r.roll = rng.NextFloat(); + const double scaled = static_cast(r.roll) * castEfficiency; + if (scaled > castThreshold) { + r.arrived = false; + r.stopFraction = Clamp01(scaled); + } else { + r.arrived = true; + r.stopFraction = 1.0; + } + return r; +} + +} // namespace sots::sim diff --git a/src/game/sim/movement.h b/src/game/sim/movement.h new file mode 100644 index 0000000..e799bb7 --- /dev/null +++ b/src/game/sim/movement.h @@ -0,0 +1,80 @@ +// Movement: the per-pass fleet step, node-line speed profile, probabilistic jumps. +#pragma once + +#include + +#include "game/sim/rng.h" +#include "game/sim/tuning.h" + +namespace sots::sim { + +struct Vec3 { + double x = 0, y = 0, z = 0; +}; + +double Distance(const Vec3& a, const Vec3& b); + +// Move `pos` toward `dest` by `amount`; snaps exactly onto `dest` when amount reaches +// the remaining distance. CONFIDENCE: high. +Vec3 AdvanceToward(const Vec3& pos, const Vec3& dest, double amount); + +// 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). +constexpr double kHalfStep = 0.5; +constexpr double kFullStep = 1.0; + +enum class WaypointKind : int { + Straight = 0, // any type not listed below: step = speed x dt + NodeLine = 2, // node-line travel with the stutter profile + GateTeleport = 4, // arrive at once + ProbabilisticJump = 5, // random scatter along the vector +}; + +// 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: +// 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. +double NodeLineSpeed(double nodeSpeed, double distToLineSystem, const TuningTable& t); + +struct MoveStepResult { + double moved = 0; // distance actually covered this pass + double fraction = 1.0; // moved / step (1 when the step was zero) + bool arrived = false; // reached the destination exactly + bool outOfFuel = false; // the fleet has no range and could not move +}; + +// Clamp a pass's step against the fleet's range and the remaining distance: +// range = minShipRange - 0.05 +// if the fleet has no range at all and range < distance: step = 0 (stranded) +// move = min(step, range, distance), never negative; arrival when move == distance. +// CONFIDENCE: high. +MoveStepResult ResolveMoveStep(double step, double minShipRange, double distance); + +// Remaining strategic range of one ship after moving; ships flagged as range-exempt +// (e.g. tankers, in-system) are not charged. Floors at 0. CONFIDENCE: high. +double ConsumeShipRange(double shipRange, double moved, bool exempt); + +// Time left in the turn after a pass consumed `fraction` of its step: passes below +// 0.9999 recurse with the rest. Returns 0 when the pass is considered complete. +// CONFIDENCE: high. +double RemainingPassTime(double fraction, double dt); + +struct JumpResult { + bool arrived = false; // exact arrival at the destination + double stopFraction = 0; // when not arrived: how far along the vector the fleet stopped + float roll = 0; +}; + +// Probabilistic jump (waypoint type 5): roll = rand01() x castEfficiency; if roll exceeds +// the cast threshold the fleet stops at fraction `roll` along the vector, otherwise it +// arrives exactly. One RNG draw. CONFIDENCE: medium (the identity of the two player +// fields as efficiency/threshold is inferred; the arithmetic is established). +JumpResult RollProbabilisticJump(double castEfficiency, double castThreshold, IRandom& rng); + +} // namespace sots::sim diff --git a/src/game/sim/numeric.h b/src/game/sim/numeric.h new file mode 100644 index 0000000..f5f6687 --- /dev/null +++ b/src/game/sim/numeric.h @@ -0,0 +1,48 @@ +// Small numeric helpers shared by the sim formulas. +// +// The original engine converts floating point to integer by truncation toward zero +// (the MSVC float-to-long helper); `Ftol`/`Ftoi64` reproduce that so every rounding site +// in this module is explicit about which conversion it performs. +#pragma once + +#include +#include + +namespace sots::sim { + +// Truncating float -> int32 conversion. Out-of-range input saturates (the original +// helper's behaviour there is undefined; saturating keeps our tests deterministic). +inline int Ftol(double v) { + if (!(v == v)) return 0; // NaN + if (v >= 2147483647.0) return 2147483647; + if (v <= -2147483648.0) return -2147483647 - 1; + return static_cast(v); // C++ static_cast truncates toward zero +} + +// Truncating float -> int64 conversion. +inline std::int64_t Ftoi64(double v) { + if (!(v == v)) return 0; + if (v >= 9223372036854775807.0) return INT64_MAX; + if (v <= -9223372036854775808.0) return INT64_MIN; + return static_cast(v); +} + +// Round-half-away-from-zero to int (used for the per-system output split). +inline int RoundToInt(double v) { return Ftol(std::round(v)); } + +inline double Clamp01(double v) { return v < 0 ? 0 : (v > 1 ? 1 : v); } + +template +inline T ClampT(T v, T lo, T hi) { return v < lo ? lo : (v > hi ? hi : v); } + +// Saturating add clamped to +/-2,000,000,000 -- the treasury never overflows. +// CONFIDENCE: high. +inline int SaturatingAdd(int a, int b) { + const std::int64_t s = static_cast(a) + static_cast(b); + constexpr std::int64_t kLimit = 2000000000; + if (s > kLimit) return static_cast(kLimit); + if (s < -kLimit) return static_cast(-kLimit); + return static_cast(s); +} + +} // namespace sots::sim diff --git a/src/game/sim/research.cpp b/src/game/sim/research.cpp new file mode 100644 index 0000000..5747f8c --- /dev/null +++ b/src/game/sim/research.cpp @@ -0,0 +1,96 @@ +#include "game/sim/research.h" + +#include +#include + +#include "game/sim/numeric.h" + +namespace sots::sim { + +bool RollEdgeAvailable(const EdgeAvailability& edge, Species species, TreeBuildMode mode, + IRandom& rng) { + if (mode == TreeBuildMode::Everything) return true; + const float p = edge.Chance(species); + if (!(p > 0.f)) return false; + if (mode == TreeBuildMode::NoRoll) return true; + if (p >= 1.f) return true; + return rng.NextFloat() <= p; +} + +double TechCostMultiplier(int applicableBonusTechsOwned) { + const double m = 1.0 - 0.25 * std::max(0, applicableBonusTechsOwned); + return std::max(0.25, m); +} + +int TechCost(int baseCost, double multiplier) { + if (baseCost == kNoResearchCost) return kNoResearchCost; + return std::max(1, Ftol(static_cast(baseCost) * multiplier)); +} + +ResearchStepResult ApplyResearchPoints(ResearchNode& node, int points, Species owner, IRandom& rng) { + ResearchStepResult r; + const int cost = node.cost; + const int lo = std::max(0, static_cast(static_cast(cost) * 50 / 100)); + const int hi = std::max(lo, static_cast(static_cast(cost) * 150 / 100)); + + r.wasCompleteBefore = cost <= node.progress; + r.spent = std::max(0, std::min(points, hi - node.progress)); + r.overbudget = points - r.spent; + node.progress += r.spent; + const bool nowComplete = cost <= node.progress; + + if (node.progress < hi) { + if (r.spent == 0) { + r.odds = 0.0; + r.roll = 1.f; + } else { + r.odds = static_cast(node.progress - lo) / static_cast(hi); + r.roll = rng.NextFloat(); + if (owner == Species::Zuul) r.roll = std::min(r.roll, rng.NextFloat()); + } + } else { + r.odds = 1.0; + r.roll = 0.f; + } + + if (r.odds < static_cast(r.roll)) { + if (!r.wasCompleteBefore && nowComplete) { + r.overbudgetEvent = true; + node.flag = TechFlag::OverBudgetNotified; + } + return r; + } + + r.completed = true; + if (cost > 0 && static_cast(node.progress) / static_cast(cost) < 0.8) { + r.completedEarly = true; + node.flag = TechFlag::CompletedEarly; + } + node.state = TechState::Researched; + return r; +} + +int DecayResearchProgress(int progress, int cost) { + if (cost == kNoResearchCost) return std::max(0, progress); + return std::max(0, progress - Ftol(static_cast(cost) * 0.05)); +} + +void DecayAllResearch(std::vector& nodes) { + for (ResearchNode& n : nodes) { + if (n.state == TechState::Available && n.progress > 0) { + n.progress = DecayResearchProgress(n.progress, n.cost); + } + } +} + +bool RollLabAccident(int oddsPercent, IRandom& rng) { + const int roll = static_cast(rng.NextInt(100)); + return roll < oddsPercent; +} + +int LabAccidentLossPercent(double minLoss, double maxLoss, IRandom& rng) { + const double f = Clamp01(static_cast(rng.NextFloat()) * (maxLoss - minLoss) + minLoss); + return static_cast(std::ceil(f * 100.0)); +} + +} // namespace sots::sim diff --git a/src/game/sim/research.h b/src/game/sim/research.h new file mode 100644 index 0000000..503fcf1 --- /dev/null +++ b/src/game/sim/research.h @@ -0,0 +1,117 @@ +// Research: tech-tree race gating, per-turn progress and completion, cost decay, +// lab-accident rolls. +#pragma once + +#include +#include + +#include "game/sim/rng.h" +#include "game/sim/species.h" + +namespace sots::sim { + +// Sentinel cost of a node that has no researched parent yet ("no cost known"). +constexpr int kNoResearchCost = INT_MAX; + +// --------------------------------------------------------------------------------------- +// Tree creation +// --------------------------------------------------------------------------------------- + +// How the per-species tree is built. Normal rolls the race percentages; NoRoll includes +// every edge with a non-zero chance; Everything includes every edge regardless. +enum class TreeBuildMode : int { Normal = 0, NoRoll = 1, Everything = 2 }; + +// Per-species availability on a tech-tree edge as parsed from an `allows` line. +// A species not named on the line keeps the default of 1.0 (always available); an +// explicit 0 excludes it. CONFIDENCE: high. +struct EdgeAvailability { + float chance[kSpeciesCount] = {1.f, 1.f, 1.f, 1.f, 1.f, 1.f, 1.f}; + + // Set one species' chance from a percentage as written in the data file. + void SetPercent(Species s, int percent) { chance[static_cast(s)] = percent / 100.f; } + float Chance(Species s) const { return chance[static_cast(s)]; } +}; + +// Whether an edge is included in one species' tree. Exactly one RNG draw is consumed +// iff the mode is Normal and 0 < chance < 1: +// include iff mode == Everything +// or (chance > 0 and (mode == NoRoll or chance >= 1 or rand01() <= chance)) +// CONFIDENCE: high. +bool RollEdgeAvailable(const EdgeAvailability& edge, Species species, TreeBuildMode mode, + IRandom& rng); + +// --------------------------------------------------------------------------------------- +// Cost +// --------------------------------------------------------------------------------------- + +// Cost multiplier from the species' research-bonus techs: 1 - 0.25 per applicable tech +// owned, floored at 0.25. CONFIDENCE: medium (which three techs apply is unresolved). +double TechCostMultiplier(int applicableBonusTechsOwned); + +// Effective research cost: INT_MAX stays INT_MAX (no cost known); otherwise at least 1. +// CONFIDENCE: high. +int TechCost(int baseCost, double multiplier); + +// --------------------------------------------------------------------------------------- +// Per-turn progress +// --------------------------------------------------------------------------------------- + +enum class TechState : int { Hidden = 0, ParentResearched = 1, Available = 2, Researched = 4 }; + +// Completion-flag values the game stamps on a node. +enum class TechFlag : int { CompletedEarly = 0, Default = 1, OverBudgetNotified = 2 }; + +struct ResearchNode { + TechState state = TechState::Available; + int cost = kNoResearchCost; // effective cost in RP (already multiplied) + int progress = 0; + TechFlag flag = TechFlag::Default; +}; + +struct ResearchStepResult { + int spent = 0; // points actually applied to the node + int overbudget = 0; // points that could not be applied (cap at 150 % of cost) + bool wasCompleteBefore = false; + bool completed = false; // research finished this turn + bool completedEarly = false; // finished below 80 % of cost + bool overbudgetEvent = false; // crossed 100 % without finishing -> notify the owner + double odds = 0; // completion odds used + float roll = 0; // roll compared against the odds +}; + +// Apply one turn of research points to the current target: +// lo = cost x 50 / 100, hi = cost x 150 / 100 (integer arithmetic, lo >= 0, hi >= lo) +// spend = min(points, hi - progress); progress += spend +// progress < hi: spend == 0 -> odds 0, roll 1 (cannot complete) +// else odds = (progress - lo) / hi, roll = rand01(); +// Zuul roll twice and keep the lower +// progress >= hi: odds 1, roll 0 (guaranteed) +// completes iff odds >= roll. Crossing 100 % without completing raises the +// over-budget notification; completing below 80 % marks the node "completed early". +// On completion the node's state becomes Researched. CONFIDENCE: high. +ResearchStepResult ApplyResearchPoints(ResearchNode& node, int points, Species owner, IRandom& rng); + +// Progress decay on a partially researched tech: lose 5 % of cost per turn, floored at 0. +// CONFIDENCE: high. +int DecayResearchProgress(int progress, int cost); + +// Apply the decay to every Available node with progress. This runs after the current +// target has been processed, so the current target decays too (net gain = spend - 5 %). +// CONFIDENCE: high. +void DecayAllResearch(std::vector& nodes); + +// --------------------------------------------------------------------------------------- +// Lab accidents +// --------------------------------------------------------------------------------------- + +// Whether a lab accident happens: roll = randint(100); accident iff roll < odds. +// One RNG draw. CONFIDENCE: medium (the odds-from-boost function is unresolved -- the +// caller supplies the odds). +bool RollLabAccident(int oddsPercent, IRandom& rng); + +// Progress lost by a non-catastrophic accident, as a whole percentage: +// ceil(clamp01(rand01() x (maxLoss - minLoss) + minLoss) x 100) +// One RNG draw. CONFIDENCE: high. +int LabAccidentLossPercent(double minLoss, double maxLoss, IRandom& rng); + +} // namespace sots::sim diff --git a/src/game/sim/rng.h b/src/game/sim/rng.h new file mode 100644 index 0000000..ea08dfd --- /dev/null +++ b/src/game/sim/rng.h @@ -0,0 +1,22 @@ +// Strategic-layer RNG interface. +// +// Every roll in the strategic simulation (tech-tree race gating, research completion, +// lab accidents, probabilistic jumps, ...) draws from one server-owned generator so that +// lockstep peers stay in sync. The sim formulas in this module never own a generator; +// they take an IRandom& so tests can inject a scripted sequence and the real engine can +// inject its MT19937-compatible generator (implemented elsewhere, under src/mars/). +#pragma once + +#include + +namespace sots::sim { + +struct IRandom { + virtual ~IRandom() = default; + // Uniform float in [0, 1). + virtual float NextFloat() = 0; + // Uniform integer in [0, n). n == 0 must return 0. + virtual std::uint32_t NextInt(std::uint32_t n) = 0; +}; + +} // namespace sots::sim diff --git a/src/game/sim/species.h b/src/game/sim/species.h new file mode 100644 index 0000000..99f25c3 --- /dev/null +++ b/src/game/sim/species.h @@ -0,0 +1,25 @@ +// Species enumeration used by every per-species table in the strategic sim. +// +// 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 +// specially (no imperial growth, capacity scaled by INDSYS_IMPERIAL_POPULATION_MOD). +// CONFIDENCE: high. +#pragma once + +namespace sots::sim { + +enum class Species : int { + Human = 0, + Hiver = 1, + Tarkas = 2, + Liir = 3, + NPC = 4, + Zuul = 5, + Morrigi = 6, +}; + +constexpr int kSpeciesCount = 7; + +constexpr bool IsNpcSpecies(Species s) { return s == Species::NPC; } + +} // namespace sots::sim diff --git a/src/game/sim/tuning.h b/src/game/sim/tuning.h new file mode 100644 index 0000000..d5c59d1 --- /dev/null +++ b/src/game/sim/tuning.h @@ -0,0 +1,71 @@ +// Tuning constants the strategic formulas read at runtime. +// +// The game loads these as `KEY value` lines from its data files (StrategyVars.txt, +// globals.txt, ...). The data file is authoritative; the executable only carries fallback +// defaults, several of which differ from the shipped file. Nothing in this module +// hard-codes a shipped value: callers fill a TuningTable (from the parsed data files) and +// pass it in. Field names are the config keys verbatim so a loader can map by name. +// +// All values default to zero so a test can set only the keys a formula reads. +#pragma once + +#include + +namespace sots::sim { + +struct TuningTable { + // ---- trade (StrategyVars) ---- + double TRADE_ROUTE_REQ_CIVPOPULATION = 0; // civilians per supported route + double TRADE_ROUTE_REQ_IMPPOPULATION = 0; // imperials per supported route + int TRADE_ROUTE_STARTUP_TURNS = 0; // routes younger than this pay a flat amount + int TRADE_ROUTE_STARTUP_INCOME = 0; + int TRADE_ROUTE_MIN_INCOME = 0; + int TRADE_ROUTE_MAX_FREIGHTERS = 0; // freighter cap per route + int TRADE_ROUTE_INCOME_PERFREIGHTER_CRQ = 0; + int TRADE_ROUTE_INCOME_PERFREIGHTER_CR = 0; + int TRADE_ROUTE_INCOME_PERFREIGHTER_DE = 0; + double TRADE_ROUTE_OWNERS_SHARE = 0; // owner's share of a route's income (0..1) + double STATION_BONUS_TRADE_INCOME = 0; // per trade station at the system + double ADDICTION_TRADE_MOD = 0; // applied when the partner is addicted + + // ---- bankruptcy ---- + int BANKRUPTCY_ELIMINATION_TURNS = 0; + double BANKRUPTCY_PROTECTION_LIMIT_FACTOR = 0; // debt floor = -factor x max income + + // ---- population ---- + double POPULATION_GROWTH_MOD = 0; + double POPULATION_GROWTH_EXP = 0; + double INDSYS_IMPERIAL_POPULATION_MOD = 0; + + // ---- slaves ---- + double SLAVES_DEATH_RATE = 0; + double SLAVES_DEATH_RATE_BYHAZARD = 0; + double SLAVES_DEATH_RATE_BYOUTPUT = 0; + std::int64_t SLAVES_MIN_DEATHS = 0; + std::int64_t SLAVES_MAX_DEATHS = -1; // -1 = no upper clamp + + // ---- system bonus (long-held stable colonies) ---- + int SYSTEMBONUS_MINTURNS = 0; + double SYSTEMBONUS_POPBONUS = 0; + double SYSTEMBONUS_POPBONUS_HOME = 0; + double SYSTEMBONUS_POPBONUS_INC = 0; + double SYSTEMBONUS_INFRABONUS = 0; + double SYSTEMBONUS_INFRABONUS_HOME = 0; + double SYSTEMBONUS_INFRABONUS_INC = 0; + + // ---- output modifiers ---- + double STATION_BONUS_IMPERIAL_OUTPUT = 0; + double STATION_BONUS_SHIPCON = 0; + double MORALE_INCREASE_OUTPUT = 0; + double MORALE_INCREASE_OUTPUT_MOD = 0; + double MORALE_DECREASE_OUTPUT = 0; + double MORALE_DECREASE_OUTPUT_MOD = 0; + double ADDICTION_OUTPUT_MOD = 0; + + // ---- movement (globals) ---- + double STUTTER_SYSTEM_INFLUENCE_RADIUS = 0; + double STUTTER_MIN_SPEED = 0; + double STUTTER_MAX_SPEED = 0; +}; + +} // namespace sots::sim diff --git a/tests/game_sim/build_and_run.sh b/tests/game_sim/build_and_run.sh new file mode 100755 index 0000000..b632b4f --- /dev/null +++ b/tests/game_sim/build_and_run.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Build and run the game/sim unit tests with plain g++ (no CMake needed). +# tests/game_sim/build_and_run.sh # build + run all +# BUILD_DIR=/some/dir tests/game_sim/build_and_run.sh +# SOTS_SAVES_JSON=/path/save.json tests/game_sim/build_and_run.sh # also runs the smoke test on real data +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +root="$(cd "$here/../.." && pwd)" +build="${BUILD_DIR:-$(mktemp -d "${TMPDIR:-/tmp}/sots-game-sim.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) + +objs=() +for s in "${srcs[@]}"; do + o="$build/$(basename "${s%.cpp}").o" + $CXX $CXXFLAGS -I"$root/src" -c "$s" -o "$o" + objs+=("$o") +done + +status=0 +for t in economy research colony movement; do + exe="$build/test_$t" + $CXX $CXXFLAGS -I"$root/src" -I"$here" "$here/test_$t.cpp" "${objs[@]}" -o "$exe" + if ! "$exe"; then status=1; fi +done + +exe="$build/smoke_real_save" +$CXX $CXXFLAGS -I"$root/src" -I"$here" "$here/smoke_real_save.cpp" "${objs[@]}" -o "$exe" +if ! "$exe"; then status=1; fi + +if [ $status -eq 0 ]; then echo "game_sim: all tests passed (build dir $build)"; else echo "game_sim: FAILURES (build dir $build)"; fi +exit $status diff --git a/tests/game_sim/check.h b/tests/game_sim/check.h new file mode 100644 index 0000000..3a3b4d5 --- /dev/null +++ b/tests/game_sim/check.h @@ -0,0 +1,72 @@ +// Minimal test helpers: CHECK macros, a scripted RNG, and a summary exit code. +#pragma once + +#include +#include +#include +#include +#include + +#include "game/sim/rng.h" + +namespace simtest { + +inline int& failures() { static int n = 0; return n; } +inline int& checks() { static int n = 0; return n; } + +inline void report(bool ok, const char* expr, const char* file, int line, const std::string& detail) { + ++checks(); + if (ok) return; + ++failures(); + std::fprintf(stderr, "FAIL %s:%d %s%s%s\n", file, line, expr, detail.empty() ? "" : " -- ", detail.c_str()); +} + +template +inline std::string pair_detail(const A& a, const B& b) { + return "got " + std::to_string(a) + ", expected " + std::to_string(b); +} + +inline bool near(double a, double b, double eps = 1e-9) { return std::fabs(a - b) <= eps; } + +inline int finish(const char* name) { + std::printf("%s: %d checks, %d failures\n", name, checks(), failures()); + return failures() == 0 ? 0 : 1; +} + +// RNG that replays scripted values and counts draws; exhausting the script is a failure. +struct ScriptedRng final : sots::sim::IRandom { + std::vector floats; + std::vector ints; + std::size_t fi = 0, ii = 0; + + explicit ScriptedRng(std::vector f = {}, std::vector i = {}) + : floats(std::move(f)), ints(std::move(i)) {} + + float NextFloat() override { + if (fi >= floats.size()) { std::fprintf(stderr, "ScriptedRng: float script exhausted\n"); ++failures(); return 0.f; } + return floats[fi++]; + } + std::uint32_t NextInt(std::uint32_t n) override { + if (n == 0) return 0; + if (ii >= ints.size()) { std::fprintf(stderr, "ScriptedRng: int script exhausted\n"); ++failures(); return 0; } + return ints[ii++] % n; + } + std::size_t floatDraws() const { return fi; } + std::size_t intDraws() const { return ii; } +}; + +// Each operand is evaluated exactly once (operands may consume RNG draws). +template +inline void check_eq(const A& a, const B& b, const char* expr, const char* file, int line) { + report(a == b, expr, file, line, pair_detail(a, b)); +} +template +inline void check_near(const A& a, const B& b, double eps, const char* expr, const char* file, int line) { + report(near(static_cast(a), static_cast(b), eps), expr, file, line, pair_detail(a, b)); +} + +} // namespace simtest + +#define CHECK(expr) ::simtest::report((expr), #expr, __FILE__, __LINE__, "") +#define CHECK_EQ(a, b) ::simtest::check_eq((a), (b), #a " == " #b, __FILE__, __LINE__) +#define CHECK_NEAR(a, b, eps) ::simtest::check_near((a), (b), (eps), #a " ~= " #b, __FILE__, __LINE__) diff --git a/tests/game_sim/mini_json.h b/tests/game_sim/mini_json.h new file mode 100644 index 0000000..9cf6ba5 --- /dev/null +++ b/tests/game_sim/mini_json.h @@ -0,0 +1,146 @@ +// Tiny dependency-free JSON reader for the real-save smoke test. Not for production use: +// no error recovery, accepts a superset of JSON, numbers parsed as double. +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace minijson { + +struct Value { + enum Kind { Null, Bool, Number, String, Array, Object } kind = Null; + bool b = false; + double num = 0; + std::string str; + std::vector arr; + std::map obj; + + const Value* get(const std::string& key) const { + if (kind != Object) return nullptr; + auto it = obj.find(key); + return it == obj.end() ? nullptr : &it->second; + } + double number(double dflt = 0) const { return kind == Number ? num : (kind == Bool ? (b ? 1 : 0) : dflt); } + bool boolean(bool dflt = false) const { return kind == Bool ? b : (kind == Number ? num != 0 : dflt); } + const std::string& string() const { return str; } + bool isArray() const { return kind == Array; } + bool isObject() const { return kind == Object; } +}; + +class Parser { + public: + explicit Parser(const std::string& s) : s_(s) {} + + bool parse(Value& out) { + skip(); + if (!value(out)) return false; + skip(); + return pos_ == s_.size(); + } + + private: + const std::string& s_; + std::size_t pos_ = 0; + + void skip() { while (pos_ < s_.size() && std::isspace(static_cast(s_[pos_]))) ++pos_; } + bool eat(char c) { if (pos_ < s_.size() && s_[pos_] == c) { ++pos_; return true; } return false; } + + bool value(Value& v) { + if (pos_ >= s_.size()) return false; + const char c = s_[pos_]; + if (c == '{') return object(v); + if (c == '[') return array(v); + if (c == '"') { v.kind = Value::String; return stringLit(v.str); } + if (s_.compare(pos_, 4, "true") == 0) { pos_ += 4; v.kind = Value::Bool; v.b = true; return true; } + if (s_.compare(pos_, 5, "false") == 0) { pos_ += 5; v.kind = Value::Bool; v.b = false; return true; } + if (s_.compare(pos_, 4, "null") == 0) { pos_ += 4; v.kind = Value::Null; return true; } + if (s_.compare(pos_, 3, "NaN") == 0) { pos_ += 3; v.kind = Value::Number; v.num = 0; return true; } + if (s_.compare(pos_, 8, "Infinity") == 0) { pos_ += 8; v.kind = Value::Number; v.num = 0; return true; } + if (s_.compare(pos_, 9, "-Infinity") == 0) { pos_ += 9; v.kind = Value::Number; v.num = 0; return true; } + return number(v); + } + + bool number(Value& v) { + const char* begin = s_.c_str() + pos_; + char* end = nullptr; + const double d = std::strtod(begin, &end); + if (end == begin) return false; + pos_ += static_cast(end - begin); + v.kind = Value::Number; + v.num = d; + return true; + } + + bool stringLit(std::string& out) { + if (!eat('"')) return false; + out.clear(); + while (pos_ < s_.size()) { + const char c = s_[pos_++]; + if (c == '"') return true; + if (c != '\\') { out.push_back(c); continue; } + if (pos_ >= s_.size()) return false; + const char e = s_[pos_++]; + switch (e) { + case 'n': out.push_back('\n'); break; + case 't': out.push_back('\t'); break; + case 'r': out.push_back('\r'); break; + case 'b': out.push_back('\b'); break; + case 'f': out.push_back('\f'); break; + case 'u': { + if (pos_ + 4 > s_.size()) return false; + const unsigned long cp = std::strtoul(s_.substr(pos_, 4).c_str(), nullptr, 16); + pos_ += 4; + if (cp < 0x80) out.push_back(static_cast(cp)); + else if (cp < 0x800) { out.push_back(static_cast(0xC0 | (cp >> 6))); out.push_back(static_cast(0x80 | (cp & 0x3F))); } + else { out.push_back(static_cast(0xE0 | (cp >> 12))); out.push_back(static_cast(0x80 | ((cp >> 6) & 0x3F))); out.push_back(static_cast(0x80 | (cp & 0x3F))); } + break; + } + default: out.push_back(e); break; + } + } + return false; + } + + bool array(Value& v) { + if (!eat('[')) return false; + v.kind = Value::Array; + skip(); + if (eat(']')) return true; + for (;;) { + Value item; + skip(); + if (!value(item)) return false; + v.arr.push_back(std::move(item)); + skip(); + if (eat(',')) continue; + return eat(']'); + } + } + + bool object(Value& v) { + if (!eat('{')) return false; + v.kind = Value::Object; + skip(); + if (eat('}')) return true; + for (;;) { + skip(); + std::string key; + if (!stringLit(key)) return false; + skip(); + if (!eat(':')) return false; + skip(); + Value item; + if (!value(item)) return false; + v.obj[key] = std::move(item); + skip(); + if (eat(',')) continue; + return eat('}'); + } + } +}; + +} // namespace minijson diff --git a/tests/game_sim/smoke_real_save.cpp b/tests/game_sim/smoke_real_save.cpp new file mode 100644 index 0000000..cf06dc1 --- /dev/null +++ b/tests/game_sim/smoke_real_save.cpp @@ -0,0 +1,113 @@ +// Real-data smoke test: feed a player's numbers from a save dump through the budget +// roll-up and print the result for eyeballing. +// +// Input: $SOTS_SAVES_JSON = path to the JSON emitted by the RE repo's save reader +// (`save_reader.py SAVE --json`). Skips cleanly when unset. No assertions on exact +// values yet: the per-system money output is not fully resolved, so the system income +// line is a placeholder. Turning this into a compare-mode test (asserting the next +// turn's savings from a save pair) is future work. +#include +#include +#include +#include +#include + +#include "game/sim/economy.h" +#include "game/sim/numeric.h" +#include "mini_json.h" + +using namespace sots::sim; + +int main() { + const char* path = std::getenv("SOTS_SAVES_JSON"); + if (!path || !*path) { + std::printf("smoke_real_save: SOTS_SAVES_JSON unset, skipped\n"); + return 0; + } + std::ifstream f(path); + if (!f) { + std::fprintf(stderr, "smoke_real_save: cannot open %s\n", path); + return 1; + } + std::stringstream ss; + ss << f.rdbuf(); + const std::string text = ss.str(); + + minijson::Value root; + minijson::Parser parser(text); + if (!parser.parse(root)) { + std::fprintf(stderr, "smoke_real_save: JSON parse failed\n"); + return 1; + } + + const minijson::Value* data = root.get("data"); + const minijson::Value* sim = data ? data->get("sim") : nullptr; + const minijson::Value* players = sim ? sim->get("players") : nullptr; + const minijson::Value* systems = sim ? sim->get("systems") : nullptr; + if (!players || !players->isArray()) { + std::fprintf(stderr, "smoke_real_save: no data.sim.players array\n"); + return 1; + } + + // Index systems by id so a player's owned-system list can be resolved. + std::map systemById; + if (systems && systems->isArray()) { + for (const minijson::Value& entry : systems->arr) { + const minijson::Value* id = entry.get("SysID"); + const minijson::Value* sys = entry.get("Sys"); + if (id && sys) systemById[static_cast(id->number())] = sys; + } + } + + std::printf("smoke_real_save: %s\n", path); + std::printf(" (system income is a placeholder: population / 1e6 per owned system)\n"); + for (const minijson::Value& entry : players->arr) { + const minijson::Value* p = entry.get("Player"); + if (!p) continue; + const minijson::Value* name = p->get("PlryName"); + const minijson::Value* npc = p->get("NPC"); + if (npc && npc->boolean()) continue; + + BudgetInputs in; + in.savings = static_cast(p->get("Sav") ? p->get("Sav")->number() : 0); + in.maintenance = static_cast(p->get("Maint") ? p->get("Maint")->number() : 0); + in.researchRate = p->get("ResRate") ? p->get("ResRate")->number() : 0; + in.resMod = p->get("ResMod") ? p->get("ResMod")->number(1) : 1; + in.resScl = p->get("ResScl") ? p->get("ResScl")->number(1) : 1; + in.trm = p->get("TRM") ? p->get("TRM")->number() : 0; + in.tra = static_cast(p->get("TRA") ? p->get("TRA")->number() : 0); + in.trp = static_cast(p->get("TRP") ? p->get("TRP")->number() : 0); + in.shrm = p->get("shrm") ? p->get("shrm")->number() : 0; + in.hasResearchTarget = p->get("ResTNm") && !p->get("ResTNm")->string().empty(); + + const minijson::Value* owners = p->get("owners"); + int ownedSystems = 0; + if (owners && owners->isArray()) { + for (const minijson::Value& o : owners->arr) { + const int sysId = static_cast(o.number(-1)); + auto it = systemById.find(sysId); + if (it == systemById.end()) continue; + const minijson::Value* abdn = it->second->get("Abdn"); + if (abdn && abdn->boolean()) continue; + const double pop = it->second->get("Pop") ? it->second->get("Pop")->number() : 0; + in.systemIncome.push_back(static_cast(pop / 1e6)); + ++ownedSystems; + } + } + in.ownsSystems = ownedSystems > 0; + + const Budget b = ComputeBudget(in, false); + std::printf(" player %-16s species=%d sav=%d maint=%d systems=%d resRate=%.2f\n", + name ? name->string().c_str() : "?", + static_cast(p->get("Species") ? p->get("Species")->number(-1) : -1), + in.savings, in.maintenance, ownedSystems, in.researchRate); + std::printf(" interest=%d debt=%d sysIncome=%d/-%d avail=%d construction=%d\n", + b.savingsInterest, b.debtInterest, b.systemIncomePositive, b.systemIncomeNegative, + b.available, b.construction); + std::printf(" researchMoney=%d RP=%d totalRP=%d net=%d -> savings next turn %d\n", + b.researchMoney, b.researchPoints, b.totalResearchPoints, b.net, + SaturatingAdd(in.savings, b.net)); + } + std::printf("smoke_real_save: done (no assertions; compare-mode is future work)\n"); + return 0; +} diff --git a/tests/game_sim/test_colony.cpp b/tests/game_sim/test_colony.cpp new file mode 100644 index 0000000..4b7fcb0 --- /dev/null +++ b/tests/game_sim/test_colony.cpp @@ -0,0 +1,309 @@ +#include "game/sim/colony.h" + +#include "check.h" + +using namespace sots::sim; + +static TuningTable tuning() { + TuningTable t; + t.POPULATION_GROWTH_MOD = 1.2; + t.POPULATION_GROWTH_EXP = 2.0; + t.INDSYS_IMPERIAL_POPULATION_MOD = 0.1; + t.SLAVES_DEATH_RATE = 0.05; + t.SLAVES_DEATH_RATE_BYHAZARD = 0.5; + t.SLAVES_DEATH_RATE_BYOUTPUT = 0.1; + t.SLAVES_MIN_DEATHS = 0; + t.SLAVES_MAX_DEATHS = -1; + t.MORALE_INCREASE_OUTPUT = 75; + t.MORALE_INCREASE_OUTPUT_MOD = 1.1; + t.MORALE_DECREASE_OUTPUT = 25; + t.MORALE_DECREASE_OUTPUT_MOD = 0.9; + t.STATION_BONUS_IMPERIAL_OUTPUT = 0.1; + t.STATION_BONUS_SHIPCON = 0.25; + t.ADDICTION_OUTPUT_MOD = 0.5; + t.SYSTEMBONUS_MINTURNS = 10; + t.SYSTEMBONUS_POPBONUS = 0.1; + t.SYSTEMBONUS_POPBONUS_HOME = 0.2; + t.SYSTEMBONUS_POPBONUS_INC = 0.01; + t.SYSTEMBONUS_INFRABONUS = 0.2; + t.SYSTEMBONUS_INFRABONUS_HOME = 0.5; + t.SYSTEMBONUS_INFRABONUS_INC = 0.05; + return t; +} + +static void test_capacity() { + TuningTable t = tuning(); + CapacityInputs c; + c.planetSize = 5; + CHECK_EQ(CarryingCapacity(c, t), std::int64_t{500000000}); + c.hazardMod = 0.5; + CHECK_EQ(CarryingCapacity(c, t), std::int64_t{250000000}); + c.arcologyTech = true; + CHECK_EQ(CarryingCapacity(c, t), std::int64_t{350000000}); + c.group = PopGroup::Civilian; + CHECK_EQ(CarryingCapacity(c, t), std::int64_t{450000000}); + c.group = PopGroup::Slaves; + CHECK_EQ(CarryingCapacity(c, t), std::int64_t{250000000}); // no arcology bonus for slaves + c.group = PopGroup::Imperial; + c.groupMaxEnabled = true; + c.groupMax = 300000000; + CHECK_EQ(CarryingCapacity(c, t), std::int64_t{300000000}); + c.ownerIsNpc = true; + CHECK_EQ(CarryingCapacity(c, t), std::int64_t{30000000}); + c.ownerIsNpc = false; + c.ownerIsDifferentSpecies = true; + c.crossSpeciesMod = 0.5; + CHECK_EQ(CarryingCapacity(c, t), std::int64_t{225000000}); // 5e8 x 0.5 x 0.5 + 1e8 + c.species = Species::NPC; + CHECK_EQ(CarryingCapacity(c, t), std::int64_t{0}); + c.species = Species::Liir; + c.speciesCanLive = false; + CHECK_EQ(CarryingCapacity(c, t), std::int64_t{0}); + c.speciesCanLive = true; + 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); +} + +static void test_growth() { + TuningTable t = tuning(); + GrowthInputs g; + g.capacity = 1000000; + + g.pop = 0; // empty colony still grows by 1 + CHECK_EQ(PopulationGrowthDelta(g, t), std::int64_t{1}); + + g.pop = 500000; // (1-0.5)^2 = 0.25; x1.2 = 0.3; x5e5 = 150000 + CHECK_EQ(PopulationGrowthDelta(g, t), std::int64_t{150000}); + + g.playerPopMod = 0.5; // 75000 + CHECK_EQ(PopulationGrowthDelta(g, t), std::int64_t{75000}); + g.playerPopMod = 1.0; + g.groupGrowthMult = 2.0; // 300000 + CHECK_EQ(PopulationGrowthDelta(g, t), std::int64_t{300000}); + g.groupGrowthMult = 0.0; // zero column is ignored + CHECK_EQ(PopulationGrowthDelta(g, t), std::int64_t{150000}); + g.flaggedByPlayer = true; + g.flaggedFactor = 0.0; + CHECK_EQ(PopulationGrowthDelta(g, t), std::int64_t{0}); // g becomes 0 -> no minimum 1 + g.flaggedByPlayer = false; + + g.pop = 1000000; // at capacity: no growth + CHECK_EQ(PopulationGrowthDelta(g, t), std::int64_t{0}); + g.pop = 2000000; // over capacity clamps to 0 growth + CHECK_EQ(PopulationGrowthDelta(g, t), std::int64_t{0}); + + g.pop = 1000000000; + g.capacity = 2000000000; // 0.3 x 1e9 = 3e8 -> capped at 5e7 + CHECK_EQ(PopulationGrowthDelta(g, t), std::int64_t{50000000}); + + g.blockaded = true; + CHECK_EQ(PopulationGrowthDelta(g, t), std::int64_t{0}); + g.blockaded = false; + g.capacity = 0; + CHECK_EQ(PopulationGrowthDelta(g, t), std::int64_t{0}); + + CHECK_EQ(ApplyImperialGrowth(500000, 1000000, 150000), std::int64_t{650000}); + CHECK_EQ(ApplyImperialGrowth(999999, 1000000, 5), std::int64_t{1000000}); + CHECK_EQ(ApplyImperialGrowth(2000000, 1000000, 0), std::int64_t{1000000}); + CHECK_EQ(ApplyImperialGrowth(1000000000, 100000000, 0), std::int64_t{950000000}); // shrink capped + CHECK_EQ(ApplyImperialGrowth(50, 10, 0), std::int64_t{50}); // floor min(pop, 100) + CHECK_EQ(ApplyImperialGrowth(500, 10, 0), std::int64_t{100}); +} + +static void test_infra_terraform() { + CHECK_EQ(InfrastructurePointsNeeded(0.0), 30304); // ceil(30303.03) + CHECK_EQ(InfrastructurePointsNeeded(1.0), 0); + CHECK_NEAR(InfrastructureGain(500), 0.0165, 1e-12); + CHECK_NEAR(InfrastructureGain(1000), 0.033, 1e-12); + int used = -1; + double infra = ApplyInfrastructurePoints(0.5, 100000, &used); + CHECK_EQ(used, 15152); // ceil(0.5 / 3.3e-5) + CHECK(infra >= 1.0 && infra < 1.0001); + infra = ApplyInfrastructurePoints(0.5, 1000, &used); + CHECK_EQ(used, 1000); + CHECK_NEAR(infra, 0.533, 1e-12); + CHECK_NEAR(DecayUnownedInfrastructure(0.5), 0.48, 1e-12); + CHECK_NEAR(DecayUnownedInfrastructure(0.01), 0.0, 0.0); + + CHECK_EQ(TerraformPointsNeeded(0.5, 0.8), 3333); // 0.3 / 9e-5 + CHECK_EQ(TerraformPointsNeeded(0.8, 0.5), 3333); + CHECK_EQ(TerraformPointsNeeded(0.5, 0.5), 0); + CHECK_NEAR(TerraformDelta(1000, 1.0, 0.5, 0.8), 0.09, 1e-12); + CHECK_NEAR(TerraformDelta(1000, 1.0, 0.8, 0.5), -0.09, 1e-12); + CHECK_NEAR(TerraformDelta(1000, 2.0, 0.5, 0.8), 0.18, 1e-12); + CHECK_NEAR(TerraformDelta(0, 2.0, 0.5, 0.8), 0.0, 0.0); +} + +static void test_slaves() { + TuningTable t = tuning(); + 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; + CHECK_NEAR(SlaveDeathRate(0.5, 0.3, 0.5, f, t), 0.16, 1e-12); + f.slaveDeathTech1 = f.slaveDeathTech2 = 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 + + 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; + CHECK_EQ(SlaveDeaths(1000, 0.2, t), std::int64_t{300}); + CHECK_EQ(SlaveDeaths(100, 0.2, t), std::int64_t{100}); // never more than present + t.SLAVES_MAX_DEATHS = 150; + CHECK_EQ(SlaveDeaths(1000, 0.2, t), std::int64_t{150}); + t.SLAVES_MIN_DEATHS = 0; + CHECK_EQ(SlaveDeaths(7, 0.2, t), std::int64_t{1}); // 1.4 truncates +} + +static void test_output() { + TuningTable t = tuning(); + OutputRates r = NormaliseOutputRates({1, 1, 1, 1}, false, false); + CHECK_NEAR(r.trade, 0.25, 1e-12); + r = NormaliseOutputRates({1, 1, 1, 1}, true, false); + CHECK_NEAR(r.terraform, 0.0, 0.0); + CHECK_NEAR(r.infra, 1.0 / 3.0, 1e-12); + r = NormaliseOutputRates({0, 0, 0, 0}, false, false); + CHECK_NEAR(r.construction, 0.25, 0.0); + r = NormaliseOutputRates({-1, 3, 0, 1}, false, true); + CHECK_NEAR(r.trade, 0.0, 0.0); + CHECK_NEAR(r.construction, 1.0, 0.0); + CHECK_NEAR(r.infra, 0.0, 0.0); + + CHECK_NEAR(MoraleOutputMultiplier(80, t), 1.1, 0.0); + CHECK_NEAR(MoraleOutputMultiplier(75, t), 1.1, 0.0); + CHECK_NEAR(MoraleOutputMultiplier(50, t), 1.0, 0.0); + CHECK_NEAR(MoraleOutputMultiplier(25, t), 0.9, 0.0); + + OutputModifiers m; + m.baseOutput = 1000; + m.morale = 80; + m.stations = 1; + CHECK_EQ(TotalSystemOutput(m, t), 1210); // 1000 x 1.1 x 1.1 + m.addictionPhase3 = true; + CHECK_EQ(TotalSystemOutput(m, t), 605); + m.addictionPhase3 = false; + m.playerOutMod = 0.5; + m.systemOutMod = 0.5; + CHECK_EQ(TotalSystemOutput(m, t), 303); // 302.5 rounds up + m.baseOutput = 0; + CHECK_EQ(TotalSystemOutput(m, t), 0); + + OutputSplit s = SplitOutput(1000, {0.5, 0.25, 0.125, 0.125}); + CHECK_EQ(s.trade, 500); + CHECK_EQ(s.construction, 250); + CHECK_EQ(s.terraform, 125); + CHECK_EQ(s.infra, 125); + + CHECK_EQ(ConstructionPoints(250, 2, t), 375); + CHECK_EQ(ConstructionPoints(250, 0, t), 250); + + OutputSplit l = SplitLeftover(100, {0.5, 0.25, 0.125, 0.125}, false, false); + CHECK_EQ(l.trade, 67); + CHECK_EQ(l.terraform, 17); + CHECK_EQ(l.infra, 17); + l = SplitLeftover(100, {0, 1, 0, 0}, true, false); // construction-only slider + CHECK_EQ(l.trade, 50); + CHECK_EQ(l.terraform, 0); + CHECK_EQ(l.infra, 50); + l = SplitLeftover(100, {0, 1, 0, 0}, true, true); + CHECK_EQ(l.trade, 100); + 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_bonuses() { + TuningTable t = tuning(); + std::int64_t pop = 900, bonus = 500; + ApplyPopulationBonus(pop, 1000, bonus); + CHECK_EQ(pop, std::int64_t{1000}); + CHECK_EQ(bonus, std::int64_t{400}); + pop = 1200; // over cap: nothing applied + ApplyPopulationBonus(pop, 1000, bonus); + CHECK_EQ(pop, std::int64_t{1200}); + CHECK_EQ(bonus, std::int64_t{400}); + + double infra = 0.95, ibon = 0.1; + ApplyInfrastructureBonus(infra, ibon); + CHECK_NEAR(infra, 1.0, 1e-12); + CHECK_NEAR(ibon, 0.05, 1e-12); + + SystemBonusInputs in; + in.stable = true; + in.turnsOwned = 11; + in.turnsSinceRebellion = 11; + in.capacity = 1000000; + std::int64_t pbon = 0; + double ibonus = 0; + AccrueSystemBonus(in, pbon, ibonus, t); + CHECK_EQ(pbon, std::int64_t{10000}); // 1e6 x 0.01 + CHECK_NEAR(ibonus, 0.05, 1e-12); + 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); + + std::int64_t p2 = 0; + double i2 = 0; + in.turnsOwned = 10; // not strictly more than MINTURNS + AccrueSystemBonus(in, p2, i2, t); + CHECK_EQ(p2, std::int64_t{0}); + in.turnsOwned = 11; + in.stable = false; + AccrueSystemBonus(in, p2, i2, t); + CHECK_EQ(p2, std::int64_t{0}); +} + +static void test_build_queue() { + std::vector q = {{1, 11, 100, 100, 50}, {2, 12, 200, 200, 0}, {3, 13, 300, 300, 70}}; + BuildQueueResult r = ProcessBuildQueue(q, 250); + CHECK_EQ(r.completedOrderIds.size(), std::size_t{1}); + CHECK_EQ(r.completedOrderIds[0], 11); + CHECK_EQ(r.moneyCharged, 50); + CHECK_EQ(r.pointsLeft, 0); + CHECK_EQ(q.size(), std::size_t{2}); + CHECK_EQ(q[0].orderId, 12); + CHECK_EQ(q[0].constructionLeft, 50); + CHECK_EQ(q[1].constructionLeft, 300); + + r = ProcessBuildQueue(q, 700); + CHECK_EQ(r.completedOrderIds.size(), std::size_t{2}); + CHECK_EQ(r.moneyCharged, 70); + CHECK_EQ(r.pointsLeft, 350); + CHECK(q.empty()); + + r = ProcessBuildQueue(q, 100); // empty queue: points pass through + CHECK_EQ(r.pointsLeft, 100); + + std::vector exact = {{1, 21, 100, 100, 0}}; + r = ProcessBuildQueue(exact, 100); // exactly enough completes + CHECK_EQ(r.completedOrderIds.size(), std::size_t{1}); + CHECK(exact.empty()); + + std::vector zero = {{1, 31, 100, 100, 0}}; + r = ProcessBuildQueue(zero, 0); + CHECK(r.completedOrderIds.empty()); + CHECK_EQ(zero[0].constructionLeft, 100); +} + +int main() { + test_capacity(); + test_growth(); + test_infra_terraform(); + test_slaves(); + test_output(); + test_bonuses(); + test_build_queue(); + return simtest::finish("test_colony"); +} diff --git a/tests/game_sim/test_economy.cpp b/tests/game_sim/test_economy.cpp new file mode 100644 index 0000000..5ef429e --- /dev/null +++ b/tests/game_sim/test_economy.cpp @@ -0,0 +1,258 @@ +#include "game/sim/economy.h" +#include "game/sim/numeric.h" + +#include "check.h" + +using namespace sots::sim; + +static void test_interest() { + CHECK_EQ(SavingsInterest(1000, true), 10); + CHECK_EQ(SavingsInterest(199, true), 1); // 1.99 truncates + CHECK_EQ(SavingsInterest(1000, false), 0); // no systems, no interest + CHECK_EQ(SavingsInterest(-500, true), 0); + CHECK_EQ(SavingsInterest(0, true), 0); + + CHECK_EQ(DebtInterest(-1000), 150); + CHECK_EQ(DebtInterest(-7), 1); // 1.05 truncates + CHECK_EQ(DebtInterest(5), 0); + CHECK_EQ(DebtInterest(0), 0); + + CHECK_EQ(MaintenanceCost(100, 2.0), 50); + CHECK_EQ(MaintenanceCost(100, 1.9), 100); // divisor truncates to 1 + CHECK_EQ(MaintenanceCost(100, 0.5), 100); // divisor 0 guarded + + CHECK_EQ(SaturatingAdd(1900000000, 500000000), 2000000000); + CHECK_EQ(SaturatingAdd(-1900000000, -500000000), -2000000000); + CHECK_EQ(SaturatingAdd(5, -7), -2); +} + +static void test_research_points() { + // 50000 / 50 = 1000; x1.15 = 1150; x0.5 = 575; x0.85 = 488.75 -> 488 + CHECK_EQ(ResearchPointsFromMoney(50000, 1, 1, 0, 0, 1, 1, 1), 488); + // (ResMod + shrm + TRM) = 2 -> 977.5 -> 977 + CHECK_EQ(ResearchPointsFromMoney(50000, 1, 1, 0.5, 0.5, 1, 1, 1), 977); + // difficulty 0.5 -> 244.375 -> 244 + CHECK_EQ(ResearchPointsFromMoney(50000, 0.5, 1, 0, 0, 1, 1, 1), 244); + // tech x2, server x0.5, scale x1 -> unchanged 488 + CHECK_EQ(ResearchPointsFromMoney(50000, 1, 1, 0, 0, 2, 0.5, 1), 488); + CHECK_EQ(ResearchPointsFromMoney(0, 1, 1, 0, 0, 1, 1, 1), 0); + // 100 money -> 0.9775 -> 0 + CHECK_EQ(ResearchPointsFromMoney(100, 1, 1, 0, 0, 1, 1, 1), 0); +} + +static void test_expenses() { + std::vector s = {{100, 300, 250}, {50, 60, 100}}; + // minimums 150; extras 150 + 10 = 160; headroom 1000-150 = 850 -> 310 + CHECK_EQ(ExpenseTotal(s, 1000), 310); + // headroom 200-150 = 50 -> 200 + CHECK_EQ(ExpenseTotal(s, 200), 200); + CHECK_EQ(ExpenseTotal({}, 1000), 0); +} + +static BudgetInputs base_inputs() { + BudgetInputs in; + in.savings = 10000; + in.ownsSystems = true; + in.systemIncome = {5000, 3000, -200}; + in.tradeIncome = 1000; + in.maintenance = 2000; + in.maintenanceDivisor = 1.0; + in.constructionDemand = 500; + in.researchRate = 0.5; + in.hasResearchTarget = true; + return in; +} + +static void test_budget_hand_case() { + Budget b = ComputeBudget(base_inputs(), false); + CHECK_EQ(b.savingsInterest, 100); + CHECK_EQ(b.debtInterest, 0); + CHECK_EQ(b.systemIncomePositive, 8000); + CHECK_EQ(b.systemIncomeNegative, 200); + CHECK_EQ(b.maintenance, 2000); + CHECK_EQ(b.expenses, 0); + // 8000 + 1000 + 100 - 200 - 2000 = 6900 + CHECK_EQ(b.available, 6900); + CHECK_EQ(b.construction, 500); + // (6900 - 500) x 0.5 = 3200 + CHECK_EQ(b.researchMoney, 3200); + // 3200/50 = 64; x1.15 = 73.6; x0.5 = 36.8; x0.85 = 31.28 -> 31 + CHECK_EQ(b.researchPoints, 31); + CHECK_EQ(b.totalResearchPoints, 31); + CHECK(b.hasResearchAllocation); + CHECK_EQ(b.researchMoneyKept, 3200); + CHECK_EQ(b.bonusIncome, 0); + CHECK_EQ(b.savingsGiven, 0); + // 6900 - 500 - 3200 + CHECK_EQ(b.net, 3200); +} + +static void test_budget_projected() { + Budget b = ComputeBudget(base_inputs(), true); + CHECK_EQ(b.researchMoney, 0); + CHECK_EQ(b.researchPoints, 0); + CHECK_EQ(b.net, 6400); +} + +static void test_budget_debt() { + BudgetInputs in = base_inputs(); + in.savings = -1000; + Budget b = ComputeBudget(in, false); + CHECK_EQ(b.savingsInterest, 0); + CHECK_EQ(b.debtInterest, 150); + // 8000 + 1000 - 200 - 2000 - 150 = 6650 + CHECK_EQ(b.available, 6650); + CHECK_EQ(b.construction, 500); + // (6650 - 500) x 0.5 = 3075 + CHECK_EQ(b.researchMoney, 3075); + CHECK_EQ(b.net, 6650 - 500 - 3075); +} + +static void test_budget_aid_and_bonus() { + BudgetInputs in = base_inputs(); + in.aidResearchPercent = 50; + in.aidSavings = 100; + in.tra = 4; + in.trp = 5; + Budget b = ComputeBudget(in, false); + CHECK_EQ(b.researchMoney, 3200); + CHECK_EQ(b.researchMoneyGiven, 1600); + CHECK_EQ(b.researchMoneyKept, 1600); + // 31 + 4 + 5 = 40; given 20; kept 20 + CHECK_EQ(b.researchPointsGiven, 20); + CHECK_EQ(b.totalResearchPoints, 20); + CHECK_EQ(b.savingsGiven, 100); + // 6900 - 500 - 1600 - 1600 - 100 + CHECK_EQ(b.net, 3100); + + in.aidResearchPercent = 250; // clamps to 100 + in.aidSavings = 0; + b = ComputeBudget(in, false); + CHECK_EQ(b.researchMoneyGiven, 3200); + CHECK_EQ(b.totalResearchPoints, 0); + + in = base_inputs(); + in.techIncomeMult = 1.1; + b = ComputeBudget(in, false); + // remaining before bonus = 3200 -> ftol(0.1 x 3200) = 320 + CHECK_EQ(b.bonusIncome, 320); + CHECK_EQ(b.net, 3520); +} + +static void test_budget_edges() { + BudgetInputs in = base_inputs(); + in.isAI = true; + Budget b = ComputeBudget(in, false); + CHECK_EQ(b.construction, 0); // AI path spends construction elsewhere + CHECK_EQ(b.researchMoney, 3450); // 6900 x 0.5 + + in = base_inputs(); + in.systemIncome = {}; + in.tradeIncome = 0; + in.maintenance = 5000; + b = ComputeBudget(in, false); + CHECK_EQ(b.available, 0); // floored at zero + CHECK_EQ(b.construction, 0); + CHECK_EQ(b.researchMoney, 0); + CHECK_EQ(b.net, 100 - 5000); // interest minus maintenance + + in = base_inputs(); + in.constructionDemand = 100000; + b = ComputeBudget(in, false); + CHECK_EQ(b.construction, 6900); // capped at available + CHECK_EQ(b.researchMoney, 0); + + in = base_inputs(); + in.expenses = {{1000, 2000, 1500}}; + b = ComputeBudget(in, false); + CHECK_EQ(b.expenses, 1500); + CHECK_EQ(b.available, 5400); +} + +static void test_trade() { + TuningTable t; + t.TRADE_ROUTE_REQ_CIVPOPULATION = 1e6; + t.TRADE_ROUTE_REQ_IMPPOPULATION = 1e6; + CHECK_EQ(TradeRoutesSupported(2.5e6, 1e6, t), 4); // ceil(2.5) + 1 + CHECK_EQ(TradeRoutesSupported(0, 0, t), 1); // minimum one + CHECK_EQ(TradeRoutesSupported(1, 0, t), 1); + TuningTable zero; + CHECK_EQ(TradeRoutesSupported(5e6, 5e6, zero), 1); // zero requirement guarded + + t.TRADE_ROUTE_STARTUP_TURNS = 3; + t.TRADE_ROUTE_STARTUP_INCOME = 7; + t.TRADE_ROUTE_MIN_INCOME = 100; + t.TRADE_ROUTE_MAX_FREIGHTERS = 5; + t.TRADE_ROUTE_INCOME_PERFREIGHTER_CRQ = 30; + t.TRADE_ROUTE_INCOME_PERFREIGHTER_CR = 20; + t.TRADE_ROUTE_INCOME_PERFREIGHTER_DE = 10; + t.STATION_BONUS_TRADE_INCOME = 0.1; + t.ADDICTION_TRADE_MOD = 0.5; + t.TRADE_ROUTE_OWNERS_SHARE = 0.6; + + TradeRouteState r; + r.ageTurns = 3; + r.freighters[0] = 2; // CRQ: 2 x 30 = 60, cap left 3 + r.freighters[1] = 4; // CR: min(4,3) = 3 x 20 = 60, cap left 0 + r.freighters[2] = 3; // DE: nothing left + CHECK_EQ(TradeRouteGrossIncome(r, t), 220); + r.tradeStationsAtSystem = 2; + CHECK_EQ(TradeRouteGrossIncome(r, t), 264); // x 1.2 + r.partnerAddicted = true; + CHECK_EQ(TradeRouteGrossIncome(r, t), 132); // x 0.5 + CHECK_EQ(TradeRouteIncome(r, true, 1.0, t), 79); // 132 x 0.6 = 79.2 + CHECK_EQ(TradeRouteIncome(r, false, 1.0, t), 52); // 132 x 0.4 = 52.8 + CHECK_EQ(TradeRouteIncome(r, true, 2.0, t), 158); // AI trade multiplier + r.ageTurns = 2; + CHECK_EQ(TradeRouteGrossIncome(r, t), 7); // startup income is flat + + TradeRouteState empty; + empty.ageTurns = 10; + CHECK_EQ(TradeRouteGrossIncome(empty, t), 100); // MIN_INCOME with no freighters + t.TRADE_ROUTE_OWNERS_SHARE = 1.5; // clamps to 1 + CHECK_EQ(TradeRouteIncome(empty, true, 1.0, t), 100); + CHECK_EQ(TradeRouteIncome(empty, false, 1.0, t), 0); +} + +static void test_bankruptcy() { + TuningTable t; + t.BANKRUPTCY_PROTECTION_LIMIT_FACTOR = 3.3; + t.BANKRUPTCY_ELIMINATION_TURNS = 5; + 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(BankruptcyLevel(0, l), 0); + + BankruptcyLimits none = ComputeBankruptcyLimits(0, t); + CHECK_EQ(none.eliminationFloor, 0); + CHECK_EQ(BankruptcyLevel(-1, none), 2); // no income at all: any debt is terminal + + BankruptcyState s; + CHECK(!BankruptcyStep(s, 1, 10, t)); + CHECK_EQ(s.warningLevel, 1); + CHECK_EQ(s.startTurn, 10); + CHECK(!BankruptcyStep(s, 2, 12, t)); // level changed: clock restarts at 12 + 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 + CHECK_EQ(s.warningLevel, 0); +} + +int main() { + test_interest(); + test_research_points(); + test_expenses(); + test_budget_hand_case(); + test_budget_projected(); + test_budget_debt(); + test_budget_aid_and_bonus(); + test_budget_edges(); + test_trade(); + test_bankruptcy(); + return simtest::finish("test_economy"); +} diff --git a/tests/game_sim/test_movement.cpp b/tests/game_sim/test_movement.cpp new file mode 100644 index 0000000..32ead9e --- /dev/null +++ b/tests/game_sim/test_movement.cpp @@ -0,0 +1,139 @@ +#include "game/sim/movement.h" + +#include "check.h" + +using namespace sots::sim; + +static void test_vectors() { + CHECK_NEAR(Distance({0, 0, 0}, {3, 4, 0}), 5.0, 1e-12); + Vec3 p = AdvanceToward({0, 0, 0}, {10, 0, 0}, 4); + CHECK_NEAR(p.x, 4.0, 1e-12); + CHECK_NEAR(p.y, 0.0, 0.0); + p = AdvanceToward({0, 0, 0}, {10, 0, 0}, 20); // snaps to the destination + CHECK_NEAR(p.x, 10.0, 0.0); + p = AdvanceToward({1, 2, 3}, {1, 2, 3}, 5); // already there + CHECK_NEAR(p.z, 3.0, 0.0); + p = AdvanceToward({0, 0, 0}, {3, 4, 0}, 2.5); // half way along a 3-4-5 + CHECK_NEAR(p.x, 1.5, 1e-12); + CHECK_NEAR(p.y, 2.0, 1e-12); +} + +static void test_steps() { + CHECK_NEAR(StraightStep(10, 0.5), 5.0, 0.0); + CHECK_NEAR(StraightStep(10, kFullStep), 10.0, 0.0); + CHECK_NEAR(StraightStep(0, kHalfStep), 0.0, 0.0); + + TuningTable t; + t.STUTTER_SYSTEM_INFLUENCE_RADIUS = 100; + t.STUTTER_MIN_SPEED = 0.2; + t.STUTTER_MAX_SPEED = 1.0; + 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 + TuningTable zero; + CHECK_NEAR(NodeLineSpeed(10, 50, zero), 0.0, 0.0); // no tuning -> no speed +} + +static void test_resolve() { + MoveStepResult r = ResolveMoveStep(5, 10, 20); + CHECK_NEAR(r.moved, 5.0, 0.0); + CHECK_NEAR(r.fraction, 1.0, 0.0); + CHECK(!r.arrived); + CHECK(!r.outOfFuel); + + r = ResolveMoveStep(5, 3, 20); // range 2.95 limits the step + CHECK_NEAR(r.moved, 2.95, 1e-12); + CHECK_NEAR(r.fraction, 0.59, 1e-12); + + r = ResolveMoveStep(5, 0, 20); // no fuel at all + CHECK_NEAR(r.moved, 0.0, 0.0); + CHECK(r.outOfFuel); + CHECK(!r.arrived); + + r = ResolveMoveStep(5, 0, 0.01); // even a tiny hop needs range + CHECK(r.outOfFuel); + + r = ResolveMoveStep(50, 100, 20); // arrives with step to spare + CHECK_NEAR(r.moved, 20.0, 0.0); + CHECK(r.arrived); + CHECK_NEAR(r.fraction, 0.4, 1e-12); + + r = ResolveMoveStep(5, 0.02, 20); // range below the margin: stuck + CHECK_NEAR(r.moved, 0.0, 0.0); + CHECK(!r.outOfFuel); + + r = ResolveMoveStep(0, 10, 20); // zero step + CHECK_NEAR(r.moved, 0.0, 0.0); + CHECK_NEAR(r.fraction, 1.0, 0.0); + + r = ResolveMoveStep(5, 10, 0); // already at the destination + CHECK(r.arrived); + + CHECK_NEAR(ConsumeShipRange(10, 3, false), 7.0, 0.0); + CHECK_NEAR(ConsumeShipRange(2, 3, false), 0.0, 0.0); + CHECK_NEAR(ConsumeShipRange(10, 3, true), 10.0, 0.0); + + CHECK_NEAR(RemainingPassTime(0.4, 1.0), 0.6, 1e-12); + CHECK_NEAR(RemainingPassTime(0.4, 0.5), 0.3, 1e-12); + CHECK_NEAR(RemainingPassTime(0.99995, 1.0), 0.0, 0.0); + CHECK_NEAR(RemainingPassTime(1.0, 1.0), 0.0, 0.0); +} + +static void test_multi_waypoint_turn() { + // A fleet with speed 10 and plenty of range covers a 4-unit leg, then continues + // with the remaining 0.6 of the turn onto the next leg. + double dt = kFullStep; + MoveStepResult a = ResolveMoveStep(StraightStep(10, dt), 100, 4); + CHECK(a.arrived); + dt = RemainingPassTime(a.fraction, dt); + CHECK_NEAR(dt, 0.6, 1e-12); + MoveStepResult b = ResolveMoveStep(StraightStep(10, dt), 96, 20); + CHECK_NEAR(b.moved, 6.0, 1e-12); + CHECK(!b.arrived); + CHECK_NEAR(RemainingPassTime(b.fraction, dt), 0.0, 0.0); +} + +static void test_jump() { + { + simtest::ScriptedRng rng({0.7f}); + JumpResult j = RollProbabilisticJump(1.0, 0.5, rng); + CHECK(!j.arrived); + CHECK_NEAR(j.stopFraction, 0.7, 1e-7); + CHECK_EQ(rng.floatDraws(), std::size_t{1}); + } + { + simtest::ScriptedRng rng({0.3f}); + JumpResult j = RollProbabilisticJump(1.0, 0.5, rng); + CHECK(j.arrived); + CHECK_NEAR(j.stopFraction, 1.0, 0.0); + } + { // efficiency scales the roll: 0.9 x 0.5 = 0.45 <= 0.5 arrives + simtest::ScriptedRng rng({0.9f}); + JumpResult j = RollProbabilisticJump(0.5, 0.5, rng); + CHECK(j.arrived); + } + { // roll equal to the threshold is not "greater": arrives + simtest::ScriptedRng rng({0.5f}); + JumpResult j = RollProbabilisticJump(1.0, 0.5, rng); + CHECK(j.arrived); + } + { // determinism: the same script gives the same outcome + auto run = [] { + simtest::ScriptedRng rng({0.6f, 0.2f, 0.95f}); + std::vector out; + for (int i = 0; i < 3; ++i) out.push_back(RollProbabilisticJump(1.0, 0.5, rng).stopFraction); + return out; + }; + CHECK(run() == run()); + } +} + +int main() { + test_vectors(); + test_steps(); + test_resolve(); + test_multi_waypoint_turn(); + test_jump(); + return simtest::finish("test_movement"); +} diff --git a/tests/game_sim/test_research.cpp b/tests/game_sim/test_research.cpp new file mode 100644 index 0000000..c530638 --- /dev/null +++ b/tests/game_sim/test_research.cpp @@ -0,0 +1,238 @@ +#include "game/sim/research.h" + +#include "check.h" + +using namespace sots::sim; + +static void test_edge_roll() { + EdgeAvailability e; + e.SetPercent(Species::Human, 50); + e.SetPercent(Species::Zuul, 0); + + { // roll at exactly the chance is included (<=) + simtest::ScriptedRng rng({0.5f}); + CHECK(RollEdgeAvailable(e, Species::Human, TreeBuildMode::Normal, rng)); + CHECK_EQ(rng.floatDraws(), std::size_t{1}); + } + { + simtest::ScriptedRng rng({0.51f}); + CHECK(!RollEdgeAvailable(e, Species::Human, TreeBuildMode::Normal, rng)); + } + { // explicit 0 excludes without drawing + simtest::ScriptedRng rng({0.0f}); + CHECK(!RollEdgeAvailable(e, Species::Zuul, TreeBuildMode::Normal, rng)); + CHECK_EQ(rng.floatDraws(), std::size_t{0}); + } + { // unlisted species default to 1.0: included, no draw + simtest::ScriptedRng rng({0.99f}); + CHECK(RollEdgeAvailable(e, Species::Morrigi, TreeBuildMode::Normal, rng)); + CHECK(RollEdgeAvailable(e, Species::Hiver, TreeBuildMode::Normal, rng)); + CHECK_EQ(rng.floatDraws(), std::size_t{0}); + } + { // NoRoll: any non-zero chance is in, zero stays out + simtest::ScriptedRng rng; + e.SetPercent(Species::Tarkas, 1); + CHECK(RollEdgeAvailable(e, Species::Tarkas, TreeBuildMode::NoRoll, rng)); + CHECK(!RollEdgeAvailable(e, Species::Zuul, TreeBuildMode::NoRoll, rng)); + } + { // Everything ignores the chance entirely + simtest::ScriptedRng rng; + CHECK(RollEdgeAvailable(e, Species::Zuul, TreeBuildMode::Everything, rng)); + } + CHECK_NEAR(e.Chance(Species::Human), 0.5, 1e-7); + CHECK_NEAR(e.Chance(Species::Liir), 1.0, 0.0); +} + +static void test_cost() { + CHECK_NEAR(TechCostMultiplier(0), 1.0, 0.0); + CHECK_NEAR(TechCostMultiplier(1), 0.75, 0.0); + CHECK_NEAR(TechCostMultiplier(3), 0.25, 0.0); + CHECK_NEAR(TechCostMultiplier(4), 0.25, 0.0); // floored + CHECK_EQ(TechCost(1000, 0.75), 750); + CHECK_EQ(TechCost(1, 0.25), 1); // never below 1 + CHECK_EQ(TechCost(kNoResearchCost, 0.5), kNoResearchCost); + CHECK_EQ(TechCost(999, 1.0), 999); +} + +static ResearchNode node(int cost, int progress = 0) { + ResearchNode n; + n.cost = cost; + n.progress = progress; + return n; +} + +static void test_progress_below_half() { + // cost 1000: lo 500, hi 1500. 400 points -> odds (400-500)/1500 < 0, roll 0 still fails. + ResearchNode n = node(1000); + simtest::ScriptedRng rng({0.0f}); + ResearchStepResult r = ApplyResearchPoints(n, 400, Species::Human, rng); + CHECK_EQ(r.spent, 400); + CHECK_EQ(r.overbudget, 0); + CHECK(!r.completed); + CHECK(!r.overbudgetEvent); + CHECK_NEAR(r.odds, -100.0 / 1500.0, 1e-12); + CHECK_EQ(n.progress, 400); + CHECK(n.state == TechState::Available); + CHECK_EQ(rng.floatDraws(), std::size_t{1}); +} + +static void test_progress_at_cost() { + // reaching 100 %: odds = 500/1500 = 1/3 + { + ResearchNode n = node(1000); + simtest::ScriptedRng rng({0.3f}); + ResearchStepResult r = ApplyResearchPoints(n, 1000, Species::Human, rng); + CHECK(r.completed); + CHECK(!r.completedEarly); + CHECK_NEAR(r.odds, 1.0 / 3.0, 1e-12); + CHECK(n.state == TechState::Researched); + CHECK(n.flag == TechFlag::Default); + } + { + ResearchNode n = node(1000); + simtest::ScriptedRng rng({0.34f}); + ResearchStepResult r = ApplyResearchPoints(n, 1000, Species::Human, rng); + CHECK(!r.completed); + CHECK(r.overbudgetEvent); // crossed 100 % without finishing + CHECK(n.flag == TechFlag::OverBudgetNotified); + CHECK(n.state == TechState::Available); + } +} + +static void test_progress_cap_150() { + ResearchNode n = node(1000); + simtest::ScriptedRng rng; // no draw expected + ResearchStepResult r = ApplyResearchPoints(n, 2000, Species::Human, rng); + CHECK_EQ(r.spent, 1500); + CHECK_EQ(r.overbudget, 500); + CHECK_NEAR(r.odds, 1.0, 0.0); + CHECK(r.completed); + CHECK(!r.completedEarly); + CHECK_EQ(rng.floatDraws(), std::size_t{0}); + CHECK_EQ(n.progress, 1500); + + // exactly 150 % also completes without a roll + ResearchNode m = node(1000, 1400); + ResearchStepResult r2 = ApplyResearchPoints(m, 100, Species::Human, rng); + CHECK(r2.completed); + CHECK_EQ(r2.overbudget, 0); +} + +static void test_completed_early() { + // progress 700 of 1000: odds 200/1500 = 0.1333; roll 0.1 completes, below 80 % -> early + ResearchNode n = node(1000, 300); + simtest::ScriptedRng rng({0.1f}); + ResearchStepResult r = ApplyResearchPoints(n, 400, Species::Human, rng); + CHECK(r.completed); + CHECK(r.completedEarly); + CHECK(n.flag == TechFlag::CompletedEarly); + // wasCompleteBefore/nowComplete both false: no over-budget event + CHECK(!r.overbudgetEvent); +} + +static void test_zero_spend() { + ResearchNode n = node(1000, 600); + simtest::ScriptedRng rng; + ResearchStepResult r = ApplyResearchPoints(n, 0, Species::Human, rng); + CHECK_EQ(r.spent, 0); + CHECK(!r.completed); + CHECK_NEAR(r.odds, 0.0, 0.0); + CHECK_NEAR(static_cast(r.roll), 1.0, 0.0); + CHECK_EQ(rng.floatDraws(), std::size_t{0}); +} + +static void test_zuul_double_roll() { + { // Zuul: 0.9 then 0.1 -> keeps 0.1 -> completes at 100 % + ResearchNode n = node(1000); + simtest::ScriptedRng rng({0.9f, 0.1f}); + ResearchStepResult r = ApplyResearchPoints(n, 1000, Species::Zuul, rng); + CHECK(r.completed); + CHECK_NEAR(static_cast(r.roll), 0.1, 1e-7); + CHECK_EQ(rng.floatDraws(), std::size_t{2}); + } + { // same script for a Human: one draw of 0.9 -> fails + ResearchNode n = node(1000); + simtest::ScriptedRng rng({0.9f, 0.1f}); + ResearchStepResult r = ApplyResearchPoints(n, 1000, Species::Human, rng); + CHECK(!r.completed); + CHECK_EQ(rng.floatDraws(), std::size_t{1}); + } +} + +static void test_decay() { + CHECK_EQ(DecayResearchProgress(300, 1000), 250); + CHECK_EQ(DecayResearchProgress(20, 1000), 0); // floored + CHECK_EQ(DecayResearchProgress(10, 30), 9); // ftol(1.5) = 1 + CHECK_EQ(DecayResearchProgress(0, 1000), 0); + CHECK_EQ(DecayResearchProgress(50, kNoResearchCost), 50); + + std::vector nodes = {node(1000, 300), node(1000, 0), node(1000, 500)}; + nodes[2].state = TechState::Researched; + ResearchNode hidden = node(1000, 100); + hidden.state = TechState::ParentResearched; + nodes.push_back(hidden); + DecayAllResearch(nodes); + CHECK_EQ(nodes[0].progress, 250); + CHECK_EQ(nodes[1].progress, 0); + CHECK_EQ(nodes[2].progress, 500); // researched: untouched + CHECK_EQ(nodes[3].progress, 100); // not available: untouched +} + +static void test_lab_accident() { + { + simtest::ScriptedRng rng({}, {5, 10, 99}); + CHECK(RollLabAccident(10, rng)); // 5 < 10 + CHECK(!RollLabAccident(10, rng)); // 10 is not < 10 + CHECK(!RollLabAccident(0, rng)); + CHECK_EQ(rng.intDraws(), std::size_t{3}); + } + { // dyadic inputs so the ceil is not on a floating-point tie + simtest::ScriptedRng rng({0.5f, 0.999f, 0.0f}); + CHECK_EQ(LabAccidentLossPercent(0.125, 0.625, rng), 38); // 0.125 + 0.5 x 0.5 = 0.375 -> ceil(37.5) + CHECK_EQ(LabAccidentLossPercent(0.125, 0.625, rng), 63); // 0.6245 -> ceil(62.45) + CHECK_EQ(LabAccidentLossPercent(0.125, 0.625, rng), 13); // 0.125 -> ceil(12.5) + CHECK_EQ(rng.floatDraws(), std::size_t{3}); + } + { + simtest::ScriptedRng rng({0.5f}); + CHECK_EQ(LabAccidentLossPercent(0.9, 1.5, rng), 100); // clamped to 1 + } +} + +static void test_determinism() { + auto run = [] { + std::vector nodes = {node(800), node(1200, 300)}; + // Zuul: two draws per turn; the current target decays 40/turn too (cost 800). + // Turn 1: 350, odds < 0. Turn 2: 310+350 = 660, odds 260/1200 vs min(0.8,0.5). + // Turn 3: 620+350 = 970, odds 570/1200 = 0.475 vs min(0.3,0.9) -> completes. + simtest::ScriptedRng rng({0.42f, 0.17f, 0.8f, 0.5f, 0.3f, 0.9f}); + std::vector trace; + for (int turn = 0; turn < 3; ++turn) { + ResearchStepResult r = ApplyResearchPoints(nodes[0], 350, Species::Zuul, rng); + trace.push_back(nodes[0].progress); + trace.push_back(r.completed ? 1 : 0); + DecayAllResearch(nodes); + trace.push_back(nodes[1].progress); + } + trace.push_back(static_cast(rng.floatDraws())); + return trace; + }; + CHECK(run() == run()); + const std::vector expected = {350, 0, 240, 660, 0, 180, 970, 1, 120, 6}; + CHECK(run() == expected); +} + +int main() { + test_edge_roll(); + test_cost(); + test_progress_below_half(); + test_progress_at_cost(); + test_progress_cap_150(); + test_completed_early(); + test_zero_spend(); + test_zuul_double_roll(); + test_decay(); + test_lab_accident(); + test_determinism(); + return simtest::finish("test_research"); +}