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

408 lines
13 KiB
Go

package loldle
import (
"context"
"errors"
"net/http"
"strconv"
"time"
"github.com/tiennm99/tiennm99bot/internal/log"
"github.com/tiennm99/tiennm99bot/internal/modules/loldle/web"
"github.com/tiennm99/tiennm99bot/internal/modules/util/guessgame"
"github.com/tiennm99/tiennm99bot/internal/modules/util/htmlgame"
)
// requestsPerMinute caps one player's API calls, every endpoint together.
const requestsPerMinute = 60
// maxNameBytes caps a guessed champion name in a request.
const maxNameBytes = 64
// 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 champion. Send /loldle for unlimited rounds."}
errNewPuzzleAPI = apiError{http.StatusConflict, "new_puzzle", "A new daily champion is up."}
errNewRoundAPI = apiError{http.StatusConflict, "new_round", "A new round has started."}
errDataChangedAPI = apiError{http.StatusConflict, "new_round", "Champion data changed, so a new round has started."}
errUnknownAPI = apiError{http.StatusUnprocessableEntity, "unknown", "Champion not found."}
errAmbiguousAPI = apiError{http.StatusUnprocessableEntity, "ambiguous", "Several champions match. Pick one from the list."}
errDuplicateAPI = apiError{http.StatusUnprocessableEntity, "duplicate", "You already guessed that champion."}
errFinishedAPI = apiError{http.StatusUnprocessableEntity, "finished", "You have already finished today's champion."}
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"
)
// cellView is one attribute of a guess: the guessed champion's own value
// and how it compares with the answer. It never carries the answer's value.
type cellView struct {
Key string `json:"key"`
Value string `json:"value"`
Result string `json:"result"` // correct | partial | wrong
Dir string `json:"dir,omitempty"` // up | down, on a wrong release year
}
// rowView is one guess on the board.
type rowView struct {
Name string `json:"name"`
ID string `json:"id"`
Marks string `json:"marks"`
Cells []cellView `json:"cells"`
}
// champView is the answer, shown once the game is over.
type champView struct {
Name string `json:"name"`
ID string `json:"id"`
Title string `json:"title,omitempty"`
}
// columnView names one attribute column.
type columnView struct {
Key string `json:"key"`
Label string `json:"label"`
}
// 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"`
Columns []columnView `json:"columns"`
Guesses []rowView `json:"guesses"`
Status string `json:"status"`
GaveUp bool `json:"gave_up,omitempty"`
Answer *champView `json:"answer,omitempty"`
Stats *statsView `json:"stats,omitempty"`
NextAt int64 `json:"next_at,omitempty"`
Share string `json:"share,omitempty"`
// Abandoned is the champion of the unlimited round a New game gave up
// as a loss, so the page can show it before the next round.
Abandoned *champView `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"`
Name string `json:"name"`
}
// 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, the champion list and the JSON API
// under routePrefix. Images may come from Data Dragon, which serves the
// champion icons, and from nowhere else.
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("GET "+routePrefix+"champions.json", s.serveChampions)
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, ddragonOrigin)
}
// serveChampions is the search list: every champion's name and icon id,
// nothing that hints at an answer.
func (s *service) serveChampions(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=3600")
_, _ = w.Write(s.championList)
}
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
}
// cardMax is the length of an unlimited round started from the token's
// card: its chat's /loldle_setmax, or the default for an inline card.
func (s *service) cardMax(ctx context.Context, c guessgame.Claims) (int, error) {
if c.InlineID != "" {
return MaxGuesses, nil
}
return s.maxFor(ctx, strconv.FormatInt(c.ChatID, 10))
}
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
}
ctx := r.Context()
if c.Unlimited() {
maxGuesses, err := s.cardMax(ctx, c)
var st guessgame.RoundState
if err == nil {
st, err = s.rounds.Load(ctx, c.UserID, maxGuesses)
}
s.writeRound(w, c, st, err)
return
}
o, err := s.LoadState(ctx, c)
if err != nil {
log.Error("loldle 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.Name) > maxNameBytes {
writeError(w, errBadRequestAPI)
return
}
c, ok := s.authorize(w, req.Token, s.cfg.now())
if !ok {
return
}
ctx := r.Context()
if c.Unlimited() {
maxGuesses, err := s.cardMax(ctx, c)
var st guessgame.RoundState
if err == nil {
st, err = s.rounds.Guess(ctx, c.UserID, req.Seq, req.Name, maxGuesses)
}
if errors.Is(err, errTargetGone) {
// The round's answer left the data: replace it, uncounted.
if _, err = s.rounds.Replace(ctx, c.UserID, maxGuesses); err == nil {
writeError(w, errDataChangedAPI)
return
}
}
s.writeRound(w, c, st, err)
return
}
o, err := s.SubmitGuess(ctx, c, req.Num, req.Name)
switch {
case errors.Is(err, guessgame.ErrNewPuzzle):
writeError(w, errNewPuzzleAPI)
case errors.Is(err, guessgame.ErrFinished):
writeError(w, errFinishedAPI)
case guessError(w, err):
case err != nil:
log.Error("loldle 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
}
maxGuesses, err := s.cardMax(r.Context(), c)
var st guessgame.RoundState
if err == nil {
st, err = s.rounds.New(r.Context(), c.UserID, req.Seq, maxGuesses)
}
s.writeRound(w, c, st, err)
}
// guessError writes the API error of a refused guess and reports whether
// err was one.
func guessError(w http.ResponseWriter, err error) bool {
switch {
case errors.Is(err, errUnknownChampion):
writeError(w, errUnknownAPI)
case errors.Is(err, errAmbiguous):
writeError(w, errAmbiguousAPI)
case errors.Is(err, errDuplicate):
writeError(w, errDuplicateAPI)
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 guessError(w, err):
case err != nil:
log.Error("loldle unlimited request failed", "err", err)
writeError(w, errInternalAPI)
default:
htmlgame.WriteJSON(w, http.StatusOK, s.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,
Columns: columns(),
Guesses: s.rowViews(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 = s.champView(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 (s *service) roundView(c guessgame.Claims, st guessgame.RoundState) view {
rd := st.Round
v := view{
Mode: modeUnlimited,
Seq: rd.Seq,
Player: c.Name,
Max: rd.MaxGuesses,
Columns: columns(),
Guesses: s.rowViews(rd.Guesses),
Status: rd.Status,
GaveUp: rd.GaveUp,
}
if st.Abandoned != nil {
v.Abandoned = s.champView(st.Abandoned.Target)
}
if !rd.Finished() {
return v
}
v.Answer = s.champView(rd.Target)
v.Stats = statsOf(st.Stats.Played, st.Stats.Wins, st.Stats.CurStreak, st.Stats.MaxStreak, st.Stats.Dist)
return v
}
func columns() []columnView {
out := make([]columnView, len(classicAttributes))
for i, a := range classicAttributes {
out[i] = columnView{Key: a.Key, Label: a.Label}
}
return out
}
// rowViews renders the guesses newest last, as stored; the page orders
// them.
func (s *service) rowViews(gs []guessgame.Guess) []rowView {
out := make([]rowView, len(gs))
for i, g := range gs {
row := rowView{Name: g.Word, Marks: g.Marks, Cells: make([]cellView, 0, len(classicAttributes))}
if c := findChampionByExactName(s.cfg.champions, g.Word); c != nil {
row.ID = c.ID
}
for _, r := range s.rowsOf(g) {
row.Cells = append(row.Cells, cellView{Key: r.Key, Value: r.GuessValue, Result: r.Result, Dir: r.Direction})
}
out[i] = row
}
return out
}
func (s *service) champView(name string) *champView {
v := &champView{Name: name}
if c := findChampionByExactName(s.cfg.champions, name); c != nil {
v.ID, v.Title = c.ID, c.Title
}
return v
}
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}
}