// 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); } // --------------------------------------------------------------------------------------- // The pass driver: what ONE TechTree::ProcessResearch call posts. // // The three cases are the three shapes lane R's guarded recapture actually captured // (findings/subsystems/golden-trace-recapture.md ยง2): an over-budget call, a completion call, // and the zero-spend calls in between. // --------------------------------------------------------------------------------------- static void keys_for_pass() { table().clear(); table()["EVENTSUM_RESEARCH_OVERBUDGET"] = "sum-ob"; table()["EVENTMSG_RESEARCH_OVERBUDGET"] = "ob %s"; table()["EVENTSUM_RESEARCH_COMPLETE"] = "sum-c"; table()["EVENTMSG_RESEARCH_COMPLETE"] = "c %s"; table()["EVENTSUM_RESEARCH_UNDERBUDGET"] = "sum-u"; table()["EVENTMSG_RESEARCH_UNDERBUDGET"] = "u %s"; table()["EVENTSUM_UNLOCKEDTECHS"] = "sum-unl"; table()["EVENTMSG_UNLOCKEDTECHS"] = "unl:"; } // recap-b3-compare call 0: alloc {node 144, 2889 points}, the roll failed on the turn the node // crossed its cost. The original took EvNxID 3 -> 4. This is harness-audit row 1. static void test_pass_overbudget_call() { keys_for_pass(); EventStorage log; log.set_nextId(3); log.turns().push_back(TurnEvents{2, {}}); log.turns().push_back(TurnEvents{3, {}}); // the bucket EVENT_SHIPS_BUILT already made std::vector out(1); out[0].techName = "144"; out[0].overbudgetEvent = true; const ResearchPassResult r = PostResearchPassEvents(log, text_source(), out, nullptr, 3); CHECK_EQ(r.overbudgetPosts, 1); CHECK_EQ(r.completionPosts, 0); CHECK_EQ(r.techsUnlockedPosts, 0); CHECK_EQ(r.ids.size(), 1u); CHECK_EQ(r.ids[0], 3); // the id the record gets CHECK_EQ(r.nextId, 4); // <- the field the compare reports CHECK_EQ(log.turns().size(), 2u); // no new bucket: turn 3 already had one CHECK(log.turns()[1].events[0].image == "EVENT_RESEARCH_OVERBUDGET"); CHECK_EQ(log.turns()[1].events[0].action, 1); CHECK(log.turns()[1].events[0].pos.x == FLT_MAX); } // The zero-spend calls (1, 2, 4, 5, 7, 8, 10, 11, 13, 14): nothing happened, nothing posts, // and EvNxID must not move -- including for a player whose list is empty and whose EvNxID is 0. static void test_pass_posts_nothing_when_nothing_happened() { keys_for_pass(); EventStorage log; std::vector out(2); out[0].techName = "90"; out[1].techName = "9"; const ResearchPassResult r = PostResearchPassEvents(log, text_source(), out, nullptr, 3); CHECK_EQ(r.ids.size(), 0u); CHECK_EQ(r.nextId, 0); // EvNxID is promoted to 1 only by an actual post CHECK_EQ(log.turns().size(), 0u); CHECK_EQ(log.total_events(), 0u); } // recap-b3-compare call 3: the completion. The original posted TWO events (EvNxID 5 -> 7): the // completion event, which this driver models, and EVENT_TECHS_UNLOCKED, whose set comes from the // unlock cascade the hook does not run. With no unlock list the driver posts one, and that under- // count is the *predicted* residual -- not a silent miss. static void test_pass_completion_call() { keys_for_pass(); EventStorage log; log.set_nextId(5); log.turns().push_back(TurnEvents{4, {}}); std::vector out(1); out[0].techName = "144"; out[0].completed = true; out[0].completedEarly = false; ResearchPassResult r = PostResearchPassEvents(log, text_source(), out, nullptr, 4); CHECK_EQ(r.completionPosts, 1); CHECK_EQ(r.techsUnlockedPosts, 0); CHECK_EQ(r.nextId, 6); // the original reached 7; the missing one is EVENT_TECHS_UNLOCKED CHECK(log.turns()[0].events[0].image == "EVENT_RESEARCH_COMPLETE"); // Given the unlock list, the same pass reaches 7 -- the model is complete, the input is not. EventStorage log2; log2.set_nextId(5); log2.turns().push_back(TurnEvents{4, {}}); const std::vector unlocked{"91", "92"}; r = PostResearchPassEvents(log2, text_source(), out, &unlocked, 4); CHECK_EQ(r.techsUnlockedPosts, 1); CHECK_EQ(r.nextId, 7); CHECK(log2.turns()[0].events[1].image == "EVENT_TECHS_UNLOCKED"); // An empty (non-null) list is "computed, and empty": still no post, but a different claim. EventStorage log3; log3.set_nextId(5); const std::vector none; r = PostResearchPassEvents(log3, text_source(), out, &none, 4); CHECK_EQ(r.techsUnlockedPosts, 0); CHECK_EQ(r.nextId, 6); } // A cheap completion takes the UNDERBUDGET image -- and posts exactly one event either way, so // the id count does not depend on the split. static void test_pass_early_completion_picks_underbudget() { keys_for_pass(); EventStorage log; std::vector out(1); out[0].techName = "7"; out[0].completed = true; out[0].completedEarly = true; const ResearchPassResult r = PostResearchPassEvents(log, text_source(), out, nullptr, 1); CHECK_EQ(r.completionPosts, 1); CHECK_EQ(r.nextId, 2); CHECK(log.turns()[0].events[0].image == "EVENT_RESEARCH_UNDERBUDGET"); } // Two different techs going over budget in the same turn differ only in EvMsg -- which IS // compared -- so both survive; the same tech twice collapses to one id. This is the property // the shim's keyless lookup has to preserve, so it is pinned through that lookup. static void test_pass_dedup_across_nodes() { EventStorage log; std::vector out(2); out[0].techName = "144"; out[0].overbudgetEvent = true; out[1].techName = "9"; out[1].overbudgetEvent = true; ResearchPassResult r = PostResearchPassEvents(log, KeylessEventText(), out, nullptr, 3); CHECK_EQ(r.ids.size(), 2u); CHECK_EQ(r.ids[0], 1); CHECK_EQ(r.ids[1], 2); CHECK_EQ(r.nextId, 3); CHECK_EQ(log.total_events(), 2u); EventStorage same; out[1].techName = "144"; // the same node twice: the original's messages would be identical r = PostResearchPassEvents(same, KeylessEventText(), out, nullptr, 3); CHECK_EQ(r.ids.size(), 2u); CHECK_EQ(r.ids[0], 1); CHECK_EQ(r.ids[1], 1); // the duplicate returns the existing id CHECK_EQ(r.nextId, 2); // and burns neither an id nor a slot CHECK_EQ(same.total_events(), 1u); } // The keyless lookup must keep an over-budget and a completion for the SAME node apart: they // share a message but differ in EvImg, which the comparator also tests. static void test_keyless_lookup_keeps_images_apart() { EventStorage log; PostResearchOverbudget(log, KeylessEventText(), "144", 3); PostResearchCompleted(log, KeylessEventText(), "144", 1.f, 3); CHECK_EQ(log.total_events(), 2u); CHECK_EQ(log.nextId(), 3); CHECK(log.turns()[0].events[0].message == log.turns()[0].events[1].message); CHECK(log.turns()[0].events[0].image != log.turns()[0].events[1].image); } 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(); test_pass_overbudget_call(); test_pass_posts_nothing_when_nothing_happened(); test_pass_completion_call(); test_pass_early_completion_picks_underbudget(); test_pass_dedup_across_nodes(); test_keyless_lookup_keeps_images_apart(); return simtest::finish("game_events"); }