merge lane E1: income chain 25/25 on the BnkEl oracle; difficulty multiplier resolved (turn.cpp union-resolved: tail phases 17, 21, 31)
This commit is contained in:
commit
0f1c007f00
12 changed files with 616 additions and 44 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
// GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1).
|
// GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1).
|
||||||
// Source: sots-re ghidra/addresses.json @ d617285, generated 2026-09-08 by tools/gen_addresses.py
|
// Source: sots-re ghidra/addresses.json @ bebdee1, generated 2026-09-08 by tools/gen_addresses.py
|
||||||
// Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated).
|
// Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated).
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
|
@ -1469,6 +1469,30 @@ constexpr uint32_t ShipDesign_off_Dtc = 0x00000134;
|
||||||
constexpr uint32_t ShipSectionDef_off_SectionClass = 0x00000260;
|
constexpr uint32_t ShipSectionDef_off_SectionClass = 0x00000260;
|
||||||
// offset unsigned int // low dword of the section's 64-bit role-flag word (high dword at +0x29c), one bit per boolean role key in the .shipsection file [verified]
|
// offset unsigned int // low dword of the section's 64-bit role-flag word (high dword at +0x29c), one bit per boolean role key in the .shipsection file [verified]
|
||||||
constexpr uint32_t ShipSectionDef_off_RoleFlagsLow = 0x00000298;
|
constexpr uint32_t ShipSectionDef_off_RoleFlagsLow = 0x00000298;
|
||||||
|
// thiscall double (ServerSystem* sys, int groupType) // `ret 4`, real end 0x0074d8e1. The income analogue of PopOutput 0x0074d8f0, and NOT the same law: it sums, over species 0..6, `(double)ftol( (double)GroupIncome(groupType, count) x moraleMod x addictionMod )` -- so the value truncates TWICE per (group, species) row, once inside GroupIncome and once after both factors. moraleMod is 0x00746910 (the same helper the output term uses) and applies to groupType 1 only; addictionMod is the float behind slot 0x00aeca48 when the system's int[7] addiction table at +0x1e4 has a non-zero entry for that species. For groupType 1 and the owner's own species on a non-independent system the count first gains the capacity surplus from two calls to 0x0074a6d0 (out slot 4, then out slot 6), max(0, B - A) [verified]
|
||||||
|
constexpr uint32_t ServerSystem_PopIncome = 0x0034d760;
|
||||||
|
// thiscall double (ServerSystem* sys) // plain `ret`, real end 0x0074b793. groupType 2 of the same loop as PopIncome, over SlaveCount(species) (0x0074b610): no morale factor and no capacity surplus, but the addiction factor still applies. Unexercised: slave counts are 0 on every call in the corpus [verified]
|
||||||
|
constexpr uint32_t ServerSystem_SlaveIncome = 0x0034b700;
|
||||||
|
// thiscall int64 (ServerSystem* sys, int species) // the slave-group population of one species; the group-2 counterpart of GroupPopulation 0x00747ba0 [unverified]
|
||||||
|
constexpr uint32_t ServerSystem_SlaveCount = 0x0034b610;
|
||||||
|
// thiscall void (ServerSystem* sys, int out[12]) // zeroes `out`, returns immediately when the caller's pointer is null or the system has no owner (+0x100), else calls ComputeOutputFromRates(out, &sys->Rts /*+0x88*/) -- the system's OWN rate sliders, not a max-mods vector. THE DISTINCTION THAT MATTERS: ComputeBudget's real (non-projected) per-system money is this function's out[3], while its projected mode and UpdateBankruptcyLimits use ComputeMaxIncome 0x007521c0. They are different numbers: this path funds the science, construction and terraform channels, so the repair pass runs and the unspent-industry and unspent-terraforming cascades into the money channel are live [verified]
|
||||||
|
constexpr uint32_t ServerSystem_ComputeOutput = 0x00351fb0;
|
||||||
|
// thiscall float (StrategyServer* srv, ServerPlayer* p) // `ret 4`, real end 0x0080f49f. Returns float32( float32(DifficultyMods_Select(p->diffMods /*+0x36c*/, p)[1]) x float32(srv->IncMod /*raw base +0xbc, the Sim block's `IncMod` tag*/) ), or just the server modifier when p is null. Every step is stored back through a 4-byte float. `ecx` here is the RAW StrategyServer base (ServerSystem+0x10), four bytes above the base the class's own methods get. THIS IS THE MISSING x1.1: at the difficulty level every corpus save carries (aidf == 1) the AI column of the table is 1.1f [verified]
|
||||||
|
constexpr uint32_t StrategyServer_IncomeDifficultyMod = 0x0040f470;
|
||||||
|
// thiscall float* (DifficultyMods* rec, ServerPlayer* p) // `ret 4`, real end 0x0059b4b3. Returns &rec->f[0] (the AI triple, at +0x04) when p is non-null AND p->[0xf9] (is-AI) is set AND p->NPC (+0xfb) is clear; otherwise &rec->f[3] (the non-AI triple, at +0x10). The three consumers read offset +0 (fleet maintenance divisor, ComputeBudget 0x0086338b), +4 (system and trade-route money, 0x0080f470 and 0x00833938) and +8 (research points bought with money, 0x0080e229 and 0x00863618) [verified]
|
||||||
|
constexpr uint32_t DifficultyMods_Select = 0x0019b490;
|
||||||
|
// cdecl void (int level, DifficultyMods* out) // real end 0x005a3a53 (Ghidra size 193 stops 2 bytes short). memcpy's the default {id 1, 1.0f x6} into `out` FIRST, then builds the table with BuildDifficultyTable 0x005a3870 and linear-searches it for id == level (stride 0x1c, from the 0x92492493 magic divide), copying the six floats on a hit. An out-of-range level therefore yields all ones rather than failing. Called from ServerPlayer::Read 0x008804d0 at 0x00880fa3, gated on 0 <= aidf < 3, which is also where ServerPlayer+0x368 (`aidf`) is stored [verified]
|
||||||
|
constexpr uint32_t LoadDifficultyRow = 0x001a3990;
|
||||||
|
// thiscall vector<DifficultyMods>* (vector<DifficultyMods>* out) // real end 0x005a3989. THE TABLE IS BUILT IN CODE, from .rdata float literals -- no data-file key, no GlobalConst slot, same shape as lane N's pop-type table. Three rows of {int id; float ai[3]; float other[3]} (0x1c): id 0 = ai {1,1,1} / other {1.5,1.5,1.5} (0x00a1b000); id 1 = ai {3.0 (0x00a0451c), 1.1 (0x009f957c), 1.5} / other {1,1,1}; id 2 = ai {1e6 (0x009ebd7c), 1.7 (0x009f9580), 2.0 (0x00a04518)} / other {1,1,1}. Read as: level 0 gives the break to the human player, levels 1 and 2 give it to the AI, and on level 2 the AI's fleet maintenance is divided by a million [verified]
|
||||||
|
constexpr uint32_t BuildDifficultyTable = 0x001a3870;
|
||||||
|
// thiscall float (ServerPlayer* p) // seven bytes: `fld DWORD [ecx+0x30c]; ret`. The save's per-player `IncMod` [verified]
|
||||||
|
constexpr uint32_t ServerPlayer_GetIncMod = 0x0040dd10;
|
||||||
|
// thiscall float (ServerPlayer* p) // `SpeciesDef(p->Species /*+0x5c*/)->+0x24`, the multiplier on the suitability MONEY cost (Zuul 0.7). A data-file value [verified]
|
||||||
|
constexpr uint32_t ServerPlayer_GetSpeciesCostFactor = 0x0040dd20;
|
||||||
|
// thiscall float (StrategyServer* srv, int species) // `ret 4`; one instruction of work: `fld DWORD [ecx + species*4 + 0xf8]`. The per-species ideal-suitability array on the RAW server base. IT IS ON THE WIRE: the Sim block's `ISsp`/`ISsu` pairs are this float[7] in species-index order, and the array is randomised per game by the map generator -- verified against every ServerPlayer's own `IdealSuit` field in all 11 corpus saves. CalcSuitMod reads THIS, not the player's field [verified]
|
||||||
|
constexpr uint32_t StrategyServer_IdealSuit = 0x0040f4b0;
|
||||||
|
// thiscall double (ServerSystem* sys) // real end 0x0074690d. Returns 0 with no owner, else float32(|IdealSuit(species) - sys->Suit|) / |owner->TerraMod (+0x134) x [0x00a1f928] / 20000|. The sign term (-1.0 when the planet's suitability is STRICTLY above the ideal) is multiplied in BEFORE the fabs at 0x00746906 and therefore cancels: the result is always >= 0. That is why the unspent-terraform cascade into the money channel is provably zero under the max-income rate vector [verified]
|
||||||
|
constexpr uint32_t ServerSystem_TerraformPointsNeeded = 0x00346890;
|
||||||
// thiscall void (ServerSystem* this, ServerPlayer* p, int encounterId) // 75 B, ret 8. THE NVE WRITER. if (!p) return; s = (int16)this->owner(+0x10)->Frame(+0x8); rec = NVE_map_at(&this->NVE(+0x284), &p->PlyrIdx(+0x28)); rec[0] = (s<<16)|s; rec[1] = encounterId. The map value is 8 bytes at node+0x10: an UNSERIALISED int16 touch stamp at +0, the saved ETS int16 at +2, the saved Eid int32 at +4 -- so ETS and the touch stamp are both set to the frame here, and only the writer at 0x007536a0 makes them differ. Sole caller is the tail's PlayerView-rebuild phase 0x007cf560, under the gate (AFlags >> PlyrIdx) & 1 [mapped]
|
// thiscall void (ServerSystem* this, ServerPlayer* p, int encounterId) // 75 B, ret 8. THE NVE WRITER. if (!p) return; s = (int16)this->owner(+0x10)->Frame(+0x8); rec = NVE_map_at(&this->NVE(+0x284), &p->PlyrIdx(+0x28)); rec[0] = (s<<16)|s; rec[1] = encounterId. The map value is 8 bytes at node+0x10: an UNSERIALISED int16 touch stamp at +0, the saved ETS int16 at +2, the saved Eid int32 at +4 -- so ETS and the touch stamp are both set to the frame here, and only the writer at 0x007536a0 makes them differ. Sole caller is the tail's PlayerView-rebuild phase 0x007cf560, under the gate (AFlags >> PlyrIdx) & 1 [mapped]
|
||||||
constexpr uint32_t ServerSystem_RecordObservation = 0x00356300;
|
constexpr uint32_t ServerSystem_RecordObservation = 0x00356300;
|
||||||
// thiscall void (ServerSystem* this, ServerPlayer* p, NveValue* src) // 70 B, ret 8. Intel sharing. rec = NVE_map_at(&this->NVE, &p->PlyrIdx(+0x28)); rec[0] = (hi16(src[0]) << 16) | (int16)this->owner(+0x10)->Frame(+0x8); rec[1] = src[1]. i.e. the receiver gets the DONOR's sighting turn (ETS) and encounter id unchanged, and only the unserialised touch stamp becomes the current frame. Never executed by any save in the corpus: no save has two players in an alliance [mapped]
|
// thiscall void (ServerSystem* this, ServerPlayer* p, NveValue* src) // 70 B, ret 8. Intel sharing. rec = NVE_map_at(&this->NVE, &p->PlyrIdx(+0x28)); rec[0] = (hi16(src[0]) << 16) | (int16)this->owner(+0x10)->Frame(+0x8); rec[1] = src[1]. i.e. the receiver gets the DONOR's sighting turn (ETS) and encounter id unchanged, and only the unserialised touch stamp becomes the current frame. Never executed by any save in the corpus: no save has two players in an alliance [mapped]
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,9 @@ int Usage() {
|
||||||
" id, comma-separated (e.g. T36)\n"
|
" id, comma-separated (e.g. T36)\n"
|
||||||
" --commit-blocked-except=IDS these blocked phases never commit\n"
|
" --commit-blocked-except=IDS these blocked phases never commit\n"
|
||||||
" --commit-rng write the advanced generator state back\n"
|
" --commit-rng write the advanced generator state back\n"
|
||||||
|
" --ai-player N player N (by PlyrIdx) is AI-controlled; repeatable.\n"
|
||||||
|
" The flag is a game-setup input the save does not carry\n"
|
||||||
|
" and it selects the AI column of the difficulty table\n"
|
||||||
" --roundtrip re-serialise the UNTOUCHED save and check byte identity\n"
|
" --roundtrip re-serialise the UNTOUCHED save and check byte identity\n"
|
||||||
"\n"
|
"\n"
|
||||||
"The two id lists exist so a blocked phase's own closed and regressed counts\n"
|
"The two id lists exist so a blocked phase's own closed and regressed counts\n"
|
||||||
|
|
@ -101,6 +104,12 @@ int main(int argc, char** argv) {
|
||||||
} else if (a.rfind("--commit-blocked-except=", 0) == 0) {
|
} else if (a.rfind("--commit-blocked-except=", 0) == 0) {
|
||||||
opt.commitExcept = SplitIds(a.substr(std::strlen("--commit-blocked-except=")));
|
opt.commitExcept = SplitIds(a.substr(std::strlen("--commit-blocked-except=")));
|
||||||
if (opt.commitExcept.empty()) return Usage();
|
if (opt.commitExcept.empty()) return Usage();
|
||||||
|
} else if (a.rfind("--ai-player=", 0) == 0) {
|
||||||
|
opt.aiPlayers.push_back(std::atoi(a.c_str() + std::strlen("--ai-player=")));
|
||||||
|
} else if (a == "--ai-player") {
|
||||||
|
std::string v;
|
||||||
|
if (!next(v)) return Usage();
|
||||||
|
opt.aiPlayers.push_back(std::atoi(v.c_str()));
|
||||||
} else if (a == "--commit-rng") {
|
} else if (a == "--commit-rng") {
|
||||||
opt.commitRng = true;
|
opt.commitRng = true;
|
||||||
} else if (a == "--roundtrip") {
|
} else if (a == "--roundtrip") {
|
||||||
|
|
|
||||||
|
|
@ -130,8 +130,12 @@ constexpr PhaseDesc kStrategic[] = {
|
||||||
constexpr PhaseDesc kPlayer[] = {
|
constexpr PhaseDesc kPlayer[] = {
|
||||||
{Driver::Player, 1, "P01", "ComputeBudget", PhaseStatus::Blocked,
|
{Driver::Player, 1, "P01", "ComputeBudget", PhaseStatus::Blocked,
|
||||||
"the formula is verified (0 divergences over 4,284 live calls) but one input is not "
|
"the formula is verified (0 divergences over 4,284 live calls) but one input is not "
|
||||||
"modelled: the money output of each owned system. That needs the population -> base-output "
|
"modelled: the money output of each owned system. NOT the same function T31 sums -- the "
|
||||||
"term, which the colony model declares unresolved. Evaluated and reported, not committed"},
|
"turn path takes ComputeOutput with the system's OWN rate sliders, so its money channel "
|
||||||
|
"carries the repair pass (which is not side-effect free) and the unspent-industry and "
|
||||||
|
"unspent-terraforming cascades, none of which are zero once the other channels are "
|
||||||
|
"funded. Only ComputeBudget's PROJECTED mode uses the max-income form that is now "
|
||||||
|
"modelled. Evaluated and reported, not committed"},
|
||||||
{Driver::Player, 2, "P02", "ApplyNetToSavings", PhaseStatus::Blocked,
|
{Driver::Player, 2, "P02", "ApplyNetToSavings", PhaseStatus::Blocked,
|
||||||
"saturating add of the budget net into savings; blocked behind P01's missing input"},
|
"saturating add of the budget net into savings; blocked behind P01's missing input"},
|
||||||
{Driver::Player, 3, "P03", "RecordBudgetDerivedFields", PhaseStatus::Blocked,
|
{Driver::Player, 3, "P03", "RecordBudgetDerivedFields", PhaseStatus::Blocked,
|
||||||
|
|
@ -220,9 +224,17 @@ constexpr PhaseDesc kTail[] = {
|
||||||
{Driver::Tail, 29, "T29", "AbortInvisibleInterceptOrders", PhaseStatus::Stub, ""},
|
{Driver::Tail, 29, "T29", "AbortInvisibleInterceptOrders", PhaseStatus::Stub, ""},
|
||||||
{Driver::Tail, 30, "T30", "RebuildCommunicationMasks", PhaseStatus::Stub, ""},
|
{Driver::Tail, 30, "T30", "RebuildCommunicationMasks", PhaseStatus::Stub, ""},
|
||||||
{Driver::Tail, 31, "T31", "UpdateBankruptcyLimits", PhaseStatus::Blocked,
|
{Driver::Tail, 31, "T31", "UpdateBankruptcyLimits", PhaseStatus::Blocked,
|
||||||
"the limits formula is modelled in game::sim; its input is the sum of every owned "
|
"the whole chain is now modelled: sum over owned, non-abandoned systems of "
|
||||||
"system's MAXIMUM money output, which needs the same unresolved population->output term "
|
"max(ComputeMaxIncome, 0), and the phase self-checks it every run against the BnkEl the "
|
||||||
"as P01. Evaluated and reported, not committed"},
|
"input save already carries -- 8 of 8 players on turn1-state with the AI flag supplied. "
|
||||||
|
"Two things keep it blocked and neither is the formula. First, `ServerPlayer+0xf9` (is "
|
||||||
|
"this player AI?) is a game-setup input the save does not carry, and it selects a "
|
||||||
|
"difficulty column worth x1.1 on an AI empire; --ai-player N supplies it. Second, BnkPr "
|
||||||
|
"needs BANKRUPTCY_PROTECTION_LIMIT_FACTOR from the data files, so it is offered only "
|
||||||
|
"with a tuning table loaded. Committing it closes NOTHING on the reference pair: the "
|
||||||
|
"limits move between turn1 and turn2 because the CIVILIAN population grows, and that "
|
||||||
|
"growth is itself not committed, so our value equals the input save's. Measured with "
|
||||||
|
"--commit-blocked=T31 --ai-player 1: 0 closed, 0 regressed"},
|
||||||
{Driver::Tail, 32, "T32", "PostIncomingFleetWarnings", PhaseStatus::Stub, ""},
|
{Driver::Tail, 32, "T32", "PostIncomingFleetWarnings", PhaseStatus::Stub, ""},
|
||||||
{Driver::Tail, 33, "T33", "ShipManagerEndOfTurnHooks", PhaseStatus::Stub, ""},
|
{Driver::Tail, 33, "T33", "ShipManagerEndOfTurnHooks", PhaseStatus::Stub, ""},
|
||||||
{Driver::Tail, 34, "T34", "RecordObservedDesigns", PhaseStatus::Stub, ""},
|
{Driver::Tail, 34, "T34", "RecordObservedDesigns", PhaseStatus::Stub, ""},
|
||||||
|
|
|
||||||
214
src/app/turn.cpp
214
src/app/turn.cpp
|
|
@ -269,6 +269,211 @@ void RunSystemTurn(Sys& s, int playerCount, SystemTotals& t) {
|
||||||
(void)playerCount;
|
(void)playerCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
// T31 UpdateBankruptcyLimits -- the per-player maximum-income roll-up
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// The input is `sum over owned, non-abandoned systems of max(ComputeMaxIncome(s), 0)`, and
|
||||||
|
// `ComputeMaxIncome` is the output total run through the money chain with the rate vector
|
||||||
|
// "all output to trade". Every term of that chain is now modelled in game::sim; what this
|
||||||
|
// function does is read its inputs off the wire.
|
||||||
|
//
|
||||||
|
// Two inputs of the chain are NOT on the wire and are named rather than guessed:
|
||||||
|
// * `ServerPlayer+0xf9`, the per-player "is AI" flag, which selects the AI column of the
|
||||||
|
// difficulty table (a x1.1 on the income at difficulty level 1). It is copied from the
|
||||||
|
// game-setup/network player record and never serialised. `TurnOptions::aiPlayers` is the
|
||||||
|
// operator's way to supply it; with nothing supplied every player takes the non-AI column
|
||||||
|
// and an AI empire's limit comes out 1/1.1 low.
|
||||||
|
// * the three game-setup handicap words (`ServerPlayer+0x224/+0x228/+0x22c`), also copied
|
||||||
|
// at game creation. `+0x224` multiplies the output total. Taken as 1.0 here, which is
|
||||||
|
// what the whole 11-save corpus measures.
|
||||||
|
// The tuning constants the chain can read -- the imperial station output bonus, the two
|
||||||
|
// morale thresholds, the addiction income modifier and the three slave-row columns -- are
|
||||||
|
// left at their unloaded zero. Every branch that reads one is UNEXERCISED in the corpus (no
|
||||||
|
// stations, no slaves, no addiction, and every colony's morale sits strictly between the two
|
||||||
|
// thresholds), so this is a hypothesis about coverage, not a claim that they do not matter.
|
||||||
|
|
||||||
|
struct MaxIncomeInputs {
|
||||||
|
std::vector<float> idealSuit; // the Sim block's ISsu array, indexed by species
|
||||||
|
double serverIncomeMod = 1.0; // the Sim block's `IncMod`
|
||||||
|
sim::TuningTable tuning; // unloaded: see above
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sum of `PopC` over the (group, species) rows of one Population node.
|
||||||
|
std::int64_t PopCount(const mars::stream::shapes::Population& p, int group, int species) {
|
||||||
|
std::int64_t n = 0;
|
||||||
|
for (const auto& g : p.groups)
|
||||||
|
if (g.popT == group && g.popS == species) n += g.popC;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
int MoraleOf(const mars::stream::shapes::Morale& m, int species) {
|
||||||
|
for (const auto& e : m.entries)
|
||||||
|
if (e.msp == species) return e.mv;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AddictedTo(const std::vector<mars::stream::shapes::AdctEntry>& a, int species) {
|
||||||
|
for (const auto& e : a)
|
||||||
|
if (e.ads == species && e.adt != 0) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `max(ComputeMaxIncome(s), 0)` for one owned system.
|
||||||
|
int SystemMaxIncomeFromWire(const Sys& s, const Player& owner, bool ownerIsAI,
|
||||||
|
const MaxIncomeInputs& ctx) {
|
||||||
|
// The system's population is credited to the independent race's species when the colony
|
||||||
|
// has one, otherwise to the owner's. `hindi` is the gate; `indi` is written either way.
|
||||||
|
const int popSpecies = s.hindi ? s.indi.indsp : owner.species;
|
||||||
|
const auto species = static_cast<sim::Species>(owner.species);
|
||||||
|
|
||||||
|
const std::int64_t resAvail =
|
||||||
|
s.res + (owner.aMine ? static_cast<std::int64_t>(s.mRes) + s.aRes2 : 0);
|
||||||
|
const std::int64_t imperial = static_cast<std::int64_t>(s.pop) + s.pbon;
|
||||||
|
|
||||||
|
// --- the output total (lane N's term) ---
|
||||||
|
double total = 0.0;
|
||||||
|
if (s.rbfl == 0) {
|
||||||
|
sim::BaseOutputInputs b;
|
||||||
|
b.imperialPopulation = imperial;
|
||||||
|
b.civilianPopulation = PopCount(s.pop2, 1, popSpecies) + PopCount(s.pbon2, 1, popSpecies);
|
||||||
|
b.civilianMorale = MoraleOf(s.cm, popSpecies);
|
||||||
|
b.independent = s.hindi;
|
||||||
|
b.transitResources = s.tRes;
|
||||||
|
b.resourcesAvailable = resAvail;
|
||||||
|
b.infra = s.infra;
|
||||||
|
b.infraBonus = s.ibon;
|
||||||
|
b.overHarvestRate = 0.0; // the max-income rate vector puts nothing on over-harvest
|
||||||
|
b.speciesBaseDemand = sim::ConstantsOf(species).resourceDemand;
|
||||||
|
b.speciesResourceOutput = sim::ConstantsOf(species).resourceOutput;
|
||||||
|
sim::OutputModifiers m;
|
||||||
|
m.baseOutput = sim::SystemBaseOutput(b, ctx.tuning);
|
||||||
|
m.playerOutMod = owner.outMod;
|
||||||
|
m.systemOutMod = s.outMod;
|
||||||
|
m.rebOutMod = owner.rebOutMod;
|
||||||
|
m.scOutMod = owner.scOutMod;
|
||||||
|
m.techOutMod = 1.0; // ServerPlayer+0x224, not on the wire
|
||||||
|
total = sim::TotalSystemOutputRaw(m, ctx.tuning);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the money chain (this lane's term) ---
|
||||||
|
sim::PopIncomeRow impRows[sim::kSpeciesCount] = {};
|
||||||
|
sim::PopIncomeRow civRows[sim::kSpeciesCount] = {};
|
||||||
|
sim::PopIncomeRow slvRows[sim::kSpeciesCount] = {};
|
||||||
|
for (int q = 0; q < sim::kSpeciesCount; ++q) {
|
||||||
|
// GroupPopulation(imperial) credits the whole colony to ONE species.
|
||||||
|
impRows[q].count = q == popSpecies ? imperial : 0;
|
||||||
|
civRows[q].count = PopCount(s.pop2, 1, q) + PopCount(s.pbon2, 1, q);
|
||||||
|
slvRows[q].count = PopCount(s.pop2, 2, q) + PopCount(s.pbon2, 2, q);
|
||||||
|
const int mor = MoraleOf(s.cm, q);
|
||||||
|
const bool add = AddictedTo(s.adct, q);
|
||||||
|
impRows[q].morale = civRows[q].morale = slvRows[q].morale = mor;
|
||||||
|
impRows[q].addicted = civRows[q].addicted = slvRows[q].addicted = add;
|
||||||
|
}
|
||||||
|
|
||||||
|
sim::SystemMoneyInputs mi;
|
||||||
|
mi.popIncomeImperial =
|
||||||
|
sim::PopulationIncome(sim::PopGroup::Imperial, impRows, true, s.hindi, ctx.tuning);
|
||||||
|
mi.popIncomeCivilian =
|
||||||
|
sim::PopulationIncome(sim::PopGroup::Civilian, civRows, true, s.hindi, ctx.tuning);
|
||||||
|
mi.slaveIncome =
|
||||||
|
sim::PopulationIncome(sim::PopGroup::Slaves, slvRows, true, s.hindi, ctx.tuning);
|
||||||
|
mi.speciesIncomeFactor = sim::ConstantsOf(species).incomeFactor;
|
||||||
|
mi.speciesCostFactor = sim::ConstantsOf(species).hazardCostFactor;
|
||||||
|
mi.playerIncMod = owner.incMod;
|
||||||
|
mi.serverIncomeMod = ctx.serverIncomeMod;
|
||||||
|
mi.difficultyIncomeMult =
|
||||||
|
sim::DifficultyModsFor(owner.aidf, ownerIsAI, owner.npc).incomeMult;
|
||||||
|
const double ideal = popSpecies >= 0 && popSpecies < static_cast<int>(ctx.idealSuit.size())
|
||||||
|
? ctx.idealSuit[static_cast<std::size_t>(popSpecies)]
|
||||||
|
: owner.idealSuit;
|
||||||
|
mi.suitCostMod =
|
||||||
|
sim::SuitabilityCostMod(s.suit, ideal, owner.suitTol, owner.rebAI, true, s.vnh);
|
||||||
|
return sim::SystemMaxIncome(total, mi);
|
||||||
|
}
|
||||||
|
|
||||||
|
void RunUpdateBankruptcyLimits(SaveGame& game, const TurnOptions& opt, PhaseRecord& rec) {
|
||||||
|
MaxIncomeInputs ctx;
|
||||||
|
ctx.serverIncomeMod = game.sim.incMod;
|
||||||
|
for (const auto& sp : game.sim.species) ctx.idealSuit.push_back(sp.issu);
|
||||||
|
|
||||||
|
// The system table, keyed by the handle id a player's `OwnId` list carries.
|
||||||
|
std::vector<const Sys*> byId;
|
||||||
|
std::vector<std::int32_t> ids;
|
||||||
|
for (const auto& e : game.sim.systems) {
|
||||||
|
ids.push_back(e.sysID);
|
||||||
|
byId.push_back(&e.sys);
|
||||||
|
}
|
||||||
|
const auto find = [&](std::int32_t id) -> const Sys* {
|
||||||
|
for (std::size_t i = 0; i < ids.size(); ++i)
|
||||||
|
if (ids[i] == id) return byId[i];
|
||||||
|
return nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
int players = 0, dangling = 0, matches = 0, compared = 0, aiOwned = 0;
|
||||||
|
std::string firstMiss;
|
||||||
|
for (auto& pe : game.sim.players) {
|
||||||
|
Player& p = pe.player;
|
||||||
|
if (p.elim) continue;
|
||||||
|
++players;
|
||||||
|
const bool isAI = opt.IsAIPlayer(p.plyrIdx);
|
||||||
|
if (isAI) ++aiOwned;
|
||||||
|
int maxIncome = 0;
|
||||||
|
for (std::int32_t id : p.owners) {
|
||||||
|
const Sys* s = find(id);
|
||||||
|
if (!s) {
|
||||||
|
++dangling;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (s->abdn) continue; // an abandoned colony is skipped, not counted as zero
|
||||||
|
maxIncome += SystemMaxIncomeFromWire(*s, p, isAI, ctx);
|
||||||
|
}
|
||||||
|
const sim::BankruptcyLimits lim =
|
||||||
|
sim::ComputeBankruptcyLimits(maxIncome, ctx.tuning);
|
||||||
|
// The limits the save already carries were computed by the ORIGINAL at the end of the
|
||||||
|
// previous turn from the same colony state, so comparing against them is a check of
|
||||||
|
// the whole income chain that needs no running game -- the same "testable on load"
|
||||||
|
// property the turn-record phase has.
|
||||||
|
++compared;
|
||||||
|
if (lim.eliminationFloor == p.bnkEl) {
|
||||||
|
++matches;
|
||||||
|
} else if (firstMiss.empty()) {
|
||||||
|
firstMiss = fmt("player %d: BnkEl ours %d, save %d (maxIncome %d)", p.plyrIdx,
|
||||||
|
lim.eliminationFloor, p.bnkEl, maxIncome);
|
||||||
|
}
|
||||||
|
++rec.invocations;
|
||||||
|
// BnkPr's factor is a data-file constant; with no tuning table its computed value is
|
||||||
|
// -0 for every player, which is a confidently wrong leaf rather than a missing one.
|
||||||
|
// It is therefore only offered when the table is loaded.
|
||||||
|
int would = lim.eliminationFloor != p.bnkEl ? 1 : 0;
|
||||||
|
const bool prModelled = opt.haveTuning;
|
||||||
|
if (prModelled && lim.protectionLimit != p.bnkPr) ++would;
|
||||||
|
if (opt.CommitBlocked("T31")) {
|
||||||
|
rec.leafWrites += would;
|
||||||
|
p.bnkEl = lim.eliminationFloor;
|
||||||
|
if (prModelled) p.bnkPr = lim.protectionLimit;
|
||||||
|
rec.committed = true;
|
||||||
|
} else {
|
||||||
|
rec.wouldWrite += would;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rec.notes.push_back(fmt("%d player(s); BnkEl reproduced for %d of %d from the input save's "
|
||||||
|
"own colony state",
|
||||||
|
players, matches, compared));
|
||||||
|
if (!firstMiss.empty()) rec.notes.push_back(firstMiss);
|
||||||
|
if (dangling) rec.notes.push_back(fmt("%d owned-system id(s) absent from the system table",
|
||||||
|
dangling));
|
||||||
|
if (aiOwned == 0)
|
||||||
|
rec.notes.push_back("no player was declared AI (--ai-player N); every player therefore "
|
||||||
|
"takes the non-AI difficulty column, which is 1/1.1 low on an AI "
|
||||||
|
"empire at difficulty level 1");
|
||||||
|
// BnkPr's factor is a data-file constant (BANKRUPTCY_PROTECTION_LIMIT_FACTOR), so with no
|
||||||
|
// tuning table loaded the protection limit is not modelled even though BnkEl is.
|
||||||
|
if (!opt.haveTuning)
|
||||||
|
rec.notes.push_back("BnkPr needs BANKRUPTCY_PROTECTION_LIMIT_FACTOR from the data "
|
||||||
|
"files, which is not loaded: only BnkEl is modelled here");
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------
|
||||||
// The tail's last phase: the per-player turn record, and its own self-check
|
// The tail's last phase: the per-player turn record, and its own self-check
|
||||||
// ---------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------
|
||||||
|
|
@ -714,6 +919,8 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) {
|
||||||
rec.leafWrites = v.leafWrites;
|
rec.leafWrites = v.leafWrites;
|
||||||
rec.committed = v.leafWrites > 0;
|
rec.committed = v.leafWrites > 0;
|
||||||
rec.notes = v.notes;
|
rec.notes = v.notes;
|
||||||
|
} else if (tp[i].index == 31) {
|
||||||
|
RunUpdateBankruptcyLimits(game, opt, rec);
|
||||||
} else if (tp[i].index == 36) {
|
} else if (tp[i].index == 36) {
|
||||||
RunFinalizeTurnRecords(game, opt, rec, recordAudit, allianceMasks);
|
RunFinalizeTurnRecords(game, opt, rec, recordAudit, allianceMasks);
|
||||||
}
|
}
|
||||||
|
|
@ -749,8 +956,11 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) {
|
||||||
"turn measured (2 words), with no derived rule behind the count -- its bound is the "
|
"turn measured (2 words), with no derived rule behind the count -- its bound is the "
|
||||||
"product of the contact and detector counts, so it is left unmodelled");
|
"product of the contact and detector counts, so it is left unmodelled");
|
||||||
r.rngUnaccounted.push_back(
|
r.rngUnaccounted.push_back(
|
||||||
"the research-allocation draw is downstream of the budget, which is blocked on the "
|
"two draws are downstream of the budget's research allocation -- ProcessResearch's "
|
||||||
"per-system money output (0 or 1 word)");
|
"completion Chance and the tech-effect callback's own roll (0 or 1 word each). The "
|
||||||
|
"allocation needs ComputeBudget's per-system money, which is ComputeOutput with the "
|
||||||
|
"system's OWN rate sliders; the max-income form of that money is now modelled and "
|
||||||
|
"self-checked (see T31), but it is NOT the one this path takes");
|
||||||
if (r.rngLoaded && r.rngWords > 0)
|
if (r.rngLoaded && r.rngWords > 0)
|
||||||
r.rngUnaccounted.push_back(
|
r.rngUnaccounted.push_back(
|
||||||
"a successful raid roll may draw one further word to pick its target; no roll "
|
"a successful raid roll may draw one further word to pick its target; no roll "
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,17 @@ struct TurnOptions {
|
||||||
bool commitRng = false;
|
bool commitRng = false;
|
||||||
// Tuning constants were loaded, so formulas that read them may run.
|
// Tuning constants were loaded, so formulas that read them may run.
|
||||||
bool haveTuning = false;
|
bool haveTuning = false;
|
||||||
|
// Which players are AI-controlled, by `PlyrIdx`. `ServerPlayer+0xf9` is a game-setup
|
||||||
|
// input that the save format does not carry (see the T31 block in turn.cpp), and the
|
||||||
|
// difficulty table selects a different column for an AI player -- at the corpus'
|
||||||
|
// difficulty level that is a x1.1 on every AI-owned system's money. Empty means "assume
|
||||||
|
// nobody is AI", which is a statement about what we were told, not about the game.
|
||||||
|
std::vector<int> aiPlayers;
|
||||||
|
bool IsAIPlayer(int playerIndex) const {
|
||||||
|
for (int i : aiPlayers)
|
||||||
|
if (i == playerIndex) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
// The game's data root, when the operator supplied one. The standalone reads no game data
|
// The game's data root, when the operator supplied one. The standalone reads no game data
|
||||||
// of its own and needs none for any other phase; the tail's turn record is the one place
|
// of its own and needs none for any other phase; the tail's turn record is the one place
|
||||||
// where a save is not enough, because a design's hull size and its defence-platform flag
|
// where a save is not enough, because a design's hull size and its defence-platform flag
|
||||||
|
|
|
||||||
|
|
@ -419,24 +419,73 @@ OutputSplit SplitLeftover(double leftover, const OutputRates& rates, bool suitAt
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
double SuitabilityCostMod(double suitability, double idealSuitability, double suitTolerance,
|
int GroupIncome(PopGroup group, std::int64_t count, const TuningTable& t) {
|
||||||
bool rebelAI, bool owned) {
|
// `fld DWORD [row+0x14]` -- the income column is a float32 in the table.
|
||||||
if (rebelAI) return 0.0;
|
const double mod = F32(PopTypeOf(group, t).incomeMod);
|
||||||
if (!owned) return 20.0;
|
return Ftol(Narrow(mod * (static_cast<double>(count) / kIncomePopulationDivisor)));
|
||||||
return std::min(std::fabs(idealSuitability - suitability), suitTolerance);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
int SystemMoneyIncome(const SystemMoneyInputs& in) {
|
double PopulationIncome(PopGroup group, const PopIncomeRow (&rows)[kSpeciesCount],
|
||||||
// Whole blocks of five trade points, worth five money each.
|
bool owned, bool independent, const TuningTable& t) {
|
||||||
double t = (in.tradePoints - std::fmod(in.tradePoints, 5.0)) * 5.0;
|
double sum = 0.0;
|
||||||
t += static_cast<double>(in.popIncomeImperial);
|
for (int sp = 0; sp < kSpeciesCount; ++sp) {
|
||||||
t += static_cast<double>(in.popIncomeCivilian);
|
const PopIncomeRow& r = rows[sp];
|
||||||
t += static_cast<double>(in.slaveIncome);
|
if (!(r.count > 0)) continue;
|
||||||
t *= in.speciesIncomeFactor;
|
// Only the civilian row takes morale, and it takes it through the same helper the
|
||||||
t *= in.playerIncMod;
|
// output term uses -- including the "no owner / independent / no record" bypass.
|
||||||
t *= in.serverIncomeMod * in.difficultyIncomeMult;
|
double morale = 1.0;
|
||||||
const double cost = in.speciesCostFactor * in.suitCostMod * 10000.0 * 1.5;
|
if (group == PopGroup::Civilian && owned && !independent) {
|
||||||
return Ftol(t - cost);
|
morale = MoraleOutputMultiplier(r.morale, t);
|
||||||
|
}
|
||||||
|
const double addiction = r.addicted ? t.ADDICTION_INCOME_MOD : 1.0;
|
||||||
|
const double base = static_cast<double>(GroupIncome(group, r.count, t));
|
||||||
|
// The SECOND truncation: per species, after both factors.
|
||||||
|
sum += static_cast<double>(Ftol(Narrow(Narrow(base * morale) * addiction)));
|
||||||
|
}
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
double SuitabilityCostMod(double suitability, double idealSuitability, double suitTolerance,
|
||||||
|
bool rebelAI, bool owned, bool vonNeumann) {
|
||||||
|
if (vonNeumann) return 0.0; // the original's very first test
|
||||||
|
if (!owned) return 20.0; // ... and it logs a warning
|
||||||
|
if (rebelAI) return 0.0;
|
||||||
|
// All three operands are 4-byte floats in the original (`server->IdealSuit[sp]`,
|
||||||
|
// `sys->Suit`, `owner->SuitTol`); the subtraction and the compare are then done on the
|
||||||
|
// x87 with no store back, so nothing is narrowed here. The compare is `<=`: a distance
|
||||||
|
// exactly at the tolerance is charged as itself, not as the cap.
|
||||||
|
const double d = std::fabs(idealSuitability - suitability);
|
||||||
|
return d <= suitTolerance ? d : suitTolerance;
|
||||||
|
}
|
||||||
|
|
||||||
|
double SystemMoneyIncomeRaw(const SystemMoneyInputs& in) {
|
||||||
|
// Whole blocks of five trade points; the same literal is the modulus and the multiplier.
|
||||||
|
// The `+ 0.0` is a real instruction (an .rdata zero) and the association below is the
|
||||||
|
// original's -- x87 addition is not associative, so neither is reorderable.
|
||||||
|
const double blocks = (in.tradePoints - std::fmod(in.tradePoints, 5.0)) * 5.0;
|
||||||
|
double t = Narrow(Narrow(blocks + 0.0) + in.popIncomeImperial);
|
||||||
|
t = Narrow(in.popIncomeCivilian + t);
|
||||||
|
t = Narrow(in.slaveIncome + t);
|
||||||
|
|
||||||
|
// Every per-player multiplier is stored back through a 4-byte float before it is used.
|
||||||
|
t = Narrow(F32(in.speciesIncomeFactor) * t);
|
||||||
|
const double diff = F32(F32(in.difficultyIncomeMult) * F32(in.serverIncomeMod));
|
||||||
|
t = Narrow(diff * Narrow(F32(in.playerIncMod) * t));
|
||||||
|
|
||||||
|
const double cost =
|
||||||
|
Narrow(F32(in.speciesCostFactor) * Narrow(Narrow(in.suitCostMod * 10000.0) * 1.5));
|
||||||
|
return t - cost;
|
||||||
|
}
|
||||||
|
|
||||||
|
int SystemMoneyIncome(const SystemMoneyInputs& in) { return Ftol(SystemMoneyIncomeRaw(in)); }
|
||||||
|
|
||||||
|
int SystemMaxIncome(double totalOutput, const SystemMoneyInputs& in) {
|
||||||
|
SystemMoneyInputs m = in;
|
||||||
|
// The max-income rate vector is trade = 1 and every other channel 0, so the trade
|
||||||
|
// points are the rounded total and the two cascade channels contribute nothing.
|
||||||
|
m.tradePoints = RoundHalfEven(totalOutput);
|
||||||
|
const int money = SystemMoneyIncome(m);
|
||||||
|
return money > 0 ? money : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
BonusApplyResult ApplyPopulationBonus(std::int64_t& pop, std::int64_t capacity,
|
BonusApplyResult ApplyPopulationBonus(std::int64_t& pop, std::int64_t capacity,
|
||||||
|
|
|
||||||
|
|
@ -420,37 +420,106 @@ int ConstructionPoints(double constructionShare, int stations, const TuningTable
|
||||||
OutputSplit SplitLeftover(double leftover, const OutputRates& rates, bool suitAtIdeal,
|
OutputSplit SplitLeftover(double leftover, const OutputRates& rates, bool suitAtIdeal,
|
||||||
bool infraFull);
|
bool infraFull);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
// Money: the population income law and a system's money output
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// The income chain is a SECOND chain off the same three-row population table, and it is not
|
||||||
|
// the output chain with a different constant: income per head is `typeIncomeMod / 14000`,
|
||||||
|
// with neither the 1.8 factor nor the 500000 divisor the output law uses, and it truncates
|
||||||
|
// TWICE per (group, species) row -- once inside the per-row term and once after the morale
|
||||||
|
// and addiction factors. Summing the species first and truncating once is wrong on any
|
||||||
|
// colony carrying more than one species or a non-unit morale/addiction factor.
|
||||||
|
// See sots-re findings/subsystems/income-term.md.
|
||||||
|
|
||||||
|
// Money contributed per head, before the type modifier. An .rdata literal.
|
||||||
|
constexpr double kIncomePopulationDivisor = 14000.0;
|
||||||
|
|
||||||
|
// One (group, count) row's money: `ftol( typeIncomeMod x (count / 14000) )`. The type
|
||||||
|
// modifier is a float32 in the table, so it is narrowed before the multiply.
|
||||||
|
// CONFIDENCE: high -- read instruction by instruction (the whole function is five
|
||||||
|
// instructions and a tail-jump into the float-to-int helper).
|
||||||
|
int GroupIncome(PopGroup group, std::int64_t count, const TuningTable& t);
|
||||||
|
|
||||||
|
struct PopIncomeRow {
|
||||||
|
std::int64_t count = 0; // heads of this species in this group
|
||||||
|
int morale = 0; // the system's Morale entry for this species
|
||||||
|
bool addicted = false; // the system's addiction entry for this species is non-zero
|
||||||
|
};
|
||||||
|
|
||||||
|
// The population income of one group type, summed over the seven species rows:
|
||||||
|
// for each species with count > 0:
|
||||||
|
// mo = civilian groups only: the morale multiplier (the SAME helper output uses)
|
||||||
|
// ad = addicted ? ADDICTION_INCOME_MOD : 1
|
||||||
|
// sum += (double) ftol( (double)GroupIncome(group, count) x mo x ad )
|
||||||
|
// Slave groups take neither morale nor the civilian capacity surplus.
|
||||||
|
// CONFIDENCE: high on the loop and both truncations; the civilian capacity-surplus term
|
||||||
|
// (which the original adds to the owner species' civilian count when the colony is at its
|
||||||
|
// cap) is the CALLER's business -- fold it into that row's `count`. No colony in the
|
||||||
|
// corpus is at its cap, so that term is UNEXERCISED either way.
|
||||||
|
double PopulationIncome(PopGroup group, const PopIncomeRow (&rows)[kSpeciesCount],
|
||||||
|
bool owned, bool independent, const TuningTable& t);
|
||||||
|
|
||||||
// Suitability term of the money cost: how far the planet is from the owner species'
|
// Suitability term of the money cost: how far the planet is from the owner species'
|
||||||
// ideal, capped by the owner's SuitTol (so the tolerance techs also cap the cost).
|
// ideal, capped by the owner's SuitTol (so the tolerance techs also cap the cost).
|
||||||
// The rebel AI pays nothing; an unowned system is charged as if 20 away.
|
// The rebel AI pays nothing, and so does a system carrying a von Neumann machine
|
||||||
|
// (`vnh`, the very first test in the original); an unowned system is charged as if 20 away.
|
||||||
|
//
|
||||||
|
// `idealSuitability` is the SERVER's per-species baseline (`server->IdealSuit[species]`),
|
||||||
|
// not the owner's own `IdealSuit` field, and the species is the system's population species
|
||||||
|
// (`indi->indsp` on an independent colony) rather than the owner's. In the 11-save corpus
|
||||||
|
// the two IdealSuit sources agree for every player, so **which one the original reads is
|
||||||
|
// not falsifiable here** -- it is read from the instruction stream only.
|
||||||
// CONFIDENCE: high.
|
// CONFIDENCE: high.
|
||||||
double SuitabilityCostMod(double suitability, double idealSuitability, double suitTolerance,
|
double SuitabilityCostMod(double suitability, double idealSuitability, double suitTolerance,
|
||||||
bool rebelAI, bool owned);
|
bool rebelAI, bool owned, bool vonNeumann = false);
|
||||||
|
|
||||||
struct SystemMoneyInputs {
|
struct SystemMoneyInputs {
|
||||||
double tradePoints = 0; // the system's trade-channel output this turn
|
double tradePoints = 0; // the system's trade-channel output this turn
|
||||||
int popIncomeImperial = 0; // income of the imperial population groups
|
double popIncomeImperial = 0; // PopulationIncome(Imperial, ...)
|
||||||
int popIncomeCivilian = 0; // income of the civilian groups (incl. surplus term)
|
double popIncomeCivilian = 0; // PopulationIncome(Civilian, ...), incl. surplus
|
||||||
int slaveIncome = 0; // income of the slave groups
|
double slaveIncome = 0; // PopulationIncome(Slaves, ...)
|
||||||
double speciesIncomeFactor = 1.0; // ConstantsOf(owner).incomeFactor (1 if unowned)
|
double speciesIncomeFactor = 1.0; // ConstantsOf(owner).incomeFactor (1 if unowned)
|
||||||
double playerIncMod = 1.0; // IncMod (1 if unowned)
|
double playerIncMod = 1.0; // IncMod (1 if unowned)
|
||||||
double serverIncomeMod = 1.0; // game-option income modifier
|
double serverIncomeMod = 1.0; // game-option income modifier (Sim tag `IncMod`)
|
||||||
double difficultyIncomeMult = 1.0; // AI difficulty trade/income multiplier
|
double difficultyIncomeMult = 1.0; // DifficultyIncomeMod's table entry
|
||||||
double speciesCostFactor = 1.0; // ConstantsOf(owner).hazardCostFactor
|
double speciesCostFactor = 1.0; // ConstantsOf(owner).hazardCostFactor
|
||||||
double suitCostMod = 0; // from SuitabilityCostMod
|
double suitCostMod = 0; // from SuitabilityCostMod
|
||||||
};
|
};
|
||||||
|
|
||||||
// Money a system contributes this turn:
|
// Money a system contributes this turn (`TradePointsToMoney`, returning the DOUBLE the
|
||||||
// t = (trade - fmod(trade, 5)) x 5 whole 5-point blocks of trade, five each
|
// original returns -- its caller is what truncates):
|
||||||
// t += imperial + civilian + slave income (the per-group income tables are inputs)
|
// t = slaves + (civilian + (((trade - fmod(trade, 5)) x 5 + 0) + imperial))
|
||||||
// t *= speciesIncomeFactor; t *= IncMod; t *= serverIncomeMod x difficultyIncomeMult
|
// t = f32(speciesIncomeFactor) x t
|
||||||
// cost = speciesCostFactor x suitCostMod x 10000 x 1.5
|
// t = f32(f32(difficultyIncomeMult) x f32(serverIncomeMod)) x (f32(IncMod) x t)
|
||||||
// money = ftol(t - cost)
|
// cost = f32(speciesCostFactor) x (suitCostMod x 10000 x 1.5)
|
||||||
// CONFIDENCE: high on the chain and constants; the per-group population income terms
|
// return t - cost
|
||||||
// (and the addiction factor inside them) are inputs because their own tables are not
|
// Whole five-point blocks of trade are worth FIVE money each after being multiplied by
|
||||||
// modelled here.
|
// five -- i.e. 25 money per block of five points; the same literal is the modulus and the
|
||||||
|
// multiplier. Every per-player multiplier is narrowed to float32 before it is applied, and
|
||||||
|
// the additive association above is the original's, which x87 does not let us reorder.
|
||||||
|
// CONFIDENCE: high -- read instruction by instruction.
|
||||||
|
double SystemMoneyIncomeRaw(const SystemMoneyInputs& in);
|
||||||
|
|
||||||
|
// The truncated form its callers store. CONFIDENCE: high.
|
||||||
int SystemMoneyIncome(const SystemMoneyInputs& in);
|
int SystemMoneyIncome(const SystemMoneyInputs& in);
|
||||||
|
|
||||||
|
// `ServerSystem::ComputeMaxIncome` -- what `ComputeBudget` and `UpdateBankruptcyLimits`
|
||||||
|
// actually sum. It runs the output total through `ComputeOutputFromRates` with the rate
|
||||||
|
// vector "all output to trade" and takes the money channel:
|
||||||
|
// trade = roundHalfEven(totalOutput) (rate 1.0, so the second rounding is a no-op)
|
||||||
|
// return max( ftol(SystemMoneyIncomeRaw(...)), 0 )
|
||||||
|
// The `max(.., 0)` is the original's `jg`: a negative-income colony contributes zero to the
|
||||||
|
// empire total rather than reducing it.
|
||||||
|
//
|
||||||
|
// The two cascade terms `ComputeOutputFromRates` can add to the money channel -- unspent
|
||||||
|
// industry and unspent terraforming points -- are provably ZERO under this rate vector:
|
||||||
|
// both are `0 - min(0, need)` with `need >= 0` (infra is clamped to 1, and the terraform
|
||||||
|
// point count takes fabs after its sign multiply). They are therefore not inputs here.
|
||||||
|
// CONFIDENCE: high; the science cascade being zero rests on the repair helper returning 0
|
||||||
|
// for zero science points, which is INFERRED rather than read.
|
||||||
|
int SystemMaxIncome(double totalOutput, const SystemMoneyInputs& in);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------
|
||||||
// System bonus and build queue
|
// System bonus and build queue
|
||||||
// ---------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,23 @@
|
||||||
|
|
||||||
namespace sots::sim {
|
namespace sots::sim {
|
||||||
|
|
||||||
|
DifficultyMods DifficultyModsFor(int level, bool isAI, bool isNpc) {
|
||||||
|
// The six floats of each row, as the image holds them: {AI triple, non-AI triple}.
|
||||||
|
// Row 0 gives the break to the player; rows 1 and 2 give it to the AI.
|
||||||
|
struct Row { float ai[3]; float other[3]; };
|
||||||
|
static constexpr Row kTable[kDifficultyLevels] = {
|
||||||
|
{{1.0f, 1.0f, 1.0f}, {1.5f, 1.5f, 1.5f}}, // 0
|
||||||
|
{{3.0f, 1.1f, 1.5f}, {1.0f, 1.0f, 1.0f}}, // 1
|
||||||
|
{{1000000.0f, 1.7f, 2.0f}, {1.0f, 1.0f, 1.0f}}, // 2
|
||||||
|
};
|
||||||
|
// An out-of-range level keeps the all-ones default the original memcpy's in first.
|
||||||
|
if (level < 0 || level >= kDifficultyLevels) return DifficultyMods{};
|
||||||
|
const Row& r = kTable[level];
|
||||||
|
const float* m = (isAI && !isNpc) ? r.ai : r.other;
|
||||||
|
return DifficultyMods{static_cast<double>(m[0]), static_cast<double>(m[1]),
|
||||||
|
static_cast<double>(m[2])};
|
||||||
|
}
|
||||||
|
|
||||||
int SavingsInterest(int savings, bool ownsSystems) {
|
int SavingsInterest(int savings, bool ownsSystems) {
|
||||||
if (savings < 0 || !ownsSystems) return 0;
|
if (savings < 0 || !ownsSystems) return 0;
|
||||||
return Ftol(static_cast<double>(savings) * 0.01);
|
return Ftol(static_cast<double>(savings) * 0.01);
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,33 @@
|
||||||
|
|
||||||
namespace sots::sim {
|
namespace sots::sim {
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
// Difficulty
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Three multipliers, selected per player from a three-row table that the executable builds
|
||||||
|
// **in code** from float literals -- there is no data-file key and no tuning-table entry for
|
||||||
|
// any of them. Each row carries two triples: one used for AI players and one for everybody
|
||||||
|
// else, so the same row makes the game easier for the human on level 0 and easier for the AI
|
||||||
|
// on levels 1 and 2. See sots-re findings/subsystems/income-term.md §3.
|
||||||
|
struct DifficultyMods {
|
||||||
|
double maintenanceDivisor = 1.0; // fleet upkeep is divided by ftol(this)
|
||||||
|
double incomeMult = 1.0; // a system's money income, and trade-route income
|
||||||
|
double researchMult = 1.0; // research points bought with money
|
||||||
|
};
|
||||||
|
|
||||||
|
// The number of rows the table holds. A level outside [0, kDifficultyLevels) selects the
|
||||||
|
// all-ones default rather than failing -- the original memcpy's that default in first and
|
||||||
|
// only overwrites it on a hit.
|
||||||
|
constexpr int kDifficultyLevels = 3;
|
||||||
|
|
||||||
|
// `level` is the player's `aidf` save field; `isAI` is the player's AI flag, which is
|
||||||
|
// **not on the wire** (it is copied from the game-setup/network player record), and an NPC
|
||||||
|
// player takes the non-AI triple whatever its AI flag says.
|
||||||
|
// CONFIDENCE: high -- table and selector both read instruction by instruction. The corpus
|
||||||
|
// carries level 1 on every player and exercises only the AI/non-AI split of that row.
|
||||||
|
DifficultyMods DifficultyModsFor(int level, bool isAI, bool isNpc);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------
|
||||||
// Budget
|
// Budget
|
||||||
// ---------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -29,14 +29,24 @@ struct SpeciesConstants {
|
||||||
double incomeFactor = 1.0; // multiplies a system's money income (Zuul 1.1, Morrigi 0.8)
|
double incomeFactor = 1.0; // multiplies a system's money income (Zuul 1.1, Morrigi 0.8)
|
||||||
double hazardCostFactor = 1.0; // multiplies the suitability money cost (Zuul 0.7)
|
double hazardCostFactor = 1.0; // multiplies the suitability money cost (Zuul 0.7)
|
||||||
bool systemBonusEligible = true; // long-stability system bonus accrues (Zuul never)
|
bool systemBonusEligible = true; // long-stability system bonus accrues (Zuul never)
|
||||||
|
// The species' base resource demand and resource-output factor -- the two fields the
|
||||||
|
// output term's harvest summand reads, off the SYSTEM OWNER's species rather than the
|
||||||
|
// colony's population species. Unlike the three above, these come from the DATA FILES;
|
||||||
|
// the values here are the ones measured live on VM140 for the three species the corpus
|
||||||
|
// exercises (Human, Tarkas, Zuul) and are 0/10 for the rest, which is UNVERIFIED for
|
||||||
|
// Hiver, Liir and Morrigi. Getting the Zuul pair wrong costs a Zuul colony exactly
|
||||||
|
// 400 output points -- 2000 money -- which is how it was found.
|
||||||
|
int resourceDemand = 0; // SpeciesDef +0x4c
|
||||||
|
double resourceOutput = 10.0; // SpeciesDef +0x50
|
||||||
};
|
};
|
||||||
|
|
||||||
// CONFIDENCE: high on the three fields (read with their constants and their consumers).
|
// CONFIDENCE: high on the first three fields (read with their constants and their
|
||||||
|
// consumers); the two resource fields are data-file values, measured not read.
|
||||||
constexpr SpeciesConstants ConstantsOf(Species s) {
|
constexpr SpeciesConstants ConstantsOf(Species s) {
|
||||||
switch (s) {
|
switch (s) {
|
||||||
case Species::Zuul: return SpeciesConstants{1.1, 0.7, false};
|
case Species::Zuul: return SpeciesConstants{1.1, 0.7, false, 10, 40.0};
|
||||||
case Species::Morrigi: return SpeciesConstants{0.8, 1.0, true};
|
case Species::Morrigi: return SpeciesConstants{0.8, 1.0, true, 0, 10.0};
|
||||||
default: return SpeciesConstants{1.0, 1.0, true};
|
default: return SpeciesConstants{1.0, 1.0, true, 0, 10.0};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,11 @@ struct TuningTable {
|
||||||
double MORALE_DECREASE_OUTPUT = 0;
|
double MORALE_DECREASE_OUTPUT = 0;
|
||||||
double MORALE_DECREASE_OUTPUT_MOD = 0;
|
double MORALE_DECREASE_OUTPUT_MOD = 0;
|
||||||
double ADDICTION_OUTPUT_MOD = 0;
|
double ADDICTION_OUTPUT_MOD = 0;
|
||||||
|
// The income counterpart, read through its own pointer slot (0x00aeca48 -> 0x00aeca44).
|
||||||
|
// It is applied per (group, species) row inside the population income term, not to the
|
||||||
|
// system total the way the output modifier is. Its file-image value is 0.9f, the same
|
||||||
|
// as ADDICTION_OUTPUT_MOD's, but they are two independent keys.
|
||||||
|
double ADDICTION_INCOME_MOD = 0;
|
||||||
|
|
||||||
// ---- movement (globals) ----
|
// ---- movement (globals) ----
|
||||||
double STUTTER_SYSTEM_INFLUENCE_RADIUS = 0;
|
double STUTTER_SYSTEM_INFLUENCE_RADIUS = 0;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
#include "game/sim/colony.h"
|
#include "game/sim/colony.h"
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
#include "check.h"
|
#include "check.h"
|
||||||
|
#include "game/sim/economy.h"
|
||||||
#include "game/sim/numeric.h"
|
#include "game/sim/numeric.h"
|
||||||
|
|
||||||
using namespace sots::sim;
|
using namespace sots::sim;
|
||||||
|
|
@ -22,6 +25,8 @@ static TuningTable tuning() {
|
||||||
t.STATION_BONUS_IMPERIAL_OUTPUT = 0.1;
|
t.STATION_BONUS_IMPERIAL_OUTPUT = 0.1;
|
||||||
t.STATION_BONUS_SHIPCON = 0.25;
|
t.STATION_BONUS_SHIPCON = 0.25;
|
||||||
t.ADDICTION_OUTPUT_MOD = 0.5;
|
t.ADDICTION_OUTPUT_MOD = 0.5;
|
||||||
|
t.ADDICTION_INCOME_MOD = 0.9;
|
||||||
|
t.SLAVES_INCOME_MOD = 3.0;
|
||||||
t.SYSTEMBONUS_MINTURNS = 10;
|
t.SYSTEMBONUS_MINTURNS = 10;
|
||||||
t.SYSTEMBONUS_POPBONUS = 0.1;
|
t.SYSTEMBONUS_POPBONUS = 0.1;
|
||||||
t.SYSTEMBONUS_POPBONUS_HOME = 0.2;
|
t.SYSTEMBONUS_POPBONUS_HOME = 0.2;
|
||||||
|
|
@ -461,6 +466,127 @@ static void test_system_money() {
|
||||||
CHECK_NEAR(ConstantsOf(Species::Human).incomeFactor, 1.0, 0.0);
|
CHECK_NEAR(ConstantsOf(Species::Human).incomeFactor, 1.0, 0.0);
|
||||||
CHECK(!ConstantsOf(Species::Zuul).systemBonusEligible);
|
CHECK(!ConstantsOf(Species::Zuul).systemBonusEligible);
|
||||||
CHECK(ConstantsOf(Species::Hiver).systemBonusEligible);
|
CHECK(ConstantsOf(Species::Hiver).systemBonusEligible);
|
||||||
|
|
||||||
|
// A von Neumann machine at the system zeroes the cost before anything else is looked at.
|
||||||
|
CHECK_NEAR(SuitabilityCostMod(0.3, 0.5, 0.15, false, true, true), 0.0, 0.0);
|
||||||
|
CHECK_NEAR(SuitabilityCostMod(0.5, 0.5, 0.15, false, false, true), 0.0, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_population_income() {
|
||||||
|
TuningTable t = tuning();
|
||||||
|
|
||||||
|
// Income per head is typeIncomeMod / 14000 -- no 1.8, no 500000. An imperial billion
|
||||||
|
// is 1e9/14000 = 71428.57..., truncated.
|
||||||
|
CHECK_EQ(GroupIncome(PopGroup::Imperial, 1000000000, t), 71428);
|
||||||
|
CHECK_EQ(GroupIncome(PopGroup::Imperial, 13999, t), 0);
|
||||||
|
CHECK_EQ(GroupIncome(PopGroup::Imperial, 14000, t), 1);
|
||||||
|
// The civilian row's modifier is the float32 0.33, so half a billion civilians give
|
||||||
|
// ftol(0.33000001311302185 x 35714.2857...) = 11785.
|
||||||
|
CHECK_EQ(GroupIncome(PopGroup::Civilian, 500000000, t), 11785);
|
||||||
|
CHECK_EQ(GroupIncome(PopGroup::Slaves, 14000, t), 3); // SLAVES_INCOME_MOD = 3
|
||||||
|
|
||||||
|
PopIncomeRow rows[kSpeciesCount] = {};
|
||||||
|
rows[0].count = 1000000000;
|
||||||
|
CHECK_NEAR(PopulationIncome(PopGroup::Imperial, rows, true, false, t), 71428.0, 0.0);
|
||||||
|
|
||||||
|
// Two species truncate SEPARATELY, so the sum is not the truncation of the sum.
|
||||||
|
PopIncomeRow two[kSpeciesCount] = {};
|
||||||
|
two[0].count = 20999; // -> 1
|
||||||
|
two[2].count = 20999; // -> 1
|
||||||
|
CHECK_NEAR(PopulationIncome(PopGroup::Imperial, two, true, false, t), 2.0, 0.0);
|
||||||
|
CHECK_EQ(GroupIncome(PopGroup::Imperial, 41998, t), 2); // ... which happens to agree here
|
||||||
|
PopIncomeRow three[kSpeciesCount] = {};
|
||||||
|
three[0].count = 13999; // -> 0
|
||||||
|
three[2].count = 13999; // -> 0
|
||||||
|
CHECK_NEAR(PopulationIncome(PopGroup::Imperial, three, true, false, t), 0.0, 0.0);
|
||||||
|
CHECK_EQ(GroupIncome(PopGroup::Imperial, 27998, t), 1); // ... and here it does NOT
|
||||||
|
|
||||||
|
// Morale applies to the civilian row only, and the product truncates again.
|
||||||
|
PopIncomeRow mor[kSpeciesCount] = {};
|
||||||
|
mor[0].count = 500000000;
|
||||||
|
mor[0].morale = 80; // >= MORALE_INCREASE_OUTPUT (75)
|
||||||
|
CHECK_NEAR(PopulationIncome(PopGroup::Civilian, mor, true, false, t),
|
||||||
|
std::floor(11785.0 * 1.1), 0.0);
|
||||||
|
// ... but not to the imperial row.
|
||||||
|
CHECK_NEAR(PopulationIncome(PopGroup::Imperial, mor, true, false, t),
|
||||||
|
static_cast<double>(GroupIncome(PopGroup::Imperial, 500000000, t)), 0.0);
|
||||||
|
// ... and an independent colony bypasses morale entirely.
|
||||||
|
CHECK_NEAR(PopulationIncome(PopGroup::Civilian, mor, true, true, t), 11785.0, 0.0);
|
||||||
|
|
||||||
|
// Addiction multiplies every row, imperial included.
|
||||||
|
PopIncomeRow add[kSpeciesCount] = {};
|
||||||
|
add[0].count = 1000000000;
|
||||||
|
add[0].addicted = true;
|
||||||
|
CHECK_NEAR(PopulationIncome(PopGroup::Imperial, add, true, false, t),
|
||||||
|
std::floor(71428.0 * 0.9), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_max_income() {
|
||||||
|
// The whole chain, with the numbers a level-1 AI Zuul colony produces: the trade points
|
||||||
|
// are the rounded output total, blocks of five are worth five each after the x5, the
|
||||||
|
// species factor is 1.1 and the difficulty income modifier another 1.1.
|
||||||
|
SystemMoneyInputs m;
|
||||||
|
m.popIncomeImperial = 71428;
|
||||||
|
m.speciesIncomeFactor = ConstantsOf(Species::Zuul).incomeFactor;
|
||||||
|
m.speciesCostFactor = ConstantsOf(Species::Zuul).hazardCostFactor;
|
||||||
|
m.suitCostMod = 0.0; // a homeworld sits exactly at its ideal
|
||||||
|
m.difficultyIncomeMult = DifficultyModsFor(1, /*isAI=*/true, /*isNpc=*/false).incomeMult;
|
||||||
|
const int ai = SystemMaxIncome(12345.0, m);
|
||||||
|
m.difficultyIncomeMult = DifficultyModsFor(1, /*isAI=*/false, /*isNpc=*/false).incomeMult;
|
||||||
|
const int human = SystemMaxIncome(12345.0, m);
|
||||||
|
// The AI's advantage on this row is exactly the 1.1 in the difficulty table.
|
||||||
|
CHECK(ai > human);
|
||||||
|
CHECK_NEAR(static_cast<double>(ai) / static_cast<double>(human), 1.1, 1e-5);
|
||||||
|
|
||||||
|
// A colony whose money comes out negative contributes ZERO to the empire total rather
|
||||||
|
// than reducing it -- the `jg` at the end of ComputeMaxIncome.
|
||||||
|
SystemMoneyInputs bad;
|
||||||
|
bad.suitCostMod = 20.0; // 20 x 15000 of cost against no income
|
||||||
|
CHECK_EQ(SystemMoneyIncome(bad), -300000);
|
||||||
|
CHECK_EQ(SystemMaxIncome(0.0, bad), 0);
|
||||||
|
|
||||||
|
// The rate vector is trade = 1, so the trade points are the half-to-even rounded total.
|
||||||
|
SystemMoneyInputs r;
|
||||||
|
CHECK_EQ(SystemMaxIncome(20.5, r), SystemMoneyIncome([] {
|
||||||
|
SystemMoneyInputs x;
|
||||||
|
x.tradePoints = 20.0; // 20.5 ties to the even neighbour
|
||||||
|
return x;
|
||||||
|
}()));
|
||||||
|
CHECK_EQ(SystemMaxIncome(21.5, r), SystemMoneyIncome([] {
|
||||||
|
SystemMoneyInputs x;
|
||||||
|
x.tradePoints = 22.0;
|
||||||
|
return x;
|
||||||
|
}()));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_difficulty_table() {
|
||||||
|
// Level 0 gives the break to the human; levels 1 and 2 give it to the AI.
|
||||||
|
const DifficultyMods e_ai = DifficultyModsFor(0, true, false);
|
||||||
|
const DifficultyMods e_pl = DifficultyModsFor(0, false, false);
|
||||||
|
CHECK_NEAR(e_ai.maintenanceDivisor, 1.0, 0.0);
|
||||||
|
CHECK_NEAR(e_ai.incomeMult, 1.0, 0.0);
|
||||||
|
CHECK_NEAR(e_pl.maintenanceDivisor, 1.5, 0.0);
|
||||||
|
CHECK_NEAR(e_pl.incomeMult, 1.5, 0.0);
|
||||||
|
|
||||||
|
const DifficultyMods n_ai = DifficultyModsFor(1, true, false);
|
||||||
|
CHECK_NEAR(n_ai.maintenanceDivisor, 3.0, 0.0);
|
||||||
|
CHECK_NEAR(n_ai.incomeMult, 1.1, 1e-7);
|
||||||
|
CHECK_NEAR(n_ai.researchMult, 1.5, 0.0);
|
||||||
|
CHECK_NEAR(DifficultyModsFor(1, false, false).incomeMult, 1.0, 0.0);
|
||||||
|
|
||||||
|
const DifficultyMods h_ai = DifficultyModsFor(2, true, false);
|
||||||
|
CHECK_NEAR(h_ai.maintenanceDivisor, 1000000.0, 0.0);
|
||||||
|
CHECK_NEAR(h_ai.incomeMult, 1.7, 1e-7);
|
||||||
|
CHECK_NEAR(h_ai.researchMult, 2.0, 0.0);
|
||||||
|
// Hard maintenance really is "divided by a million", i.e. free.
|
||||||
|
CHECK_EQ(MaintenanceCost(999999, h_ai.maintenanceDivisor), 0);
|
||||||
|
|
||||||
|
// An NPC player takes the non-AI triple whatever its AI flag says ...
|
||||||
|
CHECK_NEAR(DifficultyModsFor(1, true, true).incomeMult, 1.0, 0.0);
|
||||||
|
// ... and an out-of-range level falls back to all ones rather than failing.
|
||||||
|
CHECK_NEAR(DifficultyModsFor(-1, true, false).incomeMult, 1.0, 0.0);
|
||||||
|
CHECK_NEAR(DifficultyModsFor(3, true, false).incomeMult, 1.0, 0.0);
|
||||||
|
CHECK_NEAR(DifficultyModsFor(3, true, false).maintenanceDivisor, 1.0, 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void test_bonuses() {
|
static void test_bonuses() {
|
||||||
|
|
@ -586,6 +712,9 @@ int main() {
|
||||||
test_slaves();
|
test_slaves();
|
||||||
test_output();
|
test_output();
|
||||||
test_system_money();
|
test_system_money();
|
||||||
|
test_population_income();
|
||||||
|
test_max_income();
|
||||||
|
test_difficulty_table();
|
||||||
test_bonuses();
|
test_bonuses();
|
||||||
test_build_queue();
|
test_build_queue();
|
||||||
return simtest::finish("test_colony");
|
return simtest::finish("test_colony");
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue