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.
344 lines
14 KiB
C++
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");
|
|
}
|