mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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:
@@ -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 hospital_visits INTEGER NOT NULL DEFAULT 0`,
|
||||||
`ALTER TABLE adventure_characters ADD COLUMN robbie_visit_count 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 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 {
|
for _, stmt := range columnMigrations {
|
||||||
if _, err := d.Exec(stmt); err != nil {
|
if _, err := d.Exec(stmt); err != nil {
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ type AdventureCharacter struct {
|
|||||||
BabysitSkillFocus string
|
BabysitSkillFocus string
|
||||||
HospitalVisits int
|
HospitalVisits int
|
||||||
RobbieVisitCount int
|
RobbieVisitCount int
|
||||||
|
LastDeathDate string
|
||||||
}
|
}
|
||||||
|
|
||||||
type AdvEquipment struct {
|
type AdvEquipment struct {
|
||||||
@@ -207,6 +208,7 @@ func (c *AdventureCharacter) Kill() {
|
|||||||
c.Alive = false
|
c.Alive = false
|
||||||
deadUntil := time.Now().UTC().Add(6 * time.Hour)
|
deadUntil := time.Now().UTC().Add(6 * time.Hour)
|
||||||
c.DeadUntil = &deadUntil
|
c.DeadUntil = &deadUntil
|
||||||
|
c.LastDeathDate = time.Now().UTC().Format("2006-01-02")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Equipment Score ──────────────────────────────────────────────────────────
|
// ── Equipment Score ──────────────────────────────────────────────────────────
|
||||||
@@ -318,7 +320,7 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) {
|
|||||||
masterwork_drops_received,
|
masterwork_drops_received,
|
||||||
rival_pool, rival_unlocked_notified,
|
rival_pool, rival_unlocked_notified,
|
||||||
babysit_active, babysit_expires_at, babysit_skill_focus,
|
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(
|
FROM adventure_characters WHERE user_id = ?`, string(userID)).Scan(
|
||||||
&c.UserID, &c.DisplayName,
|
&c.UserID, &c.DisplayName,
|
||||||
&c.CombatLevel, &c.MiningSkill, &c.ForagingSkill, &c.FishingSkill,
|
&c.CombatLevel, &c.MiningSkill, &c.ForagingSkill, &c.FishingSkill,
|
||||||
@@ -330,7 +332,7 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) {
|
|||||||
&c.MasterworkDropsReceived,
|
&c.MasterworkDropsReceived,
|
||||||
&c.RivalPool, &rivalUnlocked,
|
&c.RivalPool, &rivalUnlocked,
|
||||||
&babysitAct, &babysitExp, &c.BabysitSkillFocus,
|
&babysitAct, &babysitExp, &c.BabysitSkillFocus,
|
||||||
&c.HospitalVisits, &c.RobbieVisitCount,
|
&c.HospitalVisits, &c.RobbieVisitCount, &c.LastDeathDate,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -415,7 +417,7 @@ func saveAdvCharacter(char *AdventureCharacter) error {
|
|||||||
masterwork_drops_received = ?,
|
masterwork_drops_received = ?,
|
||||||
rival_pool = ?, rival_unlocked_notified = ?,
|
rival_pool = ?, rival_unlocked_notified = ?,
|
||||||
babysit_active = ?, babysit_expires_at = ?, babysit_skill_focus = ?,
|
babysit_active = ?, babysit_expires_at = ?, babysit_skill_focus = ?,
|
||||||
hospital_visits = ?, robbie_visit_count = ?
|
hospital_visits = ?, robbie_visit_count = ?, last_death_date = ?
|
||||||
WHERE user_id = ?`,
|
WHERE user_id = ?`,
|
||||||
char.DisplayName, char.CombatLevel, char.MiningSkill, char.ForagingSkill, char.FishingSkill,
|
char.DisplayName, char.CombatLevel, char.MiningSkill, char.ForagingSkill, char.FishingSkill,
|
||||||
char.CombatXP, char.MiningXP, char.ForagingXP, char.FishingXP,
|
char.CombatXP, char.MiningXP, char.ForagingXP, char.FishingXP,
|
||||||
@@ -425,7 +427,7 @@ func saveAdvCharacter(char *AdventureCharacter) error {
|
|||||||
char.DeathReprieveLast, char.MasterworkDropsReceived,
|
char.DeathReprieveLast, char.MasterworkDropsReceived,
|
||||||
char.RivalPool, rivalUnlocked,
|
char.RivalPool, rivalUnlocked,
|
||||||
babysitAct, char.BabysitExpiresAt, char.BabysitSkillFocus,
|
babysitAct, char.BabysitExpiresAt, char.BabysitSkillFocus,
|
||||||
char.HospitalVisits, char.RobbieVisitCount,
|
char.HospitalVisits, char.RobbieVisitCount, char.LastDeathDate,
|
||||||
string(char.UserID),
|
string(char.UserID),
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -35,10 +35,9 @@ func (p *AdventurePlugin) handleHospitalCmd(ctx MessageContext) error {
|
|||||||
char.DeadUntil = nil
|
char.DeadUntil = nil
|
||||||
if err := saveAdvCharacter(char); err != nil {
|
if err := saveAdvCharacter(char); err != nil {
|
||||||
slog.Error("hospital: on-demand revive failed", "user", char.UserID, "err", err)
|
slog.Error("hospital: on-demand revive failed", "user", char.UserID, "err", err)
|
||||||
} else {
|
return p.SendDM(ctx.Sender, "Something went wrong at the hospital. Try again in a moment.")
|
||||||
p.SendDM(ctx.Sender, nurseJoyAlreadyRevived)
|
|
||||||
}
|
}
|
||||||
return nil
|
return p.SendDM(ctx.Sender, nurseJoyAlreadyRevived)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute costs
|
// Compute costs
|
||||||
|
|||||||
@@ -314,14 +314,10 @@ func (p *AdventurePlugin) midnightReset() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !char.ActionTakenToday {
|
if !char.ActionTakenToday {
|
||||||
// If the player was recently dead (revived today but couldn't act
|
// If the player died today (or yesterday — covering late-night deaths
|
||||||
// in time), preserve their streak instead of resetting it.
|
// that span midnight), grant a grace period: no shame, no streak reset.
|
||||||
// We detect this by checking if LastActionDate was yesterday —
|
if char.LastDeathDate == today ||
|
||||||
// they were active, then died, and couldn't act on revival day.
|
char.LastDeathDate == time.Now().UTC().Add(-24*time.Hour).Format("2006-01-02") {
|
||||||
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
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -118,7 +118,11 @@ func (p *LotteryPlugin) handleLotteryBuy(ctx MessageContext, args string) error
|
|||||||
tickets[i] = generateLotteryNumbers()
|
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.
|
// Build confirmation.
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|||||||
@@ -64,18 +64,37 @@ func lotteryTotalTicketCount(weekStart string) int {
|
|||||||
return count
|
return count
|
||||||
}
|
}
|
||||||
|
|
||||||
func lotteryInsertTickets(userID id.UserID, weekStart string, tickets [][]int) {
|
func lotteryInsertTickets(userID id.UserID, weekStart string, tickets [][]int) error {
|
||||||
d := db.Get()
|
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 {
|
for _, nums := range tickets {
|
||||||
data, _ := json.Marshal(nums)
|
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))
|
string(userID), weekStart, string(data))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("lottery: failed to insert ticket", "user", userID, "err", err)
|
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) {
|
func lotteryLoadUserTickets(userID id.UserID, weekStart string) ([]lotteryTicket, error) {
|
||||||
|
|||||||
@@ -148,6 +148,8 @@ func (p *LotteryPlugin) executeDraw(weekStart string) {
|
|||||||
if amount > 0 {
|
if amount > 0 {
|
||||||
p.euro.Credit(t.UserID, float64(amount), fmt.Sprintf("lottery_%dmatch", tier))
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -491,7 +491,7 @@ func (p *StatsPlugin) handleSuperStats(ctx MessageContext) error {
|
|||||||
var unoSingleEarned float64
|
var unoSingleEarned float64
|
||||||
_ = d.QueryRow(
|
_ = d.QueryRow(
|
||||||
`SELECT COALESCE(SUM(CASE
|
`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)
|
ELSE -wager END), 0)
|
||||||
FROM uno_games WHERE player_id = ?`, uid,
|
FROM uno_games WHERE player_id = ?`, uid,
|
||||||
).Scan(&unoSingleEarned)
|
).Scan(&unoSingleEarned)
|
||||||
|
|||||||
@@ -193,9 +193,9 @@ func (p *WordlePlugin) handleGuess(ctx MessageContext, guess string) error {
|
|||||||
puzzle.SolvedAt = &now
|
puzzle.SolvedAt = &now
|
||||||
|
|
||||||
definition := p.fetchDefinition(puzzle.Answer)
|
definition := p.fetchDefinition(puzzle.Answer)
|
||||||
p.updateStats(puzzle)
|
|
||||||
p.markPuzzleDone(puzzle)
|
|
||||||
payouts := p.awardPrize(puzzle)
|
payouts := p.awardPrize(puzzle)
|
||||||
|
p.updateStats(puzzle, payouts)
|
||||||
|
p.markPuzzleDone(puzzle)
|
||||||
|
|
||||||
return p.SendMessage(ctx.RoomID, renderSolvedAnnouncement(puzzle, definition, payouts))
|
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
|
puzzle.Failed = true
|
||||||
|
|
||||||
definition := p.fetchDefinition(puzzle.Answer)
|
definition := p.fetchDefinition(puzzle.Answer)
|
||||||
p.updateStats(puzzle)
|
p.updateStats(puzzle, nil)
|
||||||
p.markPuzzleDone(puzzle)
|
p.markPuzzleDone(puzzle)
|
||||||
|
|
||||||
return p.SendMessage(ctx.RoomID, renderFailedAnnouncement(puzzle, definition))
|
return p.SendMessage(ctx.RoomID, renderFailedAnnouncement(puzzle, definition))
|
||||||
@@ -455,7 +455,7 @@ func (p *WordlePlugin) expireUnsolved(roomID id.RoomID) {
|
|||||||
}
|
}
|
||||||
existing.Failed = true
|
existing.Failed = true
|
||||||
p.markPuzzleDone(existing)
|
p.markPuzzleDone(existing)
|
||||||
p.updateStats(existing)
|
p.updateStats(existing, nil)
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
|
|
||||||
definition := p.fetchDefinition(existing.Answer)
|
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()
|
d := db.Get()
|
||||||
|
|
||||||
// Tally per-player contributions.
|
// 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 {
|
if err := tx.Commit(); err != nil {
|
||||||
slog.Error("wordle: commit stats", "err", err)
|
slog.Error("wordle: commit stats", "err", err)
|
||||||
}
|
}
|
||||||
@@ -631,6 +643,7 @@ func (p *WordlePlugin) updateStats(puzzle *WordlePuzzle) {
|
|||||||
|
|
||||||
// WordlePayout tracks a payout for rendering.
|
// WordlePayout tracks a payout for rendering.
|
||||||
type WordlePayout struct {
|
type WordlePayout struct {
|
||||||
|
UserID id.UserID
|
||||||
Name string
|
Name string
|
||||||
Amount int
|
Amount int
|
||||||
Solver bool
|
Solver bool
|
||||||
@@ -684,12 +697,7 @@ func (p *WordlePlugin) awardPrize(puzzle *WordlePuzzle) []WordlePayout {
|
|||||||
amount += solverBonus
|
amount += solverBonus
|
||||||
}
|
}
|
||||||
p.euro.Credit(uid, float64(amount), "wordle_win")
|
p.euro.Credit(uid, float64(amount), "wordle_win")
|
||||||
payouts = append(payouts, WordlePayout{Name: c.name, Amount: amount, Solver: c.solver})
|
payouts = append(payouts, WordlePayout{UserID: uid, 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))
|
|
||||||
}
|
}
|
||||||
return payouts
|
return payouts
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user