game/sim: strategic formulas as pure functions (economy, research, colony, movement), 356 checks
This commit is contained in:
parent
4a15301db6
commit
72ecc74684
22 changed files with 2789 additions and 0 deletions
107
docs/game-sim.md
Normal file
107
docs/game-sim.md
Normal 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.
|
||||
28
src/game/sim/CMakeLists.txt
Normal file
28
src/game/sim/CMakeLists.txt
Normal 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
232
src/game/sim/colony.cpp
Normal 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
238
src/game/sim/colony.h
Normal 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
164
src/game/sim/economy.cpp
Normal 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
178
src/game/sim/economy.h
Normal 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
71
src/game/sim/movement.cpp
Normal 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
80
src/game/sim/movement.h
Normal 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
48
src/game/sim/numeric.h
Normal 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
96
src/game/sim/research.cpp
Normal 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
117
src/game/sim/research.h
Normal 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
22
src/game/sim/rng.h
Normal 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
25
src/game/sim/species.h
Normal 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
71
src/game/sim/tuning.h
Normal 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
37
tests/game_sim/build_and_run.sh
Executable 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
72
tests/game_sim/check.h
Normal 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
146
tests/game_sim/mini_json.h
Normal 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
|
||||
113
tests/game_sim/smoke_real_save.cpp
Normal file
113
tests/game_sim/smoke_real_save.cpp
Normal 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;
|
||||
}
|
||||
309
tests/game_sim/test_colony.cpp
Normal file
309
tests/game_sim/test_colony.cpp
Normal 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");
|
||||
}
|
||||
258
tests/game_sim/test_economy.cpp
Normal file
258
tests/game_sim/test_economy.cpp
Normal 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");
|
||||
}
|
||||
139
tests/game_sim/test_movement.cpp
Normal file
139
tests/game_sim/test_movement.cpp
Normal 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");
|
||||
}
|
||||
238
tests/game_sim/test_research.cpp
Normal file
238
tests/game_sim/test_research.cpp
Normal 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");
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue