mirror of
https://github.com/prosolis/gogobee.git
synced 2026-09-14 10:51:09 +00:00
adventure: stop the log claiming a win it didn't see
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
This commit is contained in:
@@ -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
|
// on the re-offer. The settled fee is what tells that apart from somebody
|
||||||
// who really does already have one.
|
// who really does already have one.
|
||||||
if idemKey != "" && p.euro != nil && p.euro.HasExternalTx(idemKey) {
|
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.")
|
return babysitOutcome{}, refuseAdv(errBabysitActive, "🍼 The babysitter is already here. They're not leaving until the job is done.")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,7 +76,11 @@ type zoneFirstClear struct {
|
|||||||
// SQLite returns the user_id from the same row as MIN(completed_at) (bare-column
|
// 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
|
// min/max rule), so the (zone, first clearer, time) triple is internally
|
||||||
// consistent.
|
// 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(
|
rows, err := db.Get().Query(
|
||||||
`SELECT zone_id, user_id, MIN(completed_at)
|
`SELECT zone_id, user_id, MIN(completed_at)
|
||||||
FROM dnd_zone_run
|
FROM dnd_zone_run
|
||||||
@@ -84,11 +88,10 @@ func zoneFirstClears() []zoneFirstClear {
|
|||||||
GROUP BY zone_id`)
|
GROUP BY zone_id`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("backfill: zone-firsts query", "err", err)
|
slog.Error("backfill: zone-firsts query", "err", err)
|
||||||
return nil
|
return nil, false
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var firsts []zoneFirstClear
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var f zoneFirstClear
|
var f zoneFirstClear
|
||||||
if err := rows.Scan(&f.zoneID, &f.userID, &f.completedAt); err != nil {
|
if err := rows.Scan(&f.zoneID, &f.userID, &f.completedAt); err != nil {
|
||||||
@@ -97,7 +100,11 @@ func zoneFirstClears() []zoneFirstClear {
|
|||||||
}
|
}
|
||||||
firsts = append(firsts, f)
|
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.
|
// bootstrapRealmFirstsReseed repairs the zone half of news_realm_firsts.
|
||||||
@@ -131,8 +138,16 @@ func bootstrapRealmFirstsReseed() {
|
|||||||
return
|
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
|
seeded := 0
|
||||||
for _, f := range zoneFirstClears() {
|
for _, f := range clears {
|
||||||
ts, ok := parseSQLiteTime(f.completedAt)
|
ts, ok := parseSQLiteTime(f.completedAt)
|
||||||
if !ok {
|
if !ok {
|
||||||
slog.Warn("reseed: unparseable clear time", "zone", f.zoneID, "at", f.completedAt)
|
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.
|
// dispatch per zone, attributed to its earliest boss-defeating clearer.
|
||||||
// Returns the count emitted.
|
// Returns the count emitted.
|
||||||
func (p *AdventurePlugin) backfillZoneFirsts() int {
|
func (p *AdventurePlugin) backfillZoneFirsts() int {
|
||||||
firsts := zoneFirstClears()
|
firsts, _ := zoneFirstClears()
|
||||||
|
|
||||||
n := 0
|
n := 0
|
||||||
for _, f := range firsts {
|
for _, f := range firsts {
|
||||||
|
|||||||
@@ -140,8 +140,12 @@ func TestZoneFirstClearsCountsRetiredKills(t *testing.T) {
|
|||||||
(run_id, user_id, zone_id, total_rooms, boss_defeated, abandoned, completed_at)
|
(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')`)
|
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{}
|
byZone := map[string]zoneFirstClear{}
|
||||||
for _, f := range zoneFirstClears() {
|
for _, f := range clears {
|
||||||
byZone[f.zoneID] = f
|
byZone[f.zoneID] = f
|
||||||
}
|
}
|
||||||
if len(byZone) != 3 {
|
if len(byZone) != 3 {
|
||||||
|
|||||||
@@ -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
|
// re-offer; the settled debit is what tells that apart from a player who
|
||||||
// really is already out. Same tell as performExpeditionStart's.
|
// really is already out. Same tell as performExpeditionStart's.
|
||||||
if idemKey != "" && p.euro != nil && p.euro.HasExternalTx(idemKey) {
|
if idemKey != "" && p.euro != nil && p.euro.HasExternalTx(idemKey) {
|
||||||
return resumeOutcome{Zone: zone, Day: existing.CurrentDay,
|
out := resumeOutcome{Zone: zone, Day: existing.CurrentDay,
|
||||||
Supplies: existing.Supplies, Threat: existing.ThreatLevel}, nil
|
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,
|
return resumeOutcome{}, refuseAdv(errResumeBusy,
|
||||||
"You already have an active expedition in **%s** (Day %d). Finish it or `!expedition abandon` first.",
|
"You already have an active expedition in **%s** (Day %d). Finish it or `!expedition abandon` first.",
|
||||||
|
|||||||
@@ -436,8 +436,19 @@ func completeRunAtNode(runID string, boss bool) error {
|
|||||||
if boss {
|
if boss {
|
||||||
bossI = 1
|
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 {
|
if run, _ := getZoneRun(runID); run != nil {
|
||||||
beatRunEnd(run, "cleared")
|
outcome := "ended"
|
||||||
|
if boss {
|
||||||
|
outcome = "cleared"
|
||||||
|
}
|
||||||
|
beatRunEnd(run, outcome)
|
||||||
}
|
}
|
||||||
_, err := db.Get().Exec(`
|
_, err := db.Get().Exec(`
|
||||||
UPDATE dnd_zone_run
|
UPDATE dnd_zone_run
|
||||||
|
|||||||
Reference in New Issue
Block a user