mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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
This commit is contained in:
@@ -252,9 +252,9 @@ func endRunOnLoss(owner id.UserID, runID string, death bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = abandonZoneRun(owner)
|
_ = abandonZoneRun(owner)
|
||||||
reason := "combat flee"
|
reason := lossCombatFlee
|
||||||
if death {
|
if death {
|
||||||
reason = "combat death"
|
reason = lossCombatDeath
|
||||||
}
|
}
|
||||||
forceExtractExpeditionForRunLoss(owner, reason)
|
forceExtractExpeditionForRunLoss(owner, reason)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -605,3 +605,51 @@ func emitDeathNews(userID id.UserID, location string) {
|
|||||||
OccurredAt: ts,
|
OccurredAt: ts,
|
||||||
}, userID, "")
|
}, 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, "")
|
||||||
|
}
|
||||||
|
|||||||
@@ -95,21 +95,40 @@ func forcedExtractExpedition(expID, reason string) (*Expedition, int, error) {
|
|||||||
return e, tax, nil
|
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
|
// forceExtractExpeditionForRunLoss bridges run-loss call sites (turn-based
|
||||||
// elite/boss death or flee, exploration combat death, patrol-interrupt
|
// elite/boss death or flee, exploration combat death, patrol-interrupt
|
||||||
// death) into the forced-extract flow. Those sites already abandon the
|
// death) into the forced-extract flow. Those sites already abandon the
|
||||||
// zone run, but without flipping the wrapping expedition the ambient
|
// zone run, but without flipping the wrapping expedition the ambient
|
||||||
// ticker keeps DMing about a dungeon the player walked away from. No-op
|
// ticker keeps DMing about a dungeon the player walked away from. No-op
|
||||||
// when there is no active expedition for this user.
|
// 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) {
|
func forceExtractExpeditionForRunLoss(userID id.UserID, reason string) {
|
||||||
exp, err := getActiveExpedition(userID)
|
exp, err := getActiveExpedition(userID)
|
||||||
if err != nil || exp == nil {
|
if err != nil || exp == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
day, zoneID := exp.CurrentDay, exp.ZoneID
|
||||||
if _, _, err := forcedExtractExpedition(exp.ID, reason); err != nil {
|
if _, _, err := forcedExtractExpedition(exp.ID, reason); err != nil {
|
||||||
slog.Warn("expedition: force-extract on run loss",
|
slog.Warn("expedition: force-extract on run loss",
|
||||||
"user", userID, "expedition", exp.ID, "reason", reason, "err", err)
|
"user", userID, "expedition", exp.ID, "reason", reason, "err", err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
emitRetreatNews(userID, reason, zoneID, day)
|
||||||
}
|
}
|
||||||
|
|
||||||
// finalizeExpeditionOnZoneClear bridges the run-complete seam (boss down,
|
// finalizeExpeditionOnZoneClear bridges the run-complete seam (boss down,
|
||||||
|
|||||||
@@ -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.
|
// seat off HP, which for a solo walker is the same `!TimedOut` rule.
|
||||||
closeOutZoneLoss(pres, seated, zone, "zone")
|
closeOutZoneLoss(pres, seated, zone, "zone")
|
||||||
if !result.TimedOut {
|
if !result.TimedOut {
|
||||||
forceExtractExpeditionForRunLoss(userID, "combat death")
|
forceExtractExpeditionForRunLoss(userID, lossCombatDeath)
|
||||||
} else {
|
} else {
|
||||||
forceExtractExpeditionForRunLoss(userID, "combat retreat")
|
forceExtractExpeditionForRunLoss(userID, lossCombatRetreat)
|
||||||
}
|
}
|
||||||
if line := twinBeeLine(zone.ID, DMPlayerDeath, run.RunID, narrationCadence(run)); line != "" {
|
if line := twinBeeLine(zone.ID, DMPlayerDeath, run.RunID, narrationCadence(run)); line != "" {
|
||||||
ob.WriteString(line)
|
ob.WriteString(line)
|
||||||
|
|||||||
@@ -323,7 +323,7 @@ func getActiveZoneRun(userID id.UserID) (*DungeonRun, error) {
|
|||||||
// but only when this run is the active expedition's current run so
|
// but only when this run is the active expedition's current run so
|
||||||
// a standalone (non-expedition) stale run still reaps cleanly.
|
// a standalone (non-expedition) stale run still reaps cleanly.
|
||||||
if exp, _ := getActiveExpedition(userID); exp != nil && exp.RunID == r.RunID {
|
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
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|||||||
162
internal/plugin/pete_retreat_test.go
Normal file
162
internal/plugin/pete_retreat_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user