mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-12 18:16:55 +00:00
/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.
408 lines
13 KiB
Go
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}
|
|
}
|