// 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 #include #include #include #include "check.h" // shared with tests/game_sim using namespace sots::events; // --------------------------------------------------------------------------------------- // A stub string table: key -> format. // --------------------------------------------------------------------------------------- static std::map& table() { static std::map 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 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"); }