merge game/sim

This commit is contained in:
alex 2026-09-07 17:28:45 -04:00
commit d149cf826c
22 changed files with 2789 additions and 0 deletions

107
docs/game-sim.md Normal file
View file

@ -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.

View file

@ -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()

232
src/game/sim/colony.cpp Normal file
View file

@ -0,0 +1,232 @@
#include "game/sim/colony.h"
#include <algorithm>
#include <cmath>
#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<double>(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<double>(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<double>(in.pop) / static_cast<double>(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<double>(in.pop) * g);
if (g > 0) delta = std::max<std::int64_t>(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<std::int64_t>(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<int>(std::ceil((1.0 - infra) / kInfraPerPoint));
}
double InfrastructureGain(int points) {
return (static_cast<double>(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<double>(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<double>(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<std::int64_t>(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<double>(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<std::int64_t>(0, capacity - pop);
const std::int64_t applied = std::max<std::int64_t>(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<double>(in.capacity) * t.SYSTEMBONUS_POPBONUS_INC);
const std::int64_t popRoom = Ftoi64(static_cast<double>(in.capacity) * popCapMult) - popBonus;
popBonus += std::max<std::int64_t>(0, std::min(popInc, popRoom));
const double infraRoom = infraCap - infraBonus;
infraBonus += std::max(0.0, std::min(t.SYSTEMBONUS_INFRABONUS_INC, infraRoom));
}
BuildQueueResult ProcessBuildQueue(std::vector<BuildOrder>& 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<std::ptrdiff_t>(completed));
r.pointsLeft = points;
return r;
}
} // namespace sots::sim

238
src/game/sim/colony.h Normal file
View file

@ -0,0 +1,238 @@
// Colonies: carrying capacity, population growth, infrastructure and terraforming
// point conversion, slave deaths, output split, build-queue consumption.
#pragma once
#include <cstdint>
#include <vector>
#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<int> 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<BuildOrder>& queue, int points);
} // namespace sots::sim

164
src/game/sim/economy.cpp Normal file
View file

@ -0,0 +1,164 @@
#include "game/sim/economy.h"
#include <algorithm>
#include <cmath>
#include "game/sim/numeric.h"
namespace sots::sim {
int SavingsInterest(int savings, bool ownsSystems) {
if (savings < 0 || !ownsSystems) return 0;
return Ftol(static_cast<double>(savings) * 0.01);
}
int DebtInterest(int savings) {
if (savings >= 0) return 0;
return Ftol(-static_cast<double>(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<ExpenseSlider>& 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<std::int64_t>(availableBeforeExpenses) - minimums;
const std::int64_t total = minimums + std::min(extras, headroom);
return static_cast<int>(ClampT<std::int64_t>(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<double>(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<std::int64_t>(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<int>(std::max<std::int64_t>(0, running())));
b.available = static_cast<int>(std::max<std::int64_t>(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<double>(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<int>(static_cast<std::int64_t>(b.researchMoney) * pct / 100);
b.researchPointsGiven = static_cast<int>(static_cast<std::int64_t>(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<double>(running()));
b.savingsGiven = static_cast<int>(std::min<std::int64_t>(std::max<std::int64_t>(running(), 0),
std::max(0, in.aidSavings)));
b.net = static_cast<int>(ClampT<std::int64_t>(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<std::int64_t>(n) * perFreighter[c];
capLeft -= n;
}
double v = static_cast<double>(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<double>(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<double>(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

178
src/game/sim/economy.h Normal file
View file

@ -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 <cstdint>
#include <vector>
#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<int> 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<ExpenseSlider> 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<ExpenseSlider>& 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

71
src/game/sim/movement.cpp Normal file
View file

@ -0,0 +1,71 @@
#include "game/sim/movement.h"
#include <algorithm>
#include <cmath>
#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<double>(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

80
src/game/sim/movement.h Normal file
View file

@ -0,0 +1,80 @@
// Movement: the per-pass fleet step, node-line speed profile, probabilistic jumps.
#pragma once
#include <vector>
#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

48
src/game/sim/numeric.h Normal file
View file

@ -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 <cstdint>
#include <cmath>
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<int>(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<std::int64_t>(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 <class T>
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<std::int64_t>(a) + static_cast<std::int64_t>(b);
constexpr std::int64_t kLimit = 2000000000;
if (s > kLimit) return static_cast<int>(kLimit);
if (s < -kLimit) return static_cast<int>(-kLimit);
return static_cast<int>(s);
}
} // namespace sots::sim

96
src/game/sim/research.cpp Normal file
View file

@ -0,0 +1,96 @@
#include "game/sim/research.h"
#include <algorithm>
#include <cmath>
#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<double>(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<int>(static_cast<std::int64_t>(cost) * 50 / 100));
const int hi = std::max(lo, static_cast<int>(static_cast<std::int64_t>(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<double>(node.progress - lo) / static_cast<double>(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<double>(r.roll)) {
if (!r.wasCompleteBefore && nowComplete) {
r.overbudgetEvent = true;
node.flag = TechFlag::OverBudgetNotified;
}
return r;
}
r.completed = true;
if (cost > 0 && static_cast<double>(node.progress) / static_cast<double>(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<double>(cost) * 0.05));
}
void DecayAllResearch(std::vector<ResearchNode>& 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<int>(rng.NextInt(100));
return roll < oddsPercent;
}
int LabAccidentLossPercent(double minLoss, double maxLoss, IRandom& rng) {
const double f = Clamp01(static_cast<double>(rng.NextFloat()) * (maxLoss - minLoss) + minLoss);
return static_cast<int>(std::ceil(f * 100.0));
}
} // namespace sots::sim

117
src/game/sim/research.h Normal file
View file

@ -0,0 +1,117 @@
// Research: tech-tree race gating, per-turn progress and completion, cost decay,
// lab-accident rolls.
#pragma once
#include <climits>
#include <vector>
#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<int>(s)] = percent / 100.f; }
float Chance(Species s) const { return chance[static_cast<int>(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<ResearchNode>& 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

22
src/game/sim/rng.h Normal file
View file

@ -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 <cstdint>
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

25
src/game/sim/species.h Normal file
View file

@ -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

71
src/game/sim/tuning.h Normal file
View file

@ -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 <cstdint>
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

37
tests/game_sim/build_and_run.sh Executable file
View file

@ -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

72
tests/game_sim/check.h Normal file
View file

@ -0,0 +1,72 @@
// Minimal test helpers: CHECK macros, a scripted RNG, and a summary exit code.
#pragma once
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <string>
#include <vector>
#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 <class A, class B>
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<float> floats;
std::vector<std::uint32_t> ints;
std::size_t fi = 0, ii = 0;
explicit ScriptedRng(std::vector<float> f = {}, std::vector<std::uint32_t> 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 <class A, class B>
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 <class A, class B>
inline void check_near(const A& a, const B& b, double eps, const char* expr, const char* file, int line) {
report(near(static_cast<double>(a), static_cast<double>(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__)

146
tests/game_sim/mini_json.h Normal file
View file

@ -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 <cctype>
#include <cstdlib>
#include <map>
#include <memory>
#include <string>
#include <vector>
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<Value> arr;
std::map<std::string, Value> 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<unsigned char>(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<std::size_t>(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<char>(cp));
else if (cp < 0x800) { out.push_back(static_cast<char>(0xC0 | (cp >> 6))); out.push_back(static_cast<char>(0x80 | (cp & 0x3F))); }
else { out.push_back(static_cast<char>(0xE0 | (cp >> 12))); out.push_back(static_cast<char>(0x80 | ((cp >> 6) & 0x3F))); out.push_back(static_cast<char>(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

View file

@ -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 <cstdio>
#include <cstdlib>
#include <fstream>
#include <sstream>
#include <string>
#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<int, const minijson::Value*> 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<int>(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<int>(p->get("Sav") ? p->get("Sav")->number() : 0);
in.maintenance = static_cast<int>(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<int>(p->get("TRA") ? p->get("TRA")->number() : 0);
in.trp = static_cast<int>(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<int>(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<int>(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<int>(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;
}

View file

@ -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<BuildOrder> 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<BuildOrder> 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<BuildOrder> 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");
}

View file

@ -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<ExpenseSlider> s = {{100, 300, 250}, {50, 60, 100}};
// minimums 150; extras 150 + 10 = 160; headroom 1000-150 = 850 -> 310
CHECK_EQ(ExpenseTotal(s, 1000), 310);
// headroom 200-150 = 50 -> 200
CHECK_EQ(ExpenseTotal(s, 200), 200);
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");
}

View file

@ -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<double> 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");
}

View file

@ -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<double>(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<double>(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<ResearchNode> 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<ResearchNode> 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<int> 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<int>(rng.floatDraws()));
return trace;
};
CHECK(run() == run());
const std::vector<int> 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");
}