Compare commits
3 commits
8c23f6bd74
...
6f777c2541
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f777c2541 | ||
|
|
419cbca824 | ||
|
|
ae170ecdfe |
7 changed files with 1089 additions and 2 deletions
|
|
@ -29,6 +29,7 @@ add_subdirectory(src/game/data) # typed catalogs on mars/parse+text (lib gam
|
|||
add_subdirectory(src/game/effects) # tech effects (TechId table + apply) (lib game_effects)
|
||||
add_subdirectory(src/game/design) # ship-design rules + derived stats (lib game_design)
|
||||
add_subdirectory(src/game/events) # player event log + research events (lib sots_game_events)
|
||||
add_subdirectory(src/game/combat) # post-battle strategic consequences (lib sots_game_combat)
|
||||
add_subdirectory(src/app) # the standalone turn driver (lib sots_app, sots_turn)
|
||||
|
||||
# ---- shim trace/compare infrastructure (host-testable; linked into binkw32) ----
|
||||
|
|
@ -118,7 +119,7 @@ else()
|
|||
add_executable(addr_smoke tests/addr_smoke.cpp)
|
||||
target_link_libraries(addr_smoke PRIVATE sots_addresses)
|
||||
add_test(NAME addr_smoke COMMAND addr_smoke)
|
||||
foreach(_t mars_parse game_config game_data game_design game_sim mars_stream mars_text mars_vfs shim_trace game_effects game_events shim_budget shim_techfx shim_colony shim_movement shim_events shim_player_turn shim_rng_ledger app)
|
||||
foreach(_t mars_parse game_config game_data game_design game_sim mars_stream mars_text mars_vfs shim_trace game_effects game_events game_combat shim_budget shim_techfx shim_colony shim_movement shim_events shim_player_turn shim_rng_ledger app)
|
||||
if(EXISTS ${CMAKE_SOURCE_DIR}/tests/${_t}/CMakeLists.txt)
|
||||
add_subdirectory(tests/${_t})
|
||||
endif()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1).
|
||||
// Source: sots-re ghidra/addresses.json @ 3d5f834, generated 2026-09-08 by tools/gen_addresses.py
|
||||
// Source: sots-re ghidra/addresses.json @ ab1c229, generated 2026-09-08 by tools/gen_addresses.py
|
||||
// Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated).
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
|
@ -1295,6 +1295,72 @@ constexpr uint32_t Mars_VectorHelper_AIPlayerRequestStamp_Write = 0x0029b310;
|
|||
constexpr uint32_t Mars_StreamableHelper_AIPlayerRequestStamp_vftable = 0x0061a730;
|
||||
// thiscall void (Mars::StreamableHelper<Game::AIPlayerRequestStamp>* this, Mars::Stream* s) // two named ints: `pid` at +0 and `trn` at +4. Game::AIPlayerRequestStamp is a POD with no RTTI class of its own, reached only through this specialised helper, so tools/serializers.py reports 'no serializer' for it and it has no entry in the generated wire table [verified]
|
||||
constexpr uint32_t Game_AIPlayerRequestStamp_Write = 0x00295400;
|
||||
// thiscall void (CombatResolveContext* this) // THE POST-BATTLE RETREAT PIPELINE. Exactly one caller: CombatResolver_Run 0x007d5af0, unconditionally, at 0x007d5be2. Real body 0x007d5a00..0x007d5abb; the only jcc in it is the operator-new null test whose false arm is a _CxxThrowException. It builds a ~0x2c-byte RetreatContext stack local from the resolver's ctx (rc->+0x00 = ctx->+0x00 = S; rc->+0x04 = ctx->+0x08 = enc; rc->+0x08 = ctx->+0x0c = res; a std::map<int,ServerSystem*> at rc->+0x0c with an operator_new(0x18) head node at rc->+0x10 and _Mysize rc->+0x14; a std::vector<RetreatGroup*> at rc->+0x1c/+0x20/+0x24) and runs SIX unconditional this-calls in a straight line: FUN_0079bb90 (per-player destinations), FUN_0079bcd0 (build groups), FUN_007b0320 (whole vs partial), FUN_00790790 (split partial fleets), FUN_007d5650 (execute; EVENT_FLEET_RETREATED_VIA_TELEPORT), FUN_007a7cd0 (destructor). CORRECTS combat-resolver.md's characterisation of this as 'the per-phase combat pipeline': it is ONE subsystem, retreat, not six combat phases. DRAW-FREE: a 327-function closure (E8 calls plus E9 tail-call thunks) contains zero calls to the four RNG primitives and zero inlined MT tempering immediates [verified]
|
||||
constexpr uint32_t CombatResolve_Retreat = 0x003d5a00;
|
||||
// thiscall void (RetreatContext* this) // RETREAT PHASE 1. One loop over enc->members (stride 0x44, magic 0x78787879 / sar 5). Per member: FUN_00787210(&enc->+0x1c, enc->+0x0c, member->+0x00 /*ServerPlayer*/, &r1, &r2, &r3), then this->dest[player->PlyrIdx(+0x28)] = the FIRST NON-NULL of (r1, r2, r3) via std::map<int,T*>::operator[] 0x0076bce0. So the per-player retreat destination is: nearest system you own, else nearest system with no hostile presence, else nearest system at all [verified]
|
||||
constexpr uint32_t CombatRetreat_ChooseDestinations = 0x0039bb90;
|
||||
// thiscall void (RetreatContext* this, Mars::Vector3* battlePos, ServerSystem* excludeSys, ServerPlayer* p, ServerSystem** out1, ServerSystem** out2, ServerSystem** out3) // ret 0x18. THE RETREAT DESTINATION FORMULA. THREE INDEPENDENT nearest-system searches sharing one pass over S->Systems (S->+0x44/+0x48, 4-byte stride), each with its own best-so-far seeded to the .rdata FLT_MAX at 0x009e23a8 (0x7f7fffff): out1 = nearest sys with FUN_007437e0(sys) == p (owner); out2 = nearest sys with FUN_00744070(sys, p) FALSE (no hostile presence); out3 = nearest sys, unconditional. Both skips: sys == excludeSys, and FUN_007469e0(sys). THE INDEPENDENCE IS LOAD-BEARING -- each 'best' store is INSIDE its predicate arm (0x007872c6 / 0x007872ec), so a nearer rejected system does not spoil that search; a single-loop 'find nearest then filter' gives a different answer. Distance is SQUARED: the three component deltas are each fstp'd to float32 first, the products and sum accumulate on the x87 stack, the total narrows to float32 once at 0x007872a3 (a double accumulator with one final narrowing is BIT-IDENTICAL, since a float32 delta squares exactly in double). Comparison is a strict < (fld best; fcomp d; test ah,0x41; jne skip), so an exact tie goes to the EARLIER system in the vector [verified]
|
||||
constexpr uint32_t CombatRetreat_FindDestination = 0x00387210;
|
||||
// thiscall bool (ServerSystem* this) // 33 B. return this->Dstyd(+0xc5) != 0 || (this->vnh(+0xc6) != 0 && this->vnex3(+0xc8) == 0). A destroyed system, or one hosting a Von Neumann hive that has not been cleared out. All three are SAVED bools (struct-recovery.md section 1), which is what identifies the class as Game::ServerSystem rather than a bare StarSystem. Note CombatResolve_NodeCannon 0x007bb530 uses only the +0xc5 half for its own destination filter [verified]
|
||||
constexpr uint32_t ServerSystem_IsUnusableRetreatTarget = 0x003469e0;
|
||||
// thiscall ServerPlayer* (ServerSystem* this) // 7 B: mov eax,[ecx+0x100]; ret. this->PID, the saved owner handle [verified]
|
||||
constexpr uint32_t ServerSystem_GetOwner = 0x003437e0;
|
||||
// thiscall bool (ServerSystem* this, ServerPlayer* p) // ret 4. return (ServerPlayer_HostileMaskAt(p, this) & this->AFlags(+0xd4)) != 0 [verified]
|
||||
constexpr uint32_t ServerSystem_HasHostilePresence = 0x00344070;
|
||||
// thiscall uint32 (ServerPlayer* this, ServerSystem* sys) // ret 4. THE HOSTILITY MASK, and it carries a diplomacy rule that is nowhere in findings/subsystems/: ownerBit = (sys && sys->PID(+0x100) && sys->TAcq(+0x80) == S->Frame) ? (1 << sys->PID->PlyrIdx) : 0; return ~( (1 << this->PlyrIdx(+0x28)) | (this->CF(+0x174) & ~ownerBit) | this->NA(+0x170) | this->AL(+0x16c) ). A SYSTEM WHOSE OWNER CAPTURED IT ON THE CURRENT TURN LOSES THAT OWNER'S CEASEFIRE COVER. The current turn is reached as this->+0x8->+0x8, which is S->+0xc = Frame, because ServerPlayer+0x8 == S+4. The complement is over all 32 bits, harmless only because it is immediately ANDed with a presence mask [verified]
|
||||
constexpr uint32_t ServerPlayer_HostileMaskAt = 0x0040dff0;
|
||||
// thiscall void (RetreatContext* this) // RETREAT PHASE 2. One loop over res->+0x68/+0x6c -- a 0x14-STRIDE VECTOR OF RETREAT REQUESTS (magic 0x66666667 / sar 3), a Game::EncounterResults member not previously mapped: req->+0x04 ship handle, req->+0x08 mode word, req->+0x0c requested-destination handle, req->+0x10 second key word. Per request: ship = HandleMap::Resolve(S->+0x84, req->+0x04); skip if null, if ship->Dep(+0x50) != 0, if ship->EncID(+0x7c) == 0x15, if FUN_004f47f0(ship->EncID) (a DB-driven encounter-type mask), or if ship->PlrID(+0x10) == S->Players[S->+0x1c0] (the locally-controlled player). dest = CombatRetreat_ResolveShipDestination(ctx, req, ship); on 0 it logs 'Retreat: Could not find retreat destination for %s.' at level 2 and skips. Then, UNLESS (req->+0x08 == 1 && owner->Species(+0x5c) == 1), skip the ship if StarShip_IsGroundedByDamage(ship). Finally find-or-create the group via CombatRetreat_FindGroup and push_back the ship [verified]
|
||||
constexpr uint32_t CombatRetreat_BuildGroups = 0x0039bcd0;
|
||||
// thiscall ServerSystem* (RetreatContext* this, void* req, StarShip* ship) // ret 8. Returns 0 when ship is null or ship->+0x04 (its handle id) is 0. Else: if req->+0x0c != 0, HandleMap::Resolve(S->+0x84, req->+0x0c) and RETURN IT if found -- the explicitly ordered destination; if not found, log 'Retreat: %s cannot retreat to specified location. Location not found.' at level 2 with ship->PlrID->+0x40 (the player's name std::string) and fall through. Fall-through: return this->dest[ship->PlrID(+0x10)->PlyrIdx(+0x28)], the phase-1 default [verified]
|
||||
constexpr uint32_t CombatRetreat_ResolveShipDestination = 0x0039bc50;
|
||||
// thiscall RetreatGroup* (RetreatContext* this, StarShip* ship, ServerSystem* dest, int mode, int variant) // ret 0x10. Linear scan of this->groups (+0x1c/+0x20) for the group matching ALL FOUR key words: g->+0x00 == ship->PlrID(+0x10), g->+0x04 == dest, g->+0x2c == mode, g->+0x30 == variant. Returns 0 when absent [verified]
|
||||
constexpr uint32_t CombatRetreat_FindGroup = 0x003871c0;
|
||||
// thiscall RetreatGroup* (RetreatGroup* this) // 41 B; ENUMERATES the 0x34-byte heap record (operator new(0x34) at 0x0079bdee). Writes: +0x00 owner ServerPlayer* = 0; +0x04 dest ServerSystem* = 0; +0x08/+0x0c/+0x10 vector<StarShip*> = 0 (allocator +0x14 never written, per earned-rule 5); +0x18/+0x1c/+0x20 vector<StarFleet*> = 0 (allocator +0x24); +0x28 BYTE partial = 0; +0x2c int mode = 0; +0x30 int variant = -1. Total 0x34 [verified]
|
||||
constexpr uint32_t RetreatGroup_Ctor = 0x0038f1c0;
|
||||
// thiscall bool (StarShip* this) // 46 B. design = this->DesID(+0x14); return (design->+0x74 != 0 || design->+0x78 != 0) && design->+0x12c < 2 && (FLT_EPSILON > this->+0x30). The .rdata float at 0x009e1ef8 is 0x34000000 = 1.1920929e-07, FLT_EPSILON -- the test is against an epsilon, NOT against zero, and it is strict, so a health exactly AT the epsilon is not grounded. this->+0x30 is the third float of the inline ShipHealth at 0x24..0x33 (R1 guesses drive). True means the ship CANNOT retreat. The species-1 / mode-1 gate path skips this test entirely [verified]
|
||||
constexpr uint32_t StarShip_IsGroundedByDamage = 0x00415090;
|
||||
// cdecl bool (int encounterType) // 67 B. DB-SOURCED, DO NOT HARD-CODE. Returns false for encounterType > 0x17. Otherwise lazily builds a bitmask once (flag at 0x00b0e988, cache at 0x00b0e984) by FUN_004f47b0, which ORs 1 << table[i] over a count at 0x00ae04e0 and an int array at 0x00adfe88 -- both .bss, filled from the game data files at load -- and returns (mask >> encounterType) & 1. The 0x17 bound is the same EncounterType range PickDominantEncounterType 0x004f4c40 returns over [verified]
|
||||
constexpr uint32_t IsBlockedEncounterType = 0x000f47f0;
|
||||
// thiscall void (RetreatContext* this) // RETREAT PHASE 3. Per group in this->groups: tally the group's ships by ship->FltID(+0x64) into a local 8-byte-stride vector<{StarFleet*, int}> (linear search, push {f,0} then ++count); then per distinct fleet f, if count == (f->+0xa8 - f->+0xa4)/4 (i.e. EVERY ship the fleet has is in this group) push f into g->fleets(+0x18), else set g->partial(+0x28) = 1. WARNING: the jb/jae pairs at 0x007b0504 and 0x007b0570 are MSVC's inlined vector::push_back aliasing check (_Inside(&_Val)), NOT a phase gate -- both arms converge on *_Mylast = value; _Mylast += 4 at 0x007b05ce. Draw-free, event-free, writes only the group records [verified]
|
||||
constexpr uint32_t CombatRetreat_ClassifyFleets = 0x003b0320;
|
||||
// thiscall void (RetreatContext* this) // RETREAT PHASE 4, and THE BIGGEST SAVED-STATE WRITER ON THE COMBAT PATH. Per group, gated on g->partial(+0x28) != 0: newFleet = StrategyServer_CreateFleet(S+4, IDMap_AllocateLocalID(S+4), g->owner, &enc->+0x1c, NULL) -- note the id mint is evaluated INSIDE the argument list, see IDMap_AllocateLocalID; push newFleet into g->fleets BEFORE the ship loop; then per ship in g->ships, skip it if ship->FltID(+0x64) is ALREADY in g->fleets (its whole fleet is retreating, leave it), else StarFleet_RemoveShip(old, ship), and if ((old->+0xa8 - old->+0xa4) & ~3) == 0 call StrategyServer_DestroyFleet(S+4, old, 0, 0), then StarFleet_AddShip(newFleet, ship). Finally if (enc->+0x0c) StarSystem_FleetArrives(enc->+0x0c, newFleet). PREDICTED ORIGINAL BUG: the fleet is created before the loop, so a group flagged partial creates a fleet even when every ship turns out to belong to a wholly-retreating fleet -- a zero-ship fleet in the save [verified]
|
||||
constexpr uint32_t CombatRetreat_SplitFleets = 0x00390790;
|
||||
// thiscall void (StarFleet* this, StarShip* ship) // ret 4. this->NShips(+0xa4).push_back(ship); SHIP->FltID(+0x64) = this (the saved handle); design = ship->DesID(+0x14); this->+0xb8 |= design->+0xb8 and this->+0xbc |= design->+0xbc (capability masks); this->+0xc0 (WORD, the fleet's gate-traffic cost) += FUN_0056f430(design->+0x12c); if bit 0x20000 of +0xb8 changed state, push_back this into (this->+0x10)->+0x114; FUN_00705c70(this) to recompute derived stats [verified]
|
||||
constexpr uint32_t StarFleet_AddShip = 0x003062e0;
|
||||
// thiscall void (StarFleet* this, StarShip* ship) // ret 4. Linear-search this->NShips(+0xa4/+0xa8) for ship; return if absent. If the fleet's flight plan is non-empty (+0xc8 != +0xcc) and IsGateTransitWaypoint(wpt->+0x08) 0x0056e6e0, this->PID(+0x58)->GTraf(+0x14c) -= (int16)this->+0xc0. Then SHIP->FltID(+0x64) = 0; erase the slot (memmove + _Mylast -= 4); FUN_00700090(this); FUN_00705070(this, ship); re-add the gate-traffic term under the same test; if bit 0x20000 of +0xb8 changed, find-and-erase this from (this->+0x10)->+0x114; if any ship remains, FUN_00705c70(this). GTraf is a SAVED ServerPlayer field [verified]
|
||||
constexpr uint32_t StarFleet_RemoveShip = 0x003063a0;
|
||||
// thiscall void (ServerSystem* this, StarFleet* f) // ret 4. f->Pos(+0x18/+0x1c/+0x20) = this->Pos (three raw dword copies); StarFleet_SetLocation(f, this); bit = 1 << f->PID(+0x58)->PlyrIdx(+0x28); this->FFlags(+0xd8) |= bit; this->+0x16c.push_back(f) (the SAVED NumFlts/Flt list); then recompute: if (FFlags & bit || GFlags(+0xdc) & bit || PID(+0x100) == owner) { AFlags(+0xd4) |= bit; VFlags(+0xcc) |= bit; } else AFlags &= ~bit -- on this path the else arm is unreachable because FFlags was just set [verified]
|
||||
constexpr uint32_t StarSystem_FleetArrives = 0x0034f240;
|
||||
// thiscall void (StarFleet* this, void* loc) // 16 B: this->LocID(+0xa0) = loc. Saved handle [verified]
|
||||
constexpr uint32_t StarFleet_SetLocation = 0x002fe2f0;
|
||||
// thiscall void* (StarFleet* this) // 19 B: eax = this->LocID(+0xa0); return (eax && eax->+0x14 == 0) ? eax : 0. The +0x14 word is a location-class tag; the sibling FUN_006fe320 returns it only when the tag is 2. The retreat path uses this one, whose class carries the fleet list at +0x16c and the name std::string at +0xa8 [verified]
|
||||
constexpr uint32_t StarFleet_GetLocationIfNode = 0x002fe300;
|
||||
// thiscall void (RetreatContext* this) // RETREAT PHASE 5. Two nested loops: over this->groups, then over g->fleets (whole fleets from phase 3 plus any new fleet from phase 4). Per fleet f: (a) if (enc->+0x0c && !ServerSystem_IsExploredBy(enc->+0x0c, f->PID(+0x58))) StrategyServer_GrantSystemIntel(S, S->+0x44[enc->+0x0c->Idx(+0x5c)], f->PID) -- RETREATING FROM AN UNEXPLORED SYSTEM REVEALS IT, writing the SAVED EFlags and PlayerView; (b) if CombatRetreat_UsesGate(g) && g->dest(+0x04) != 0, the GATE ARM: StarSystem_FleetDeparts(GetLocationIfNode(f), f), StarSystem_FleetArrives(g->dest, f), then two _snprintf'd strings from the .bss format-string pointers at 0x00aedf0c and 0x00aedf14 (destination Name(+0xa8), fleet FtName(+0x5c)) and EventStorage_PostEvent(ServerPlayer_GetEventStorage(f->PID), {msg, summary, f, 0, S->Frame, 'EVENT_FLEET_RETREATED_VIA_TELEPORT', 0}); (c) else the MOVE ARM: if (!StrategyServer_OrderFleetMove(S+4, f, &g->dest, 1)) log 'Retreat: Unable to set destination for retreat for %s.' at level 2 with f->PID->+0x40, else StarFleet_SetFlag(f, 2, true) and StarSystem_FleetDeparts(GetLocationIfNode(f), f) [verified]
|
||||
constexpr uint32_t CombatRetreat_Execute = 0x003d5650;
|
||||
// cdecl bool (RetreatGroup* g) // 48 B, ret 4 (ecx is set at the one call site but unused). return g != 0 && g->mode(+0x2c) == 1 && g->owner(+0x00) != 0 && g->owner->Species(+0x5c) == 1 && g->dest(+0x04) != 0. Species 1 is the gate-building species; this is the ONLY gate on EVENT_FLEET_RETREATED_VIA_TELEPORT, which is posted nowhere else in the image. The same (mode == 1 && species == 1) pair is what lets a ship with a dead drive past StarShip_IsGroundedByDamage in phase 2 [verified]
|
||||
constexpr uint32_t CombatRetreat_UsesGate = 0x003859f0;
|
||||
// thiscall bool (ServerSystem* this, ServerPlayer* p) // 34 B, ret 4: return (this->EFlags(+0xd0) & (1 << p->PlyrIdx(+0x28))) != 0. EFlags is a SAVED int [verified]
|
||||
constexpr uint32_t ServerSystem_IsExploredBy = 0x00343880;
|
||||
// thiscall void (ServerSystem* this, ServerPlayer* p, bool on) // 82 B, ret 8. bit = 1 << p->PlyrIdx(+0x28); wasSet = (this->EFlags(+0xd0) & bit) != 0; on ? EFlags |= bit : EFlags &= ~bit; then this->vft[0x1c](p, wasSet, on) -- AN INDIRECT EDGE, unresolved here, flagged for the vtable-inversion lane [verified]
|
||||
constexpr uint32_t ServerSystem_SetExploredBy = 0x003438b0;
|
||||
// thiscall void (StrategyServer* this /*the S frame*/, ServerSystem* sys, ServerPlayer* p) // 195 B, ret 8. Returns immediately if either pointer is null. ServerSystem_SetExploredBy(sys, p, true); FUN_006e4620(this->+0x154 /*the node-line/graph object*/, sys, p, 1); then builds a stack temp via FUN_00755ab0, fills it with FUN_007561d0(sys, &tmp, p), and applies it through FUN_007d1f00(this->+0x224, &sys->Idx(+0x5c), &p->PlyrIdx(+0x28), &tmp) -> FUN_0075cf50 -> FUN_00753430, destroying the temp with FUN_006bba00. this->+0x224 is INFERRED to be the per-(system, player) StarSystem::PlayerView map -- combat-done-tail.md phase 17 has its tree head at S+0x228 and its size word at S+0x22c, which puts the map object at S+0x224 -- but the final leg FUN_00753430 was NOT read, so the exact field written is a labelled hypothesis [hypothesis]
|
||||
constexpr uint32_t StrategyServer_GrantSystemIntel = 0x003d5080;
|
||||
// thiscall void (RetreatContext* this) // RETREAT PHASE 6, and it is only a destructor -- it writes no game state. FUN_00793090(this->+0x1c, this->+0x20) deletes every RetreatGroup; then vector::erase(begin,end) (a degenerate memmove of 0 bytes plus _Mylast = _Myfirst) and operator delete on the buffer, zeroing +0x1c/+0x20/+0x24; then _Tree::erase(head->_Left, head) via FUN_006a4e40 on the map at this->+0x0c and operator delete on its head node [verified]
|
||||
constexpr uint32_t CombatRetreat_ContextDtor = 0x003a7cd0;
|
||||
// thiscall StarFleet* (StrategyServer* this /*the S+4 frame*/, int oid, ServerPlayer* owner, Mars::Vector3* pos, const char* nameOverride) // 347 B, ret 0x10. operator new(0x120) -- SIZEOF(Game::StarFleet) = 0x120 BY ENUMERATION, agreeing with struct-recovery.md section 3 whose last member FtMS sits at +0x11c -- then ctor FUN_00706140; IDMap_Insert(this+0x80, obj, oid) which writes the id into obj->+0x04; obj->+0x10 = this (the S+4 pointer, NOT a ServerPlayer as it is on StarShip); obj->PID(+0x58) = owner; the name from FUN_00856310(owner) (-> FUN_00855f80 on owner+0x298) assigned into obj->FtName(+0x5c) unless nameOverride is given; pos copied to BOTH +0x18/+0x1c/+0x20 (Pos) and +0x4c/+0x50/+0x54 (PrvPos); StarFleet_SetFlag(obj, 0x400, true); push_back into the vector at this->+0x60 (= S+0x64), THE SAVED NumFlts/FltID/Flt MASTER LIST; then the virtual (*this)->vft[0x10](this, obj). Draw-free, including the name generator. DELEGATED instruction-level read; the call site is mine [verified]
|
||||
constexpr uint32_t StrategyServer_CreateFleet = 0x0045b340;
|
||||
// thiscall int (StrategyServer* this /*the S+4 frame*/) // AN 8-BYTE TAIL-CALL THUNK: sub ecx,0xffffff80 (ecx += 0x80) then jmp FUN_008b8b70, which is push [ecx+0x18] (the local node index); call IDMap_AllocateID; ret. IT TAKES NO STACK ARGUMENTS. At the one retreat call site 0x007907d8 three pushes sit immediately before it and belong to the LATER call to StrategyServer_CreateFleet -- the id mint is evaluated inside that call's argument list. Checking ret N against the push count is what catches this; see combat-retreat-pipeline.md section 7.3. The IDMap subobject therefore lives at (S+4)+0x80 [verified]
|
||||
constexpr uint32_t IDMap_AllocateLocalID = 0x0040f710;
|
||||
// thiscall int (IDMap* this, int nodeIndex) // ret 4. THE OBJECT-ID ALLOCATOR. Closes struct-recovery.md section 7's open item 'OID allocation (R2's x16)': R2's x16 is the shl 4 below. Layout: this->+0x08/+0x0c is a vector<NodeEntry> of STRIDE 0x14 (pinned twice -- FUN_008b8a70's 0x66666667 / sar 3 divide-by-20, and the lea eax,[edi+edi*4] with scale 4 here); NodeEntry is a std::map<int,void*> (0x10) followed by the counter at +0x10; this->+0x18 is the local node index. Body: returns 0 if nodeIndex == -1 (logs 'IDMap: Map not initialized.') or the node does not exist; else 0x008b8b16 'inc DWORD PTR [ecx+eax*4+0x10]' PRE-INCREMENTS the per-node counter, and on wrap to 0 logs 'IDMap: NextID wrapping for node %d.' and increments AGAIN (0x008b8b30), so 0 is never issued -- 0 is INVALID_NETWORK_ID; then id = (counter << 4) | (nodeIndex & 0xF) at 0x008b8b38/0x008b8b3e, with an overflow log if the counter no longer round-trips. THE COUNTER IS ALMOST CERTAINLY THE SAVE'S 'NMnx' TAG (StrategyServer block order: KeyPath, NMSz, NMLc, NMnx, ModCount, Frame) -- LABELLED HYPOTHESIS: StrategyServer::Write 0x0079fa70 was not read for it. DELEGATED instruction-level read [verified]
|
||||
constexpr uint32_t IDMap_AllocateID = 0x004b8ae0;
|
||||
// thiscall void (IDMap* this, void* obj, int id) // ret 8. Logs 'IDMap: Object already exists with id %d.' and continues if the id is taken; refuses id 0 with 'IDMap: Object with INVALID_NETWORK_ID not inserted.'; node = id & 0xF, bounds-checked; WRITES THE ID INTO obj->+0x04 (the NetworkObject handle slot every serialized object uses); then _Buynode + _Insert into the map at _Myfirst + node*0x14. DELEGATED instruction-level read [verified]
|
||||
constexpr uint32_t IDMap_Insert = 0x004b9350;
|
||||
// thiscall void (StrategyServer* this /*the S+4 frame*/, StarFleet* f, bool killRemainingShips, int shipDestroyArg) // 1352 B, ret 0xc. Called from the retreat split as (f, 0, 0), so the ship-massacre block 0x0088bbf1..0x0088bc82 is dead on that path. A REAL DELETE (vtable slot 0 with flag 1, the MSVC scalar deleting destructor, at 0x0088bea6). Order: (1) sweep the master fleet vector this->+0x60/+0x64 for any fleet whose flight plan (+0xc8/+0xcc, waypoint stride 0x1c) carries a waypoint whose field_0 is f's id -- for each, re-plan it via FUN_00707290 and POST 'EVENT_FLEET_INTERCEPT_ABORTED' to THAT fleet's owner, inlining player+0x29c rather than calling ServerPlayer::GetEventStorage; (2) optionally destroy remaining ships; (3) erase from the owner's fleet map at owner+0x2b4; (4) per player, FUN_00812110(player+0x29c, fleetId, -1) to purge queued events referencing it; (5) erase from this->+0x60 or log 'StrategySim: Can't delete %s (%i), not found in master list!'; (6) detach from its location; (7) vft[0x14](f), erase from this->+0x114, FUN_008b92a0(this+0x80, f); (8) delete. No RNG, no inlined MT draw, no SETurnResults stride. DELEGATED instruction-level read, full body to the next function start [verified]
|
||||
constexpr uint32_t StrategyServer_DestroyFleet = 0x0048b980;
|
||||
// thiscall bool (StrategyServer* this /*the S+4 frame*/, StarFleet* f, ServerSystem** dests, unsigned count) // 801 B, ret 0xc. Returns false if f is null, or if the path solver FUN_007066c0 sets any of bits 0x008 / 0x010 / 0x400 in its flag word (then logging 'StrategySim: %s (%s) move not permitted at this time.' at level 2) -- THE MEANING OF THOSE THREE BITS IS UNKNOWN, FUN_007066c0 was not opened. Before that gate it SNAPS the fleet's position onto its current system when they differ, tested by FUN_0080ec50 with fucompp EXACT IEEE EQUALITY and no epsilon; that snap is committed even on the false path. On success it builds a 0x1c-stride waypoint vector and commits it through FUN_00707080, which debits owner->GTraf(+0x14c) by (int16)f->+0xc0, writes f->+0xd8..+0xf8 and +0x100..+0x108 (cached source/destination positions and the leg record), then re-credits GTraf; finally it cancels conflicting ship actions via FUN_00849280. No RNG and no x87 in the function itself. DELEGATED instruction-level read, full body to the next function start [verified]
|
||||
constexpr uint32_t StrategyServer_OrderFleetMove = 0x004653c0;
|
||||
// thiscall void** (std::map<int, void*>* this, const int* key) // 125 B, ret 4. MSVC std::map<int,T*>::operator[]: _Lbound over the tree from this->_Myhead(+0x04)->_Parent, testing _Isnil at node+0x15 and the key at node+0x0c; if found returns &node->_Myval.second (node+0x10), else default-inserts the pair {key, 0} via _Buynode 0x008b91d0 + _Insert 0x0072b400 and returns the same. NODE IS 0x18 BY ENUMERATION from _Buynode's operator new(0x18): _Left +0x00, _Parent +0x04, _Right +0x08, pair<int,void*> at +0x0c/+0x10, and _Color/_Isnil written as ONE 16-bit store at +0x14/+0x15, plus 2 bytes padding. DELEGATED instruction-level read [verified]
|
||||
constexpr uint32_t Map_IntPtr_Subscript = 0x0036bce0;
|
||||
// thiscall uint32_t (Mars::RNG* this /*ecx = THE OBJECT, not &mt*/) // plain RET, no stack args. THE FOURTH DRAW ENTRY POINT. Whole 84-byte body read from the instruction stream: `cmp [ecx+0x9c8],0; push esi; lea esi,[ecx+4]; jne skip; mov ecx,esi; call RNG_Twist; skip: eax=[esi+0x9c0]; dec [esi+0x9c4]; ecx=*eax; eax+=4; [esi+0x9c0]=eax;` then the standard Mars temper (shr 11 / and 0xff3a58ad shl 7 / and 0xffffdf8c shl 15 / shr 18) and `ret`. EXACTLY ONE MT WORD, UNCONDITIONAL -- no rejection loop, no early-out, no branch except the lazy twist. Contrast RNG_NextFloat and RNG_NextInt, which are entered with ECX = &mt = obj+4; this one takes the object and does the +4 itself. Body ends 0x004f76c3 (Ghidra's 84 is correct here), then 12 int3 to 0x004f76d0. 11 callers image-wide; in StrategyServer::ProcessTurn's direct-call closure at DEPTH 4 via ProcessFleetMovement 0x007da9a0 -> MoveFleet 0x007d9ee0 -> ProbabilisticJump 0x007b6700 @0x007b67e7 [verified]
|
||||
constexpr uint32_t Mars_RNG_NextUInt = 0x000f7670;
|
||||
// thiscall float (Mars::RNG* this /*ecx = THE OBJECT*/, float lo, float hi) // RET 8. FIFTH DRAW ENTRY POINT, in no previous lane's primitive set. `add ecx,4; call RNG_NextFloat` then `lo + (float)((hi-lo) * unit)`, with the product STORED TO A FLOAT before the add and the sum stored to a float again -- two roundings, both must be reproduced. EXACTLY ONE MT WORD. In StrategyServer::ProcessTurn's closure at depth 3 via ServerPlayer::ProcessTurn -> 0x00889dc0 (call sites 0x0088a1bd, 0x0088a20f) [verified]
|
||||
|
|
@ -1411,6 +1477,52 @@ constexpr uint32_t StrategyServer_FinalizeTurnRecords = 0x0038a0e0;
|
|||
constexpr uint32_t ServerSystem_ComputeMaxIncome = 0x003521c0;
|
||||
// note Game::SNMAllCombatDone RTTI vtable, four slots (0x0079e590, 0x0082a100, 0x0082a170, 0x0079e500 -- the middle pair are the network Read/Write). Layout by enumeration from the two stack constructors and from every offset OnAllCombatDone_Tail reads: `struct SNMAllCombatDone { void* vptr; std::vector<EncounterResults> results; }`, 0x10 bytes -- which is why the handler passes msg+4 and not msg. Three construction sites: RunCombatRound 0x007cc847 (stack), the combat server FUN_007cfd00+0x541 = 0x007d0241 (stack; sends it to every player whose +0x44 is 4 or 5, then sets combatServer->+0x60 = 9; NOTE Ghidra sizes FUN_007cfd00 at 384 B but its real body runs to the ret at 0x007d02b9), and the deserialization factory 0x008663b0 (operator new(0x14) -- 4 bytes larger than the enumerated size, UNEXPLAINED) [verified]
|
||||
constexpr uint32_t SNMAllCombatDone_layout = 0x00624758;
|
||||
// thiscall double (ServerSystem* sys, double overHarvestRate) // `ret 8`, real end 0x007505a5. Returns 0 when the system has no owner (+0x100) or rbfl (+0x1dc) is non-zero. Otherwise the SUM of three terms -- the over-harvest demand x SpeciesDef+0x50, (TRes + available resources) x StripMineFraction x 0.9, and the population output of groups 0, 1 and 2 -- multiplied in one uninterrupted 80-bit chain by player OutMod, sys.OutMod, player +0x224, RebOutMod, ScOutMod, and finally by ADDICTION_OUTPUT_MOD when the addiction phase is >= 3. SIDE-EFFECT FREE: it and all seven callees were checked for stores to the game state (0x0074a6d0's only writes are through its int64 out-parameters), which is why this and not ComputeOutputFromRates is the compare target -- that one repairs ships in orbit [verified]
|
||||
constexpr uint32_t ServerSystem_ComputeTotalOutput = 0x00350480;
|
||||
// thiscall double (ServerSystem* sys, int groupType, int species, double count) // `ret 0x10`, real end 0x0074b871. THE population -> output term: returns 0 for count <= 0, else max(0, POPTYPE[groupType].outputMod x (stationFactor x 1.8) x moraleMod x (count / 500000)). stationFactor is 1 + stations x STATION_BONUS_IMPERIAL_OUTPUT and applies to groupType 0 of an owned system only (and only while that constant is > 0); moraleMod applies to groupType 1 only. So output points per head are typeOutputMod x 1.8 / 500000 -- exactly 3.6e-6 for an imperial population with no stations [verified]
|
||||
constexpr uint32_t ServerSystem_GroupOutput = 0x0034b7a0;
|
||||
// thiscall double (ServerSystem* sys, int groupType) // `ret 4`, real end 0x0074da01. Sums GroupOutput over species 0..6 using GroupPopulation(groupType, species). For groupType 1 and the owner's own species on a non-independent system it first adds a capacity surplus: two calls to 0x0074a6d0 with the int64 out-pointer in argument slot 4 and then in slot 6, surplus = max(0, B - A). The income analogue is 0x0074d760 [verified]
|
||||
constexpr uint32_t ServerSystem_PopOutput = 0x0034d8f0;
|
||||
// thiscall double (ServerSystem* sys) // real end 0x0074b901. Sums over species 0..6: max(0, POPTYPE[2].outputMod x 1.8 x (slaves / 500000)). Does NOT go through GroupOutput, so it carries neither the station factor nor the morale multiplier. Slave counts come from 0x0074b610 [verified]
|
||||
constexpr uint32_t ServerSystem_SlaveOutput = 0x0034b880;
|
||||
// thiscall float (ServerSystem* sys) // real end 0x00747ddd. R = clamp01(signed_cbrt((pbon + Pop) / 100) x 0.01); if 0.0001 + R >= 1 the infrastructure term SUBSTITUTES it outright (R := float32(ibon + Infra)); returns float32(min(R, float32(ibon + Infra))). The substitution is not a clamp: a colony with a pending infrastructure bonus can return a fraction above 1 [verified]
|
||||
constexpr uint32_t ServerSystem_StripMineFraction = 0x00347d30;
|
||||
// thiscall double (ServerSystem* sys, double overHarvestRate) // `ret 8`, real end 0x007484c4. CORRECTS strategic-turn-internals.md 3.3, which called this the population base-output term: its population is the RESOURCE stock (Res, plus MRes + ARes2 when the owner strip-mines), and the species constant at +0x4c is ADDED, not a cap -- the cap is the resource stock. B = rate > 0 ? max(rate x available x clamp01((pbon + Pop) x 1e-5), 1.0) : 0; return min(available, max(speciesBaseDemand + B, 0)). Called twice per output pass: once inside ComputeTotalOutput and once by ComputeOutputFromRates as the resource ledger's strip-mine demand [verified]
|
||||
constexpr uint32_t ServerSystem_OverHarvestDemand = 0x003483b0;
|
||||
// thiscall int64 (ServerSystem* sys, int groupType, int species) // groupType 0 returns (pbon + Pop) sign-extended, but ONLY for the system's effective species (indi->+4 when independent, else owner->Species) and 0 for every other; otherwise Population::Count(pbon2) + Population::Count(Pop2) for that (type, species) [verified]
|
||||
constexpr uint32_t ServerSystem_GroupPopulation = 0x00347ba0;
|
||||
// thiscall double (ServerSystem* sys, int species) // `ret 4`. Returns 1.0 when the system has no owner, is independent, or its Morale int[7] entry for the species is exactly 0 -- the zero guard matters, because 0 <= MORALE_DECREASE_OUTPUT would otherwise apply the penalty to every species with no record. Otherwise m >= MORALE_INCREASE_OUTPUT -> MORALE_INCREASE_OUTPUT_MOD, m <= MORALE_DECREASE_OUTPUT -> MORALE_DECREASE_OUTPUT_MOD, each used only when strictly positive, else 1.0 [verified]
|
||||
constexpr uint32_t ServerSystem_MoraleOutputMod = 0x00346910;
|
||||
// cdecl PopTypeRow* (int groupType) // table base 0x00b104e8, stride 0x30, three rows; groupType > 2 falls back to a lazily-initialised sentinel row at 0x00b10578 that no caller reaches. Row fields used here: +0x10 output modifier, +0x14 income modifier, +8 maximum population [verified]
|
||||
constexpr uint32_t PopTypeRow = 0x00135e00;
|
||||
// cdecl void () // builds the three-row population-type table IN CODE from x87 literals, rotating six values with fxch rather than storing them. Imperial {+4 1.0, maxpop 50000000, out 1.0, income 1.0, +0x18 1.0, +0x1c 1.0, +0x20 1.0}; civilian {+4 0.25, maxpop 20000000, out 0.33, income 0.33, +0x18 1.0, +0x1c 0.5, +0x20 2.0}; slaves {+4 0.0, maxpop 0, out SLAVES_OUTPUT_MOD, income SLAVES_INCOME_MOD, +0x18 SLAVES_REPAIR_MOD, +0x1c 0.0}. So the whole population -> output law is carried by the executable except the three slave modifiers [verified]
|
||||
constexpr uint32_t InitPopTypeTable = 0x00135ca0;
|
||||
// cdecl int (int groupType, int64 count) // ftol(POPTYPE[groupType].incomeModifier x (count / 14000.0)). The income analogue of GroupOutput; note the divisor is 14000, not the output law's 500000, and there is no 1.8 factor [verified]
|
||||
constexpr uint32_t GroupIncome = 0x00135e80;
|
||||
// cdecl SpeciesDef* (int species) // table base 0x00b10a00, stride 0x184, seven rows; species > 6 returns a lazily-constructed default at 0x00b105b0. Fields read by the output chain: +0x4c the base resource demand (an int) and +0x50 the resource output factor (a float). The table is .bss, so both come from the data files [verified]
|
||||
constexpr uint32_t SpeciesDefTable_Get = 0x00145cc0;
|
||||
// cdecl double (double x) // x >= 0 ? pow(x, 1/3) : -pow(-x, 1/3), with the exponent taken from the double 0.3333333333333333 at 0x00a3a7c8. Used by StripMineFraction [verified]
|
||||
constexpr uint32_t SignedCubeRoot = 0x004e5680;
|
||||
// offset offset float ScOutMod -- the last of the five output multipliers ComputeTotalOutput applies, and the innermost in the x87 chain [verified]
|
||||
constexpr uint32_t ServerPlayer_off_ScOutMod = 0x0000012c;
|
||||
// offset offset float -- the game-setup handicap OUTPUT multiplier, the sibling of SetupIncomeMult (+0x228) and SetupResearchMult (+0x22c). Read by ComputeTotalOutput as the third of its five multipliers [verified]
|
||||
constexpr uint32_t ServerPlayer_off_SetupOutputMult = 0x00000224;
|
||||
// data PopTypeRow[3] -- .bss, filled by InitPopTypeTable at startup. Stride 0x30 [verified]
|
||||
constexpr uint32_t PopTypeTable_base = 0x007104e8;
|
||||
// data SpeciesDef[7] -- .bss, filled from the data files. Stride 0x184 [verified]
|
||||
constexpr uint32_t SpeciesDefTable_base = 0x00710a00;
|
||||
// data float** -- pointer slot; storage 0x00af08f0, which unusually carries a value (0.1f) in the file image rather than being .bss [verified]
|
||||
constexpr uint32_t GlobalConst_slot_STATION_BONUS_IMPERIAL_OUTPUT = 0x006f08f4;
|
||||
// data int** -- pointer slot read by MoraleOutputMod; the threshold is an INT compared against the Morale int[7] entry [verified]
|
||||
constexpr uint32_t GlobalConst_slot_MORALE_INCREASE_OUTPUT = 0x006ec784;
|
||||
// data float** -- pointer slot read by MoraleOutputMod [verified]
|
||||
constexpr uint32_t GlobalConst_slot_MORALE_INCREASE_OUTPUT_MOD = 0x006ec78c;
|
||||
// data int** -- pointer slot read by MoraleOutputMod [verified]
|
||||
constexpr uint32_t GlobalConst_slot_MORALE_DECREASE_OUTPUT = 0x006ec794;
|
||||
// data float** -- pointer slot read by MoraleOutputMod [verified]
|
||||
constexpr uint32_t GlobalConst_slot_MORALE_DECREASE_OUTPUT_MOD = 0x006ec79c;
|
||||
// data float -- .bss storage reached through the slot at 0x00ae2e88; the output modifier of the slave row of the population-type table. SLAVES_INCOME_MOD is 0x00b0e9ac (slot 0x00ae2e84) and SLAVES_REPAIR_MOD is 0x00b0e9b4 (slot 0x00ae2e8c) [verified]
|
||||
constexpr uint32_t GlobalConst_storage_SLAVES_OUTPUT_MOD = 0x0070e9b0;
|
||||
// data const float 0.5f -- the progress-ratio threshold in ServerPlayer::ProcessTurn's `ResT != NULL && ResErrRoll != 0 && CONST < progressRatio` gate (events.md §3, window 0x008915ec-0x00891624). Read out of dumps/sots.exe: .rdata bytes at 0x00a2c788 are 00 00 00 3f (float 0.5); the following word 0x00a2c78c is float 100.0, so this is a float32, NOT the double an 8-byte read would suggest (that reads as 5.28e13). CONSEQUENCE, measured on the live game: ResErrRoll survives into ProcessResearch only while progress/cost <= 0.5 at the START of the turn, so the OnTechResearched draw (0x0088df20) can fire only when a single turn supplies more than half the target tech's remaining cost. See the board's `research_roll_pending save` row [verified]
|
||||
constexpr uint32_t ResearchRollProgressThreshold = 0x0062c788;
|
||||
// thiscall void __thiscall Game::TurnCommands::Write(Mars::IStream* s) -- the writer for the `Player.<id>.TurnCommands_v5` custom-data block (CDT id -> one CD frame). 764 bytes, no loops of its own. Two halves. (1) A PROLOGUE of six flag-gated groups, each a WriteBool on a member followed, only when that bool is set, by the group's payload; write order is NOT offset order, which is why the offset-sorted layout view cannot be aligned to the wire: bool@0x0c gates f32@0x08 (research rate); bool@0x14 gates i32@0x10 (research target tech); bool@0x20 gates i32@0x18 + f32@0x1c (research boost spend + fraction); bool@0x2c gates bool@0x24 + i32@0x28; bool@0x3c gates f32@0x30,0x34,0x38; bool@0x6c gates a StreamableHelper<Game::CivilianRatios> frame on the member at 0x40. i32@0x04 (player id) is written first and unconditionally. (2) TWENTY-SEVEN std::list<T> members at 0x70..0x1a8, stride 0x0c ({_Myhead, _Mysize, _Alval}), each passed to its own free-function writer as helper(stream, &list). Every one is written unconditionally, so an empty list still costs one zero int: 8 prologue items + 27 zero counts = the 35-item block every no-orders save carries [verified]
|
||||
|
|
@ -1555,6 +1667,34 @@ constexpr uint32_t TechTree_ProcessResearch_TechsUnlockedCollector = 0x00187cc3;
|
|||
constexpr uint32_t ServerPlayer_OnTechResearched_RecordObservedTech = 0x00491790;
|
||||
// site site in ServerPlayer::OnTechResearched, second statement: `if (this->ResT(+0x294) == def) { if (this->ResearchRollPending(+0x3b4)) RollResearchEvent(this); this->ResearchRollPending = 0; this->ResT = 0; }`. RollResearchEvent (0x0088df20) draws ONE NextFloat unconditionally and then enters ServerPlayer_OnResearchRollSucceeded (0x00889d60) only when roll < ResearchEventOdds -- the odds are 0 for every tech outside the plague and AI-rebellion families, so that branch is normally dead. CORRECTED BY LANE K 2026-09-08: that one word is the cost of REACHING the branch, not of a fired roll -- the plague path draws a SECOND word (NextInt) and posts EVENT_PLAGUE_OUTBREAK, the rebellion path cancels the research. A fired roll costs one or two words. This is the extra RNG a completion consumes, and clearing ResT means a second completion in the same pass consumes none [verified]
|
||||
constexpr uint32_t ServerPlayer_OnTechResearched_ResearchRollBlock = 0x00491790;
|
||||
// member Game::ServerSpyManager* StrategyServer::SpyManager, in lane T's S+4 frame (absolute StrategyServer+0x15c). StrategyServer ctor 0x007d78d0: `call 0x00832a30` (the ServerSpyManager ctor, identified by its store of vftable 0x00a3073c) then `mov [esi+0x15c],eax` at 0x007d7d8e. Corroborated independently by 0x007dcf90, which the RTTI inverse map shows is Game::StrategyServer vftable 0x00a26034 slot 14 at sub-object +4: it calls the same two ctors and stores at [esi+0x154] and [esi+0x158], exactly 4 lower than the base-frame 0x158/0x15c, as a +4 `this` requires. Sits immediately after StrategyServer_off_TradeManager (lane T, 0x154 in the same frame) [verified]
|
||||
constexpr uint32_t StrategyServer_off_SpyManager = 0x00000158;
|
||||
// data void* Game::ServerSpyManager::vftable[18] // sub-object +0, COL 0x00a87ed4, bases Game::IServerSpyManager / Game::ISpyManager / Mars::IStreamable. Unlike the trade manager there is no *Impl: ServerSpyManager is itself concrete (no purecall slots) and has no derived class. A second vftable 0x00a30728 sits at sub-object +4 with 3 slots [verified]
|
||||
constexpr uint32_t ServerSpyManager_vftable = 0x0063073c;
|
||||
// thiscall void (Game::ServerTradeManagerImpl* this) // vftable 0x00a31b74 slot 7 (+0x1c). Reached ONLY virtually, from 0x007d97ea in StrategyServer::OnAllCombatDone_Tail phase 23 (call 6 of 8). Zero direct call sites. Body not read; its direct+tail-jump closure is 189 functions and contains no RNG entry point and none of the eleven inlined-draw functions [mapped]
|
||||
constexpr uint32_t ServerTradeManagerImpl_vslot7 = 0x0048ad60;
|
||||
// thiscall void (Game::ServerTradeManagerImpl* this, int arg1) // vftable 0x00a31b74 slot 8 (+0x20). Reached ONLY virtually, from 0x007d97b6 in OnAllCombatDone_Tail phase 23 (call 2 of 8); the site pushes one argument. Closure 86 functions, draw-free [mapped]
|
||||
constexpr uint32_t ServerTradeManagerImpl_vslot8 = 0x0048e8d0;
|
||||
// thiscall void (Game::ServerTradeManagerImpl* this) // vftable 0x00a31b74 slot 9 (+0x24). Reached ONLY virtually, from 0x007d97dd (phase 23, call 5 of 8). Closure 185 functions, draw-free [mapped]
|
||||
constexpr uint32_t ServerTradeManagerImpl_vslot9 = 0x00468060;
|
||||
// thiscall void (Game::ServerTradeManagerImpl* this) // vftable 0x00a31b74 slot 11 (+0x2c). Reached ONLY virtually, from 0x007d97d0 (phase 23, call 4 of 8). Closure 188 functions, draw-free [mapped]
|
||||
constexpr uint32_t ServerTradeManagerImpl_vslot11 = 0x00448570;
|
||||
// thiscall void (Game::ServerTradeManagerImpl* this) // vftable 0x00a31b74 slot 12 (+0x30). Reached ONLY virtually, from 0x007d97c3 (phase 23, call 3 of 8). Closure 193 functions, draw-free [mapped]
|
||||
constexpr uint32_t ServerTradeManagerImpl_vslot12 = 0x0048e920;
|
||||
// thiscall void (Game::ServerTradeManagerImpl* this) // vftable 0x00a31b74 slot 13 (+0x34). Reached ONLY virtually, from 0x007d97f7 (phase 23, call 7 of 8). REACHES THE STRATEGIC GENERATOR: -> 0x00820ca0, NextFloat at 0x00820e18 with the generator loaded as [reg+0x16c] then `lea ecx,[ecx+4]`; and -> 0x0088b440, NextInt at 0x0088b613 with `mov ecx,[ecx+0x16c]; add ecx,4`. Neither was ever observed firing: lane Z measured 0 tail words on 8 turns [mapped]
|
||||
constexpr uint32_t ServerTradeManagerImpl_vslot13 = 0x0048ef80;
|
||||
// thiscall void (Game::ServerTradeManagerImpl* this) // vftable 0x00a31b74 slot 14 (+0x38). Reached ONLY virtually, from 0x007d97a7 (phase 23, call 1 of 8). Closure 143 functions, draw-free [mapped]
|
||||
constexpr uint32_t ServerTradeManagerImpl_vslot14 = 0x004590d0;
|
||||
// thiscall void (Game::ServerTradeManagerImpl* this) // vftable 0x00a31b74 slot 15 (+0x3c). Reached ONLY virtually, from 0x007d9804 (phase 23, call 8 of 8). REACHES THE STRATEGIC GENERATOR: Chance at 0x0082cdb8, generator loaded at 0x0082cda4 as `mov eax,[eax+0x16c]` then `mov ecx,eax`. Never observed firing [mapped]
|
||||
constexpr uint32_t ServerTradeManagerImpl_vslot15 = 0x0042cca0;
|
||||
// thiscall void (Game::ServerSpyManager* this) // vftable 0x00a3073c slot 13 (+0x34). Reached ONLY virtually, from 0x007d9811 in OnAllCombatDone_Tail phase 23 -- the ninth call of the block, and the only one whose receiver is StrategyServer+0x15c rather than +0x158. REACHES THE STRATEGIC GENERATOR: Chance at 0x00887c8a on `mov ecx,[ecx+0x16c]`, and through 0x008408e0 Chance at 0x00840929 and 0x00840a3c plus NextInt at 0x008409c7, all on [reg+0x16c]. Never observed firing [mapped]
|
||||
constexpr uint32_t ServerSpyManager_vslot13 = 0x004877b0;
|
||||
// thiscall void (Game::ServerSpyManager* this) // vftable 0x00a3073c slot 14 (+0x38). Reached ONLY virtually, from 0x007d989b in OnAllCombatDone_Tail phase 33 (call 1 of 2). REACHES THE STRATEGIC GENERATOR: Chance at 0x0088dc43 on `mov ecx,[eax+0x16c]`. Never observed firing [mapped]
|
||||
constexpr uint32_t ServerSpyManager_vslot14 = 0x0048db80;
|
||||
// thiscall void (Game::ServerSpyManager* this) // vftable 0x00a3073c slot 15 (+0x3c). Reached ONLY virtually, from 0x007d98a8 in OnAllCombatDone_Tail phase 33 (call 2 of 2). Closure 230 functions, draw-free [mapped]
|
||||
constexpr uint32_t ServerSpyManager_vslot15 = 0x00487f30;
|
||||
// thiscall void (void* this) // the do-nothing body every Game::SVScriptObject-derived class inherits in the hook slots it does not override. Occupies most of slots 4/11/25/27/30 across the 30 SVSO classes, which is what makes the non-stub overrides countable: 14 at slot 4, 7 at slot 11, 8 at slot 25, 1 at slot 30, 0 at slot 27 [mapped]
|
||||
constexpr uint32_t SVScriptObject_EmptyOverride = 0x0040c5a0;
|
||||
// cdecl Game::SVScriptObject* (int encID) // The EncObj factory. `dec eax; cmp eax,0x16; ja <null>; jmp dword [eax*4 + 0x0052bf60]` -- a 23-entry dword jump table indexed by encID-1. Live ids: 1 VonNeumann, 3 Swarm, 4 Derelict, 5 Monitor, 7 SystemKiller, 8 PuppetMaster, 9 SlaversRefuel, 10 SwarmQueen, 14 Locust, 17 CrowRuins, 20 Refugees, 21 Ortgay. Ids 2, 6, 11-13, 15, 16, 18, 19, 22, 23 and everything outside 1..23 return NULL. Class names read off the vftable store in each ctor [verified]
|
||||
constexpr uint32_t SVScriptObject_FactoryByEncID = 0x0012bf00;
|
||||
// data void* [23] // the jump table SVScriptObject_FactoryByEncID indexes with encID-1 [verified]
|
||||
|
|
|
|||
10
src/game/combat/CMakeLists.txt
Normal file
10
src/game/combat/CMakeLists.txt
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# Combat's strategic consequences. Not the tactical battle -- the part that runs on the
|
||||
# strategic server once a battle has already been decided, and whose output lands in the
|
||||
# save file. Pure; no state, no I/O, no random draws.
|
||||
add_library(sots_game_combat STATIC
|
||||
retreat.cpp)
|
||||
target_include_directories(sots_game_combat PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/../..)
|
||||
target_compile_features(sots_game_combat PUBLIC cxx_std_17)
|
||||
if(NOT MSVC)
|
||||
target_compile_options(sots_game_combat PRIVATE -Wall -Wextra)
|
||||
endif()
|
||||
327
src/game/combat/retreat.cpp
Normal file
327
src/game/combat/retreat.cpp
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
#include "game/combat/retreat.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace sots::combat {
|
||||
namespace {
|
||||
|
||||
// The original's per-ship "is the drive gone" test compares against the single-precision
|
||||
// epsilon, not against zero.
|
||||
constexpr float kDriveHealthEpsilon = 1.1920928955078125e-07f; // FLT_EPSILON
|
||||
|
||||
// Highest encounter-type id the blocked-type bitmask is defined over. Ids above this are
|
||||
// never blocked -- the original range-checks before shifting, which matters because a shift
|
||||
// count of 32 or more is undefined.
|
||||
constexpr int kMaxEncounterType = 0x17;
|
||||
|
||||
// Squared distance between two positions, reproducing the original's arithmetic exactly.
|
||||
//
|
||||
// The three component differences are each narrowed to float32 before use; the products and
|
||||
// their sum are then accumulated at higher precision and the total is narrowed to float32
|
||||
// once, at the end. Doing the middle in `double` is not an approximation of the original's
|
||||
// 80-bit registers: a float32 delta carries 24 significand bits, so its square is exact in
|
||||
// double, and the sum of three such squares needs at most ~50 bits, also exact. The single
|
||||
// rounding is the final narrowing, and it happens in the same place.
|
||||
float SquaredDistance(const Vec3f& a, const Vec3f& b) {
|
||||
const float dx = a.x - b.x;
|
||||
const float dy = a.y - b.y;
|
||||
const float dz = a.z - b.z;
|
||||
const double sum = (static_cast<double>(dx) * dx + static_cast<double>(dy) * dy) +
|
||||
static_cast<double>(dz) * dz;
|
||||
return static_cast<float>(sum);
|
||||
}
|
||||
|
||||
std::uint32_t BitOf(int playerIndex) {
|
||||
if (playerIndex < 0 || playerIndex >= 32) return 0;
|
||||
return std::uint32_t{1} << playerIndex;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Destination search
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
bool IsUsableRetreatTarget(const SystemView& s) {
|
||||
if (s.destroyed) return false;
|
||||
if (s.hiveHost && !s.hiveExhausted) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
std::uint32_t HostileMask(const PlayerView& p, const SystemView& s, int currentTurn) {
|
||||
// A system whose owner took it THIS turn does not shelter that owner behind a ceasefire.
|
||||
std::uint32_t ownerBit = 0;
|
||||
if (s.ownerPlayer >= 0 && s.turnAcquired == currentTurn) ownerBit = BitOf(s.ownerPlayer);
|
||||
|
||||
const std::uint32_t friendly = BitOf(p.index) | (p.diplomacy.ceaseFire & ~ownerBit) |
|
||||
p.diplomacy.nonAggression | p.diplomacy.alliance;
|
||||
return ~friendly;
|
||||
}
|
||||
|
||||
bool HasHostilePresence(const PlayerView& p, const SystemView& s, int currentTurn) {
|
||||
return (HostileMask(p, s, currentTurn) & s.presenceMask) != 0;
|
||||
}
|
||||
|
||||
int ChooseRetreatDestination(const std::vector<SystemView>& systems,
|
||||
int battleSystemIndex,
|
||||
const Vec3f& battlePos,
|
||||
const PlayerView& player,
|
||||
int currentTurn) {
|
||||
// Three independent nearest-searches, each with its own best-so-far seeded to FLT_MAX.
|
||||
// A candidate that fails a predicate leaves that search's best-so-far alone, which is
|
||||
// the whole reason the three cannot be collapsed into one loop with early exits.
|
||||
float bestOwned = 3.4028234663852886e+38f; // FLT_MAX
|
||||
float bestQuiet = 3.4028234663852886e+38f;
|
||||
float bestAny = 3.4028234663852886e+38f;
|
||||
int owned = -1, quiet = -1, any = -1;
|
||||
|
||||
for (const SystemView& s : systems) {
|
||||
if (s.index == battleSystemIndex) continue;
|
||||
if (!IsUsableRetreatTarget(s)) continue;
|
||||
|
||||
const float d = SquaredDistance(s.pos, battlePos);
|
||||
|
||||
if (d < bestOwned && s.ownerPlayer == player.index) {
|
||||
bestOwned = d;
|
||||
owned = s.index;
|
||||
}
|
||||
if (d < bestQuiet && !HasHostilePresence(player, s, currentTurn)) {
|
||||
bestQuiet = d;
|
||||
quiet = s.index;
|
||||
}
|
||||
if (d < bestAny) {
|
||||
bestAny = d;
|
||||
any = s.index;
|
||||
}
|
||||
}
|
||||
|
||||
if (owned >= 0) return owned;
|
||||
if (quiet >= 0) return quiet;
|
||||
return any;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Per-ship eligibility
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
bool IsGroundedByDamage(const ShipView& ship) {
|
||||
if (!ship.design.hasDriveSectionA && !ship.design.hasDriveSectionB) return false;
|
||||
if (ship.design.mobilityClass >= 2) return false;
|
||||
return kDriveHealthEpsilon > ship.driveHealth;
|
||||
}
|
||||
|
||||
bool ShipMayRetreat(const ShipView& ship,
|
||||
const PlayerView& owner,
|
||||
std::uint32_t blockedEncounterTypes,
|
||||
int localPlayerIndex) {
|
||||
if (ship.departed) return false;
|
||||
if (ship.encounterType == kNeverRetreatsEncounterType) return false;
|
||||
if (ship.encounterType >= 0 && ship.encounterType <= kMaxEncounterType &&
|
||||
(blockedEncounterTypes & (std::uint32_t{1} << ship.encounterType)) != 0) {
|
||||
return false;
|
||||
}
|
||||
// The locally-controlled player's own ships are handled elsewhere and are skipped here.
|
||||
if (ship.ownerPlayer >= 0 && ship.ownerPlayer == localPlayerIndex) return false;
|
||||
|
||||
// The gate species does not fly out, so a dead drive does not ground it.
|
||||
const bool gating = ship.mode == kGateRetreatMode && owner.species == kGateSpecies;
|
||||
if (!gating && IsGroundedByDamage(ship)) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Grouping
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
std::vector<RetreatGroup> GroupRetreats(const std::vector<ShipView>& ships,
|
||||
const std::vector<int>& destinationPerShip) {
|
||||
std::vector<RetreatGroup> groups;
|
||||
for (std::size_t i = 0; i < ships.size(); ++i) {
|
||||
const ShipView& sh = ships[i];
|
||||
const int dest = i < destinationPerShip.size() ? destinationPerShip[i] : -1;
|
||||
|
||||
RetreatGroup* found = nullptr;
|
||||
for (RetreatGroup& g : groups) {
|
||||
if (g.ownerPlayer == sh.ownerPlayer && g.destinationSystem == dest &&
|
||||
g.mode == sh.mode && g.variant == sh.variant) {
|
||||
found = &g;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (found == nullptr) {
|
||||
RetreatGroup g;
|
||||
g.ownerPlayer = sh.ownerPlayer;
|
||||
g.destinationSystem = dest;
|
||||
g.mode = sh.mode;
|
||||
g.variant = sh.variant;
|
||||
groups.push_back(g);
|
||||
found = &groups.back();
|
||||
}
|
||||
found->ships.push_back(sh.id);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
void ClassifyFleets(RetreatGroup& group,
|
||||
const std::vector<ShipView>& ships,
|
||||
const std::vector<FleetView>& fleets) {
|
||||
// Count this group's ships per source fleet, in first-seen order.
|
||||
std::vector<std::pair<int, int>> counts; // (fleetId, count)
|
||||
for (int shipId : group.ships) {
|
||||
auto sh = std::find_if(ships.begin(), ships.end(),
|
||||
[shipId](const ShipView& s) { return s.id == shipId; });
|
||||
if (sh == ships.end()) continue;
|
||||
auto c = std::find_if(counts.begin(), counts.end(),
|
||||
[&](const std::pair<int, int>& p) { return p.first == sh->fleetId; });
|
||||
if (c == counts.end()) {
|
||||
counts.emplace_back(sh->fleetId, 1);
|
||||
} else {
|
||||
++c->second;
|
||||
}
|
||||
}
|
||||
|
||||
for (const auto& [fleetId, n] : counts) {
|
||||
auto f = std::find_if(fleets.begin(), fleets.end(),
|
||||
[id = fleetId](const FleetView& v) { return v.id == id; });
|
||||
const int total = f == fleets.end() ? -1 : f->shipCount;
|
||||
if (total == n) {
|
||||
group.wholeFleets.push_back(fleetId);
|
||||
} else {
|
||||
group.partial = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool UsesGateRetreat(const RetreatGroup& group, const PlayerView& owner) {
|
||||
if (group.mode != kGateRetreatMode) return false;
|
||||
if (owner.species != kGateSpecies) return false;
|
||||
return group.destinationSystem >= 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// The plan
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
RetreatPlan BuildRetreatPlan(const std::vector<SystemView>& systems,
|
||||
const std::vector<FleetView>& fleets,
|
||||
const std::vector<ShipView>& ships,
|
||||
const std::vector<PlayerView>& players,
|
||||
int battleSystemIndex,
|
||||
const Vec3f& battlePos,
|
||||
int currentTurn,
|
||||
std::uint32_t blockedEncounterTypes,
|
||||
int localPlayerIndex,
|
||||
const std::vector<std::uint32_t>& exploredMaskPerSystem) {
|
||||
RetreatPlan plan;
|
||||
|
||||
auto playerOf = [&players](int idx) -> const PlayerView* {
|
||||
for (const PlayerView& p : players) {
|
||||
if (p.index == idx) return &p;
|
||||
}
|
||||
return nullptr;
|
||||
};
|
||||
|
||||
// Phase 1: one default destination per player, computed once and shared.
|
||||
std::vector<std::pair<int, int>> defaultDest; // (playerIndex, systemIndex)
|
||||
for (const PlayerView& p : players) {
|
||||
defaultDest.emplace_back(
|
||||
p.index, ChooseRetreatDestination(systems, battleSystemIndex, battlePos, p, currentTurn));
|
||||
}
|
||||
auto defaultFor = [&defaultDest](int idx) {
|
||||
for (const auto& [pi, si] : defaultDest) {
|
||||
if (pi == idx) return si;
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
// Phase 2: filter, resolve each ship's destination, group.
|
||||
std::vector<ShipView> eligible;
|
||||
std::vector<int> dests;
|
||||
for (const ShipView& sh : ships) {
|
||||
const PlayerView* owner = playerOf(sh.ownerPlayer);
|
||||
if (owner == nullptr) continue;
|
||||
if (!ShipMayRetreat(sh, *owner, blockedEncounterTypes, localPlayerIndex)) continue;
|
||||
// A named destination wins; otherwise the owner's default. A named destination that
|
||||
// does not resolve falls back to the default too (and logs, in the original).
|
||||
int d = sh.requestedDestination;
|
||||
if (d < 0) d = defaultFor(sh.ownerPlayer);
|
||||
eligible.push_back(sh);
|
||||
dests.push_back(d);
|
||||
}
|
||||
|
||||
plan.groups = GroupRetreats(eligible, dests);
|
||||
|
||||
// Phase 3 / 4: whole versus partial, and the split.
|
||||
std::vector<std::pair<int, int>> movedOut; // (fleetId, ships taken away)
|
||||
for (RetreatGroup& g : plan.groups) {
|
||||
ClassifyFleets(g, eligible, fleets);
|
||||
|
||||
if (g.partial) {
|
||||
g.needsNewFleet = true;
|
||||
for (int shipId : g.ships) {
|
||||
auto sh = std::find_if(eligible.begin(), eligible.end(),
|
||||
[shipId](const ShipView& s) { return s.id == shipId; });
|
||||
if (sh == eligible.end()) continue;
|
||||
// A ship whose own fleet is retreating whole stays exactly where it is.
|
||||
const bool wholeFleetRuns =
|
||||
std::find(g.wholeFleets.begin(), g.wholeFleets.end(), sh->fleetId) !=
|
||||
g.wholeFleets.end();
|
||||
if (wholeFleetRuns) continue;
|
||||
|
||||
auto sp = std::find_if(g.splits.begin(), g.splits.end(),
|
||||
[&](const FleetSplit& s) { return s.sourceFleet == sh->fleetId; });
|
||||
if (sp == g.splits.end()) {
|
||||
FleetSplit s;
|
||||
s.sourceFleet = sh->fleetId;
|
||||
s.ships.push_back(shipId);
|
||||
g.splits.push_back(s);
|
||||
} else {
|
||||
sp->ships.push_back(shipId);
|
||||
}
|
||||
|
||||
auto m = std::find_if(movedOut.begin(), movedOut.end(),
|
||||
[&](const std::pair<int, int>& p) { return p.first == sh->fleetId; });
|
||||
if (m == movedOut.end()) {
|
||||
movedOut.emplace_back(sh->fleetId, 1);
|
||||
} else {
|
||||
++m->second;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const PlayerView* owner = playerOf(g.ownerPlayer);
|
||||
if (owner == nullptr) {
|
||||
g.move = RetreatMove::None;
|
||||
} else if (UsesGateRetreat(g, *owner)) {
|
||||
g.move = RetreatMove::Gate;
|
||||
} else if (g.destinationSystem >= 0) {
|
||||
g.move = RetreatMove::FlightPlan;
|
||||
} else {
|
||||
g.move = RetreatMove::None;
|
||||
}
|
||||
|
||||
// Phase 5's side effect: leaving a system you had not explored reveals it.
|
||||
if (battleSystemIndex >= 0 && g.ownerPlayer >= 0 &&
|
||||
static_cast<std::size_t>(battleSystemIndex) < exploredMaskPerSystem.size()) {
|
||||
const std::uint32_t explored = exploredMaskPerSystem[battleSystemIndex];
|
||||
if ((explored & BitOf(g.ownerPlayer)) == 0 &&
|
||||
std::find(plan.exploredGrants.begin(), plan.exploredGrants.end(), g.ownerPlayer) ==
|
||||
plan.exploredGrants.end()) {
|
||||
plan.exploredGrants.push_back(g.ownerPlayer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A source fleet that gives up every ship it had is destroyed. Fleets that ran whole are
|
||||
// not affected -- they keep their ships and simply move.
|
||||
for (const auto& [fleetId, taken] : movedOut) {
|
||||
auto f = std::find_if(fleets.begin(), fleets.end(),
|
||||
[id = fleetId](const FleetView& v) { return v.id == id; });
|
||||
if (f != fleets.end() && f->shipCount == taken) plan.emptiedFleets.push_back(fleetId);
|
||||
}
|
||||
|
||||
return plan;
|
||||
}
|
||||
|
||||
} // namespace sots::combat
|
||||
255
src/game/combat/retreat.h
Normal file
255
src/game/combat/retreat.h
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
// Retreat: what happens to the losers' ships after a battle resolves.
|
||||
//
|
||||
// This is the first of the post-battle sub-phases and it is the one with strategic
|
||||
// consequences: it decides where beaten fleets go, splits fleets whose ships did not all
|
||||
// run, deletes fleets that end up empty, and grants the retreating side knowledge of the
|
||||
// system it was beaten at. All of that lands in saved state.
|
||||
//
|
||||
// It is DRAW-FREE. The whole sub-tree contains no random draw of any kind, so this module
|
||||
// needs no generator and its output is a pure function of its input.
|
||||
//
|
||||
// Everything here is a PLAN, not a mutation. The functions below decide *what* should
|
||||
// happen; applying it -- creating a fleet, reparenting a ship, deleting an emptied fleet --
|
||||
// needs an object store with id allocation, which lives above this layer. Keeping the two
|
||||
// apart is what makes the decision testable on the host.
|
||||
//
|
||||
// CONFIDENCE: the destination search, the eligibility gates, the grouping key and the
|
||||
// whole-versus-partial rule are all read from the instruction stream. See the campaign note
|
||||
// on the retreat pipeline for the per-claim breakdown.
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
namespace sots::combat {
|
||||
|
||||
// ------------------------------------------------------------------------------------
|
||||
// Inputs
|
||||
// ------------------------------------------------------------------------------------
|
||||
|
||||
// Positions are float32 in the original and the destination search compares SQUARED
|
||||
// distances accumulated in float32. Keeping the type narrow here is deliberate: widening it
|
||||
// changes which system wins a near-tie.
|
||||
struct Vec3f {
|
||||
float x = 0.0f, y = 0.0f, z = 0.0f;
|
||||
};
|
||||
|
||||
// A system as the retreat search sees it. Every field is a saved one.
|
||||
struct SystemView {
|
||||
int index = -1; // the system's own index
|
||||
Vec3f pos{}; // system position
|
||||
bool destroyed = false; // system was destroyed
|
||||
bool hiveHost = false; // a self-replicator hive is present
|
||||
bool hiveExhausted = false; // ...and it has been cleared out
|
||||
int ownerPlayer = -1; // -1 when unowned
|
||||
int turnAcquired = -1; // the turn the current owner took it
|
||||
std::uint32_t presenceMask = 0; // bit per player judged "present" here
|
||||
};
|
||||
|
||||
// The three per-player diplomacy bitmasks. `self` is excluded from hostility implicitly.
|
||||
struct Diplomacy {
|
||||
std::uint32_t alliance = 0;
|
||||
std::uint32_t nonAggression = 0;
|
||||
std::uint32_t ceaseFire = 0;
|
||||
};
|
||||
|
||||
// A player, only as much of one as the retreat rules read.
|
||||
struct PlayerView {
|
||||
int index = -1; // player index, the bit position in every mask above
|
||||
int species = -1; // species id; species 1 retreats by gate
|
||||
Diplomacy diplomacy{};
|
||||
};
|
||||
|
||||
// Species that retreats by opening a gate at the destination instead of flying home.
|
||||
constexpr int kGateSpecies = 1;
|
||||
|
||||
// The retreat "mode" word that selects the gate path. Any other value flies.
|
||||
constexpr int kGateRetreatMode = 1;
|
||||
|
||||
// A ship's design, only the fields the eligibility gate reads.
|
||||
struct DesignView {
|
||||
bool hasDriveSectionA = false; // one of two design slots that must be populated
|
||||
bool hasDriveSectionB = false;
|
||||
int mobilityClass = 0; // >= 2 means the design is never grounded this way
|
||||
};
|
||||
|
||||
// One ship the battle reported as trying to leave.
|
||||
struct ShipView {
|
||||
int id = -1;
|
||||
int fleetId = -1; // the fleet it is in right now
|
||||
int ownerPlayer = -1;
|
||||
bool departed = false; // already left the system under a previous order
|
||||
int encounterType = -1; // which encounter faction it belongs to, -1 for a normal ship
|
||||
DesignView design{};
|
||||
float driveHealth = 0.0f;
|
||||
int requestedDestination = -1; // system index the battle asked for, -1 for none
|
||||
int mode = 0; // retreat mode word
|
||||
int variant = 0; // second grouping word, carried through untouched
|
||||
};
|
||||
|
||||
// A fleet, only its size (the whole-versus-partial rule needs nothing else).
|
||||
struct FleetView {
|
||||
int id = -1;
|
||||
int shipCount = 0;
|
||||
int ownerPlayer = -1;
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------------------------
|
||||
// Destination search
|
||||
// ------------------------------------------------------------------------------------
|
||||
|
||||
// Systems that can never be retreated to at all: a destroyed system, or one hosting a
|
||||
// self-replicator hive that has not been cleared. CONFIDENCE: high.
|
||||
bool IsUsableRetreatTarget(const SystemView& s);
|
||||
|
||||
// The mask of players `p` counts as hostile to, evaluated against one system.
|
||||
//
|
||||
// hostile = ~( selfBit | (ceaseFire & ~ownerBit) | nonAggression | alliance )
|
||||
//
|
||||
// where `ownerBit` is the system owner's bit, but ONLY when that owner took the system on
|
||||
// the current turn -- a system captured this turn loses its ceasefire cover. Unowned
|
||||
// systems, and systems held since an earlier turn, contribute no ownerBit.
|
||||
//
|
||||
// The complement is taken over the full 32 bits, so bits above the player count are set.
|
||||
// That is harmless because it is only ever ANDed with a presence mask, which never has
|
||||
// them, but a reimplementation that narrows the mask must narrow both sides together.
|
||||
// CONFIDENCE: high.
|
||||
std::uint32_t HostileMask(const PlayerView& p, const SystemView& s, int currentTurn);
|
||||
|
||||
// True when any player hostile to `p` is present at `s`. CONFIDENCE: high.
|
||||
bool HasHostilePresence(const PlayerView& p, const SystemView& s, int currentTurn);
|
||||
|
||||
// The default retreat destination for one player, given where the battle happened.
|
||||
//
|
||||
// Three independent nearest-system searches run over the whole system list in one pass,
|
||||
// each keeping its own best-so-far, all skipping the battle's own system and any system
|
||||
// `IsUsableRetreatTarget` rejects:
|
||||
//
|
||||
// 1. nearest system the player owns
|
||||
// 2. nearest system with no hostile presence
|
||||
// 3. nearest system, unconditionally
|
||||
//
|
||||
// The answer is the first of those that found anything. Note that a candidate failing a
|
||||
// predicate does NOT spoil that search's best-so-far -- the three are genuinely independent,
|
||||
// which is why (2) can name a system further away than one (1) rejected.
|
||||
//
|
||||
// Distance is the SQUARED euclidean distance, accumulated in float32, and the comparison is
|
||||
// a strict `<`, so on an exact tie the earlier system in the list wins. Returns -1 when the
|
||||
// list yields nothing. CONFIDENCE: high.
|
||||
int ChooseRetreatDestination(const std::vector<SystemView>& systems,
|
||||
int battleSystemIndex,
|
||||
const Vec3f& battlePos,
|
||||
const PlayerView& player,
|
||||
int currentTurn);
|
||||
|
||||
// ------------------------------------------------------------------------------------
|
||||
// Per-ship eligibility
|
||||
// ------------------------------------------------------------------------------------
|
||||
|
||||
// True when a ship is too badly hurt to run on its own: its design has a drive section, its
|
||||
// mobility class is below 2, and its drive health has fallen to (effectively) zero -- the
|
||||
// comparison is against a single-precision epsilon, so a health of exactly 0 is grounded
|
||||
// and anything at or above the epsilon is not.
|
||||
//
|
||||
// The gate species skips this test entirely; it does not fly out, so a dead drive does not
|
||||
// stop it. CONFIDENCE: high.
|
||||
bool IsGroundedByDamage(const ShipView& ship);
|
||||
|
||||
// Encounter-faction ids that may never retreat. The original reads a data-driven bitmask
|
||||
// here plus one hard-coded id; `blockedEncounterTypes` is that bitmask, and the hard-coded
|
||||
// exclusion is folded in by the caller supplying it. DB-SOURCED -- do not hard-code the
|
||||
// mask in a reimplementation, load it.
|
||||
constexpr int kNeverRetreatsEncounterType = 0x15;
|
||||
|
||||
// Whether a reported ship is actually allowed to retreat. `blockedEncounterTypes` is the
|
||||
// data-driven bitmask above; bits are indexed by encounter type and only types 0..23 are
|
||||
// tested. CONFIDENCE: high.
|
||||
bool ShipMayRetreat(const ShipView& ship,
|
||||
const PlayerView& owner,
|
||||
std::uint32_t blockedEncounterTypes,
|
||||
int localPlayerIndex);
|
||||
|
||||
// ------------------------------------------------------------------------------------
|
||||
// Grouping and the plan
|
||||
// ------------------------------------------------------------------------------------
|
||||
|
||||
// How a group leaves.
|
||||
enum class RetreatMove {
|
||||
None, // no destination was resolved; nothing moves and a warning is logged
|
||||
Gate, // relocated to the destination at once, and an event is posted
|
||||
FlightPlan, // given a move order and detached from the system
|
||||
};
|
||||
|
||||
// Ships pulled out of one fleet because only part of that fleet ran.
|
||||
struct FleetSplit {
|
||||
int sourceFleet = -1;
|
||||
std::vector<int> ships{};
|
||||
};
|
||||
|
||||
// One retreat group. Groups are keyed by (owner, destination, mode, variant) -- four words,
|
||||
// all four compared -- so one player retreating to two places in one battle produces two
|
||||
// groups, and so does one player retreating to one place by two different modes.
|
||||
struct RetreatGroup {
|
||||
int ownerPlayer = -1;
|
||||
int destinationSystem = -1;
|
||||
int mode = 0;
|
||||
int variant = 0;
|
||||
|
||||
std::vector<int> ships{}; // every eligible ship, in report order
|
||||
std::vector<int> wholeFleets{}; // fleets every one of whose ships is in `ships`
|
||||
bool partial = false; // at least one fleet ran only in part
|
||||
|
||||
// Filled by the planner:
|
||||
bool needsNewFleet = false; // a fleet must be created for the split-off ships
|
||||
std::vector<FleetSplit> splits{};
|
||||
RetreatMove move = RetreatMove::None;
|
||||
};
|
||||
|
||||
// A fleet that ends up with no ships left and is therefore destroyed. Destroying a fleet
|
||||
// also aborts every intercept order aimed at it, which posts an event to each interceptor's
|
||||
// owner -- that is why this list matters outside this module.
|
||||
struct RetreatPlan {
|
||||
std::vector<RetreatGroup> groups{};
|
||||
std::vector<int> emptiedFleets{}; // fleets left with zero ships, to be destroyed
|
||||
std::vector<int> exploredGrants{}; // players who learn the battle system by leaving it
|
||||
};
|
||||
|
||||
// Assign each eligible ship to its group. `destinationFor` is consulted per ship: a ship
|
||||
// that named a destination gets it, otherwise the owner's default from
|
||||
// `ChooseRetreatDestination`. Groups appear in first-seen order. CONFIDENCE: high.
|
||||
std::vector<RetreatGroup> GroupRetreats(const std::vector<ShipView>& ships,
|
||||
const std::vector<int>& destinationPerShip);
|
||||
|
||||
// Decide, per group, which source fleets ran whole and which ran only in part.
|
||||
// A fleet runs whole when the number of its ships in the group equals its total ship count.
|
||||
// CONFIDENCE: high.
|
||||
void ClassifyFleets(RetreatGroup& group,
|
||||
const std::vector<ShipView>& ships,
|
||||
const std::vector<FleetView>& fleets);
|
||||
|
||||
// Whether a group leaves by gate: mode word equal to the gate mode, owner of the gate
|
||||
// species, and a destination actually resolved. CONFIDENCE: high.
|
||||
bool UsesGateRetreat(const RetreatGroup& group, const PlayerView& owner);
|
||||
|
||||
// The whole thing. `battleSystemIndex` is -1 when the battle was not at a system, which
|
||||
// suppresses both the explore grant and the "put the new fleet here" step.
|
||||
//
|
||||
// The split rule, stated exactly because it is easy to get subtly wrong: a group that has
|
||||
// `partial` set gets ONE new fleet, appended to `wholeFleets` before any ship is moved. A
|
||||
// ship is then moved into it only if its own source fleet is not already in that list --
|
||||
// which is what keeps ships belonging to a wholly-retreating fleet where they are. A source
|
||||
// fleet that loses all of its group's ships and had none left over is, by construction,
|
||||
// a whole fleet and is never split.
|
||||
// CONFIDENCE: high on the rule; the emptied-fleet list is a consequence of it.
|
||||
RetreatPlan BuildRetreatPlan(const std::vector<SystemView>& systems,
|
||||
const std::vector<FleetView>& fleets,
|
||||
const std::vector<ShipView>& ships,
|
||||
const std::vector<PlayerView>& players,
|
||||
int battleSystemIndex,
|
||||
const Vec3f& battlePos,
|
||||
int currentTurn,
|
||||
std::uint32_t blockedEncounterTypes,
|
||||
int localPlayerIndex,
|
||||
const std::vector<std::uint32_t>& exploredMaskPerSystem);
|
||||
|
||||
} // namespace sots::combat
|
||||
6
tests/game_combat/CMakeLists.txt
Normal file
6
tests/game_combat/CMakeLists.txt
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# game/combat tests: hand-computed cases for the post-battle retreat rules.
|
||||
add_executable(game_combat_test_retreat test_retreat.cpp)
|
||||
target_link_libraries(game_combat_test_retreat PRIVATE sots_game_combat)
|
||||
target_include_directories(game_combat_test_retreat PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
target_compile_options(game_combat_test_retreat PRIVATE -Wall -Wextra -pedantic)
|
||||
add_test(NAME game_combat_retreat COMMAND game_combat_test_retreat)
|
||||
348
tests/game_combat/test_retreat.cpp
Normal file
348
tests/game_combat/test_retreat.cpp
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
// Hand-computed cases for the post-battle retreat rules.
|
||||
//
|
||||
// Every expected value here was worked out from the rule, not from running the code, and
|
||||
// each case is annotated with which rule it pins. The three that are most worth keeping are
|
||||
// the independent-best-so-far case (a nearest-search that a naive single-loop version gets
|
||||
// wrong), the whole-versus-partial split, and the ceasefire-lapses-on-capture case.
|
||||
#include "game/combat/retreat.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
using namespace sots::combat;
|
||||
|
||||
namespace {
|
||||
|
||||
int g_checks = 0;
|
||||
int g_fails = 0;
|
||||
|
||||
void check(bool ok, const char* what) {
|
||||
++g_checks;
|
||||
if (!ok) {
|
||||
++g_fails;
|
||||
std::fprintf(stderr, "FAIL: %s\n", what);
|
||||
}
|
||||
}
|
||||
|
||||
void check_eq(int got, int expected, const char* what) {
|
||||
++g_checks;
|
||||
if (got != expected) {
|
||||
++g_fails;
|
||||
std::fprintf(stderr, "FAIL: %s -- got %d, expected %d\n", what, got, expected);
|
||||
}
|
||||
}
|
||||
|
||||
SystemView Sys(int index, float x, int owner = -1) {
|
||||
SystemView s;
|
||||
s.index = index;
|
||||
s.pos = Vec3f{x, 0.0f, 0.0f};
|
||||
s.ownerPlayer = owner;
|
||||
return s;
|
||||
}
|
||||
|
||||
PlayerView Plr(int index, int species = 0) {
|
||||
PlayerView p;
|
||||
p.index = index;
|
||||
p.species = species;
|
||||
return p;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
void TestUsableTarget() {
|
||||
SystemView s = Sys(0, 0.0f);
|
||||
check(IsUsableRetreatTarget(s), "a plain system is a usable retreat target");
|
||||
|
||||
s.destroyed = true;
|
||||
check(!IsUsableRetreatTarget(s), "a destroyed system is not");
|
||||
|
||||
s = Sys(0, 0.0f);
|
||||
s.hiveHost = true;
|
||||
check(!IsUsableRetreatTarget(s), "a system with a live hive is not");
|
||||
|
||||
s.hiveExhausted = true;
|
||||
check(IsUsableRetreatTarget(s), "...but a cleared hive lifts the exclusion");
|
||||
}
|
||||
|
||||
void TestHostileMask() {
|
||||
PlayerView p = Plr(0);
|
||||
p.diplomacy.alliance = 0b0010u; // player 1 allied
|
||||
p.diplomacy.nonAggression = 0b0100u; // player 2 under NAP
|
||||
p.diplomacy.ceaseFire = 0b1000u; // player 3 under ceasefire
|
||||
|
||||
SystemView s = Sys(0, 0.0f, /*owner=*/-1);
|
||||
const std::uint32_t m = HostileMask(p, s, /*currentTurn=*/10);
|
||||
check((m & 0b0001u) == 0, "self is never hostile");
|
||||
check((m & 0b0010u) == 0, "an ally is not hostile");
|
||||
check((m & 0b0100u) == 0, "a NAP partner is not hostile");
|
||||
check((m & 0b1000u) == 0, "a ceasefire partner is not hostile");
|
||||
check((m & 0b10000u) != 0, "an unrelated player is hostile");
|
||||
|
||||
// The rule this case exists for: a system its owner took THIS turn loses ceasefire
|
||||
// cover, so the owner counts as hostile there and nowhere else.
|
||||
SystemView fresh = Sys(1, 1.0f, /*owner=*/3);
|
||||
fresh.turnAcquired = 10;
|
||||
check((HostileMask(p, fresh, 10) & 0b1000u) != 0,
|
||||
"a ceasefire partner IS hostile at a system it captured this turn");
|
||||
|
||||
SystemView old = Sys(2, 2.0f, /*owner=*/3);
|
||||
old.turnAcquired = 4;
|
||||
check((HostileMask(p, old, 10) & 0b1000u) == 0,
|
||||
"...but not at one it has held since an earlier turn");
|
||||
}
|
||||
|
||||
void TestDestinationPrefersOwned() {
|
||||
// Battle at x = 0. Systems at 1 (neutral), 5 (ours), 2 (neutral). Owned wins even
|
||||
// though it is furthest.
|
||||
std::vector<SystemView> sys{Sys(10, 1.0f), Sys(11, 5.0f, /*owner=*/0), Sys(12, 2.0f)};
|
||||
check_eq(ChooseRetreatDestination(sys, /*battleSystemIndex=*/99, Vec3f{0, 0, 0}, Plr(0), 1),
|
||||
11, "the nearest OWNED system wins over nearer neutral ones");
|
||||
}
|
||||
|
||||
void TestDestinationIndependentBests() {
|
||||
// This is the case a single-loop implementation gets wrong.
|
||||
//
|
||||
// Battle at x = 0, player 0 owns nothing. Hostile player 1 is present at x = 1.
|
||||
// x = 1 hostile present, unowned -> only search 3 accepts it
|
||||
// x = 3 quiet, unowned -> searches 2 and 3
|
||||
// Search 2's best-so-far must NOT have been spoiled by the nearer hostile system, so
|
||||
// the answer is 3.0's system. A naive "keep one best, then filter" gives -1 or the
|
||||
// wrong id.
|
||||
std::vector<SystemView> sys{Sys(20, 1.0f), Sys(21, 3.0f)};
|
||||
sys[0].presenceMask = 0b0010u; // player 1 present
|
||||
check_eq(ChooseRetreatDestination(sys, 99, Vec3f{0, 0, 0}, Plr(0), 1), 21,
|
||||
"a nearer hostile system does not spoil the quiet-system search");
|
||||
|
||||
// And with every system hostile, search 3 still answers with the nearest of them.
|
||||
sys[1].presenceMask = 0b0010u;
|
||||
check_eq(ChooseRetreatDestination(sys, 99, Vec3f{0, 0, 0}, Plr(0), 1), 20,
|
||||
"with nowhere quiet, the unconditional nearest wins");
|
||||
}
|
||||
|
||||
void TestDestinationSkipsAndTies() {
|
||||
std::vector<SystemView> sys{Sys(30, 1.0f), Sys(31, 1.0f)};
|
||||
check_eq(ChooseRetreatDestination(sys, 99, Vec3f{0, 0, 0}, Plr(0), 1), 30,
|
||||
"an exact tie goes to the earlier system (the comparison is strict <)");
|
||||
|
||||
// The battle's own system is never a destination.
|
||||
std::vector<SystemView> two{Sys(40, 1.0f), Sys(41, 9.0f)};
|
||||
check_eq(ChooseRetreatDestination(two, /*battleSystemIndex=*/40, Vec3f{0, 0, 0}, Plr(0), 1),
|
||||
41, "the battle's own system is excluded");
|
||||
|
||||
check_eq(ChooseRetreatDestination({}, 99, Vec3f{0, 0, 0}, Plr(0), 1), -1,
|
||||
"an empty galaxy yields no destination");
|
||||
}
|
||||
|
||||
void TestShipEligibility() {
|
||||
ShipView sh;
|
||||
sh.id = 1;
|
||||
sh.ownerPlayer = 0;
|
||||
sh.design.hasDriveSectionA = true;
|
||||
sh.design.mobilityClass = 0;
|
||||
sh.driveHealth = 1.0f;
|
||||
const PlayerView owner = Plr(0);
|
||||
|
||||
check(ShipMayRetreat(sh, owner, 0, /*localPlayerIndex=*/7), "a healthy ship may retreat");
|
||||
|
||||
ShipView dead = sh;
|
||||
dead.driveHealth = 0.0f;
|
||||
check(IsGroundedByDamage(dead), "zero drive health grounds a ship");
|
||||
check(!ShipMayRetreat(dead, owner, 0, 7), "...and it therefore may not retreat");
|
||||
|
||||
// Exactly at the epsilon is NOT grounded: the test is `epsilon > health`.
|
||||
ShipView edge = sh;
|
||||
edge.driveHealth = 1.1920928955078125e-07f;
|
||||
check(!IsGroundedByDamage(edge), "drive health exactly at the epsilon is not grounded");
|
||||
|
||||
// The gate species does not fly out, so a dead drive does not stop it.
|
||||
ShipView hiver = dead;
|
||||
hiver.mode = kGateRetreatMode;
|
||||
check(ShipMayRetreat(hiver, Plr(0, kGateSpecies), 0, 7),
|
||||
"the gate species retreats with a dead drive");
|
||||
check(!ShipMayRetreat(hiver, Plr(0, /*species=*/2), 0, 7),
|
||||
"...but only the gate species does");
|
||||
|
||||
ShipView gone = sh;
|
||||
gone.departed = true;
|
||||
check(!ShipMayRetreat(gone, owner, 0, 7), "a ship that already left is skipped");
|
||||
|
||||
ShipView monster = sh;
|
||||
monster.encounterType = kNeverRetreatsEncounterType;
|
||||
check(!ShipMayRetreat(monster, owner, 0, 7), "the hard-excluded encounter type never retreats");
|
||||
|
||||
ShipView blocked = sh;
|
||||
blocked.encounterType = 6;
|
||||
check(!ShipMayRetreat(blocked, owner, /*blockedEncounterTypes=*/1u << 6, 7),
|
||||
"a data-blocked encounter type never retreats");
|
||||
check(ShipMayRetreat(blocked, owner, /*blockedEncounterTypes=*/1u << 5, 7),
|
||||
"...and an unrelated bit does not block it");
|
||||
|
||||
ShipView mine = sh;
|
||||
mine.ownerPlayer = 7;
|
||||
check(!ShipMayRetreat(mine, Plr(7), 0, /*localPlayerIndex=*/7),
|
||||
"the locally-controlled player's ships are skipped here");
|
||||
}
|
||||
|
||||
void TestGrouping() {
|
||||
std::vector<ShipView> ships(4);
|
||||
for (int i = 0; i < 4; ++i) ships[i].id = i;
|
||||
ships[0].ownerPlayer = 0; ships[0].mode = 0; ships[0].variant = 0;
|
||||
ships[1].ownerPlayer = 0; ships[1].mode = 0; ships[1].variant = 0;
|
||||
ships[2].ownerPlayer = 0; ships[2].mode = 1; ships[2].variant = 0; // different mode
|
||||
ships[3].ownerPlayer = 1; ships[3].mode = 0; ships[3].variant = 0; // different owner
|
||||
|
||||
auto groups = GroupRetreats(ships, {5, 5, 5, 5});
|
||||
check_eq(static_cast<int>(groups.size()), 3,
|
||||
"groups split on owner and on mode, not just on destination");
|
||||
check_eq(static_cast<int>(groups[0].ships.size()), 2, "the first group holds both same-key ships");
|
||||
|
||||
// Same owner, same mode, different destination -> two groups.
|
||||
auto split = GroupRetreats({ships[0], ships[1]}, {5, 6});
|
||||
check_eq(static_cast<int>(split.size()), 2, "groups split on destination too");
|
||||
}
|
||||
|
||||
void TestWholeVersusPartial() {
|
||||
// Fleet 100 has 2 ships and both run; fleet 200 has 3 ships and only 1 runs.
|
||||
std::vector<FleetView> fleets{{100, 2, 0}, {200, 3, 0}};
|
||||
std::vector<ShipView> ships(3);
|
||||
for (std::size_t i = 0; i < ships.size(); ++i) {
|
||||
ships[i].id = static_cast<int>(i);
|
||||
ships[i].ownerPlayer = 0;
|
||||
ships[i].design.hasDriveSectionA = true;
|
||||
ships[i].driveHealth = 1.0f;
|
||||
}
|
||||
ships[0].fleetId = 100;
|
||||
ships[1].fleetId = 100;
|
||||
ships[2].fleetId = 200;
|
||||
|
||||
RetreatGroup g;
|
||||
g.ownerPlayer = 0;
|
||||
g.ships = {0, 1, 2};
|
||||
ClassifyFleets(g, ships, fleets);
|
||||
|
||||
check_eq(static_cast<int>(g.wholeFleets.size()), 1, "one fleet ran whole");
|
||||
check_eq(g.wholeFleets[0], 100, "...and it is the fleet that lost every ship");
|
||||
check(g.partial, "the other fleet ran only in part");
|
||||
}
|
||||
|
||||
void TestPlanSplitsAndEmpties() {
|
||||
std::vector<SystemView> sys{Sys(1, 10.0f, /*owner=*/0)};
|
||||
std::vector<FleetView> fleets{{100, 2, 0}, {200, 3, 0}};
|
||||
|
||||
std::vector<ShipView> ships(3);
|
||||
for (std::size_t i = 0; i < ships.size(); ++i) {
|
||||
ships[i].id = static_cast<int>(i);
|
||||
ships[i].ownerPlayer = 0;
|
||||
ships[i].design.hasDriveSectionA = true;
|
||||
ships[i].driveHealth = 1.0f;
|
||||
ships[i].requestedDestination = -1;
|
||||
}
|
||||
ships[0].fleetId = 100;
|
||||
ships[1].fleetId = 100;
|
||||
ships[2].fleetId = 200;
|
||||
|
||||
const RetreatPlan plan =
|
||||
BuildRetreatPlan(sys, fleets, ships, {Plr(0)}, /*battleSystemIndex=*/0,
|
||||
Vec3f{0, 0, 0}, /*currentTurn=*/3, /*blockedEncounterTypes=*/0,
|
||||
/*localPlayerIndex=*/7, /*exploredMaskPerSystem=*/{});
|
||||
|
||||
check_eq(static_cast<int>(plan.groups.size()), 1, "all three ships share one group");
|
||||
const RetreatGroup& g = plan.groups[0];
|
||||
check_eq(g.destinationSystem, 1, "they retreat to the player's own system");
|
||||
check(g.needsNewFleet, "a partial retreat needs a new fleet");
|
||||
check_eq(static_cast<int>(g.splits.size()), 1, "exactly one fleet is split");
|
||||
check_eq(g.splits[0].sourceFleet, 200, "and it is the partially-retreating one");
|
||||
check_eq(static_cast<int>(g.splits[0].ships.size()), 1, "one ship moves out of it");
|
||||
check(std::find(g.wholeFleets.begin(), g.wholeFleets.end(), 100) != g.wholeFleets.end(),
|
||||
"the wholly-retreating fleet moves intact and is NOT split");
|
||||
check(plan.emptiedFleets.empty(), "no fleet was emptied -- fleet 200 keeps two ships");
|
||||
check(g.move == RetreatMove::FlightPlan, "a non-gate species is given a move order");
|
||||
}
|
||||
|
||||
void TestPlanEmptiesAFleet() {
|
||||
// Fleet 300 has 1 ship; fleet 400 has 2, and one of 400's ships retreats to a DIFFERENT
|
||||
// destination, which puts the two ships in different groups -- so neither group sees
|
||||
// 400 as whole, both split it, and it ends up empty.
|
||||
std::vector<SystemView> sys{Sys(1, 10.0f, 0), Sys(2, 20.0f, 0)};
|
||||
std::vector<FleetView> fleets{{400, 2, 0}};
|
||||
|
||||
std::vector<ShipView> ships(2);
|
||||
for (std::size_t i = 0; i < ships.size(); ++i) {
|
||||
ships[i].id = static_cast<int>(i);
|
||||
ships[i].ownerPlayer = 0;
|
||||
ships[i].fleetId = 400;
|
||||
ships[i].design.hasDriveSectionA = true;
|
||||
ships[i].driveHealth = 1.0f;
|
||||
}
|
||||
ships[0].requestedDestination = 1;
|
||||
ships[1].requestedDestination = 2;
|
||||
|
||||
const RetreatPlan plan = BuildRetreatPlan(sys, fleets, ships, {Plr(0)}, 0, Vec3f{0, 0, 0},
|
||||
3, 0, 7, {});
|
||||
check_eq(static_cast<int>(plan.groups.size()), 2, "two destinations make two groups");
|
||||
check_eq(static_cast<int>(plan.emptiedFleets.size()), 1,
|
||||
"a fleet that gives up every ship across both groups is emptied");
|
||||
check_eq(plan.emptiedFleets[0], 400, "...and it is fleet 400");
|
||||
}
|
||||
|
||||
void TestExploreGrant() {
|
||||
std::vector<SystemView> sys{Sys(0, 0.0f), Sys(1, 10.0f, 0)};
|
||||
std::vector<FleetView> fleets{{100, 1, 0}};
|
||||
std::vector<ShipView> ships(1);
|
||||
ships[0].id = 0;
|
||||
ships[0].fleetId = 100;
|
||||
ships[0].ownerPlayer = 0;
|
||||
ships[0].design.hasDriveSectionA = true;
|
||||
ships[0].driveHealth = 1.0f;
|
||||
|
||||
const RetreatPlan unexplored =
|
||||
BuildRetreatPlan(sys, fleets, ships, {Plr(0)}, /*battleSystemIndex=*/0, Vec3f{0, 0, 0},
|
||||
3, 0, 7, /*exploredMaskPerSystem=*/{0u, 0u});
|
||||
check_eq(static_cast<int>(unexplored.exploredGrants.size()), 1,
|
||||
"retreating from an unexplored system reveals it");
|
||||
|
||||
const RetreatPlan explored =
|
||||
BuildRetreatPlan(sys, fleets, ships, {Plr(0)}, 0, Vec3f{0, 0, 0}, 3, 0, 7, {0b1u, 0u});
|
||||
check(explored.exploredGrants.empty(), "...and does nothing when it was already explored");
|
||||
|
||||
const RetreatPlan deepSpace =
|
||||
BuildRetreatPlan(sys, fleets, ships, {Plr(0)}, /*battleSystemIndex=*/-1, Vec3f{0, 0, 0},
|
||||
3, 0, 7, {0u, 0u});
|
||||
check(deepSpace.exploredGrants.empty(), "a battle away from any system grants nothing");
|
||||
}
|
||||
|
||||
void TestGateRetreat() {
|
||||
RetreatGroup g;
|
||||
g.mode = kGateRetreatMode;
|
||||
g.destinationSystem = 5;
|
||||
check(UsesGateRetreat(g, Plr(0, kGateSpecies)), "gate species + gate mode + a destination");
|
||||
check(!UsesGateRetreat(g, Plr(0, /*species=*/3)), "a different species flies instead");
|
||||
|
||||
g.mode = 0;
|
||||
check(!UsesGateRetreat(g, Plr(0, kGateSpecies)), "a different mode flies instead");
|
||||
|
||||
g.mode = kGateRetreatMode;
|
||||
g.destinationSystem = -1;
|
||||
check(!UsesGateRetreat(g, Plr(0, kGateSpecies)), "no destination means no gate");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
TestUsableTarget();
|
||||
TestHostileMask();
|
||||
TestDestinationPrefersOwned();
|
||||
TestDestinationIndependentBests();
|
||||
TestDestinationSkipsAndTies();
|
||||
TestShipEligibility();
|
||||
TestGrouping();
|
||||
TestWholeVersusPartial();
|
||||
TestPlanSplitsAndEmpties();
|
||||
TestPlanEmptiesAFleet();
|
||||
TestExploreGrant();
|
||||
TestGateRetreat();
|
||||
|
||||
std::printf("game_combat/retreat: %d checks, %d failures\n", g_checks, g_fails);
|
||||
return g_fails == 0 ? 0 : 1;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue