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