From 509df7fadf16ebbdcb02074464d6126aeff40ac4 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 24 Jul 2026 22:07:38 -0700 Subject: [PATCH] adventure: stop the log claiming a win it didn't see MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four things the review found, all of them the code telling a player something that isn't true. A run that walks into a dead end filed its ending as "cleared" whatever the caller said, so both the liveblog and the summary reported a clear for a party that merely ran out of map — and the end beat is first-writer-wins, so nothing later could take it back. It says "cleared" only for a boss now. The re-offer branches of babysit and resume returned a zero cost, so a player who was in fact charged read "0 coins" in the verdict. Both re-quote the price they actually took. And the realm-firsts reseed retired its one-shot job even when the read under it had failed, which on a transient fault at Init would have left the ledger mis-dated permanently. The read now says whether it worked, and the job stays open when it didn't. Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ --- internal/plugin/adventure_babysit.go | 9 ++++++- internal/plugin/bootstrap_pete_news.go | 27 ++++++++++++++----- .../plugin/bootstrap_realm_firsts_test.go | 6 ++++- internal/plugin/dnd_expedition_extract.go | 11 ++++++-- internal/plugin/zone_graph_nav.go | 13 ++++++++- 5 files changed, 55 insertions(+), 11 deletions(-) diff --git a/internal/plugin/adventure_babysit.go b/internal/plugin/adventure_babysit.go index 3cd62bd..8c48124 100644 --- a/internal/plugin/adventure_babysit.go +++ b/internal/plugin/adventure_babysit.go @@ -144,7 +144,14 @@ func (p *AdventurePlugin) performBabysitPurchase(uid id.UserID, days int, idemKe // on the re-offer. The settled fee is what tells that apart from somebody // who really does already have one. if idemKey != "" && p.euro != nil && p.euro.HasExternalTx(idemKey) { - return babysitOutcome{Days: days, PetName: char.PetName}, nil + // Re-quote the fee rather than leaving it zero: the verdict this + // feeds prints the coin figure, and "0 coins" would be a false + // receipt for a hire the player did pay for. + return babysitOutcome{ + Days: days, + Cost: babysitDailyCost(dndLevelForUser(char.UserID)) * days, + PetName: char.PetName, + }, nil } return babysitOutcome{}, refuseAdv(errBabysitActive, "🍼 The babysitter is already here. They're not leaving until the job is done.") } diff --git a/internal/plugin/bootstrap_pete_news.go b/internal/plugin/bootstrap_pete_news.go index c647538..20e83b3 100644 --- a/internal/plugin/bootstrap_pete_news.go +++ b/internal/plugin/bootstrap_pete_news.go @@ -76,7 +76,11 @@ type zoneFirstClear struct { // SQLite returns the user_id from the same row as MIN(completed_at) (bare-column // min/max rule), so the (zone, first clearer, time) triple is internally // consistent. -func zoneFirstClears() []zoneFirstClear { +// ok is false only when the read itself failed. A caller that is about to mark +// a one-shot job complete has to be able to tell "no zone has ever been cleared" +// from "the query fell over", or a transient DB fault at boot retires the repair +// permanently. +func zoneFirstClears() (firsts []zoneFirstClear, ok bool) { rows, err := db.Get().Query( `SELECT zone_id, user_id, MIN(completed_at) FROM dnd_zone_run @@ -84,11 +88,10 @@ func zoneFirstClears() []zoneFirstClear { GROUP BY zone_id`) if err != nil { slog.Error("backfill: zone-firsts query", "err", err) - return nil + return nil, false } defer rows.Close() - var firsts []zoneFirstClear for rows.Next() { var f zoneFirstClear if err := rows.Scan(&f.zoneID, &f.userID, &f.completedAt); err != nil { @@ -97,7 +100,11 @@ func zoneFirstClears() []zoneFirstClear { } firsts = append(firsts, f) } - return firsts + if err := rows.Err(); err != nil { + slog.Error("backfill: zone-firsts rows", "err", err) + return nil, false + } + return firsts, true } // bootstrapRealmFirstsReseed repairs the zone half of news_realm_firsts. @@ -131,8 +138,16 @@ func bootstrapRealmFirstsReseed() { return } + clears, ok := zoneFirstClears() + if !ok { + // The read failed. Leave the job unmarked so the next boot tries again — + // marking it here would retire the repair on the strength of a transient + // DB fault and leave the ledger wrong forever. + return + } + seeded := 0 - for _, f := range zoneFirstClears() { + for _, f := range clears { ts, ok := parseSQLiteTime(f.completedAt) if !ok { slog.Warn("reseed: unparseable clear time", "zone", f.zoneID, "at", f.completedAt) @@ -155,7 +170,7 @@ func bootstrapRealmFirstsReseed() { // dispatch per zone, attributed to its earliest boss-defeating clearer. // Returns the count emitted. func (p *AdventurePlugin) backfillZoneFirsts() int { - firsts := zoneFirstClears() + firsts, _ := zoneFirstClears() n := 0 for _, f := range firsts { diff --git a/internal/plugin/bootstrap_realm_firsts_test.go b/internal/plugin/bootstrap_realm_firsts_test.go index 47c4fdf..662c685 100644 --- a/internal/plugin/bootstrap_realm_firsts_test.go +++ b/internal/plugin/bootstrap_realm_firsts_test.go @@ -140,8 +140,12 @@ func TestZoneFirstClearsCountsRetiredKills(t *testing.T) { (run_id, user_id, zone_id, total_rooms, boss_defeated, abandoned, completed_at) VALUES ('r7', '@josie:x', 'forest_shadows', 6, 1, 1, '2026-02-14 09:00:00')`) + clears, ok := zoneFirstClears() + if !ok { + t.Fatal("zoneFirstClears reported a read failure") + } byZone := map[string]zoneFirstClear{} - for _, f := range zoneFirstClears() { + for _, f := range clears { byZone[f.zoneID] = f } if len(byZone) != 3 { diff --git a/internal/plugin/dnd_expedition_extract.go b/internal/plugin/dnd_expedition_extract.go index 2019bc4..a81c709 100644 --- a/internal/plugin/dnd_expedition_extract.go +++ b/internal/plugin/dnd_expedition_extract.go @@ -508,8 +508,15 @@ func (p *AdventurePlugin) performResume(uid id.UserID, loadoutTok, idemKey strin // re-offer; the settled debit is what tells that apart from a player who // really is already out. Same tell as performExpeditionStart's. if idemKey != "" && p.euro != nil && p.euro.HasExternalTx(idemKey) { - return resumeOutcome{Zone: zone, Day: existing.CurrentDay, - Supplies: existing.Supplies, Threat: existing.ThreatLevel}, nil + out := resumeOutcome{Zone: zone, Day: existing.CurrentDay, + Supplies: existing.Supplies, Threat: existing.ThreatLevel} + // Re-price the same loadout at the same tier so the verdict this feeds + // can still say what it cost. Leaving Purchase zero would file a + // "re-outfitted for 0 coins" receipt for a trip that was paid for. + if pp, perr := resolveLoadoutOrParse(strings.TrimSpace(loadoutTok), zone.Tier); perr == nil { + out.Purchase = pp + } + return out, nil } return resumeOutcome{}, refuseAdv(errResumeBusy, "You already have an active expedition in **%s** (Day %d). Finish it or `!expedition abandon` first.", diff --git a/internal/plugin/zone_graph_nav.go b/internal/plugin/zone_graph_nav.go index 08c5d9b..6dd17d4 100644 --- a/internal/plugin/zone_graph_nav.go +++ b/internal/plugin/zone_graph_nav.go @@ -436,8 +436,19 @@ func completeRunAtNode(runID string, boss bool) error { if boss { bossI = 1 } + // Only a boss kill is a clear. This function also closes a non-boss + // dead-end — the party simply ran out of map — and beatRunEnd is + // first-writer-wins, so calling that "cleared" would put a lie in the + // liveblog and the run summary that nothing downstream could correct. + // "ended" is deliberately outside the {cleared,died,retreated,abandoned} + // set: the prompt renderer already degrades an unknown outcome to a + // neutral "the run ended", which is exactly what happened. if run, _ := getZoneRun(runID); run != nil { - beatRunEnd(run, "cleared") + outcome := "ended" + if boss { + outcome = "cleared" + } + beatRunEnd(run, outcome) } _, err := db.Get().Exec(` UPDATE dnd_zone_run