sots-engine/tests/game_events/test_events.cpp
alex a7348be72c 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.
2026-09-08 02:45:57 -04:00

344 lines
14 KiB
C++

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