Fix streak grace period, UNO earnings, hospital UX, wordle/lottery atomicity

- Streak grace now checks LastDeathDate instead of LastActionDate (was
  suppressing streak updates for all active players)
- UNO single-player earnings use net payout (minus wager) not gross
- Hospital sends error message on save failure instead of silent no-op
- Wordle total_earned update moved into stats transaction
- Lottery ticket inserts + community pot add wrapped in single transaction
  with euro refund on failure
- Lottery fixed-tier ticket prize updated with actual prorated amount
- Added last_death_date column to adventure_characters

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-04-08 00:54:58 -07:00
parent 26d2846481
commit f57dcd93a5
9 changed files with 63 additions and 32 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View File

@@ -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

View File

@@ -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) {

View File

@@ -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)
}
}
}

View File

@@ -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)

View File

@@ -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
}