Files
tiennm99 c0231ceadf feat(wordledaily): add the daily Wordle HTML5 game
/wordledaily sends a BotFather game card whose page plays one shared word
per day, changing at 07:00 ICT. The server checks guesses, saves each
player's board after every guess and keeps NYT-style stats. A win is
reported with setGameScore, and in a group one live results message shows
everyone's colour grids.

/wordledaily_subscribe and /wordledaily_unsubscribe opt a chat or topic in
to the 07:00 push: yesterday's recap, opening with the answer and the
group's win streak, then today's card.

The HTML5 game plumbing that noitu used is now shared in util/htmlgame, and
the wordle word list and scoring live in wordle/wordlist.
2026-10-09 17:20:02 +07:00

404 lines
12 KiB
Go

package noitu
import (
"errors"
"net/http"
"strings"
"time"
"unicode/utf8"
"github.com/tiennm99/tiennm99bot/internal/log"
"github.com/tiennm99/tiennm99bot/internal/modules/noitu/dict"
"github.com/tiennm99/tiennm99bot/internal/modules/noitu/engine"
"github.com/tiennm99/tiennm99bot/internal/modules/noitu/opponent"
"github.com/tiennm99/tiennm99bot/internal/modules/util/htmlgame"
)
const (
maxBodyBytes = htmlgame.MaxBodyBytes
maxWordRunes = 64
maxWordTokens = 8
)
// apiError is the JSON error body every API failure returns.
type apiError struct {
status int
Code string `json:"error"`
Message string `json:"message"`
}
func (e apiError) Error() string { return "noitu: " + e.Code }
var (
errBadRequest = apiError{http.StatusBadRequest, "bad_request", "Yêu cầu không hợp lệ."}
errWordTooLong = apiError{http.StatusBadRequest, "bad_request", "Từ quá dài."}
errBadTokenAPI = apiError{http.StatusUnauthorized, "bad_token", "Liên kết trò chơi không hợp lệ. Hãy mở lại trò chơi từ Telegram."}
errTokenExpAPI = apiError{http.StatusUnauthorized, "token_expired", "Liên kết trò chơi đã hết hạn. Hãy bấm Chơi trong Telegram để mở lại."}
errNoSession = apiError{http.StatusNotFound, "no_session", "Ván chơi không còn nữa. Hãy bắt đầu ván mới."}
errGameOver = apiError{http.StatusConflict, "game_over", "Ván chơi đã kết thúc."}
errTooFast = apiError{http.StatusTooManyRequests, "too_fast", "Bạn thao tác nhanh quá, thử lại sau giây lát."}
errBusyAPI = apiError{http.StatusServiceUnavailable, "busy", "Máy chủ đang bận, thử lại sau."}
errInternalAPI = apiError{http.StatusInternalServerError, "internal", "Có lỗi xảy ra, thử lại sau."}
rejectMessages = map[engine.Reason]string{
engine.ReasonTooFewSyllables: "Từ phải có ít nhất 2 tiếng.",
engine.ReasonNotInDictionary: "Từ này không có trong từ điển.",
engine.ReasonWrongLink: "Từ phải bắt đầu bằng tiếng “%s”.",
engine.ReasonAlreadyUsed: "Từ này đã được dùng rồi.",
engine.ReasonTimeout: "Hết giờ!",
}
)
// sessionView is the game state the page renders. See docs/noitu-game.md.
type sessionView struct {
Session string `json:"session"`
Player string `json:"player"`
Difficulty string `json:"difficulty"`
Status string `json:"status"` // playing | won | lost
EndReason string `json:"end_reason"`
Current string `json:"current"`
Chain []chainEntry `json:"chain"`
Score int `json:"score"`
TurnLimitMS int64 `json:"turn_limit_ms"`
DeadlineMS int64 `json:"deadline_ms"`
ServerNowMS int64 `json:"server_now_ms"`
Suggestions []string `json:"suggestions"`
ScoreReported string `json:"score_reported"`
}
type moveResult struct {
Accepted bool `json:"accepted"`
Reason string `json:"reason"`
Message string `json:"message"`
PlayerWord string `json:"player_word"`
BotWord string `json:"bot_word"`
}
type moveResponse struct {
Result moveResult `json:"result"`
State sessionView `json:"state"`
}
// 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+"{$}", serveAsset("index.html"))
mux.HandleFunc("GET "+routePrefix+"app.js", serveAsset("app.js"))
mux.HandleFunc("GET "+routePrefix+"app.css", serveAsset("app.css"))
mux.HandleFunc("POST "+routePrefix+"api/start", s.apiStart)
mux.HandleFunc("POST "+routePrefix+"api/move", s.apiMove)
mux.HandleFunc("POST "+routePrefix+"api/state", s.apiState)
mux.HandleFunc("POST "+routePrefix+"api/give-up", s.apiGiveUp)
mux.HandleFunc("POST "+routePrefix+"api/room/join", s.apiRoomJoin)
mux.HandleFunc("POST "+routePrefix+"api/room/state", s.apiRoomState)
mux.HandleFunc("POST "+routePrefix+"api/room/start", s.apiRoomStart)
mux.HandleFunc("POST "+routePrefix+"api/room/move", s.apiRoomMove)
mux.HandleFunc("POST "+routePrefix+"api/room/give-up", s.apiRoomGiveUp)
return securityHeaders(mux)
}
// securityHeaders applies to every response under the game prefix. The token
// travels in the page URL, so no referrer may leak it. Telegram Web embeds
// games in an iframe, so framing is deliberately not restricted.
func securityHeaders(next http.Handler) http.Handler {
return htmlgame.SecurityHeaders(routePrefix, next)
}
func writeJSON(w http.ResponseWriter, status int, v any) { htmlgame.WriteJSON(w, status, v) }
func writeError(w http.ResponseWriter, e apiError) { writeJSON(w, e.status, e) }
// decode reads one JSON object of at most maxBodyBytes with no unknown fields
// and nothing after it.
func decode(w http.ResponseWriter, r *http.Request, dst any) bool {
if err := htmlgame.DecodeJSON(w, r, dst); err != nil {
writeError(w, errBadRequest)
return false
}
return true
}
type startRequest struct {
Token string `json:"token"`
Difficulty string `json:"difficulty"`
}
func (s *service) apiStart(w http.ResponseWriter, r *http.Request) {
var req startRequest
if !decode(w, r, &req) {
return
}
now := s.cfg.now()
c, ok := s.verifyRequestToken(w, req.Token, now)
if !ok {
return
}
if c.PvP {
writeError(w, errPvPSoloAPI)
return
}
difficulty, ok := opponent.ParseDifficulty(req.Difficulty)
if !ok {
writeError(w, errBadRequest)
return
}
if !s.sessions.allowStart(c.UserID, now) {
writeError(w, errTooFast)
return
}
s.sweep(now)
sess, err := s.newSession(c, difficulty, now)
if err != nil {
log.Error("noitu start failed", "err", err)
writeError(w, errInternalAPI)
return
}
displaced, err := s.sessions.add(sess)
if err != nil {
writeError(w, errBusyAPI)
return
}
if displaced != nil {
s.settle(displaced, now)
}
sess.mu.Lock()
defer sess.mu.Unlock()
writeJSON(w, http.StatusOK, s.view(sess, now))
}
func (s *service) newSession(c claims, d opponent.Difficulty, now time.Time) (*session, error) {
id, err := newSessionID()
if err != nil {
return nil, err
}
rng := s.cfg.newRNG()
strategy, err := opponent.New(d, rng)
if err != nil {
return nil, err
}
eng, err := engine.New(s.words, s.words.RandomOpening(rng, minOpeningOut), turnLimit, turnGrace, now)
if err != nil {
return nil, err
}
sess := &session{id: id, claims: c, difficulty: d, eng: eng, strategy: strategy, lastSeen: now}
sess.chain = append(sess.chain, s.entry(eng.Opening(), "bot", 0))
return sess, nil
}
func (s *service) entry(word, by string, points int) chainEntry {
m := s.words.Meanings(word)
if m == nil {
m = []dict.Sense{}
}
return chainEntry{Word: word, By: by, Points: points, Meanings: m}
}
type sessionRequest struct {
Session string `json:"session"`
}
type moveRequest struct {
Session string `json:"session"`
Word string `json:"word"`
}
// lockedSession finds and locks the session named in a request, settling an
// expired turn first and answering no_session when the session is gone.
// timedOut reports that this call ended the game on the clock. On ok the
// caller must unlock.
func (s *service) lockedSession(w http.ResponseWriter, id string, now time.Time) (sess *session, timedOut, ok bool) {
sess = s.sessions.get(id)
if sess == nil {
writeError(w, errNoSession)
return nil, false, false
}
sess.mu.Lock()
if sess.eng.Timeout(now) {
s.finish(sess, now)
timedOut = true
}
if sess.expired(now) {
sess.mu.Unlock()
s.sessions.remove(sess)
writeError(w, errNoSession)
return nil, false, false
}
return sess, timedOut, true
}
func (s *service) apiState(w http.ResponseWriter, r *http.Request) {
var req sessionRequest
if !decode(w, r, &req) {
return
}
now := s.cfg.now()
sess, _, ok := s.lockedSession(w, req.Session, now)
if !ok {
return
}
defer sess.mu.Unlock()
if !sess.eng.Over() {
sess.lastSeen = now
}
writeJSON(w, http.StatusOK, s.view(sess, now))
}
func (s *service) apiGiveUp(w http.ResponseWriter, r *http.Request) {
var req sessionRequest
if !decode(w, r, &req) {
return
}
now := s.cfg.now()
sess, _, ok := s.lockedSession(w, req.Session, now)
if !ok {
return
}
defer sess.mu.Unlock()
if s.resign(sess) {
s.finish(sess, now)
}
writeJSON(w, http.StatusOK, s.view(sess, now))
}
func (s *service) apiMove(w http.ResponseWriter, r *http.Request) {
var req moveRequest
if !decode(w, r, &req) {
return
}
now := s.cfg.now()
sess, timedOut, ok := s.lockedSession(w, req.Session, now)
if !ok {
return
}
defer sess.mu.Unlock()
if timedOut {
// The move arrived after the deadline plus grace: it ended the game.
writeJSON(w, http.StatusOK, moveResponse{Result: s.rejected(sess, engine.ReasonTimeout), State: s.view(sess, now)})
return
}
if sess.eng.Over() {
writeError(w, errGameOver)
return
}
if now.Sub(sess.lastMove) < moveInterval {
writeError(w, errTooFast)
return
}
sess.lastMove, sess.lastSeen = now, now
if wordTooLong(req.Word) {
writeError(w, errWordTooLong)
return
}
result, err := s.play(sess, req.Word, now)
if err != nil {
log.Error("noitu bot move failed", "err", err)
writeError(w, errInternalAPI)
return
}
writeJSON(w, http.StatusOK, moveResponse{Result: result, State: s.view(sess, now)})
}
func wordTooLong(word string) bool {
return utf8.RuneCountInString(word) > maxWordRunes || len(strings.Fields(word)) > maxWordTokens
}
// play submits the human's word and, when it is accepted, the bot's reply.
// Caller holds sess.mu.
func (s *service) play(sess *session, word string, now time.Time) (moveResult, error) {
mv, reason := sess.eng.Submit(engine.Human, word, now)
if reason != engine.ReasonNone {
if sess.eng.Over() {
s.finish(sess, now)
}
return s.rejected(sess, reason), nil
}
sess.chain = append(sess.chain, s.entry(mv.Word, "player", mv.Points))
result := moveResult{Accepted: true, PlayerWord: mv.Word}
switch {
case len(sess.chain) >= maxChain:
sess.eng.EndMaxMoves()
case sess.eng.BotStuck():
// The bot has no reply: BotStuck already ended the game as a win.
default:
botWord, err := s.botMove(sess, now)
if err != nil {
return moveResult{}, err
}
result.BotWord = botWord
if len(sess.chain) >= maxChain {
sess.eng.EndMaxMoves()
}
}
if sess.eng.Over() {
s.finish(sess, now)
}
return result, nil
}
// botMove plays the strategy's choice. Every strategy picks from the legal
// moves, so a rejection here is a bug; fall back to the first legal move
// rather than leaving the game stuck on the bot's turn.
func (s *service) botMove(sess *session, now time.Time) (string, error) {
word, err := sess.strategy.Choose(sess.eng)
if err != nil {
word = ""
}
mv, reason := sess.eng.Submit(engine.Bot, word, now)
if reason != engine.ReasonNone {
legal := sess.eng.LegalMoves()
if len(legal) == 0 {
return "", errors.New("noitu: bot has no move but BotStuck did not end the game")
}
if mv, reason = sess.eng.Submit(engine.Bot, legal[0], now); reason != engine.ReasonNone {
return "", errors.New("noitu: engine rejected a legal bot move: " + string(reason))
}
}
sess.chain = append(sess.chain, s.entry(mv.Word, "bot", mv.Points))
return mv.Word, nil
}
func (s *service) rejected(sess *session, reason engine.Reason) moveResult {
return rejectedFor(reason, sess.eng.Current())
}
// rejectedFor explains a rejected word; current is the syllable to answer.
func rejectedFor(reason engine.Reason, current string) moveResult {
msg := rejectMessages[reason]
if reason == engine.ReasonWrongLink {
msg = strings.Replace(msg, "%s", current, 1)
}
return moveResult{Reason: string(reason), Message: msg}
}
// view renders the session for the page. Caller holds sess.mu.
func (s *service) view(sess *session, now time.Time) sessionView {
v := sessionView{
Session: sess.id,
Player: sess.claims.Name,
Difficulty: string(sess.difficulty),
Status: "playing",
EndReason: string(sess.eng.EndReason()),
Current: sess.eng.Current(),
Chain: sess.chain,
Score: sess.eng.Score(),
TurnLimitMS: turnLimit.Milliseconds(),
ServerNowMS: now.UnixMilli(),
Suggestions: []string{},
ScoreReported: sess.report,
}
if sess.eng.Over() {
v.Status = "lost"
if sess.eng.Winner() == engine.Human {
v.Status = "won"
}
if sess.suggestions != nil {
v.Suggestions = sess.suggestions
}
return v
}
v.DeadlineMS = sess.eng.Deadline().UnixMilli()
return v
}