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