Files
tiennm99bot/internal/modules/util/guessgame/rounds.go
T
tiennm99 629b513c91 feat(games): move /wordle and /loldle to the web games and add /loldledaily
/wordle and /loldle now send the wordle and loldle BotFather games in an
unlimited mode: each player gets their own word or champion, saved after
every guess, with a New game button. Cards the bot did not record as
unlimited, including the daily cards, play the daily puzzle. The helper
commands keep working on the player's round.

/loldledaily mirrors /wordledaily: one champion per day at 07:00 ICT,
subscribe and unsubscribe, the morning recap and the live group results.

The daily and unlimited machinery is shared in util/guessgame, the old
in-chat wordle module is removed, and a one-time startup migration carries
each player's stats and unfinished round over without changing old data.
2026-10-10 09:29:48 +07:00

318 lines
10 KiB
Go

package guessgame
import (
"context"
"errors"
"strconv"
"time"
"github.com/tiennm99/tiennm99bot/internal/keylock"
"github.com/tiennm99/tiennm99bot/internal/storage"
)
// Unlimited mode: every player has their own current round with a random
// answer, kept server-side under "uround:<uid>", and their own stats under
// "ustats:<uid>". A round is replaced, never appended to, so one document a
// player is all it stores.
// Round is a player's current unlimited round.
type Round struct {
Seq int `json:"seq" bson:"seq"` // 1 for the player's first round, then one more each round
Target string `json:"target" bson:"target"`
Guesses []Guess `json:"guesses" bson:"guesses"`
Status string `json:"status" bson:"status"`
GaveUp bool `json:"gave_up,omitempty" bson:"gave_up,omitempty"`
MaxGuesses int `json:"max_guesses" bson:"max_guesses"` // frozen when the round starts
StartedAt int64 `json:"started_at,omitempty" bson:"started_at,omitempty"` // unix ms of the first guess
FinishedAt int64 `json:"finished_at,omitempty" bson:"finished_at,omitempty"`
}
// Finished reports whether the round is won or lost.
func (r *Round) Finished() bool { return r.Status == StatusWon || r.Status == StatusLost }
// RoundStats is a player's unlimited record. A streak counts consecutive
// wins. LastSeq is the last round folded in, so a finished round counts
// once even when its stats write is retried.
type RoundStats struct {
Played int `json:"played" bson:"played"`
Wins int `json:"wins" bson:"wins"`
CurStreak int `json:"cur_streak" bson:"cur_streak"`
MaxStreak int `json:"max_streak" bson:"max_streak"`
Dist []int `json:"dist" bson:"dist"` // wins by guess count, index 0 = 1 guess
LastAt int64 `json:"last_at,omitempty" bson:"last_at,omitempty"`
LastSeq int `json:"last_seq" bson:"last_seq"`
}
// RoundsConfig is one game's unlimited mode.
type RoundsConfig struct {
Store storage.Collection
Rules Rules // Judge scores the guesses
// Pick returns a random answer for a new round; prev is the answer of
// the round it replaces, "" for a player's first.
Pick func(prev string) string
// DistLen sizes the stats' guess distribution: the largest guess
// budget a round can have.
DistLen int
Now func() time.Time
}
// RoundState is a player's round after a request.
type RoundState struct {
Round Round
Stats RoundStats
// Ended reports that this request finished the round.
Ended bool
// Abandoned is the round a New request gave up, set only when that
// counted as a loss.
Abandoned *Round
}
// Rounds is a game's unlimited mode. Every method holds the player's lock,
// so the page and the chat commands never interleave on one round.
type Rounds struct {
cfg RoundsConfig
rounds storage.DocStore[Round]
stats storage.DocStore[RoundStats]
locks keylock.Map
}
// NewRounds builds a game's unlimited mode, or nil without storage.
func NewRounds(cfg RoundsConfig) *Rounds {
if cfg.Store == nil {
return nil
}
if cfg.Now == nil {
cfg.Now = time.Now
}
return &Rounds{
cfg: cfg,
rounds: storage.Typed[Round](cfg.Store),
stats: storage.Typed[RoundStats](cfg.Store),
}
}
// RoundKey is the key of a player's current round.
func RoundKey(userID int64) string { return "uround:" + strconv.FormatInt(userID, 10) }
// RoundStatsKey is the key of a player's unlimited stats.
func RoundStatsKey(userID int64) string { return "ustats:" + strconv.FormatInt(userID, 10) }
func roundLockKey(userID int64) string { return "r:" + strconv.FormatInt(userID, 10) }
// load returns the player's round, found=false when they never had one.
func (r *Rounds) load(ctx context.Context, userID int64) (Round, bool, error) {
rd, _, err := r.rounds.Get(ctx, RoundKey(userID))
switch {
case err == nil:
return rd, true, nil
case errors.Is(err, storage.ErrNotFound):
return Round{}, false, nil
default:
return Round{}, false, err
}
}
// LoadStats returns the player's unlimited stats, zero when they never
// finished a round.
func (r *Rounds) LoadStats(ctx context.Context, userID int64) (RoundStats, error) {
st, _, err := r.stats.Get(ctx, RoundStatsKey(userID))
if errors.Is(err, storage.ErrNotFound) {
st, err = RoundStats{}, nil
}
if len(st.Dist) != r.cfg.DistLen {
st.Dist = append(st.Dist, make([]int, r.cfg.DistLen)...)[:r.cfg.DistLen]
}
return st, err
}
// start writes a fresh round after prev (zero for the first) and returns it.
func (r *Rounds) start(ctx context.Context, userID int64, prev Round, maxGuesses int) (Round, error) {
rd := Round{Seq: prev.Seq + 1, Target: r.cfg.Pick(prev.Target), Guesses: []Guess{}, Status: StatusPlaying, MaxGuesses: maxGuesses}
if err := r.rounds.Put(ctx, RoundKey(userID), rd); err != nil {
return Round{}, err
}
return rd, nil
}
// current returns the player's round, starting their first one when there
// is none, with their stats. A finished round whose stats write failed is
// folded in now. Caller holds the player's lock.
func (r *Rounds) current(ctx context.Context, userID int64, maxGuesses int) (RoundState, error) {
rd, found, err := r.load(ctx, userID)
if err != nil {
return RoundState{}, err
}
if !found {
if rd, err = r.start(ctx, userID, Round{}, maxGuesses); err != nil {
return RoundState{}, err
}
}
st, err := r.settle(ctx, userID, rd)
if err != nil {
return RoundState{}, err
}
return RoundState{Round: rd, Stats: st}, nil
}
// settle folds a finished round into the stats, once. Caller holds the
// player's lock.
func (r *Rounds) settle(ctx context.Context, userID int64, rd Round) (RoundStats, error) {
st, err := r.LoadStats(ctx, userID)
if err != nil || !rd.Finished() || st.LastSeq >= rd.Seq {
return st, err
}
st.Played++
if rd.Status == StatusWon {
st.Wins++
if n := len(rd.Guesses); n >= 1 && n <= len(st.Dist) {
st.Dist[n-1]++
}
st.CurStreak++
st.MaxStreak = max(st.MaxStreak, st.CurStreak)
} else {
st.CurStreak = 0
}
st.LastSeq, st.LastAt = rd.Seq, rd.FinishedAt
if err := r.stats.Put(ctx, RoundStatsKey(userID), st); err != nil {
return RoundStats{}, err
}
return st, nil
}
// finish ends the round as status and saves it, then folds it into the
// stats. The round is saved first and is the record: a failed stats write
// is repaired by the next request.
func (r *Rounds) finish(ctx context.Context, userID int64, rd *Round, status string) (RoundStats, error) {
rd.Status, rd.FinishedAt = status, r.cfg.Now().UnixMilli()
if err := r.rounds.Put(ctx, RoundKey(userID), *rd); err != nil {
return RoundStats{}, err
}
return r.settle(ctx, userID, *rd)
}
// Load returns the player's current round, finished or not, starting their
// first one with maxGuesses when they have none.
func (r *Rounds) Load(ctx context.Context, userID int64, maxGuesses int) (RoundState, error) {
defer r.locks.Acquire(roundLockKey(userID))()
return r.current(ctx, userID, maxGuesses)
}
// Guess plays input on the player's current round. seq is the round the
// caller shows; a stale one is refused so a board never mixes two rounds,
// and 0 plays whatever round is current. A player without a round gets one
// with maxGuesses. Rules.Judge errors pass through unchanged.
func (r *Rounds) Guess(ctx context.Context, userID int64, seq int, input string, maxGuesses int) (RoundState, error) {
defer r.locks.Acquire(roundLockKey(userID))()
s, err := r.current(ctx, userID, maxGuesses)
if err != nil {
return RoundState{}, err
}
rd := s.Round
if seq != 0 && seq != rd.Seq {
return s, ErrNewRound
}
if rd.Finished() {
return s, ErrFinished
}
g, err := r.cfg.Rules.Judge(input, rd.Target, rd.Guesses)
if err != nil {
return s, err
}
now := r.cfg.Now().UnixMilli()
if rd.StartedAt == 0 {
rd.StartedAt = now
}
rd.Guesses = append(rd.Guesses, g)
if g.Word == rd.Target || len(rd.Guesses) >= rd.MaxGuesses {
status := StatusLost
if g.Word == rd.Target {
status = StatusWon
}
st, err := r.finish(ctx, userID, &rd, status)
if err != nil {
return RoundState{}, err
}
return RoundState{Round: rd, Stats: st, Ended: true}, nil
}
if err := r.rounds.Put(ctx, RoundKey(userID), rd); err != nil {
return RoundState{}, err
}
return RoundState{Round: rd, Stats: s.Stats}, nil
}
// New starts the player's next round with maxGuesses. seq is the round the
// caller shows: a stale one returns the current round unchanged, so a
// double click starts one round, and 0 always starts one. A round still
// being played is given up first; that counts as a loss once it has a
// guess, and a round nobody guessed on is just replaced.
func (r *Rounds) New(ctx context.Context, userID int64, seq, maxGuesses int) (RoundState, error) {
defer r.locks.Acquire(roundLockKey(userID))()
s, err := r.current(ctx, userID, maxGuesses)
if err != nil {
return RoundState{}, err
}
prev := s.Round
if seq != 0 && seq != prev.Seq {
return s, nil
}
var abandoned *Round
if !prev.Finished() && len(prev.Guesses) > 0 {
prev.GaveUp = true
if s.Stats, err = r.finish(ctx, userID, &prev, StatusLost); err != nil {
return RoundState{}, err
}
abandoned = &prev
}
rd, err := r.start(ctx, userID, prev, maxGuesses)
if err != nil {
return RoundState{}, err
}
return RoundState{Round: rd, Stats: s.Stats, Abandoned: abandoned}, nil
}
// GiveUp ends the player's round being played as a loss. On a finished
// round it changes nothing and reports Ended false; without a round it
// returns ErrNoRound.
func (r *Rounds) GiveUp(ctx context.Context, userID int64) (RoundState, error) {
defer r.locks.Acquire(roundLockKey(userID))()
rd, found, err := r.load(ctx, userID)
if err != nil {
return RoundState{}, err
}
if !found {
return RoundState{}, ErrNoRound
}
st, err := r.settle(ctx, userID, rd)
if err != nil {
return RoundState{}, err
}
if rd.Finished() {
return RoundState{Round: rd, Stats: st}, nil
}
rd.GaveUp = true
if st, err = r.finish(ctx, userID, &rd, StatusLost); err != nil {
return RoundState{}, err
}
return RoundState{Round: rd, Stats: st, Ended: true}, nil
}
// Replace starts the player's next round without counting the current one,
// for a round that can no longer be played, such as one whose answer left
// the game's data.
func (r *Rounds) Replace(ctx context.Context, userID int64, maxGuesses int) (RoundState, error) {
defer r.locks.Acquire(roundLockKey(userID))()
prev, _, err := r.load(ctx, userID)
if err != nil {
return RoundState{}, err
}
rd, err := r.start(ctx, userID, prev, maxGuesses)
if err != nil {
return RoundState{}, err
}
st, err := r.LoadStats(ctx, userID)
if err != nil {
return RoundState{}, err
}
return RoundState{Round: rd, Stats: st}, nil
}