diff --git a/internal/plugin/combat_party_finish.go b/internal/plugin/combat_party_finish.go index 7c612b7..b242c89 100644 --- a/internal/plugin/combat_party_finish.go +++ b/internal/plugin/combat_party_finish.go @@ -252,9 +252,9 @@ func endRunOnLoss(owner id.UserID, runID string, death bool) { } } _ = abandonZoneRun(owner) - reason := "combat flee" + reason := lossCombatFlee if death { - reason = "combat death" + reason = lossCombatDeath } forceExtractExpeditionForRunLoss(owner, reason) } diff --git a/internal/plugin/dnd_combat.go b/internal/plugin/dnd_combat.go index 2429236..e4a300f 100644 --- a/internal/plugin/dnd_combat.go +++ b/internal/plugin/dnd_combat.go @@ -605,3 +605,51 @@ func emitDeathNews(userID id.UserID, location string) { OccurredAt: ts, }, userID, "") } + +// emitRetreatNews files a BULLETIN when an expedition ends with the player +// walking out alive. +// +// Until this existed the news had no way to say "it went badly but nobody +// died", so it never said it. Pete's whole taxonomy was arrival, companion_hire, +// death, milestone, rival_result and zone_first — every one of them a win, a +// death, or an introduction. A run that simply fell apart emitted nothing, and +// the feed showed a realm where adventurers only ever triumph or die. +// +// That was not a rare gap. Before §6, 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 look unlucky in the +// feed — they looked absent. +// +// Bulletin, not priority: a retreat is a bad day, not a funeral. A death already +// files its own priority dispatch from markAdventureDead, and a wipe that killed +// someone must not ALSO be reported as a retreat — hence the reason gate rather +// than an "expedition ended" catch-all. An idle reap is excluded too: a player +// who closed their laptop did not flee anything, and Pete announcing that they +// were driven from the field would be a lie about a person by name. +func emitRetreatNews(userID id.UserID, reason string, zoneID ZoneID, day int) { + switch reason { + case lossCombatRetreat, lossCombatFlee: + default: + return // a death tells its own story; an idle reap is not a story + } + if !peteclient.Enabled() || !newsEmissionOn() { + return + } + name := charName(userID) + if name == "" { + return + } + zone := zoneOrFallback(zoneID) + ts := nowUnix() + emitFact(peteclient.Fact{ + GUID: fmt.Sprintf("retreat:%s:%d", eventToken(userID, fmt.Sprintf("%d", ts)), ts), + EventType: "retreat", + Tier: "bulletin", + Subject: name, + Zone: zone.Display, + Level: charLevel(userID), + Count: day, // the day they got to before it fell apart + Outcome: "retreated", + OccurredAt: ts, + }, userID, "") +} diff --git a/internal/plugin/dnd_expedition_extract.go b/internal/plugin/dnd_expedition_extract.go index 051cab8..55a101e 100644 --- a/internal/plugin/dnd_expedition_extract.go +++ b/internal/plugin/dnd_expedition_extract.go @@ -95,21 +95,40 @@ func forcedExtractExpedition(expID, reason string) (*Expedition, int, error) { return e, tax, nil } +// The reasons a run can end badly. They were bare strings at four call sites; +// they are constants now because the news seam has to tell them apart — a +// retreat is a story and a death is a different story, and an idle reap is +// neither. +const ( + lossCombatDeath = "combat death" + lossCombatRetreat = "combat retreat" // solo: ran out the phase clock and withdrew + lossCombatFlee = "combat flee" // party: the turn engine broke off + lossIdleTimeout = "run idle-timeout (§4.3 stale-run reap)" +) + // forceExtractExpeditionForRunLoss bridges run-loss call sites (turn-based // elite/boss death or flee, exploration combat death, patrol-interrupt // death) into the forced-extract flow. Those sites already abandon the // zone run, but without flipping the wrapping expedition the ambient // ticker keeps DMing about a dungeon the player walked away from. No-op // when there is no active expedition for this user. +// +// It is also the one chokepoint every bad ending passes through, which makes it +// where the retreat dispatch is filed. Read the expedition BEFORE the extract: +// forcedExtractExpedition stamps it 'abandoned' and zeroes the live fields, so +// afterwards there is no day count left to report. func forceExtractExpeditionForRunLoss(userID id.UserID, reason string) { exp, err := getActiveExpedition(userID) if err != nil || exp == nil { return } + day, zoneID := exp.CurrentDay, exp.ZoneID if _, _, err := forcedExtractExpedition(exp.ID, reason); err != nil { slog.Warn("expedition: force-extract on run loss", "user", userID, "expedition", exp.ID, "reason", reason, "err", err) + return } + emitRetreatNews(userID, reason, zoneID, day) } // finalizeExpeditionOnZoneClear bridges the run-complete seam (boss down, diff --git a/internal/plugin/dnd_zone_cmd.go b/internal/plugin/dnd_zone_cmd.go index bc712d9..9f84e8f 100644 --- a/internal/plugin/dnd_zone_cmd.go +++ b/internal/plugin/dnd_zone_cmd.go @@ -1207,9 +1207,9 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z // seat off HP, which for a solo walker is the same `!TimedOut` rule. closeOutZoneLoss(pres, seated, zone, "zone") if !result.TimedOut { - forceExtractExpeditionForRunLoss(userID, "combat death") + forceExtractExpeditionForRunLoss(userID, lossCombatDeath) } else { - forceExtractExpeditionForRunLoss(userID, "combat retreat") + forceExtractExpeditionForRunLoss(userID, lossCombatRetreat) } if line := twinBeeLine(zone.ID, DMPlayerDeath, run.RunID, narrationCadence(run)); line != "" { ob.WriteString(line) diff --git a/internal/plugin/dnd_zone_run.go b/internal/plugin/dnd_zone_run.go index 8dd846a..4b08ba8 100644 --- a/internal/plugin/dnd_zone_run.go +++ b/internal/plugin/dnd_zone_run.go @@ -323,7 +323,7 @@ func getActiveZoneRun(userID id.UserID) (*DungeonRun, error) { // but only when this run is the active expedition's current run so // a standalone (non-expedition) stale run still reaps cleanly. if exp, _ := getActiveExpedition(userID); exp != nil && exp.RunID == r.RunID { - forceExtractExpeditionForRunLoss(userID, "run idle-timeout (§4.3 stale-run reap)") + forceExtractExpeditionForRunLoss(userID, lossIdleTimeout) } return nil, nil } diff --git a/internal/plugin/pete_retreat_test.go b/internal/plugin/pete_retreat_test.go new file mode 100644 index 0000000..7a214f1 --- /dev/null +++ b/internal/plugin/pete_retreat_test.go @@ -0,0 +1,162 @@ +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) + } + } +}