game/events: the player event log and the five research events
Recovers the game's event-posting API so the engine can post events and the
compare harness can see them. Until now the owner's event list was invisible to
every layer: B3's replace-mode oracle failed by exactly one item across 40,300
(an unposted EVENT_RESEARCH_OVERBUDGET) while its compare read clean, and B2's
clean compare bounds the economy fields only.
New pure module src/game/events:
* EventStorage / TurnEvents / PlayerEvent -- the list is bucketed by TURN, not
flat, which the save-editor struct notes had wrong.
* EventStorage::Post reproducing the original's rules, including the four that
change save bytes: the FLT_MAX (not infinity) default position; action 0 with
no subject and no position storing as 2; per-bucket dedup that compares
message/image/location/position/action but NOT summary; and EvNxID starting
at 0 and being promoted to 1 on the first post.
* PruneOldTurns reproduced with its off-by-one: of a leading run of buckets
older than turn-50 it erases n-1, so one stale bucket always survives. The
survivor is serialized, so correcting it would diverge.
* research_events: the five events the research path raises, their EvImg
identifiers and string-table keys, and the 0.8 completion split evaluated
against (double)0.8f rather than the decimal 0.8.
Localized text is deliberately absent: only the EVENTSUM_/EVENTMSG_ keys are
here and the text resolves through a caller-supplied lookup, as the game does.
The four event offsets the B3 hook carried as local literals now come from the
generated header; they are read off instructions rather than inferred from the
save schema.
tests/game_events: 112 checks including a replay of the event list
turn3-state.sav actually holds. ctest 31/31 -> 32/32.
docs/E-events.md carries the proposed region and Coverage wording for the next
B3 recapture.
This commit is contained in:
parent
7584badab3
commit
a7348be72c
12 changed files with 1094 additions and 11 deletions
|
|
@ -28,6 +28,7 @@ add_subdirectory(src/game/config) # flat KEY/value constants loader (lib sot
|
|||
add_subdirectory(src/game/data) # typed catalogs on mars/parse+text (lib game_data)
|
||||
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)
|
||||
|
||||
# ---- shim trace/compare infrastructure (host-testable; linked into binkw32) ----
|
||||
add_library(shim_trace STATIC
|
||||
|
|
@ -95,7 +96,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 shim_budget shim_techfx shim_colony shim_movement)
|
||||
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)
|
||||
if(EXISTS ${CMAKE_SOURCE_DIR}/tests/${_t}/CMakeLists.txt)
|
||||
add_subdirectory(tests/${_t})
|
||||
endif()
|
||||
|
|
|
|||
132
docs/E-events.md
Normal file
132
docs/E-events.md
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
# E — the player event log
|
||||
|
||||
Lane E's job was to recover the game's event-posting API so the engine can post events and
|
||||
the compare harness can see them. This is the engine-side half; the RE half (addresses,
|
||||
instruction-level evidence, save cross-check) is `sots-re/findings/subsystems/events.md`.
|
||||
|
||||
## Why
|
||||
|
||||
Two behavioural milestones write into the owner's event list and neither modelled it:
|
||||
|
||||
* **B3 (`TechTree::ProcessResearch`)** failed its replace-mode oracle by exactly one item
|
||||
across 40,300 — an `EVENT_RESEARCH_OVERBUDGET` our code never posted. Compare mode was
|
||||
clean because the list was not a declared region.
|
||||
* **B2 (`ServerPlayer::OnTechResearched`)** has the same gap, and no replace-mode oracle
|
||||
behind it at all: its clean compare bounds the economy fields only, and its oracle pass was
|
||||
deliberately run on a turn with no completion.
|
||||
|
||||
The harness audit put the event list at the top of its 23-item table for exactly this reason
|
||||
(`docs/harness-audit.md` §1 rows 1 and 2).
|
||||
|
||||
## What landed
|
||||
|
||||
`src/game/events/` — a new pure module, `sots_game_events`:
|
||||
|
||||
| file | contents |
|
||||
|---|---|
|
||||
| `event_log.h/.cpp` | `PlayerEvent` / `TurnEvents` / `EventStorage`, and `EventStorage::Post` reproducing the original's posting rules |
|
||||
| `research_events.h/.cpp` | the five events the research path raises: their `EvImg` identifiers, their string-table keys, the 0.8 completion split, and one posting helper each |
|
||||
|
||||
`tests/game_events/` — 112 checks, green (`ctest` 31/31 → 32/32).
|
||||
|
||||
The four event offsets the B3 hook had been carrying as local literals
|
||||
(`kPlayerEventsOff`, `kEventStorageSize`, `kEventsVecOff`, `kEventsNextIdOff`) now come from
|
||||
the generated header (`A::ServerPlayer_off_Events`, `A::EventStorage_sizeof`,
|
||||
`A::EventStorage_off_Events`, `A::EventStorage_off_EvNxID`). They were "recovered from the
|
||||
save schema, not from an instruction"; they are now read off instructions and travel through
|
||||
the sanctioned channel.
|
||||
|
||||
## The five rules that are easy to get wrong
|
||||
|
||||
Each of these changes the bytes a save-hash oracle compares.
|
||||
|
||||
1. **The default position is `FLT_MAX`, not infinity.** The constructor copies a static
|
||||
`Vector3` of three `0x7f7fffff` words. Writing `+inf` (`0x7f800000`) changes the save.
|
||||
`findings/subsystems/formula-gaps.md` records this as `{inf,inf,inf}`; it is wrong.
|
||||
2. **`action == 0` with no subject and no position is stored as `2`.** `EVENT_TEMPERANCE` is
|
||||
posted with a literal `0` and hits this rule, so the correct stored value is 2.
|
||||
3. **Posting is deduplicated per turn bucket**, on `action`, `location`, all three position
|
||||
floats, `message` and `image` — but **not** `summary`. A duplicate returns the existing
|
||||
id and burns neither an id nor a slot.
|
||||
4. **`EvNxID` starts at 0** and is promoted to 1 on the first post, then post-incremented.
|
||||
A player who has never seen an event serializes `EvNxID = 0` (two of the four players in
|
||||
`turn3-state.sav` do).
|
||||
5. **The prune has an off-by-one and it is load-bearing.** Buckets older than
|
||||
`turn - 50` are erased *except the last one of the leading stale run*, so one stale bucket
|
||||
always survives and a single leading stale bucket is never removed at all. It is
|
||||
reproduced, not corrected — the survivor is serialized.
|
||||
|
||||
Also modelled: the completion event's message goes through a 256-byte `_snprintf` buffer, so
|
||||
it truncates; the two events posted from `ProcessResearch` format into a `std::string` and do
|
||||
not.
|
||||
|
||||
## What is deliberately NOT here
|
||||
|
||||
The **displayed text**. `EvDsc` and `EvMsg` are localized strings from the shipped string
|
||||
table; only their keys (`EVENTSUM_*` / `EVENTMSG_*`) are in the engine, and the text is
|
||||
resolved through a caller-supplied lookup, exactly as the game resolves it through its own
|
||||
slot table. `game::data::StringTable` already provides the lookup.
|
||||
|
||||
`EventStorage` is also **not wired into any hook** yet. Posting from `ours` changes what
|
||||
compare and replace mode do on the VM, and that is a measured change lane R has to schedule —
|
||||
see the next section for the exact shape it should take.
|
||||
|
||||
## Proposed: what the B3 hook should declare next
|
||||
|
||||
The `events` **Result** region already exists on `Game::TechTree::ProcessResearch`
|
||||
(`src/shim/hooks/research.cpp`, `describe_events`), reporting `turns_bytes`, `next_id` and
|
||||
the three vector words. That is enough to make the missing post *visible*, and after the
|
||||
golden-trace recapture it should show a divergence on exactly the over-budget call. It is not
|
||||
enough to make it *pass*: a passing compare needs `ours` to produce the same `next_id`, which
|
||||
means posting.
|
||||
|
||||
Two ways to close it, in increasing order of what they prove:
|
||||
|
||||
**(a) Count-only.** `ours` calls `EventStorage::Post` on its own `sots::events::EventStorage`
|
||||
and the descriptor compares only the resulting `next_id` delta against the original's. This
|
||||
proves the *decision* (did we post, and how many) without touching game memory. It is cheap,
|
||||
it is safe in compare mode, and it converts the audit's row 1 from "known defect" to
|
||||
"checked". It cannot prove the text.
|
||||
|
||||
**(b) Delegate, as M2 delegates to `LoadWeapon`.** In replace mode, `ours` calls the game's
|
||||
own `EventStorage::PostEvent` (`A::EventStorage_PostEvent`, `__thiscall`, `ret 0x4c`) on the
|
||||
owner's real storage, building the two by-value `std::string` arguments the way the callers
|
||||
do. That is the only path that makes the save hash match, because the game composes the text
|
||||
from its own string table. The prototype is pinned and written back to Ghidra; the awkward
|
||||
part is constructing two MSVC `std::string`s by value from MinGW code, which needs the same
|
||||
raw-frame trick the other by-value call sites use.
|
||||
|
||||
Recommended: (a) first, on the next B3 recapture, because it is a strict improvement and
|
||||
carries no risk to the live game; (b) when a replace-mode oracle for B2 is scheduled, since
|
||||
that is the run that actually needs the text.
|
||||
|
||||
The Coverage note the descriptor carries today should then change from
|
||||
|
||||
```
|
||||
c.unmodelled("posts EVENT_RESEARCH_OVERBUDGET on the owner's EventStorage ...",
|
||||
Risk::High, "the message text is composed from the tech name", "region:events");
|
||||
```
|
||||
|
||||
to, under (a):
|
||||
|
||||
```
|
||||
c.unmodelled("posts EVENT_RESEARCH_OVERBUDGET on the owner's EventStorage: ours reproduces "
|
||||
"the decision and the id sequence, so region:events compares next_id, but the "
|
||||
"composed EvDsc/EvMsg text is not reproduced and no region can see it",
|
||||
Risk::Medium, "text comes from the game's string table", "region:events");
|
||||
```
|
||||
|
||||
`Risk::High` drops to `Risk::Medium` only once the count is actually compared; it stays
|
||||
`High` until then.
|
||||
|
||||
## Open
|
||||
|
||||
* The convenience wrapper at `A::` … (`0x00886470` in `sots-re`, not exported here) also
|
||||
posts, twice. Not read; not on the research path.
|
||||
* `EventStorage`'s save-side *write* function was not located, only the read. The field
|
||||
order is identical in both directions and the save confirms it, so this is a coverage gap
|
||||
rather than a confidence gap.
|
||||
* The 50-turn prune has never been observed running — the reference saves are at turn ≤ 3.
|
||||
The unit tests pin the behaviour; nothing has measured it against the game.
|
||||
* `EvCID` is constructed 0 and never written by `PostEvent`. Some other caller must set it;
|
||||
which one is unknown.
|
||||
|
|
@ -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 @ ff67ec0, generated 2026-09-08 by tools/gen_addresses.py
|
||||
// Source: sots-re ghidra/addresses.json @ 84d5609, 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>
|
||||
|
|
@ -655,5 +655,117 @@ constexpr uint32_t g_ptr_STUTTER_SYSTEM_INFLUENCE_RADIUS = 0x006ebc44;
|
|||
constexpr uint32_t g_ptr_STUTTER_MIN_SPEED = 0x006ebc48;
|
||||
// data float** -- pointer slot; storage 0x00b212d4 (shipped value 0.33). NOTE min == max in the shipped data, so the stutter ramp collapses to a constant 0.33x inside any influence sphere [verified]
|
||||
constexpr uint32_t g_ptr_STUTTER_MAX_SPEED = 0x006ebc4c;
|
||||
// thiscall EventStorage* (ServerPlayer* this) /* whole body: lea eax,[ecx+0x29c]; ret. No stack args, plain RET */ [verified]
|
||||
constexpr uint32_t ServerPlayer_GetEventStorage = 0x0040db00;
|
||||
// thiscall void* (ServerPlayer* this) /* eax = [this+8] ? [this+8]-4 : 0. StrategyServer primary base; turn = *(int*)(result+0x0c) */ [verified]
|
||||
constexpr uint32_t ServerPlayer_GetServer = 0x0040e320;
|
||||
// thiscall int (EventStorage* this, std::string summary /*BY VALUE 0x1c -> EvDsc*/, std::string message /*BY VALUE 0x1c -> EvMsg*/, void* obj, Vector3* pos, int turn, const char* img, int act) RET 0x4c. Returns the event id (a duplicate's id if one already exists in the turn bucket). Callee frees both by-value string buffers. img==NULL -> "". act==0 && obj==NULL && pos==NULL -> stored EvAct becomes 2. EvLoc = obj ? obj[+4] : 0; EvPos = obj ? obj[+0x18..0x20] : pos ? *pos : FLT_MAX triple. 161 call sites in 110 functions: this is the whole simulation's event API [verified]
|
||||
constexpr uint32_t EventStorage_PostEvent = 0x004862b0;
|
||||
// thiscall TurnEvents* (EventStorage* this, int turn) RET 4. Linear scan with NO early exit, so it returns the LAST bucket whose EvTurn == turn; otherwise appends a new bucket (ctor 0x00884cb0, vtable 0x00a0f07c) and sets its EvTurn [verified]
|
||||
constexpr uint32_t EventStorage_GetOrCreateTurnBucket = 0x00485380;
|
||||
// thiscall PlayerEvent* (EventStorage* this, TurnEvents* bucket, PlayerEvent* candidate) RET 8. NULL bucket -> 0. Match requires EvAct, EvLoc, all three EvPos floats (fucompp), EvMsg and EvImg to be equal. EvDsc is NOT compared [verified]
|
||||
constexpr uint32_t EventStorage_FindDuplicate = 0x00425d40;
|
||||
// thiscall void (EventStorage* this, int turn) RET 4. Cutoff = turn - 0x32 (50), a code constant. Shifts from the LAST bucket of the leading run with EvTurn < cutoff, so it erases n-1 of n leading stale buckets: one stale bucket always survives and a single leading stale bucket is never removed [verified]
|
||||
constexpr uint32_t EventStorage_PruneOldTurns = 0x00479eb0;
|
||||
// thiscall void (EventStorage* this, IStreamable* s) RET 4. Order: EvNxID (int, tag 0x00a2bd90), then nested collection "Events" (tag 0x00a2bda0, descriptor vtable 0x00a2da8c) over this+4 [verified]
|
||||
constexpr uint32_t EventStorage_Read = 0x00425cc0;
|
||||
// thiscall void (TurnEvents* this, IStreamable* s) RET 4. Order: EvTurn (int, tag 0x00a2bd98), then nested collection "Events" (descriptor vtable 0x00a2da7c) over this+8 [verified]
|
||||
constexpr uint32_t TurnEvents_Write = 0x00425bb0;
|
||||
// thiscall void (TurnEvents* this, IStreamable* s) RET 4. Same field order as TurnEvents_Write [verified]
|
||||
constexpr uint32_t TurnEvents_Read = 0x00425c40;
|
||||
// thiscall PlayerEvent* (PlayerEvent* this) RET 0. vptr=0x00a21958; EvEID=EvLoc=EvAct=EvCID=0; the three std::strings = "" (0x009e100c); EvPos = the Vector3 global at 0x00af0dc8 = {FLT_MAX, FLT_MAX, FLT_MAX} (0x7f7fffff x3, NOT infinity) [verified]
|
||||
constexpr uint32_t PlayerEvent_ctor = 0x0044ee30;
|
||||
// thiscall void (PlayerEvent* this, IStreamable* s) RET 4. vftable slot 1. Field order on the wire: EvEID(+4) EvDsc(+8) EvMsg(+0x24) EvImg(+0x50) EvLoc(+0x40) EvPos(+0x44) EvAct(+0x6c) EvCID(+0x70) [verified]
|
||||
constexpr uint32_t PlayerEvent_Serialize = 0x00425970;
|
||||
// thiscall call site: EventStorage::PostEvent for EVENT_RESEARCH_OVERBUDGET. Guard at 0x005879e9: !wasDone && nowDone && owner, inside the completion-roll-FAILED branch (chance < draw). Sets TechNode.flag(+0x2c)=2 at 0x00587ba3. EvAct=1, obj=NULL, pos=NULL [verified]
|
||||
constexpr uint32_t ProcessResearch_PostEventOverbudget = 0x00187b97;
|
||||
// thiscall call site: EventStorage::PostEvent for EVENT_TECHS_UNLOCKED, once after the per-node loop, if any node has state==2 and turnAvailable==currentTurn. EvAct=1, obj=NULL, pos=NULL [verified]
|
||||
constexpr uint32_t ProcessResearch_PostEventTechsUnlocked = 0x00187ff4;
|
||||
// thiscall call site: EventStorage::PostEvent for EVENT_RESEARCH_COMPLETE / _UNDERBUDGET. Guarded by !silent ([ebp+0xc]==0). Message is _snprintf'd (0x008c8eb0) into a 0x100-byte buffer, so >255 chars truncate. EvAct=1 [verified]
|
||||
constexpr uint32_t OnTechResearched_PostEventComplete = 0x004919b5;
|
||||
// thiscall call site: EventStorage::PostEvent for EVENT_TEMPERANCE. Guarded by !silent AND by the 'a system was cured' local at [ebp-0x189]. Pushed act=0 with obj=pos=NULL, so the STORED EvAct is 2 [verified]
|
||||
constexpr uint32_t OnTechResearched_PostEventTemperance = 0x00492427;
|
||||
// thiscall call site: EventStorage::PostEvent for EVENT_NO_RESEARCH. Condition at 0x0089162a: ResT(+0x294)==NULL && ListAvailableTechs(0x00584e50, turn, INT_MAX, 1) returned empty && TechTree 0x0057da90 != 0. EvAct=1 [verified]
|
||||
constexpr uint32_t ServerPlayer_ProcessTurn_PostEventNoResearch = 0x0049168c;
|
||||
// data const char* "Research completed at %d of %d (%.1f%%). (Odds: %.2f, Roll: %.2f)\n" -- log line on the completion-roll-SUCCEEDED branch, 0x00587977 [verified]
|
||||
constexpr uint32_t TechTree_ProcessResearch_LogFormat = 0x006007a8;
|
||||
// offset EventStorage ServerPlayer::Events -- embedded, size 0x1c. Confirmed by ServerPlayer_GetEventStorage [verified]
|
||||
constexpr uint32_t ServerPlayer_off_Events = 0x0000029c;
|
||||
// offset std::vector<TurnEvents> _Myfirst (element stride 0x18; _Mylast +0x08, _Myend +0x0c, _Alval +0x10) [verified]
|
||||
constexpr uint32_t EventStorage_off_Events = 0x00000004;
|
||||
// offset int EvNxID -- next event id. Starts at 0; PostEvent promotes 0->1 on the first post, then post-increments. ServerPlayer+0x2b0 [verified]
|
||||
constexpr uint32_t EventStorage_off_EvNxID = 0x00000014;
|
||||
// offset sizeof(EventStorage) [verified]
|
||||
constexpr uint32_t EventStorage_sizeof = 0x0000001c;
|
||||
// offset int EvTurn (after the vptr at +0) [verified]
|
||||
constexpr uint32_t TurnEvents_off_EvTurn = 0x00000004;
|
||||
// offset std::vector<PlayerEvent> _Myfirst (stride 0x74; _Mylast +0x0c, _Myend +0x10, _Alval +0x14) [verified]
|
||||
constexpr uint32_t TurnEvents_off_Events = 0x00000008;
|
||||
// offset sizeof(TurnEvents); the outer vector's stride, from the /24 divide at 0x008853b3 [verified]
|
||||
constexpr uint32_t TurnEvents_sizeof = 0x00000018;
|
||||
// offset int EvEID (after the vptr at +0) [verified]
|
||||
constexpr uint32_t PlayerEvent_off_EvEID = 0x00000004;
|
||||
// offset std::string EvDsc (summary / title), 0x1c bytes [verified]
|
||||
constexpr uint32_t PlayerEvent_off_EvDsc = 0x00000008;
|
||||
// offset std::string EvMsg (body), 0x1c bytes [verified]
|
||||
constexpr uint32_t PlayerEvent_off_EvMsg = 0x00000024;
|
||||
// offset int EvLoc (object id, or 0) [verified]
|
||||
constexpr uint32_t PlayerEvent_off_EvLoc = 0x00000040;
|
||||
// offset float[3] EvPos (default FLT_MAX x3) [verified]
|
||||
constexpr uint32_t PlayerEvent_off_EvPos = 0x00000044;
|
||||
// offset std::string EvImg (event-type name, e.g. "EVENT_RESEARCH_OVERBUDGET"), 0x1c bytes [verified]
|
||||
constexpr uint32_t PlayerEvent_off_EvImg = 0x00000050;
|
||||
// offset int EvAct [verified]
|
||||
constexpr uint32_t PlayerEvent_off_EvAct = 0x0000006c;
|
||||
// offset int EvCID -- ctor sets 0 and PostEvent never writes it [verified]
|
||||
constexpr uint32_t PlayerEvent_off_EvCID = 0x00000070;
|
||||
// offset sizeof(PlayerEvent) = 116. Two independent confirmations: the /116 divide at 0x00825d5f and PostEvent's 'mov [_Mylast-0x70], id' writing EvEID at element+4 [verified]
|
||||
constexpr uint32_t PlayerEvent_sizeof = 0x00000074;
|
||||
// offset prune cutoff = turn - 50; a code constant at 0x00879ec3, not config [verified]
|
||||
constexpr uint32_t EventStorage_PruneWindowTurns = 0x00000032;
|
||||
// data void** -- PlayerEvent vftable. slot0 dtor 0x007694d0, slot1 Serialize 0x00825970, slot2 0x00825ab0. RTTI locator 0x00a80a7c [verified]
|
||||
constexpr uint32_t g_vft_PlayerEvent = 0x00621958;
|
||||
// data void** -- TurnEvents vftable (bucket ctor 0x00884cb0); slot0 is the virtual dtor the pruner calls [verified]
|
||||
constexpr uint32_t g_vft_TurnEvents = 0x0060f07c;
|
||||
// data const char[12][8] -- stride 8: EvEID EvNxID EvTurn Events EvPos EvLoc EvMsg EvImg EvDsc EvCID EvAct sasc [verified]
|
||||
constexpr uint32_t g_EventTagTable = 0x0062bd88;
|
||||
// data float[3] = {0x7f7fffff, 0x7f7fffff, 0x7f7fffff} = FLT_MAX. PlayerEvent's default EvPos. NOT infinity [verified]
|
||||
constexpr uint32_t g_Vector3_Invalid = 0x006f0dc8;
|
||||
// data const char* "EVENT_RESEARCH_OVERBUDGET" -- EvImg pushed at 0x00587b24 [verified]
|
||||
constexpr uint32_t g_str_EVENT_RESEARCH_OVERBUDGET = 0x0060078c;
|
||||
// data const char* "EVENT_RESEARCH_COMPLETE" -- EvImg for progress/cost >= 0.8 [verified]
|
||||
constexpr uint32_t g_str_EVENT_RESEARCH_COMPLETE = 0x00633368;
|
||||
// data const char* "EVENT_RESEARCH_UNDERBUDGET" -- EvImg for progress/cost < 0.8 [verified]
|
||||
constexpr uint32_t g_str_EVENT_RESEARCH_UNDERBUDGET = 0x00633380;
|
||||
// data const char* "EVENT_TEMPERANCE" -- EvImg at 0x008923ae; posted with act=0 so the stored EvAct is 2 [verified]
|
||||
constexpr uint32_t g_str_EVENT_TEMPERANCE = 0x00633340;
|
||||
// data const char* "EVENT_TECHS_UNLOCKED" (length 0x14 pushed at 0x00587eb6) [verified]
|
||||
constexpr uint32_t g_str_EVENT_TECHS_UNLOCKED = 0x00600774;
|
||||
// data const char* "EVENT_NO_RESEARCH" -- EvImg at 0x0089168c [verified]
|
||||
constexpr uint32_t g_str_EVENT_NO_RESEARCH = 0x0063332c;
|
||||
// data const char** -> slot 0x00ae48e0; Strings.csv key EVENTSUM_RESEARCH_OVERBUDGET = "Research Over Budget" [verified]
|
||||
constexpr uint32_t g_ptr_EVENTSUM_RESEARCH_OVERBUDGET = 0x006e48e4;
|
||||
// data const char** -> slot 0x00ae48e8; key EVENTMSG_RESEARCH_OVERBUDGET = "Research for %s has gone overbudget." [verified]
|
||||
constexpr uint32_t g_ptr_EVENTMSG_RESEARCH_OVERBUDGET = 0x006e48ec;
|
||||
// data const char** -> slot 0x00ae48f0; key EVENTSUM_UNLOCKEDTECHS = "New Technologies Available" [verified]
|
||||
constexpr uint32_t g_ptr_EVENTSUM_UNLOCKEDTECHS = 0x006e48f4;
|
||||
// data const char** -> slot 0x00ae48f8; key EVENTMSG_UNLOCKEDTECHS [verified]
|
||||
constexpr uint32_t g_ptr_EVENTMSG_UNLOCKEDTECHS = 0x006e48fc;
|
||||
// data const char** -> slot 0x00af09e8; key EVENTSUM_RESEARCH_COMPLETE = "Research Complete" [verified]
|
||||
constexpr uint32_t g_ptr_EVENTSUM_RESEARCH_COMPLETE = 0x006f09ec;
|
||||
// data const char** -> slot 0x00af09f0; key EVENTMSG_RESEARCH_COMPLETE = "Tech %s has been acquired" [verified]
|
||||
constexpr uint32_t g_ptr_EVENTMSG_RESEARCH_COMPLETE = 0x006f09f4;
|
||||
// data const char** -> slot 0x00af09f8; key EVENTSUM_RESEARCH_UNDERBUDGET = "Research Breakthrough!" [verified]
|
||||
constexpr uint32_t g_ptr_EVENTSUM_RESEARCH_UNDERBUDGET = 0x006f09fc;
|
||||
// data const char** -> slot 0x00af0a00; key EVENTMSG_RESEARCH_UNDERBUDGET [verified]
|
||||
constexpr uint32_t g_ptr_EVENTMSG_RESEARCH_UNDERBUDGET = 0x006f0a04;
|
||||
// data const char** -> slot 0x00af0a88; key EVENTSUM_ADDICTION_TEMPERENCE (shipped misspelling) [verified]
|
||||
constexpr uint32_t g_ptr_EVENTSUM_ADDICTION_TEMPERENCE = 0x006f0a8c;
|
||||
// data const char** -> slot 0x00af0a90; key EVENTMSG_ADDICTION_TEMPERENCE [verified]
|
||||
constexpr uint32_t g_ptr_EVENTMSG_ADDICTION_TEMPERENCE = 0x006f0a94;
|
||||
// data double 0.800000011920929 = (double)0.8f. progress/cost >= this -> EVENT_RESEARCH_COMPLETE, else EVENT_RESEARCH_UNDERBUDGET; the same constant gates TechNode.flag=0 in ProcessResearch [verified]
|
||||
constexpr uint32_t g_dbl_ResearchUnderbudgetThreshold = 0x005e20c8;
|
||||
// data double 0.0 -- the research completion draw is NextFloat()*(1.0-this)+this, so it is a plain NextFloat() [verified]
|
||||
constexpr uint32_t g_dbl_ResearchRollBias = 0x005e1e68;
|
||||
|
||||
} // namespace sots::addr
|
||||
|
|
|
|||
11
src/game/events/CMakeLists.txt
Normal file
11
src/game/events/CMakeLists.txt
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# Player event log: the per-empire turn-event list every simulation subsystem posts into,
|
||||
# plus the five events the research path raises. Pure; depends on nothing but the standard
|
||||
# library, so the shim and the host tests can both link it.
|
||||
add_library(sots_game_events STATIC
|
||||
event_log.cpp
|
||||
research_events.cpp)
|
||||
target_include_directories(sots_game_events PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/../..)
|
||||
target_compile_features(sots_game_events PUBLIC cxx_std_17)
|
||||
if(NOT MSVC)
|
||||
target_compile_options(sots_game_events PRIVATE -Wall -Wextra)
|
||||
endif()
|
||||
89
src/game/events/event_log.cpp
Normal file
89
src/game/events/event_log.cpp
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
#include "game/events/event_log.h"
|
||||
|
||||
#include <cfloat>
|
||||
#include <utility>
|
||||
|
||||
namespace sots::events {
|
||||
|
||||
EventPos InvalidEventPos() {
|
||||
EventPos p;
|
||||
p.x = FLT_MAX;
|
||||
p.y = FLT_MAX;
|
||||
p.z = FLT_MAX;
|
||||
return p;
|
||||
}
|
||||
|
||||
TurnEvents& EventStorage::GetOrCreateTurnBucket(int turn) {
|
||||
// No early exit: the last match wins, as the original's loop does.
|
||||
TurnEvents* found = nullptr;
|
||||
for (TurnEvents& b : turns_) {
|
||||
if (b.turn == turn) found = &b;
|
||||
}
|
||||
if (found) return *found;
|
||||
turns_.push_back(TurnEvents{});
|
||||
turns_.back().turn = turn;
|
||||
return turns_.back();
|
||||
}
|
||||
|
||||
const PlayerEvent* EventStorage::FindDuplicate(const TurnEvents& bucket,
|
||||
const PlayerEvent& candidate) const {
|
||||
for (const PlayerEvent& e : bucket.events) {
|
||||
if (e.action != candidate.action) continue;
|
||||
if (e.location != candidate.location) continue;
|
||||
if (e.pos != candidate.pos) continue;
|
||||
if (e.message != candidate.message) continue;
|
||||
if (e.image != candidate.image) continue;
|
||||
// summary is deliberately not compared.
|
||||
return &e;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void EventStorage::PruneOldTurns(int turn) {
|
||||
const int cutoff = turn - kPruneWindowTurns;
|
||||
if (turns_.empty()) return;
|
||||
|
||||
// Index of the last bucket in the LEADING run of stale buckets; turns_.size() means the
|
||||
// run is empty (the original leaves its cursor on _Mylast in that case).
|
||||
std::size_t lastStale = turns_.size();
|
||||
for (std::size_t i = 0; i < turns_.size(); ++i) {
|
||||
if (turns_[i].turn >= cutoff) break;
|
||||
lastStale = i;
|
||||
}
|
||||
if (lastStale == turns_.size()) return; // nothing stale at the front
|
||||
if (lastStale == 0) return; // a single leading stale bucket is kept
|
||||
turns_.erase(turns_.begin(), turns_.begin() + static_cast<std::ptrdiff_t>(lastStale));
|
||||
}
|
||||
|
||||
int EventStorage::Post(std::string summary, std::string message, const EventSubject* subject,
|
||||
const EventPos* pos, int turn, std::string_view image, int action) {
|
||||
PlayerEvent ev;
|
||||
ev.summary = std::move(summary);
|
||||
ev.message = std::move(message);
|
||||
ev.location = subject ? subject->id : 0;
|
||||
ev.image.assign(image);
|
||||
ev.action = (action == 0 && subject == nullptr && pos == nullptr) ? 2 : action;
|
||||
if (subject) {
|
||||
ev.pos = subject->pos;
|
||||
} else if (pos) {
|
||||
ev.pos = *pos;
|
||||
} // else: the constructor's FLT_MAX sentinel
|
||||
|
||||
PruneOldTurns(turn);
|
||||
TurnEvents& bucket = GetOrCreateTurnBucket(turn);
|
||||
if (const PlayerEvent* dup = FindDuplicate(bucket, ev)) return dup->id;
|
||||
|
||||
bucket.events.push_back(std::move(ev));
|
||||
if (nextId_ == 0) nextId_ = 1;
|
||||
const int id = nextId_++;
|
||||
bucket.events.back().id = id;
|
||||
return id;
|
||||
}
|
||||
|
||||
std::size_t EventStorage::total_events() const {
|
||||
std::size_t n = 0;
|
||||
for (const TurnEvents& b : turns_) n += b.events.size();
|
||||
return n;
|
||||
}
|
||||
|
||||
} // namespace sots::events
|
||||
141
src/game/events/event_log.h
Normal file
141
src/game/events/event_log.h
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
// Player event log: the per-empire list of turn events the UI shows and the save stores.
|
||||
//
|
||||
// This is the whole simulation's notification API -- one posting function reached from 161
|
||||
// call sites, from research completion to fleet arrival to bankruptcy. It is modelled here
|
||||
// because two behavioural milestones (the research pass and the tech-completion callback)
|
||||
// write into it, and until it exists in the engine every "0 divergences" on those two is
|
||||
// bounded to the economy fields.
|
||||
//
|
||||
// Shape, on disk and in memory:
|
||||
//
|
||||
// EventStorage { int nextId; vector<TurnEvents> turns; }
|
||||
// TurnEvents { int turn; vector<PlayerEvent> events; }
|
||||
// PlayerEvent { int id; string summary, message; int location;
|
||||
// float pos[3]; string image; int action; int chainId; }
|
||||
//
|
||||
// i.e. the list is bucketed **by turn**, not flat. Field names here map to the on-disk tags
|
||||
// as: id=EvEID, summary=EvDsc, message=EvMsg, location=EvLoc, pos=EvPos, image=EvImg,
|
||||
// action=EvAct, chainId=EvCID; nextId=EvNxID, turn=EvTurn.
|
||||
//
|
||||
// CONFIDENCE: high. Every rule below was read out of the instruction stream and the record
|
||||
// layout was cross-checked field by field against three real saves.
|
||||
// See sots-re findings/subsystems/events.md.
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace sots::events {
|
||||
|
||||
// The position a `PlayerEvent` carries when it has no place on the map. The original's
|
||||
// constructor copies a static Vector3 of three FLT_MAX words (0x7f7fffff), **not** infinity;
|
||||
// writing +inf here would change the save bytes and break the oracle. CONFIDENCE: high
|
||||
// (byte-confirmed in the constructor and in every position-less event in a real save).
|
||||
struct EventPos {
|
||||
float x = 0;
|
||||
float y = 0;
|
||||
float z = 0;
|
||||
|
||||
friend bool operator==(const EventPos& a, const EventPos& b) {
|
||||
// The original compares with `fucompp`, i.e. IEEE equality: a NaN coordinate never
|
||||
// matches itself, and the FLT_MAX sentinels always do.
|
||||
return a.x == b.x && a.y == b.y && a.z == b.z;
|
||||
}
|
||||
friend bool operator!=(const EventPos& a, const EventPos& b) { return !(a == b); }
|
||||
};
|
||||
|
||||
EventPos InvalidEventPos();
|
||||
|
||||
// A map object an event is attached to (a system, a fleet). The original reads the id from
|
||||
// the object's `+4` word and the position from its `+0x18` Vector3; the caller here has
|
||||
// already resolved both.
|
||||
struct EventSubject {
|
||||
int id = 0;
|
||||
EventPos pos;
|
||||
};
|
||||
|
||||
struct PlayerEvent {
|
||||
int id = 0; // EvEID -- assigned by Post, never by the caller
|
||||
std::string summary; // EvDsc -- short title
|
||||
std::string message; // EvMsg -- body
|
||||
int location = 0; // EvLoc -- subject id, or 0
|
||||
EventPos pos; // EvPos -- defaults to InvalidEventPos()
|
||||
std::string image; // EvImg -- event-type identifier, e.g. "EVENT_SHIPS_BUILT"
|
||||
int action = 0; // EvAct
|
||||
int chainId = 0; // EvCID -- constructed 0 and never written by Post
|
||||
|
||||
PlayerEvent() : pos(InvalidEventPos()) {}
|
||||
};
|
||||
|
||||
struct TurnEvents {
|
||||
int turn = 0;
|
||||
std::vector<PlayerEvent> events;
|
||||
};
|
||||
|
||||
class EventStorage {
|
||||
public:
|
||||
// Buckets older than `turn - kPruneWindowTurns` are candidates for pruning. A code
|
||||
// constant in the original, not a config key. CONFIDENCE: high.
|
||||
static constexpr int kPruneWindowTurns = 50;
|
||||
|
||||
// Post one event and return its id.
|
||||
//
|
||||
// The steps, in the original's order:
|
||||
// 1. build the record from the arguments (defaults from the constructor);
|
||||
// 2. `location` = subject ? subject->id : 0;
|
||||
// 3. `image` = the given name (an empty name is stored as "");
|
||||
// 4. `action` = act, **except** that act == 0 with no subject and no position is
|
||||
// stored as 2 -- an easy rule to miss and one that changes the save bytes;
|
||||
// 5. position: the subject's wins, else the explicit one, else the FLT_MAX sentinel;
|
||||
// 6. PruneOldTurns(turn);
|
||||
// 7. bucket = GetOrCreateTurnBucket(turn);
|
||||
// 8. if an equal event is already in that bucket, return **its** id and post nothing;
|
||||
// 9. append; promote nextId 0 -> 1; id = nextId++; stamp it on the appended record.
|
||||
//
|
||||
// CONFIDENCE: high.
|
||||
int Post(std::string summary, std::string message, const EventSubject* subject,
|
||||
const EventPos* pos, int turn, std::string_view image, int action);
|
||||
|
||||
// Convenience for the common "no place on the map" case (every research event).
|
||||
int PostGlobal(std::string summary, std::string message, int turn, std::string_view image,
|
||||
int action) {
|
||||
return Post(std::move(summary), std::move(message), nullptr, nullptr, turn, image, action);
|
||||
}
|
||||
|
||||
// Returns the **last** bucket whose turn matches, creating one at the back if there is
|
||||
// none. The original's scan has no early exit, so a duplicate turn bucket (which nothing
|
||||
// in the posting path creates, but a loaded save could carry) resolves to the later one.
|
||||
// CONFIDENCE: high.
|
||||
TurnEvents& GetOrCreateTurnBucket(int turn);
|
||||
|
||||
// The duplicate test the original applies before appending: `action`, `location`, all
|
||||
// three position floats, `message` and `image` must be equal. **`summary` is not
|
||||
// compared.** Returns nullptr when there is no match. CONFIDENCE: high.
|
||||
const PlayerEvent* FindDuplicate(const TurnEvents& bucket, const PlayerEvent& candidate) const;
|
||||
|
||||
// Drop buckets older than the window -- faithfully, including the off-by-one.
|
||||
//
|
||||
// The original walks the *leading* run of buckets with `turn < cutoff`, leaves its cursor
|
||||
// on the **last** one of that run, and shifts from there. So it erases n-1 of n: one stale
|
||||
// bucket always survives, a single leading stale bucket is never removed at all, and a
|
||||
// stale bucket that sits after a fresh one is never reached. This is reproduced rather
|
||||
// than corrected -- the surviving bucket is serialized, so "fixing" it diverges.
|
||||
// CONFIDENCE: high.
|
||||
void PruneOldTurns(int turn);
|
||||
|
||||
int nextId() const { return nextId_; }
|
||||
void set_nextId(int v) { nextId_ = v; }
|
||||
const std::vector<TurnEvents>& turns() const { return turns_; }
|
||||
std::vector<TurnEvents>& turns() { return turns_; }
|
||||
|
||||
// Total events across every bucket -- for tests and for the trace describer.
|
||||
std::size_t total_events() const;
|
||||
|
||||
private:
|
||||
std::vector<TurnEvents> turns_;
|
||||
int nextId_ = 0; // EvNxID starts at 0; Post promotes it to 1 on the first post
|
||||
};
|
||||
|
||||
} // namespace sots::events
|
||||
88
src/game/events/research_events.cpp
Normal file
88
src/game/events/research_events.cpp
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
#include "game/events/research_events.h"
|
||||
|
||||
namespace sots::events {
|
||||
|
||||
bool ResearchCompletedOnBudget(float progressRatio) {
|
||||
// The original widens the float ratio and compares it against the double nearest 0.8f.
|
||||
return static_cast<double>(progressRatio) >= kResearchOnBudgetRatio;
|
||||
}
|
||||
|
||||
std::string FormatEventText(std::string_view fmt, std::string_view arg, std::size_t cap) {
|
||||
std::string out;
|
||||
out.reserve(fmt.size() + arg.size());
|
||||
bool substituted = false;
|
||||
for (std::size_t i = 0; i < fmt.size(); ++i) {
|
||||
if (fmt[i] == '%' && i + 1 < fmt.size()) {
|
||||
if (fmt[i + 1] == 's' && !substituted) {
|
||||
out.append(arg.begin(), arg.end());
|
||||
substituted = true;
|
||||
++i;
|
||||
continue;
|
||||
}
|
||||
if (fmt[i + 1] == '%') {
|
||||
out.push_back('%');
|
||||
++i;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push_back(fmt[i]);
|
||||
}
|
||||
if (cap != 0 && out.size() > cap) out.resize(cap);
|
||||
return out;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
int PostPair(EventStorage& log, const EventText& text, const EventTextKeys& keys,
|
||||
std::string_view arg, std::size_t cap, int turn, std::string_view img, int action) {
|
||||
std::string summary = FormatEventText(text(keys.summaryKey), arg, cap);
|
||||
std::string message = FormatEventText(text(keys.messageKey), arg, cap);
|
||||
return log.PostGlobal(std::move(summary), std::move(message), turn, img, action);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int PostResearchOverbudget(EventStorage& log, const EventText& text, std::string_view techName,
|
||||
int turn) {
|
||||
// Formatted into a std::string by the original, so no length cap.
|
||||
return PostPair(log, text, kTextResearchOverbudget, techName, 0, turn, kImgResearchOverbudget,
|
||||
1);
|
||||
}
|
||||
|
||||
int PostResearchCompleted(EventStorage& log, const EventText& text, std::string_view techName,
|
||||
float progressRatio, int turn) {
|
||||
const bool onBudget = ResearchCompletedOnBudget(progressRatio);
|
||||
const EventTextKeys& keys = onBudget ? kTextResearchComplete : kTextResearchUnderbudget;
|
||||
const std::string_view img = onBudget ? kImgResearchComplete : kImgResearchUnderbudget;
|
||||
|
||||
// Only the message goes through the 256-byte buffer; the summary is taken verbatim from
|
||||
// its slot without formatting.
|
||||
std::string summary(text(keys.summaryKey));
|
||||
std::string message = FormatEventText(text(keys.messageKey), techName, kCompletionMessageCap);
|
||||
return log.PostGlobal(std::move(summary), std::move(message), turn, img, 1);
|
||||
}
|
||||
|
||||
int PostTemperance(EventStorage& log, const EventText& text, int turn) {
|
||||
// action 0 with no subject and no position -> EventStorage::Post stores 2.
|
||||
return PostPair(log, text, kTextTemperance, {}, 0, turn, kImgTemperance, 0);
|
||||
}
|
||||
|
||||
int PostTechsUnlocked(EventStorage& log, const EventText& text,
|
||||
const std::vector<std::string>& techNames, std::string_view separator,
|
||||
int turn) {
|
||||
std::string summary(text(kTextTechsUnlocked.summaryKey));
|
||||
std::string message(text(kTextTechsUnlocked.messageKey));
|
||||
for (const std::string& name : techNames) {
|
||||
message.append(separator.begin(), separator.end());
|
||||
message.append(name);
|
||||
}
|
||||
return log.PostGlobal(std::move(summary), std::move(message), turn, kImgTechsUnlocked, 1);
|
||||
}
|
||||
|
||||
int PostNoResearch(EventStorage& log, const EventText& text, int turn) {
|
||||
std::string summary(text(kTextNoResearch.summaryKey));
|
||||
std::string message(text(kTextNoResearch.messageKey));
|
||||
return log.PostGlobal(std::move(summary), std::move(message), turn, kImgNoResearch, 1);
|
||||
}
|
||||
|
||||
} // namespace sots::events
|
||||
133
src/game/events/research_events.h
Normal file
133
src/game/events/research_events.h
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
// The five events the strategic research path posts, in the order a turn produces them.
|
||||
//
|
||||
// Two functions raise all of them. `TechTree::ProcessResearch` posts EVENT_RESEARCH_OVERBUDGET
|
||||
// once per node inside its allocation loop, and EVENT_TECHS_UNLOCKED once after it;
|
||||
// `ServerPlayer::OnTechResearched` (reached through SetResearched, and only when it is not
|
||||
// running silently) posts EVENT_RESEARCH_COMPLETE or _UNDERBUDGET, and EVENT_TEMPERANCE;
|
||||
// `ServerPlayer::ProcessTurn` posts EVENT_NO_RESEARCH at the end of the phase.
|
||||
//
|
||||
// Two kinds of string are involved and they are deliberately kept apart:
|
||||
//
|
||||
// * the **event-type identifier** (`EvImg`) is a stable interface name the UI keys its icon
|
||||
// on. It is a literal in the executable and is reproduced here.
|
||||
// * the **displayed text** (`EvDsc`, `EvMsg`) is localized content that lives in the
|
||||
// shipped string table. Only the *keys* are here; the text is looked up at run time
|
||||
// through the caller's table, exactly as the game does. Nothing in this header carries
|
||||
// shipped prose.
|
||||
//
|
||||
// CONFIDENCE: high. See sots-re findings/subsystems/events.md.
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
#include "game/events/event_log.h"
|
||||
|
||||
namespace sots::events {
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Event-type identifiers (EvImg)
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
inline constexpr std::string_view kImgResearchOverbudget = "EVENT_RESEARCH_OVERBUDGET";
|
||||
inline constexpr std::string_view kImgResearchComplete = "EVENT_RESEARCH_COMPLETE";
|
||||
inline constexpr std::string_view kImgResearchUnderbudget = "EVENT_RESEARCH_UNDERBUDGET";
|
||||
inline constexpr std::string_view kImgTemperance = "EVENT_TEMPERANCE";
|
||||
inline constexpr std::string_view kImgTechsUnlocked = "EVENT_TECHS_UNLOCKED";
|
||||
inline constexpr std::string_view kImgNoResearch = "EVENT_NO_RESEARCH";
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// String-table keys for the displayed text
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
// A summary/message key pair. `summaryKey` yields EvDsc, `messageKey` yields EvMsg; the
|
||||
// message format takes at most one `%s` substitution (the tech name), and some summaries
|
||||
// take none.
|
||||
struct EventTextKeys {
|
||||
std::string_view summaryKey;
|
||||
std::string_view messageKey;
|
||||
};
|
||||
|
||||
inline constexpr EventTextKeys kTextResearchOverbudget{"EVENTSUM_RESEARCH_OVERBUDGET",
|
||||
"EVENTMSG_RESEARCH_OVERBUDGET"};
|
||||
inline constexpr EventTextKeys kTextResearchComplete{"EVENTSUM_RESEARCH_COMPLETE",
|
||||
"EVENTMSG_RESEARCH_COMPLETE"};
|
||||
inline constexpr EventTextKeys kTextResearchUnderbudget{"EVENTSUM_RESEARCH_UNDERBUDGET",
|
||||
"EVENTMSG_RESEARCH_UNDERBUDGET"};
|
||||
// Note the shipped misspelling of "temperance" in the key.
|
||||
inline constexpr EventTextKeys kTextTemperance{"EVENTSUM_ADDICTION_TEMPERENCE",
|
||||
"EVENTMSG_ADDICTION_TEMPERENCE"};
|
||||
inline constexpr EventTextKeys kTextTechsUnlocked{"EVENTSUM_UNLOCKEDTECHS",
|
||||
"EVENTMSG_UNLOCKEDTECHS"};
|
||||
inline constexpr EventTextKeys kTextNoResearch{"EVENTSUM_NO_RESEARCH", "EVENTMSG_NO_RESEARCH"};
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Rules
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
// The completion event splits on progress/cost against 0.8 -- stored in the image as the
|
||||
// double nearest to the float 0.8f, so the comparison is against 0.800000011920929 and not
|
||||
// against the decimal 0.8. `ratio >= threshold` selects COMPLETE; below it selects
|
||||
// UNDERBUDGET, which despite its name is the *cheap*, early completion. CONFIDENCE: high.
|
||||
inline constexpr double kResearchOnBudgetRatio = 0.800000011920929;
|
||||
|
||||
bool ResearchCompletedOnBudget(float progressRatio);
|
||||
|
||||
// The completion message is built by the original with `_snprintf` into a 256-byte stack
|
||||
// buffer and then measured with `strlen`. On overflow MSVC's `_snprintf` neither terminates
|
||||
// the buffer nor bounds the following scan, so the original's behaviour past 255 characters
|
||||
// is undefined; we cap deliberately and say so rather than pretending it is defined.
|
||||
// The two events posted from ProcessResearch (over-budget, unlocked techs) format straight
|
||||
// into a std::string and are NOT capped.
|
||||
inline constexpr std::size_t kCompletionMessageCap = 255;
|
||||
|
||||
// Substitute a single `%s` in `fmt` with `arg`. Only the first `%s` is replaced; `%%` is
|
||||
// left alone; a format with no `%s` comes back unchanged (which is what the over-budget
|
||||
// *summary* and both no-research strings do). `cap` of 0 means no limit.
|
||||
std::string FormatEventText(std::string_view fmt, std::string_view arg, std::size_t cap = 0);
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Posting helpers
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
// Anything that can turn a string-table key into text. Returning an empty view for an unknown
|
||||
// key matches the game, whose slots start out pointing at "".
|
||||
using TextLookup = std::string_view (*)(void* ctx, std::string_view key);
|
||||
|
||||
struct EventText {
|
||||
TextLookup lookup = nullptr;
|
||||
void* ctx = nullptr;
|
||||
|
||||
std::string_view operator()(std::string_view key) const {
|
||||
return lookup ? lookup(ctx, key) : std::string_view{};
|
||||
}
|
||||
};
|
||||
|
||||
// `EvAct = 1`, no subject, no position.
|
||||
int PostResearchOverbudget(EventStorage& log, const EventText& text, std::string_view techName,
|
||||
int turn);
|
||||
|
||||
// Picks COMPLETE or UNDERBUDGET from `progressRatio`; `EvAct = 1`, message capped at 255.
|
||||
int PostResearchCompleted(EventStorage& log, const EventText& text, std::string_view techName,
|
||||
float progressRatio, int turn);
|
||||
|
||||
// `EvAct` is pushed as **0** with no subject and no position, so the stored action is 2.
|
||||
// Reproducing that is the whole point of routing this through EventStorage::Post.
|
||||
int PostTemperance(EventStorage& log, const EventText& text, int turn);
|
||||
|
||||
// The message is the unlocked-techs preamble followed by `separator + name` per tech, in the
|
||||
// order the caller supplies -- note the separator comes BEFORE each name, so the preamble is
|
||||
// immediately followed by one. The original appends a single "\n" (the one-byte constant it
|
||||
// passes to std::string::append), which is `kTechsUnlockedSeparator`. `EvAct = 1`.
|
||||
inline constexpr std::string_view kTechsUnlockedSeparator = "\n";
|
||||
|
||||
int PostTechsUnlocked(EventStorage& log, const EventText& text,
|
||||
const std::vector<std::string>& techNames, std::string_view separator,
|
||||
int turn);
|
||||
|
||||
// `EvAct = 1`; both strings come from the key pair unformatted.
|
||||
int PostNoResearch(EventStorage& log, const EventText& text, int turn);
|
||||
|
||||
} // namespace sots::events
|
||||
|
|
@ -31,15 +31,15 @@ constexpr std::size_t kRngSize = A::RNG_size; // 0x9cc
|
|||
constexpr std::size_t kTreeHeadSize = A::TechTree_off_Nodes + 0xc; // owner + the node vector
|
||||
constexpr std::size_t kMaxNodes = 8192; // loop guard for a garbage vector header
|
||||
|
||||
// Not in the generated header (it is recovered from the save schema, not from an instruction):
|
||||
// ServerPlayer+0x29c is an inline Game::EventStorage, 0x1c bytes, holding a
|
||||
// vector<TurnEvents> at +4 and the next-event id EvNxID at +0x14. Posting an event bumps
|
||||
// EvNxID and grows the turn's list -- the write B3's compare could not see.
|
||||
// Source: sots-re/findings/objects/struct-recovery.md, ServerPlayer table row 0x29c.
|
||||
constexpr std::size_t kPlayerEventsOff = 0x29c;
|
||||
constexpr std::size_t kEventStorageSize = 0x1c;
|
||||
constexpr std::size_t kEventsVecOff = 0x04;
|
||||
constexpr std::size_t kEventsNextIdOff = 0x14;
|
||||
// The owner's inline Game::EventStorage: a vector<TurnEvents> at +4 and the next-event id
|
||||
// EvNxID at +0x14. Posting an event bumps EvNxID and grows the turn's list -- the write B3's
|
||||
// compare could not see. These four were promoted out of this file and into the generated
|
||||
// header once lane E read them off the instruction stream (ServerPlayer::GetEventStorage is
|
||||
// literally `lea eax,[ecx+0x29c]; ret`, and PostEvent's counter update names +0x14).
|
||||
constexpr std::size_t kPlayerEventsOff = A::ServerPlayer_off_Events; // 0x29c
|
||||
constexpr std::size_t kEventStorageSize = A::EventStorage_sizeof; // 0x1c
|
||||
constexpr std::size_t kEventsVecOff = A::EventStorage_off_Events; // 0x04
|
||||
constexpr std::size_t kEventsNextIdOff = A::EventStorage_off_EvNxID; // 0x14
|
||||
// Whole-object guard spans. ServerPlayer is 0x3e0 and the TechTree header we care about ends
|
||||
// at the order counter (+0x20). Source: findings/control-flow/turn-spine.md object table.
|
||||
constexpr std::size_t kPlayerSize = 0x3e0;
|
||||
|
|
|
|||
7
tests/game_events/CMakeLists.txt
Normal file
7
tests/game_events/CMakeLists.txt
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# game/events tests: the posting rules read out of the instruction stream, plus a replay of
|
||||
# the event list a real save holds. The canonical runner is build_and_run.sh (plain g++).
|
||||
add_executable(game_events_test test_events.cpp)
|
||||
target_link_libraries(game_events_test PRIVATE sots_game_events)
|
||||
target_include_directories(game_events_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} ${CMAKE_CURRENT_SOURCE_DIR}/../game_sim ${CMAKE_CURRENT_SOURCE_DIR}/../../src)
|
||||
target_compile_options(game_events_test PRIVATE -Wall -Wextra -pedantic)
|
||||
add_test(NAME game_events COMMAND game_events_test)
|
||||
25
tests/game_events/build_and_run.sh
Executable file
25
tests/game_events/build_and_run.sh
Executable file
|
|
@ -0,0 +1,25 @@
|
|||
#!/usr/bin/env bash
|
||||
# Build and run the game/events unit tests with plain g++ (no CMake needed).
|
||||
# tests/game_events/build_and_run.sh
|
||||
# BUILD_DIR=/some/dir tests/game_events/build_and_run.sh
|
||||
set -euo pipefail
|
||||
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
root="$(cd "$here/../.." && pwd)"
|
||||
build="${BUILD_DIR:-$(mktemp -d "${TMPDIR:-/tmp}/sots-game-events.XXXXXX")}"
|
||||
mkdir -p "$build"
|
||||
|
||||
CXX="${CXX:-g++}"
|
||||
CXXFLAGS="${CXXFLAGS:--std=c++17 -O1 -g -Wall -Wextra -Werror -pedantic}"
|
||||
srcs=("$root"/src/game/events/event_log.cpp "$root"/src/game/events/research_events.cpp)
|
||||
|
||||
objs=()
|
||||
for s in "${srcs[@]}"; do
|
||||
o="$build/$(basename "${s%.cpp}").o"
|
||||
$CXX $CXXFLAGS -I"$root/src" -c "$s" -o "$o"
|
||||
objs+=("$o")
|
||||
done
|
||||
|
||||
exe="$build/test_events"
|
||||
$CXX $CXXFLAGS -I"$root/src" -I"$here" -I"$root/tests/game_sim" "$here/test_events.cpp" "${objs[@]}" -o "$exe"
|
||||
if "$exe"; then echo "game_events: all tests passed (build dir $build)"; else echo "game_events: FAILURES (build dir $build)"; exit 1; fi
|
||||
344
tests/game_events/test_events.cpp
Normal file
344
tests/game_events/test_events.cpp
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
// game/events unit tests.
|
||||
//
|
||||
// The cases are the rules read out of the instruction stream, plus one replay of the event
|
||||
// list a real save actually contains (turn3-state.sav, player 1: two identical ship-built
|
||||
// events in different turn buckets followed by the research over-budget event, next id 4).
|
||||
// No shipped prose appears here: the text pipeline is exercised with placeholder formats and
|
||||
// the real string-table *keys*, which is what the engine carries.
|
||||
#include "game/events/event_log.h"
|
||||
#include "game/events/research_events.h"
|
||||
|
||||
#include <cfloat>
|
||||
#include <cmath>
|
||||
#include <map>
|
||||
#include <string>
|
||||
|
||||
#include "check.h" // shared with tests/game_sim
|
||||
|
||||
using namespace sots::events;
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// A stub string table: key -> format.
|
||||
// ---------------------------------------------------------------------------------------
|
||||
static std::map<std::string, std::string>& table() {
|
||||
static std::map<std::string, std::string> t;
|
||||
return t;
|
||||
}
|
||||
static std::string_view lookup(void*, std::string_view key) {
|
||||
auto it = table().find(std::string(key));
|
||||
return it == table().end() ? std::string_view{} : std::string_view(it->second);
|
||||
}
|
||||
static EventText text_source() { return EventText{&lookup, nullptr}; }
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
static void test_ids_and_next_id() {
|
||||
EventStorage log;
|
||||
CHECK_EQ(log.nextId(), 0); // EvNxID starts at 0, not 1
|
||||
CHECK_EQ(log.PostGlobal("a", "m1", 5, "IMG", 1), 1);
|
||||
CHECK_EQ(log.nextId(), 2);
|
||||
CHECK_EQ(log.PostGlobal("a", "m2", 5, "IMG", 1), 2);
|
||||
CHECK_EQ(log.PostGlobal("a", "m3", 5, "IMG", 1), 3);
|
||||
CHECK_EQ(log.nextId(), 4);
|
||||
CHECK_EQ(log.turns().size(), 1u);
|
||||
CHECK_EQ(log.total_events(), 3u);
|
||||
CHECK_EQ(log.turns()[0].turn, 5);
|
||||
CHECK_EQ(log.turns()[0].events[0].id, 1);
|
||||
CHECK_EQ(log.turns()[0].events[2].id, 3);
|
||||
}
|
||||
|
||||
static void test_default_record() {
|
||||
EventStorage log;
|
||||
log.PostGlobal("sum", "msg", 3, "EVENT_X", 7);
|
||||
const PlayerEvent& e = log.turns()[0].events[0];
|
||||
CHECK(e.summary == "sum");
|
||||
CHECK(e.message == "msg");
|
||||
CHECK(e.image == "EVENT_X");
|
||||
CHECK_EQ(e.location, 0);
|
||||
CHECK_EQ(e.action, 7);
|
||||
CHECK_EQ(e.chainId, 0); // EvCID is constructed 0 and Post never writes it
|
||||
// The sentinel is FLT_MAX, not infinity -- byte-confirmed in the save.
|
||||
CHECK(e.pos.x == FLT_MAX && e.pos.y == FLT_MAX && e.pos.z == FLT_MAX);
|
||||
CHECK(!std::isinf(e.pos.x));
|
||||
}
|
||||
|
||||
static void test_action_zero_becomes_two() {
|
||||
EventStorage log;
|
||||
// act 0, no subject, no position -> stored as 2 (this is what EVENT_TEMPERANCE hits).
|
||||
log.PostGlobal("s", "m", 1, "A", 0);
|
||||
CHECK_EQ(log.turns()[0].events[0].action, 2);
|
||||
|
||||
// act 0 WITH a position -> stays 0. The rule needs all three conditions.
|
||||
EventPos p{1.f, 2.f, 3.f};
|
||||
log.Post("s", "m2", nullptr, &p, 1, "A", 0);
|
||||
CHECK_EQ(log.turns()[0].events[1].action, 0);
|
||||
|
||||
// act 0 with a subject -> stays 0.
|
||||
EventSubject sub{42, EventPos{4.f, 5.f, 6.f}};
|
||||
log.Post("s", "m3", &sub, nullptr, 1, "A", 0);
|
||||
CHECK_EQ(log.turns()[0].events[2].action, 0);
|
||||
|
||||
// a non-zero act is never rewritten.
|
||||
log.PostGlobal("s", "m4", 1, "A", 5);
|
||||
CHECK_EQ(log.turns()[0].events[3].action, 5);
|
||||
}
|
||||
|
||||
static void test_position_precedence() {
|
||||
EventStorage log;
|
||||
EventSubject sub{288, EventPos{-11.92860221862793f, 4.719004154205322f, 2.3178513050079346f}};
|
||||
EventPos other{9.f, 9.f, 9.f};
|
||||
// The subject wins over an explicit position.
|
||||
log.Post("s", "m", &sub, &other, 2, "EVENT_SHIPS_BUILT", 0);
|
||||
const PlayerEvent& e = log.turns()[0].events[0];
|
||||
CHECK_EQ(e.location, 288);
|
||||
CHECK(e.pos.x == -11.92860221862793f);
|
||||
CHECK(e.pos.z == 2.3178513050079346f);
|
||||
|
||||
// With no subject, the explicit position is used.
|
||||
log.Post("s", "m2", nullptr, &other, 2, "X", 1);
|
||||
CHECK(log.turns()[0].events[1].pos.y == 9.f);
|
||||
CHECK_EQ(log.turns()[0].events[1].location, 0);
|
||||
}
|
||||
|
||||
static void test_dedup_rules() {
|
||||
EventStorage log;
|
||||
CHECK_EQ(log.PostGlobal("summary A", "same message", 4, "IMG", 1), 1);
|
||||
|
||||
// Same message/image/action/location/position, DIFFERENT summary -> still a duplicate:
|
||||
// the original's comparator does not look at EvDsc.
|
||||
CHECK_EQ(log.PostGlobal("summary B", "same message", 4, "IMG", 1), 1);
|
||||
CHECK_EQ(log.total_events(), 1u);
|
||||
CHECK_EQ(log.nextId(), 2); // a duplicate does not burn an id
|
||||
|
||||
// Any of the compared fields differing makes it a new event.
|
||||
CHECK_EQ(log.PostGlobal("summary A", "other message", 4, "IMG", 1), 2);
|
||||
CHECK_EQ(log.PostGlobal("summary A", "same message", 4, "OTHER", 1), 3);
|
||||
CHECK_EQ(log.PostGlobal("summary A", "same message", 4, "IMG", 2), 4);
|
||||
EventSubject sub{7, EventPos{0.f, 0.f, 0.f}};
|
||||
CHECK_EQ(log.Post("summary A", "same message", &sub, nullptr, 4, "IMG", 1), 5);
|
||||
CHECK_EQ(log.total_events(), 5u);
|
||||
}
|
||||
|
||||
static void test_dedup_is_per_turn_bucket() {
|
||||
// The real save carries the same EVENT_SHIPS_BUILT record in the turn-2 and turn-3
|
||||
// buckets with ids 1 and 2. Dedup must not collapse them.
|
||||
EventStorage log;
|
||||
EventSubject sub{288, EventPos{-11.92860221862793f, 4.719004154205322f, 2.3178513050079346f}};
|
||||
CHECK_EQ(log.Post("Ships Built", "one ship", &sub, nullptr, 2, "EVENT_SHIPS_BUILT", 0), 1);
|
||||
CHECK_EQ(log.Post("Ships Built", "one ship", &sub, nullptr, 3, "EVENT_SHIPS_BUILT", 0), 2);
|
||||
CHECK_EQ(log.turns().size(), 2u);
|
||||
CHECK_EQ(log.total_events(), 2u);
|
||||
}
|
||||
|
||||
static void test_bucket_lookup_takes_the_last_match() {
|
||||
EventStorage log;
|
||||
// A loaded save could hold two buckets for the same turn; the original's scan has no
|
||||
// early exit, so the later one is the one that gets appended to.
|
||||
log.turns().push_back(TurnEvents{});
|
||||
log.turns().back().turn = 9;
|
||||
log.turns().push_back(TurnEvents{});
|
||||
log.turns().back().turn = 9;
|
||||
log.PostGlobal("s", "m", 9, "IMG", 1);
|
||||
CHECK_EQ(log.turns()[0].events.size(), 0u);
|
||||
CHECK_EQ(log.turns()[1].events.size(), 1u);
|
||||
}
|
||||
|
||||
static void test_prune_window_and_off_by_one() {
|
||||
const int W = EventStorage::kPruneWindowTurns;
|
||||
CHECK_EQ(W, 50);
|
||||
|
||||
auto make = [](std::initializer_list<int> turns) {
|
||||
EventStorage s;
|
||||
for (int t : turns) {
|
||||
s.turns().push_back(TurnEvents{});
|
||||
s.turns().back().turn = t;
|
||||
}
|
||||
return s;
|
||||
};
|
||||
|
||||
// Nothing stale: untouched.
|
||||
{
|
||||
EventStorage s = make({100, 101});
|
||||
s.PruneOldTurns(120);
|
||||
CHECK_EQ(s.turns().size(), 2u);
|
||||
}
|
||||
// A single leading stale bucket is NEVER removed (the original returns early).
|
||||
{
|
||||
EventStorage s = make({10, 100});
|
||||
s.PruneOldTurns(120); // cutoff 70; bucket 10 is stale
|
||||
CHECK_EQ(s.turns().size(), 2u);
|
||||
CHECK_EQ(s.turns()[0].turn, 10);
|
||||
}
|
||||
// Two leading stale buckets: exactly one is erased -- the later stale one survives.
|
||||
{
|
||||
EventStorage s = make({10, 11, 100});
|
||||
s.PruneOldTurns(120);
|
||||
CHECK_EQ(s.turns().size(), 2u);
|
||||
CHECK_EQ(s.turns()[0].turn, 11);
|
||||
CHECK_EQ(s.turns()[1].turn, 100);
|
||||
}
|
||||
// Three leading stale: two erased, one survives.
|
||||
{
|
||||
EventStorage s = make({10, 11, 12, 100});
|
||||
s.PruneOldTurns(120);
|
||||
CHECK_EQ(s.turns().size(), 2u);
|
||||
CHECK_EQ(s.turns()[0].turn, 12);
|
||||
}
|
||||
// A stale bucket AFTER a fresh one is never reached.
|
||||
{
|
||||
EventStorage s = make({100, 10, 11});
|
||||
s.PruneOldTurns(120);
|
||||
CHECK_EQ(s.turns().size(), 3u);
|
||||
}
|
||||
// The cutoff is strict: turn == cutoff is kept.
|
||||
{
|
||||
EventStorage s = make({70, 71, 100});
|
||||
s.PruneOldTurns(120); // cutoff exactly 70
|
||||
CHECK_EQ(s.turns().size(), 3u);
|
||||
}
|
||||
// Post prunes before it appends.
|
||||
{
|
||||
EventStorage s = make({1, 2, 3});
|
||||
s.PostGlobal("a", "b", 120, "IMG", 1);
|
||||
CHECK_EQ(s.turns().size(), 2u); // buckets 1 and 2 gone, 3 survives, 120 added
|
||||
CHECK_EQ(s.turns()[0].turn, 3);
|
||||
CHECK_EQ(s.turns()[1].turn, 120);
|
||||
}
|
||||
}
|
||||
|
||||
static void test_on_budget_threshold() {
|
||||
// The comparison is against the double nearest 0.8f, so 0.8f itself is "on budget".
|
||||
CHECK(ResearchCompletedOnBudget(0.8f));
|
||||
CHECK(ResearchCompletedOnBudget(1.0f));
|
||||
CHECK(ResearchCompletedOnBudget(std::nextafter(0.8f, 1.0f)));
|
||||
CHECK(!ResearchCompletedOnBudget(std::nextafter(0.8f, 0.0f)));
|
||||
CHECK(!ResearchCompletedOnBudget(0.79f));
|
||||
CHECK(!ResearchCompletedOnBudget(0.0f));
|
||||
// The decimal 0.8 is BELOW (double)0.8f, so a naive `ratio >= 0.8` would flip this case.
|
||||
CHECK(kResearchOnBudgetRatio > 0.8);
|
||||
}
|
||||
|
||||
static void test_format_event_text() {
|
||||
CHECK(FormatEventText("A %s B", "X") == "A X B");
|
||||
CHECK(FormatEventText("no substitution", "X") == "no substitution");
|
||||
CHECK(FormatEventText("%s", "Waldo Units") == "Waldo Units");
|
||||
CHECK(FormatEventText("%s and %s", "X") == "X and %s"); // only the first
|
||||
CHECK(FormatEventText("100%% sure", "X") == "100% sure");
|
||||
CHECK(FormatEventText("abcdef", "X", 3) == "abc");
|
||||
CHECK(FormatEventText("ab%sef", "CD", 0) == "abCDef");
|
||||
}
|
||||
|
||||
static void test_research_event_posts() {
|
||||
table().clear();
|
||||
table()["EVENTSUM_RESEARCH_OVERBUDGET"] = "SUM-OB";
|
||||
table()["EVENTMSG_RESEARCH_OVERBUDGET"] = "MSG-OB %s .";
|
||||
table()["EVENTSUM_RESEARCH_COMPLETE"] = "SUM-C";
|
||||
table()["EVENTMSG_RESEARCH_COMPLETE"] = "MSG-C %s";
|
||||
table()["EVENTSUM_RESEARCH_UNDERBUDGET"] = "SUM-UB";
|
||||
table()["EVENTMSG_RESEARCH_UNDERBUDGET"] = "MSG-UB %s";
|
||||
table()["EVENTSUM_ADDICTION_TEMPERENCE"] = "SUM-T";
|
||||
table()["EVENTMSG_ADDICTION_TEMPERENCE"] = "MSG-T";
|
||||
table()["EVENTSUM_UNLOCKEDTECHS"] = "SUM-U";
|
||||
table()["EVENTMSG_UNLOCKEDTECHS"] = "available:";
|
||||
table()["EVENTSUM_NO_RESEARCH"] = "SUM-N";
|
||||
table()["EVENTMSG_NO_RESEARCH"] = "MSG-N";
|
||||
const EventText t = text_source();
|
||||
|
||||
EventStorage log;
|
||||
CHECK_EQ(PostResearchOverbudget(log, t, "Waldo Units", 3), 1);
|
||||
{
|
||||
const PlayerEvent& e = log.turns()[0].events.back();
|
||||
CHECK(e.summary == "SUM-OB");
|
||||
CHECK(e.message == "MSG-OB Waldo Units .");
|
||||
CHECK(e.image == kImgResearchOverbudget);
|
||||
CHECK_EQ(e.action, 1);
|
||||
CHECK_EQ(e.location, 0);
|
||||
CHECK(e.pos.x == FLT_MAX);
|
||||
}
|
||||
|
||||
CHECK_EQ(PostResearchCompleted(log, t, "Waldo Units", 1.0f, 3), 2);
|
||||
CHECK(log.turns()[0].events.back().image == kImgResearchComplete);
|
||||
CHECK(log.turns()[0].events.back().summary == "SUM-C");
|
||||
|
||||
CHECK_EQ(PostResearchCompleted(log, t, "Other Tech", 0.5f, 3), 3);
|
||||
CHECK(log.turns()[0].events.back().image == kImgResearchUnderbudget);
|
||||
CHECK(log.turns()[0].events.back().message == "MSG-UB Other Tech");
|
||||
|
||||
// Temperance pushes act 0 with no subject and no position -> stored action 2.
|
||||
CHECK_EQ(PostTemperance(log, t, 3), 4);
|
||||
CHECK_EQ(log.turns()[0].events.back().action, 2);
|
||||
CHECK(log.turns()[0].events.back().image == kImgTemperance);
|
||||
|
||||
CHECK_EQ(PostTechsUnlocked(log, t, {"A", "B"}, kTechsUnlockedSeparator, 3), 5);
|
||||
CHECK(log.turns()[0].events.back().message == "available:\nA\nB");
|
||||
CHECK_EQ(log.turns()[0].events.back().action, 1);
|
||||
|
||||
CHECK_EQ(PostNoResearch(log, t, 3), 6);
|
||||
CHECK(log.turns()[0].events.back().image == kImgNoResearch);
|
||||
CHECK(log.turns()[0].events.back().summary == "SUM-N");
|
||||
CHECK_EQ(log.turns()[0].events.back().action, 1);
|
||||
}
|
||||
|
||||
static void test_completion_message_is_capped() {
|
||||
table().clear();
|
||||
table()["EVENTSUM_RESEARCH_COMPLETE"] = "S";
|
||||
table()["EVENTMSG_RESEARCH_COMPLETE"] = std::string("%s");
|
||||
EventStorage log;
|
||||
PostResearchCompleted(log, text_source(), std::string(400, 'x'), 1.0f, 1);
|
||||
CHECK_EQ(log.turns()[0].events.back().message.size(), kCompletionMessageCap);
|
||||
|
||||
// The over-budget message goes into a std::string in the original, so it is NOT capped.
|
||||
table()["EVENTSUM_RESEARCH_OVERBUDGET"] = "S";
|
||||
table()["EVENTMSG_RESEARCH_OVERBUDGET"] = "%s";
|
||||
EventStorage log2;
|
||||
PostResearchOverbudget(log2, text_source(), std::string(400, 'y'), 1);
|
||||
CHECK_EQ(log2.turns()[0].events.back().message.size(), 400u);
|
||||
}
|
||||
|
||||
// Replay of the list verify/results/saves/turn3-state.sav actually holds for player 1.
|
||||
static void test_real_save_shape() {
|
||||
table().clear();
|
||||
table()["EVENTSUM_RESEARCH_OVERBUDGET"] = "sum";
|
||||
table()["EVENTMSG_RESEARCH_OVERBUDGET"] = "msg %s";
|
||||
|
||||
EventStorage log;
|
||||
EventSubject kedolarra{288,
|
||||
EventPos{-11.92860221862793f, 4.719004154205322f, 2.3178513050079346f}};
|
||||
CHECK_EQ(log.Post("built", "one ship", &kedolarra, nullptr, 2, "EVENT_SHIPS_BUILT", 0), 1);
|
||||
CHECK_EQ(log.Post("built", "one ship", &kedolarra, nullptr, 3, "EVENT_SHIPS_BUILT", 0), 2);
|
||||
CHECK_EQ(PostResearchOverbudget(log, text_source(), "Waldo Units", 3), 3);
|
||||
|
||||
// EvNxID 4, two buckets (turn 2 with one event, turn 3 with two), ids 1/2/3.
|
||||
CHECK_EQ(log.nextId(), 4);
|
||||
CHECK_EQ(log.turns().size(), 2u);
|
||||
CHECK_EQ(log.turns()[0].turn, 2);
|
||||
CHECK_EQ(log.turns()[0].events.size(), 1u);
|
||||
CHECK_EQ(log.turns()[1].turn, 3);
|
||||
CHECK_EQ(log.turns()[1].events.size(), 2u);
|
||||
CHECK_EQ(log.turns()[1].events[1].id, 3);
|
||||
CHECK(log.turns()[1].events[1].image == "EVENT_RESEARCH_OVERBUDGET");
|
||||
CHECK_EQ(log.turns()[1].events[1].action, 1);
|
||||
CHECK_EQ(log.turns()[1].events[1].location, 0);
|
||||
CHECK(log.turns()[1].events[1].pos.x == FLT_MAX);
|
||||
// The ship-built events carry the system id and its position, and action 0 stays 0
|
||||
// because a subject is present.
|
||||
CHECK_EQ(log.turns()[0].events[0].location, 288);
|
||||
CHECK_EQ(log.turns()[0].events[0].action, 0);
|
||||
}
|
||||
|
||||
int main() {
|
||||
test_ids_and_next_id();
|
||||
test_default_record();
|
||||
test_action_zero_becomes_two();
|
||||
test_position_precedence();
|
||||
test_dedup_rules();
|
||||
test_dedup_is_per_turn_bucket();
|
||||
test_bucket_lookup_takes_the_last_match();
|
||||
test_prune_window_and_off_by_one();
|
||||
test_on_budget_threshold();
|
||||
test_format_event_text();
|
||||
test_research_event_posts();
|
||||
test_completion_message_is_capped();
|
||||
test_real_save_shape();
|
||||
return simtest::finish("game_events");
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue