Files
gogobee/internal/plugin/pete_retreat_test.go
prosolis e98029e6ac Pete can finally report a run that simply fell apart
Pete's entire taxonomy was arrival, companion_hire, death, milestone,
rival_result and zone_first — every one of them a win, a death, or an
introduction. An expedition that ended with the player walking out alive emitted
nothing at all, so the feed showed a realm where adventurers only ever triumph
or die.

That is not a rare gap. Before the §6 slot work, a cleric retreated on 167 of
500 simulated expeditions: a third of that class's runs ended in a way the news
was structurally incapable of reporting. Casters did not read as unlucky in the
feed. They read as absent.

So: a `retreat` bulletin, filed from forceExtractExpeditionForRunLoss — the one
chokepoint every bad ending already passes through. It carries who, where, and
the day they got to before it came apart.

Gated on the reason, not on "the expedition ended":
  - a death already files its own priority dispatch, and must not ALSO be
    reported as a retreat;
  - an idle reap is not a retreat. A player who closed their laptop did not flee
    anything, and Pete announcing by name that they were driven from the field
    would be a lie about a person.
The four reason strings were bare literals at their call sites; they are
constants now, because the gate cannot be allowed to drift from them.

The day count is read BEFORE forcedExtractExpedition, which stamps the row
'abandoned' and takes the live fields with it.

Claude-Session: https://claude.ai/code/session_01B2MwktU4RgfWkar8HM3zZn
2026-07-12 10:31:34 -07:00

163 lines
5.6 KiB
Go

package plugin
import (
"encoding/json"
"testing"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// The retreat bulletin: the news can finally say "it went badly and nobody
// died". Before this, every event type Pete could file was a win, a death, or an
// introduction — so a run that simply fell apart was reported as nothing at all,
// and a class that retreated a third of the time just looked absent from the
// feed.
//
// The reason gate is the whole safety property. A death must not ALSO be filed
// as a retreat (it already files its own priority dispatch), and an idle reap
// must not be filed at all — Pete telling the realm that a named player was
// driven from the field, when in truth they closed their laptop, is a lie about
// a person by name.
func retreatFactFor(t *testing.T, uid id.UserID) map[string]any {
t.Helper()
var payload string
err := db.Get().QueryRow(
`SELECT payload FROM pete_emit_queue WHERE guid LIKE 'retreat:%'`).Scan(&payload)
if err != nil {
t.Fatalf("no retreat fact queued: %v", err)
}
var f map[string]any
if err := json.Unmarshal([]byte(payload), &f); err != nil {
t.Fatal(err)
}
return f
}
func TestRetreatNews_ReasonGate(t *testing.T) {
cases := []struct {
reason string
want int
why string
}{
{lossCombatRetreat, 1, "a solo walker who ran out the clock and withdrew is news"},
{lossCombatFlee, 1, "a party that broke off is news"},
{lossCombatDeath, 0, "a death already files its own priority dispatch — do not double-report it as a retreat"},
{lossIdleTimeout, 0, "an idle reap is not a retreat; the player closed their laptop, they did not flee"},
}
for _, tc := range cases {
t.Run(tc.reason, func(t *testing.T) {
setupEmptyTestDB(t)
enablePeteSeam(t)
uid := id.UserID("@retreat:example.org")
zoneCmdTestCharacter(t, uid, 10)
emitRetreatNews(uid, tc.reason, ZoneID("underforge"), 3)
if got := queuedCount(t, "retreat:%"); got != tc.want {
t.Fatalf("reason %q queued %d retreat facts, want %d — %s",
tc.reason, got, tc.want, tc.why)
}
})
}
}
// The bulletin has to carry enough for Pete to write a sentence: who, where, how
// far they got. A retreat with no day count is just "someone left".
func TestRetreatNews_CarriesTheStory(t *testing.T) {
setupEmptyTestDB(t)
enablePeteSeam(t)
uid := id.UserID("@retreat-story:example.org")
zoneCmdTestCharacter(t, uid, 10)
emitRetreatNews(uid, lossCombatRetreat, ZoneID("underforge"), 3)
f := retreatFactFor(t, uid)
if f["event_type"] != "retreat" {
t.Errorf("event_type = %v, want retreat", f["event_type"])
}
// Bulletin, not priority: a retreat is a bad day, not a funeral.
if f["tier"] != "bulletin" {
t.Errorf("tier = %v, want bulletin", f["tier"])
}
if f["outcome"] != "retreated" {
t.Errorf("outcome = %v, want retreated", f["outcome"])
}
if f["count"] != float64(3) {
t.Errorf("count = %v, want 3 (the day it fell apart)", f["count"])
}
if f["zone"] == nil || f["zone"] == "" {
t.Error("no zone — Pete cannot say where it happened")
}
if f["subject"] == nil || f["subject"] == "" {
t.Error("no subject — Pete cannot say who it happened to")
}
// GUID prefix must equal the event type; Pete's taxonomy keys off it.
if guid, _ := f["guid"].(string); len(guid) < 8 || guid[:8] != "retreat:" {
t.Errorf("guid %q must be prefixed with the event type", guid)
}
}
// The wiring, not just the emitter. Everything above calls emitRetreatNews
// directly, which proves nothing about the game: the fact only ships if the real
// run-loss chokepoint actually calls it, on a real expedition, and reads the day
// count BEFORE forcedExtractExpedition stamps the row 'abandoned' and takes the
// live fields with it. Drive the chokepoint.
func TestRetreatNews_WiredToTheRunLossChokepoint(t *testing.T) {
setupEmptyTestDB(t)
enablePeteSeam(t)
uid := id.UserID("@retreat-wired:example")
campTestCharacter(t, uid, 1)
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1, HarshMod: 1})
if err != nil {
t.Fatal(err)
}
forceExtractExpeditionForRunLoss(uid, lossCombatRetreat)
if got := queuedCount(t, "retreat:%"); got != 1 {
t.Fatalf("the run-loss chokepoint queued %d retreat facts, want 1 — "+
"emitRetreatNews passes its own unit tests but is not actually wired to the game", got)
}
f := retreatFactFor(t, uid)
// The day must survive the extract. Read it after forcedExtractExpedition and
// it is gone — the row is 'abandoned' and the live fields are zeroed.
if f["count"] != float64(exp.CurrentDay) {
t.Errorf("count = %v, want %d — the day count was read after the extract wiped it",
f["count"], exp.CurrentDay)
}
// And the expedition really did end; a bulletin about a run still in progress
// would be worse than no bulletin.
if still, _ := getActiveExpedition(uid); still != nil {
t.Error("expedition still active after a run-loss extract")
}
}
// The opt-out covers the new event type too. A player who asked not to be named
// must not be named when they lose — that is precisely when they'd mind most.
func TestRetreatNews_HonoursOptOut(t *testing.T) {
setupEmptyTestDB(t)
enablePeteSeam(t)
uid := id.UserID("@retreat-shy:example.org")
zoneCmdTestCharacter(t, uid, 10)
setNewsOptout(uid, true)
emitRetreatNews(uid, lossCombatRetreat, ZoneID("underforge"), 2)
f := retreatFactFor(t, uid)
if f["subject"] != anonName {
t.Fatalf("subject = %v, want %q — the opt-out must cover retreats", f["subject"], anonName)
}
actors, _ := f["actors"].([]any)
for _, a := range actors {
if a != anonName {
t.Fatalf("actors leaked %v past the opt-out", a)
}
}
}