TechTree::ProcessResearch's events region now compares a modelled value instead of reporting a known defect. ours posts the pass's events into its own sots::events::EventStorage, seeded from a scan of the owner's list taken BEFORE the original runs, and writes only the counts into the region's scratch copy. The game's PostEvent is never called and no live byte moves; replace mode still posts nothing, because a bumped EvNxID with no record behind it would corrupt the oracle's save. - game/events: PostResearchPassEvents (the decision half, pure) + KeylessEventText - shim/hooks/event_inputs (new lib shim_events, host-tested): the live<->model adapter, carrying game pointers as explicit uint32 so a 64-bit host build cannot alias them - research hook: the wiring, a new observed_techs region for ServerPlayer+0x274, and turn / events_next_id_in / events_dedup_risk in the args so the count model's own assumption is measured rather than assumed - EVENT_TECHS_UNLOCKED is NOT posted: its trigger is pinned but needs SetResearched's unlock cascade, which ours does not run. The driver takes the unlock list as an input and is handed 'no list', so a missing input cannot look like a modelled negative. Predicted residual: next_id short by exactly 1 on a completion call. ctest 33/33 (shim_events_unit is new), clean_room_check OK. The shim TU is syntax-checked only: no MinGW cross toolchain on this box. See docs/P-events-wiring.md for the exact prediction for the next VM run.
505 lines
21 KiB
C++
505 lines
21 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);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------
|
|
// 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<ResearchPassOutcome> 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<ResearchPassOutcome> 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<ResearchPassOutcome> 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<std::string> 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<std::string> 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<ResearchPassOutcome> 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<ResearchPassOutcome> 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");
|
|
}
|