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.
128 lines
5.3 KiB
C++
128 lines
5.3 KiB
C++
#include "game/events/research_events.h"
|
|
|
|
namespace sots::events {
|
|
|
|
bool ResearchCompletedOnBudget(float progressRatio) {
|
|
// The original widens the float ratio and compares it against the double nearest 0.8f.
|
|
return static_cast<double>(progressRatio) >= kResearchOnBudgetRatio;
|
|
}
|
|
|
|
std::string FormatEventText(std::string_view fmt, std::string_view arg, std::size_t cap) {
|
|
std::string out;
|
|
out.reserve(fmt.size() + arg.size());
|
|
bool substituted = false;
|
|
for (std::size_t i = 0; i < fmt.size(); ++i) {
|
|
if (fmt[i] == '%' && i + 1 < fmt.size()) {
|
|
if (fmt[i + 1] == 's' && !substituted) {
|
|
out.append(arg.begin(), arg.end());
|
|
substituted = true;
|
|
++i;
|
|
continue;
|
|
}
|
|
if (fmt[i + 1] == '%') {
|
|
out.push_back('%');
|
|
++i;
|
|
continue;
|
|
}
|
|
}
|
|
out.push_back(fmt[i]);
|
|
}
|
|
if (cap != 0 && out.size() > cap) out.resize(cap);
|
|
return out;
|
|
}
|
|
|
|
namespace {
|
|
|
|
int PostPair(EventStorage& log, const EventText& text, const EventTextKeys& keys,
|
|
std::string_view arg, std::size_t cap, int turn, std::string_view img, int action) {
|
|
std::string summary = FormatEventText(text(keys.summaryKey), arg, cap);
|
|
std::string message = FormatEventText(text(keys.messageKey), arg, cap);
|
|
return log.PostGlobal(std::move(summary), std::move(message), turn, img, action);
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int PostResearchOverbudget(EventStorage& log, const EventText& text, std::string_view techName,
|
|
int turn) {
|
|
// Formatted into a std::string by the original, so no length cap.
|
|
return PostPair(log, text, kTextResearchOverbudget, techName, 0, turn, kImgResearchOverbudget,
|
|
1);
|
|
}
|
|
|
|
int PostResearchCompleted(EventStorage& log, const EventText& text, std::string_view techName,
|
|
float progressRatio, int turn) {
|
|
const bool onBudget = ResearchCompletedOnBudget(progressRatio);
|
|
const EventTextKeys& keys = onBudget ? kTextResearchComplete : kTextResearchUnderbudget;
|
|
const std::string_view img = onBudget ? kImgResearchComplete : kImgResearchUnderbudget;
|
|
|
|
// Only the message goes through the 256-byte buffer; the summary is taken verbatim from
|
|
// its slot without formatting.
|
|
std::string summary(text(keys.summaryKey));
|
|
std::string message = FormatEventText(text(keys.messageKey), techName, kCompletionMessageCap);
|
|
return log.PostGlobal(std::move(summary), std::move(message), turn, img, 1);
|
|
}
|
|
|
|
int PostTemperance(EventStorage& log, const EventText& text, int turn) {
|
|
// action 0 with no subject and no position -> EventStorage::Post stores 2.
|
|
return PostPair(log, text, kTextTemperance, {}, 0, turn, kImgTemperance, 0);
|
|
}
|
|
|
|
int PostTechsUnlocked(EventStorage& log, const EventText& text,
|
|
const std::vector<std::string>& techNames, std::string_view separator,
|
|
int turn) {
|
|
std::string summary(text(kTextTechsUnlocked.summaryKey));
|
|
std::string message(text(kTextTechsUnlocked.messageKey));
|
|
for (const std::string& name : techNames) {
|
|
message.append(separator.begin(), separator.end());
|
|
message.append(name);
|
|
}
|
|
return log.PostGlobal(std::move(summary), std::move(message), turn, kImgTechsUnlocked, 1);
|
|
}
|
|
|
|
int PostNoResearch(EventStorage& log, const EventText& text, int turn) {
|
|
std::string summary(text(kTextNoResearch.summaryKey));
|
|
std::string message(text(kTextNoResearch.messageKey));
|
|
return log.PostGlobal(std::move(summary), std::move(message), turn, kImgNoResearch, 1);
|
|
}
|
|
|
|
ResearchPassResult PostResearchPassEvents(EventStorage& log, const EventText& text,
|
|
const std::vector<ResearchPassOutcome>& outcomes,
|
|
const std::vector<std::string>* unlockedTechNames,
|
|
int turn) {
|
|
ResearchPassResult r;
|
|
for (const ResearchPassOutcome& o : outcomes) {
|
|
// The two are the failed and the succeeded side of one roll, so at most one fires.
|
|
if (o.overbudgetEvent) {
|
|
r.ids.push_back(PostResearchOverbudget(log, text, o.techName, turn));
|
|
++r.overbudgetPosts;
|
|
} else if (o.completed) {
|
|
// PostResearchCompleted picks the image from the ratio; feed it a ratio on the
|
|
// correct side of the threshold rather than re-deriving one, so the two callers
|
|
// cannot drift apart. Both branches post exactly one event, so the count is the
|
|
// same either way.
|
|
const float ratio = o.completedEarly ? 0.f : 1.f;
|
|
r.ids.push_back(PostResearchCompleted(log, text, o.techName, ratio, turn));
|
|
++r.completionPosts;
|
|
}
|
|
}
|
|
if (unlockedTechNames && !unlockedTechNames->empty()) {
|
|
r.ids.push_back(
|
|
PostTechsUnlocked(log, text, *unlockedTechNames, kTechsUnlockedSeparator, turn));
|
|
++r.techsUnlockedPosts;
|
|
}
|
|
r.nextId = log.nextId();
|
|
return r;
|
|
}
|
|
|
|
namespace {
|
|
std::string_view KeylessLookup(void*, std::string_view) { return std::string_view("%s"); }
|
|
} // namespace
|
|
|
|
EventText KeylessEventText() {
|
|
EventText t;
|
|
t.lookup = &KeylessLookup;
|
|
t.ctx = nullptr;
|
|
return t;
|
|
}
|
|
|
|
} // namespace sots::events
|