sots-engine/src/game/events/event_log.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

89 lines
2.9 KiB
C++

#include "game/events/event_log.h"
#include <cfloat>
#include <utility>
namespace sots::events {
EventPos InvalidEventPos() {
EventPos p;
p.x = FLT_MAX;
p.y = FLT_MAX;
p.z = FLT_MAX;
return p;
}
TurnEvents& EventStorage::GetOrCreateTurnBucket(int turn) {
// No early exit: the last match wins, as the original's loop does.
TurnEvents* found = nullptr;
for (TurnEvents& b : turns_) {
if (b.turn == turn) found = &b;
}
if (found) return *found;
turns_.push_back(TurnEvents{});
turns_.back().turn = turn;
return turns_.back();
}
const PlayerEvent* EventStorage::FindDuplicate(const TurnEvents& bucket,
const PlayerEvent& candidate) const {
for (const PlayerEvent& e : bucket.events) {
if (e.action != candidate.action) continue;
if (e.location != candidate.location) continue;
if (e.pos != candidate.pos) continue;
if (e.message != candidate.message) continue;
if (e.image != candidate.image) continue;
// summary is deliberately not compared.
return &e;
}
return nullptr;
}
void EventStorage::PruneOldTurns(int turn) {
const int cutoff = turn - kPruneWindowTurns;
if (turns_.empty()) return;
// Index of the last bucket in the LEADING run of stale buckets; turns_.size() means the
// run is empty (the original leaves its cursor on _Mylast in that case).
std::size_t lastStale = turns_.size();
for (std::size_t i = 0; i < turns_.size(); ++i) {
if (turns_[i].turn >= cutoff) break;
lastStale = i;
}
if (lastStale == turns_.size()) return; // nothing stale at the front
if (lastStale == 0) return; // a single leading stale bucket is kept
turns_.erase(turns_.begin(), turns_.begin() + static_cast<std::ptrdiff_t>(lastStale));
}
int EventStorage::Post(std::string summary, std::string message, const EventSubject* subject,
const EventPos* pos, int turn, std::string_view image, int action) {
PlayerEvent ev;
ev.summary = std::move(summary);
ev.message = std::move(message);
ev.location = subject ? subject->id : 0;
ev.image.assign(image);
ev.action = (action == 0 && subject == nullptr && pos == nullptr) ? 2 : action;
if (subject) {
ev.pos = subject->pos;
} else if (pos) {
ev.pos = *pos;
} // else: the constructor's FLT_MAX sentinel
PruneOldTurns(turn);
TurnEvents& bucket = GetOrCreateTurnBucket(turn);
if (const PlayerEvent* dup = FindDuplicate(bucket, ev)) return dup->id;
bucket.events.push_back(std::move(ev));
if (nextId_ == 0) nextId_ = 1;
const int id = nextId_++;
bucket.events.back().id = id;
return id;
}
std::size_t EventStorage::total_events() const {
std::size_t n = 0;
for (const TurnEvents& b : turns_) n += b.events.size();
return n;
}
} // namespace sots::events