diff --git a/internal/db/db.go b/internal/db/db.go index 41561c8..6598154 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -82,6 +82,7 @@ func runMigrations(d *sql.DB) error { `ALTER TABLE adventure_characters ADD COLUMN hospital_visits INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE adventure_characters ADD COLUMN robbie_visit_count INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE wordle_stats ADD COLUMN total_earned INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE adventure_characters ADD COLUMN last_death_date TEXT NOT NULL DEFAULT ''`, } for _, stmt := range columnMigrations { if _, err := d.Exec(stmt); err != nil { diff --git a/internal/plugin/adventure_character.go b/internal/plugin/adventure_character.go index 134bc73..ef6ac12 100644 --- a/internal/plugin/adventure_character.go +++ b/internal/plugin/adventure_character.go @@ -58,6 +58,7 @@ type AdventureCharacter struct { BabysitSkillFocus string HospitalVisits int RobbieVisitCount int + LastDeathDate string } type AdvEquipment struct { @@ -207,6 +208,7 @@ func (c *AdventureCharacter) Kill() { c.Alive = false deadUntil := time.Now().UTC().Add(6 * time.Hour) c.DeadUntil = &deadUntil + c.LastDeathDate = time.Now().UTC().Format("2006-01-02") } // ── Equipment Score ────────────────────────────────────────────────────────── @@ -318,7 +320,7 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) { masterwork_drops_received, rival_pool, rival_unlocked_notified, babysit_active, babysit_expires_at, babysit_skill_focus, - hospital_visits, robbie_visit_count + hospital_visits, robbie_visit_count, last_death_date FROM adventure_characters WHERE user_id = ?`, string(userID)).Scan( &c.UserID, &c.DisplayName, &c.CombatLevel, &c.MiningSkill, &c.ForagingSkill, &c.FishingSkill, @@ -330,7 +332,7 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) { &c.MasterworkDropsReceived, &c.RivalPool, &rivalUnlocked, &babysitAct, &babysitExp, &c.BabysitSkillFocus, - &c.HospitalVisits, &c.RobbieVisitCount, + &c.HospitalVisits, &c.RobbieVisitCount, &c.LastDeathDate, ) if err != nil { return nil, err @@ -415,7 +417,7 @@ func saveAdvCharacter(char *AdventureCharacter) error { masterwork_drops_received = ?, rival_pool = ?, rival_unlocked_notified = ?, babysit_active = ?, babysit_expires_at = ?, babysit_skill_focus = ?, - hospital_visits = ?, robbie_visit_count = ? + hospital_visits = ?, robbie_visit_count = ?, last_death_date = ? WHERE user_id = ?`, char.DisplayName, char.CombatLevel, char.MiningSkill, char.ForagingSkill, char.FishingSkill, char.CombatXP, char.MiningXP, char.ForagingXP, char.FishingXP, @@ -425,7 +427,7 @@ func saveAdvCharacter(char *AdventureCharacter) error { char.DeathReprieveLast, char.MasterworkDropsReceived, char.RivalPool, rivalUnlocked, babysitAct, char.BabysitExpiresAt, char.BabysitSkillFocus, - char.HospitalVisits, char.RobbieVisitCount, + char.HospitalVisits, char.RobbieVisitCount, char.LastDeathDate, string(char.UserID), ) return err diff --git a/internal/plugin/adventure_hospital.go b/internal/plugin/adventure_hospital.go index 5665623..d631ac5 100644 --- a/internal/plugin/adventure_hospital.go +++ b/internal/plugin/adventure_hospital.go @@ -35,10 +35,9 @@ func (p *AdventurePlugin) handleHospitalCmd(ctx MessageContext) error { char.DeadUntil = nil if err := saveAdvCharacter(char); err != nil { slog.Error("hospital: on-demand revive failed", "user", char.UserID, "err", err) - } else { - p.SendDM(ctx.Sender, nurseJoyAlreadyRevived) + return p.SendDM(ctx.Sender, "Something went wrong at the hospital. Try again in a moment.") } - return nil + return p.SendDM(ctx.Sender, nurseJoyAlreadyRevived) } // Compute costs diff --git a/internal/plugin/adventure_scheduler.go b/internal/plugin/adventure_scheduler.go index b060fb5..09f0d40 100644 --- a/internal/plugin/adventure_scheduler.go +++ b/internal/plugin/adventure_scheduler.go @@ -314,14 +314,10 @@ func (p *AdventurePlugin) midnightReset() error { } if !char.ActionTakenToday { - // If the player was recently dead (revived today but couldn't act - // in time), preserve their streak instead of resetting it. - // We detect this by checking if LastActionDate was yesterday — - // they were active, then died, and couldn't act on revival day. - recentlyDead := char.LastActionDate == today || - char.LastActionDate == time.Now().UTC().Add(-24*time.Hour).Format("2006-01-02") - if recentlyDead { - // Grace period — don't shame, don't reset + // If the player died today (or yesterday — covering late-night deaths + // that span midnight), grant a grace period: no shame, no streak reset. + if char.LastDeathDate == today || + char.LastDeathDate == time.Now().UTC().Add(-24*time.Hour).Format("2006-01-02") { continue } diff --git a/internal/plugin/lottery.go b/internal/plugin/lottery.go index c9bda9a..6852294 100644 --- a/internal/plugin/lottery.go +++ b/internal/plugin/lottery.go @@ -118,7 +118,11 @@ func (p *LotteryPlugin) handleLotteryBuy(ctx MessageContext, args string) error tickets[i] = generateLotteryNumbers() } - lotteryInsertTickets(ctx.Sender, weekStart, tickets) + if err := lotteryInsertTickets(ctx.Sender, weekStart, tickets); err != nil { + // Refund — tickets didn't persist. + p.euro.Credit(ctx.Sender, cost, "lottery_refund") + return p.SendReply(ctx.RoomID, ctx.EventID, "Something went wrong buying tickets. You've been refunded.") + } // Build confirmation. var sb strings.Builder diff --git a/internal/plugin/lottery_db.go b/internal/plugin/lottery_db.go index f5a9d86..2496655 100644 --- a/internal/plugin/lottery_db.go +++ b/internal/plugin/lottery_db.go @@ -64,18 +64,37 @@ func lotteryTotalTicketCount(weekStart string) int { return count } -func lotteryInsertTickets(userID id.UserID, weekStart string, tickets [][]int) { +func lotteryInsertTickets(userID id.UserID, weekStart string, tickets [][]int) error { d := db.Get() + tx, err := d.Begin() + if err != nil { + slog.Error("lottery: begin tx", "err", err) + return err + } + defer tx.Rollback() + for _, nums := range tickets { data, _ := json.Marshal(nums) - _, err := d.Exec(`INSERT INTO lottery_tickets (user_id, week_start, numbers) VALUES (?, ?, ?)`, + _, err := tx.Exec(`INSERT INTO lottery_tickets (user_id, week_start, numbers) VALUES (?, ?, ?)`, string(userID), weekStart, string(data)) if err != nil { slog.Error("lottery: failed to insert ticket", "user", userID, "err", err) + return err } } - // Each ticket costs €1 — add to community pot. - communityPotAdd(len(tickets)) + + // Each ticket costs €1 — add to community pot (same transaction). + _, err = tx.Exec( + `INSERT INTO community_pot (id, balance, updated_at) + VALUES (1, ?, CURRENT_TIMESTAMP) + ON CONFLICT(id) DO UPDATE SET balance = balance + ?, updated_at = CURRENT_TIMESTAMP`, + len(tickets), len(tickets)) + if err != nil { + slog.Error("lottery: failed to add to community pot", "err", err) + return err + } + + return tx.Commit() } func lotteryLoadUserTickets(userID id.UserID, weekStart string) ([]lotteryTicket, error) { diff --git a/internal/plugin/lottery_draw.go b/internal/plugin/lottery_draw.go index 5771a35..9a25b77 100644 --- a/internal/plugin/lottery_draw.go +++ b/internal/plugin/lottery_draw.go @@ -148,6 +148,8 @@ func (p *LotteryPlugin) executeDraw(weekStart string) { if amount > 0 { p.euro.Credit(t.UserID, float64(amount), fmt.Sprintf("lottery_%dmatch", tier)) } + // Update ticket with actual prorated prize (not nominal) + lotteryUpdateTicketResult(t.ID, *t.MatchCount, amount) } } } diff --git a/internal/plugin/stats.go b/internal/plugin/stats.go index d87e8e5..5a6f516 100644 --- a/internal/plugin/stats.go +++ b/internal/plugin/stats.go @@ -491,7 +491,7 @@ func (p *StatsPlugin) handleSuperStats(ctx MessageContext) error { var unoSingleEarned float64 _ = d.QueryRow( `SELECT COALESCE(SUM(CASE - WHEN result IN ('player_win','sudden_death_player') THEN pot_before - pot_after + WHEN result IN ('player_win','sudden_death_player') THEN (pot_before - pot_after) - wager ELSE -wager END), 0) FROM uno_games WHERE player_id = ?`, uid, ).Scan(&unoSingleEarned) diff --git a/internal/plugin/wordle.go b/internal/plugin/wordle.go index 96accc9..d7d70f6 100644 --- a/internal/plugin/wordle.go +++ b/internal/plugin/wordle.go @@ -193,9 +193,9 @@ func (p *WordlePlugin) handleGuess(ctx MessageContext, guess string) error { puzzle.SolvedAt = &now definition := p.fetchDefinition(puzzle.Answer) - p.updateStats(puzzle) - p.markPuzzleDone(puzzle) payouts := p.awardPrize(puzzle) + p.updateStats(puzzle, payouts) + p.markPuzzleDone(puzzle) return p.SendMessage(ctx.RoomID, renderSolvedAnnouncement(puzzle, definition, payouts)) } @@ -205,7 +205,7 @@ func (p *WordlePlugin) handleGuess(ctx MessageContext, guess string) error { puzzle.Failed = true definition := p.fetchDefinition(puzzle.Answer) - p.updateStats(puzzle) + p.updateStats(puzzle, nil) p.markPuzzleDone(puzzle) return p.SendMessage(ctx.RoomID, renderFailedAnnouncement(puzzle, definition)) @@ -455,7 +455,7 @@ func (p *WordlePlugin) expireUnsolved(roomID id.RoomID) { } existing.Failed = true p.markPuzzleDone(existing) - p.updateStats(existing) + p.updateStats(existing, nil) p.mu.Unlock() definition := p.fetchDefinition(existing.Answer) @@ -567,7 +567,7 @@ func (p *WordlePlugin) markPuzzleDone(puzzle *WordlePuzzle) { ) } -func (p *WordlePlugin) updateStats(puzzle *WordlePuzzle) { +func (p *WordlePlugin) updateStats(puzzle *WordlePuzzle, payouts []WordlePayout) { d := db.Get() // Tally per-player contributions. @@ -624,6 +624,18 @@ func (p *WordlePlugin) updateStats(puzzle *WordlePuzzle) { } } + // Update total_earned from payouts (same transaction as stats). + for _, po := range payouts { + if po.Amount > 0 { + _, err := tx.Exec( + `UPDATE wordle_stats SET total_earned = total_earned + ? WHERE user_id = ?`, + po.Amount, string(po.UserID)) + if err != nil { + slog.Error("wordle: update total_earned", "user", po.UserID, "err", err) + } + } + } + if err := tx.Commit(); err != nil { slog.Error("wordle: commit stats", "err", err) } @@ -631,6 +643,7 @@ func (p *WordlePlugin) updateStats(puzzle *WordlePuzzle) { // WordlePayout tracks a payout for rendering. type WordlePayout struct { + UserID id.UserID Name string Amount int Solver bool @@ -684,12 +697,7 @@ func (p *WordlePlugin) awardPrize(puzzle *WordlePuzzle) []WordlePayout { amount += solverBonus } p.euro.Credit(uid, float64(amount), "wordle_win") - payouts = append(payouts, WordlePayout{Name: c.name, Amount: amount, Solver: c.solver}) - - // Track earnings in wordle_stats - d := db.Get() - d.Exec(`UPDATE wordle_stats SET total_earned = total_earned + ? WHERE user_id = ?`, - amount, string(uid)) + payouts = append(payouts, WordlePayout{UserID: uid, Name: c.name, Amount: amount, Solver: c.solver}) } return payouts }