Files
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

311 lines
9.9 KiB
Go

package wordledaily
import (
"errors"
"net/http"
"strings"
"time"
"github.com/tiennm99/tiennm99bot/internal/log"
"github.com/tiennm99/tiennm99bot/internal/modules/util/guessgame"
"github.com/tiennm99/tiennm99bot/internal/modules/util/htmlgame"
"github.com/tiennm99/tiennm99bot/internal/modules/wordle/wordlist"
"github.com/tiennm99/tiennm99bot/internal/modules/wordledaily/web"
)
// requestsPerMinute caps one player's API calls, every endpoint together.
const requestsPerMinute = 60
// apiError is the JSON error body every API failure returns.
type apiError struct {
status int
Code string `json:"error"`
Message string `json:"message"`
}
var (
errBadRequestAPI = apiError{http.StatusBadRequest, "bad_request", "Bad request."}
errBadTokenAPI = apiError{http.StatusUnauthorized, "bad_token", "This game link is not valid. Open the game again from the chat."}
errExpiredAPI = apiError{http.StatusUnauthorized, "expired", "This game link has expired. Open the game again from the chat."}
errBadModeAPI = apiError{http.StatusForbidden, "bad_mode", "This card plays the daily puzzle. Send /wordle for unlimited rounds."}
errNewPuzzleAPI = apiError{http.StatusConflict, "new_puzzle", "A new puzzle has started."}
errNewRoundAPI = apiError{http.StatusConflict, "new_round", "A new round has started."}
errLengthAPI = apiError{http.StatusUnprocessableEntity, "length", "Word must be exactly 5 letters."}
errUnknownAPI = apiError{http.StatusUnprocessableEntity, "unknown", "Not in the word list."}
errFinishedAPI = apiError{http.StatusUnprocessableEntity, "finished", "You have already finished today's puzzle."}
errRoundFinishedAPI = apiError{http.StatusUnprocessableEntity, "finished", "This round is over. Start a new game."}
errTooFastAPI = apiError{http.StatusTooManyRequests, "rate_limited", "Too many requests. Wait a moment and try again."}
errInternalAPI = apiError{http.StatusInternalServerError, "internal", "Something went wrong. Try again later."}
)
// Page modes, as the view reports them.
const (
modeDaily = "daily"
modeUnlimited = "unlimited"
)
// guessView is one row of the board: the word and its c/p/w marks.
type guessView struct {
Word string `json:"word"`
Marks string `json:"marks"`
}
// statsView is the player's record in the card's mode, shown once the game
// is over.
type statsView struct {
Played int `json:"played"`
WinPct int `json:"win_pct"`
Cur int `json:"cur"`
Max int `json:"max"`
Dist []int `json:"dist"`
}
// view is the state the page renders. The answer, the stats and the share
// text are present only once the game is over. A daily view carries the
// puzzle's number, date and the next puzzle's start; an unlimited one the
// round's number.
type view struct {
Mode string `json:"mode"`
Num int `json:"num,omitempty"`
Date string `json:"date,omitempty"`
Seq int `json:"seq,omitempty"`
Player string `json:"player"`
Max int `json:"max"`
Len int `json:"len"`
Guesses []guessView `json:"guesses"`
Status string `json:"status"`
GaveUp bool `json:"gave_up,omitempty"`
Answer string `json:"answer,omitempty"`
Stats *statsView `json:"stats,omitempty"`
NextAt int64 `json:"next_at,omitempty"`
Share string `json:"share,omitempty"`
// Abandoned is the answer of the unlimited round a New game gave up as
// a loss, so the page can show it before the next round.
Abandoned string `json:"abandoned,omitempty"`
}
type stateRequest struct {
Token string `json:"token"`
}
// guessRequest is a guess on the board the page shows: Num names a daily
// puzzle, Seq an unlimited round.
type guessRequest struct {
Token string `json:"token"`
Num int `json:"num"`
Seq int `json:"seq"`
Word string `json:"word"`
}
// newRequest starts the next unlimited round after round Seq.
type newRequest struct {
Token string `json:"token"`
Seq int `json:"seq"`
}
// handler serves the page, its assets and the JSON API under routePrefix.
func (s *service) handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET "+routePrefix+"{$}", htmlgame.ServeAsset(web.FS, "index.html"))
mux.HandleFunc("GET "+routePrefix+"app.js", htmlgame.ServeAsset(web.FS, "app.js"))
mux.HandleFunc("GET "+routePrefix+"app.css", htmlgame.ServeAsset(web.FS, "app.css"))
mux.HandleFunc("POST "+routePrefix+"api/state", s.apiState)
mux.HandleFunc("POST "+routePrefix+"api/guess", s.apiGuess)
mux.HandleFunc("POST "+routePrefix+"api/new", s.apiNew)
return htmlgame.SecurityHeaders(routePrefix, mux)
}
func writeError(w http.ResponseWriter, e apiError) { htmlgame.WriteJSON(w, e.status, e) }
// authorize verifies the token and the player's request budget, writing
// the error itself when either fails.
func (s *service) authorize(w http.ResponseWriter, token string, now time.Time) (guessgame.Claims, bool) {
c, err := s.verifyToken(token, now)
switch {
case errors.Is(err, htmlgame.ErrTokenExpired):
writeError(w, errExpiredAPI)
return guessgame.Claims{}, false
case err != nil:
writeError(w, errBadTokenAPI)
return guessgame.Claims{}, false
}
if !s.limiter.Allow(c.UserID, now) {
writeError(w, errTooFastAPI)
return guessgame.Claims{}, false
}
return c, true
}
func (s *service) apiState(w http.ResponseWriter, r *http.Request) {
var req stateRequest
if htmlgame.DecodeJSON(w, r, &req) != nil {
writeError(w, errBadRequestAPI)
return
}
c, ok := s.authorize(w, req.Token, s.cfg.now())
if !ok {
return
}
if c.Unlimited() {
st, err := s.rounds.Load(r.Context(), c.UserID, maxGuesses)
s.writeRound(w, c, st, err)
return
}
o, err := s.LoadState(r.Context(), c)
if err != nil {
log.Error("wordledaily state failed", "err", err)
writeError(w, errInternalAPI)
return
}
htmlgame.WriteJSON(w, http.StatusOK, s.view(c, o))
}
func (s *service) apiGuess(w http.ResponseWriter, r *http.Request) {
var req guessRequest
if htmlgame.DecodeJSON(w, r, &req) != nil || len(req.Word) > 32 {
writeError(w, errBadRequestAPI)
return
}
c, ok := s.authorize(w, req.Token, s.cfg.now())
if !ok {
return
}
if c.Unlimited() {
st, err := s.rounds.Guess(r.Context(), c.UserID, req.Seq, req.Word, maxGuesses)
s.writeRound(w, c, st, err)
return
}
o, err := s.SubmitGuess(r.Context(), c, req.Num, req.Word)
switch {
case errors.Is(err, guessgame.ErrNewPuzzle):
writeError(w, errNewPuzzleAPI)
case errors.Is(err, guessgame.ErrFinished):
writeError(w, errFinishedAPI)
case wordError(w, err):
case err != nil:
log.Error("wordledaily guess failed", "err", err)
writeError(w, errInternalAPI)
default:
htmlgame.WriteJSON(w, http.StatusOK, s.view(c, o))
}
}
// apiNew starts the player's next unlimited round, giving up the current
// one. A stale seq answers with the current round, so a double click
// starts one round.
func (s *service) apiNew(w http.ResponseWriter, r *http.Request) {
var req newRequest
if htmlgame.DecodeJSON(w, r, &req) != nil || req.Seq < 1 {
writeError(w, errBadRequestAPI)
return
}
c, ok := s.authorize(w, req.Token, s.cfg.now())
if !ok {
return
}
if !c.Unlimited() {
writeError(w, errBadModeAPI)
return
}
st, err := s.rounds.New(r.Context(), c.UserID, req.Seq, maxGuesses)
s.writeRound(w, c, st, err)
}
// wordError writes the API error of a refused word and reports whether err
// was one.
func wordError(w http.ResponseWriter, err error) bool {
switch {
case errors.Is(err, errWordEmpty), errors.Is(err, errWordLength):
writeError(w, errLengthAPI)
case errors.Is(err, errWordUnknown):
writeError(w, errUnknownAPI)
default:
return false
}
return true
}
// writeRound answers an unlimited request.
func (s *service) writeRound(w http.ResponseWriter, c guessgame.Claims, st guessgame.RoundState, err error) {
switch {
case errors.Is(err, guessgame.ErrNewRound):
writeError(w, errNewRoundAPI)
case errors.Is(err, guessgame.ErrFinished):
writeError(w, errRoundFinishedAPI)
case wordError(w, err):
case err != nil:
log.Error("wordledaily unlimited request failed", "err", err)
writeError(w, errInternalAPI)
default:
htmlgame.WriteJSON(w, http.StatusOK, roundView(c, st))
}
}
// view renders a daily game for the page. The answer leaves the server
// only once the game is over.
func (s *service) view(c guessgame.Claims, o guessgame.Outcome) view {
v := view{
Mode: modeDaily,
Num: o.Num,
Date: s.PuzzleDate(o.Num),
Player: c.Name,
Max: maxGuesses,
Len: wordlist.WordLength,
Guesses: guessViews(o.P.Guesses),
Status: o.P.Status,
NextAt: s.PuzzleStart(o.Num + 1).Unix(),
}
if v.Status == "" {
v.Status = guessgame.StatusPlaying
}
if !o.P.Finished() {
return v
}
v.Answer = strings.ToUpper(o.Answer)
st := o.Stats
v.Stats = statsOf(st.Played, st.Wins, guessgame.DisplayStreak(st, o.Num), st.MaxStreak, st.Dist)
v.Share = s.ShareText(o.P)
return v
}
// roundView renders an unlimited round for the page. The answer leaves the
// server only once the round is over.
func roundView(c guessgame.Claims, st guessgame.RoundState) view {
rd := st.Round
v := view{
Mode: modeUnlimited,
Seq: rd.Seq,
Player: c.Name,
Max: rd.MaxGuesses,
Len: wordlist.WordLength,
Guesses: guessViews(rd.Guesses),
Status: rd.Status,
GaveUp: rd.GaveUp,
}
if st.Abandoned != nil {
v.Abandoned = strings.ToUpper(st.Abandoned.Target)
}
if !rd.Finished() {
return v
}
v.Answer = strings.ToUpper(rd.Target)
v.Stats = statsOf(st.Stats.Played, st.Stats.Wins, st.Stats.CurStreak, st.Stats.MaxStreak, st.Stats.Dist)
return v
}
func guessViews(gs []guessgame.Guess) []guessView {
out := make([]guessView, len(gs))
for i, g := range gs {
out[i] = guessView{Word: strings.ToUpper(g.Word), Marks: g.Marks}
}
return out
}
func statsOf(played, wins, cur, maxStreak int, dist []int) *statsView {
pct := 0
if played > 0 {
pct = (wins*100 + played/2) / played
}
return &statsView{Played: played, WinPct: pct, Cur: cur, Max: maxStreak, Dist: dist}
}