mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-11 03:13:46 +00:00
feat(noitu): add /noitupvp group play
/noitupvp posts the noitu game card in a group and records it as a PvP card. Everyone who presses Play on that card joins one room: the first member hosts and starts, 2 to 8 players take 30-second turns, a player whose turn runs out or who gives up is eliminated, and the last one standing wins. Each player's points, plus a winner bonus, go to the card's high-score table, and the bot replies to the card with the result. The engine now shares one board between the solo game and the new multiplayer match, so both use the same word rules and scoring.
This commit is contained in:
1 parent
231fc96b5f
commit
b8565164b3
24 files changed
+3200
-215
No files matched your search
@@ -16,8 +16,9 @@ const (
|
||||
)
|
||||
|
||||
// handlePlay answers a Play press with the game URL carrying a signed token,
|
||||
// or with an alert when the game cannot open. Every path answers the query,
|
||||
// so the client never keeps spinning.
|
||||
// or with an alert when the game cannot open. A press on a /noitupvp card
|
||||
// marks the token PvP, so the page joins the card's room. Every path answers
|
||||
// the query, so the client never keeps spinning.
|
||||
func (s *service) handlePlay(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
q := update.CallbackQuery
|
||||
if !s.enabled() {
|
||||
@@ -27,6 +28,16 @@ func (s *service) handlePlay(ctx context.Context, b *bot.Bot, update *models.Upd
|
||||
if form == "" {
|
||||
return answerAlert(ctx, b, q.ID, msgNoGameTarget)
|
||||
}
|
||||
// Cards live only in groups, whose chat IDs are negative; a private chat
|
||||
// never needs the lookup, so a storage fault cannot block its solo game.
|
||||
if c.InlineID == "" && c.ChatID < 0 {
|
||||
card, ok, err := s.lookupCard(ctx, c.ChatID, c.MessageID)
|
||||
if err != nil {
|
||||
_ = answerAlert(ctx, b, q.ID, msgCardLookup)
|
||||
return err
|
||||
}
|
||||
c.PvP, c.ThreadID = ok, card.ThreadID
|
||||
}
|
||||
c.Expiry = s.cfg.now().Add(tokenTTL).Unix()
|
||||
token, err := signToken(s.cfg.key, c)
|
||||
if err != nil {
|
||||
@@ -35,7 +46,7 @@ func (s *service) handlePlay(ctx context.Context, b *bot.Bot, update *models.Upd
|
||||
}
|
||||
// The form tells whether ?game= shares arrive as inline messages; the IDs
|
||||
// themselves are never logged.
|
||||
log.Debug("noitu play", "address", form)
|
||||
log.Debug("noitu play", "address", form, "pvp", c.PvP)
|
||||
_, err = b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{
|
||||
CallbackQueryID: q.ID,
|
||||
URL: s.cfg.baseURL + routePrefix + "?t=" + url.QueryEscape(token),
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"iter"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules/noitu/dict"
|
||||
)
|
||||
|
||||
// board is the state and the word rules every game shares, whoever plays it:
|
||||
// the dictionary, the chain so far, the syllable to answer, the used words
|
||||
// and the turn clock. Engine (the human against the bot) and Match (people
|
||||
// against each other) embed it and add only their own turn order.
|
||||
type board struct {
|
||||
dict Dictionary
|
||||
opening string
|
||||
used map[string]struct{}
|
||||
current string
|
||||
turnLimit time.Duration
|
||||
grace time.Duration
|
||||
deadline time.Time
|
||||
history []Move
|
||||
}
|
||||
|
||||
// newBoard opens a chain on opening, with the first turn starting now.
|
||||
//
|
||||
// grace is extra time after the deadline during which a submission still
|
||||
// counts, to absorb network latency. It never earns speed points.
|
||||
func newBoard(d Dictionary, opening string, turnLimit, grace time.Duration, now time.Time) (board, error) {
|
||||
if d == nil {
|
||||
return board{}, errors.New("engine: nil dictionary")
|
||||
}
|
||||
if turnLimit <= 0 || grace < 0 {
|
||||
return board{}, fmt.Errorf("engine: invalid turn limit %v / grace %v", turnLimit, grace)
|
||||
}
|
||||
canonical, ok := d.Resolve(opening)
|
||||
if !ok {
|
||||
return board{}, fmt.Errorf("engine: opening word %q is not in the dictionary", opening)
|
||||
}
|
||||
last, _ := d.LastSyllable(canonical)
|
||||
b := board{
|
||||
dict: d,
|
||||
opening: canonical,
|
||||
used: map[string]struct{}{canonical: {}},
|
||||
current: last,
|
||||
turnLimit: turnLimit,
|
||||
grace: grace,
|
||||
deadline: now.Add(turnLimit),
|
||||
}
|
||||
if !b.HasLegalMove() {
|
||||
return board{}, fmt.Errorf("engine: opening word %q ends on %q, which starts no other word", canonical, last)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// check validates a word against the chain without playing it. On success it
|
||||
// returns the move (By and Seat left for the caller) and the word's last
|
||||
// syllable.
|
||||
//
|
||||
// Checks run in this order: syllable count, dictionary, link, reuse.
|
||||
// Resolving before the link check matters: the canonical spelling can change
|
||||
// the first syllable ("sỹ hai" → "sĩ hai").
|
||||
func (b *board) check(raw string, now time.Time) (Move, string, Reason) {
|
||||
normalized, syllables, err := dict.Normalize(raw)
|
||||
if err != nil || !dict.HasEnoughSyllables(syllables) {
|
||||
return Move{}, "", ReasonTooFewSyllables
|
||||
}
|
||||
canonical, ok := b.dict.Resolve(normalized)
|
||||
if !ok {
|
||||
return Move{}, "", ReasonNotInDictionary
|
||||
}
|
||||
first, _ := b.dict.FirstSyllable(canonical)
|
||||
if first != b.current {
|
||||
return Move{}, "", ReasonWrongLink
|
||||
}
|
||||
if _, played := b.used[canonical]; played {
|
||||
return Move{}, "", ReasonAlreadyUsed
|
||||
}
|
||||
last, _ := b.dict.LastSyllable(canonical)
|
||||
// Count the canonical word's syllables: a variant spelling never changes
|
||||
// the count, but the canonical form is what was played.
|
||||
n := len(syllablesOf(canonical))
|
||||
return Move{Word: canonical, Syllables: n, Points: b.pointsFor(n, first, now), At: now}, last, ReasonNone
|
||||
}
|
||||
|
||||
// play records a move check accepted and hands the next player a full turn.
|
||||
func (b *board) play(m Move, last string, now time.Time) {
|
||||
b.used[m.Word] = struct{}{}
|
||||
b.history = append(b.history, m)
|
||||
b.current = last
|
||||
b.deadline = now.Add(b.turnLimit)
|
||||
}
|
||||
|
||||
func syllablesOf(word string) []string {
|
||||
_, s, _ := dict.Normalize(word)
|
||||
return s
|
||||
}
|
||||
|
||||
// Opening is the word the chain started from.
|
||||
func (b *board) Opening() string { return b.opening }
|
||||
|
||||
// Current is the syllable the next word must start with.
|
||||
func (b *board) Current() string { return b.current }
|
||||
|
||||
// Deadline is when the current turn ends, before the grace period.
|
||||
func (b *board) Deadline() time.Time { return b.deadline }
|
||||
|
||||
// TurnLimit is the length of one turn.
|
||||
func (b *board) TurnLimit() time.Duration { return b.turnLimit }
|
||||
|
||||
// History returns the played moves after the opening word, oldest first.
|
||||
func (b *board) History() []Move { return slices.Clone(b.history) }
|
||||
|
||||
// ChainLength counts the words played, opening word included.
|
||||
func (b *board) ChainLength() int { return len(b.history) + 1 }
|
||||
|
||||
// Expired reports whether the current turn ran out, grace period included.
|
||||
func (b *board) Expired(now time.Time) bool {
|
||||
return now.After(b.deadline.Add(b.grace))
|
||||
}
|
||||
|
||||
// Used reports whether a canonical word has been played.
|
||||
func (b *board) Used(word string) bool {
|
||||
_, ok := b.used[word]
|
||||
return ok
|
||||
}
|
||||
|
||||
// WordsStartingWith iterates the dictionary words starting with syllable,
|
||||
// played ones included.
|
||||
func (b *board) WordsStartingWith(syllable string) iter.Seq[string] {
|
||||
return b.dict.WordsStartingWith(syllable)
|
||||
}
|
||||
|
||||
// LastSyllable reports the last syllable of a canonical word.
|
||||
func (b *board) LastSyllable(word string) (string, bool) {
|
||||
return b.dict.LastSyllable(word)
|
||||
}
|
||||
|
||||
// LegalMoves lists every unused word that answers the current syllable.
|
||||
func (b *board) LegalMoves() []string {
|
||||
var out []string
|
||||
for w := range b.dict.WordsStartingWith(b.current) {
|
||||
if !b.Used(w) {
|
||||
out = append(out, w)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// HasLegalMove reports whether the player to act has anything to play.
|
||||
func (b *board) HasLegalMove() bool {
|
||||
for w := range b.dict.WordsStartingWith(b.current) {
|
||||
if !b.Used(w) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Suggestions lists up to n words the player to act could still play, sorted.
|
||||
// Shown to a player who lost; empty means the position was a dead end.
|
||||
func (b *board) Suggestions(n int) []string {
|
||||
if n <= 0 {
|
||||
return nil
|
||||
}
|
||||
moves := b.LegalMoves()
|
||||
slices.Sort(moves)
|
||||
return moves[:min(n, len(moves))]
|
||||
}
|
||||
@@ -1,20 +1,16 @@
|
||||
// Package engine holds the rules of one nối từ game between a human and the
|
||||
// bot: what counts as a legal move, whose turn it is, when the turn expires
|
||||
// and how a word is scored.
|
||||
// Package engine holds the rules of one nối từ game, either a human against
|
||||
// the bot or people against each other: what counts as a legal move, whose
|
||||
// turn it is, when the turn expires and how a word is scored.
|
||||
//
|
||||
// It is a two-player cut of tiennm99/noitu server/internal/game/engine.go.
|
||||
// It is ported from tiennm99/noitu server/internal/game/engine.go: Engine
|
||||
// is the two-player cut against the bot, Match the game between people.
|
||||
// The engine never reads the clock: every call that depends on time takes a
|
||||
// now argument, so the rules are testable without a timer.
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"iter"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules/noitu/dict"
|
||||
)
|
||||
|
||||
// Side is one of the two players.
|
||||
@@ -55,9 +51,9 @@ type EndReason string
|
||||
// End reasons. EndNone means the game is still in play.
|
||||
const (
|
||||
EndNone EndReason = ""
|
||||
EndTimeout EndReason = "timeout" // the human ran out of time
|
||||
EndNoLegalMove EndReason = "no_legal_move" // the human was left a dead end
|
||||
EndGaveUp EndReason = "gave_up" // the human resigned
|
||||
EndTimeout EndReason = "timeout" // the player to act ran out of time
|
||||
EndNoLegalMove EndReason = "no_legal_move" // the player to act was left a dead end
|
||||
EndGaveUp EndReason = "gave_up" // the player resigned
|
||||
EndBotStuck EndReason = "bot_stuck" // the bot had no legal move
|
||||
EndMaxMoves EndReason = "max_moves" // the chain reached the per-game cap
|
||||
)
|
||||
@@ -72,27 +68,22 @@ type Dictionary interface {
|
||||
}
|
||||
|
||||
// Move is one played word. Word is the canonical spelling, which may differ
|
||||
// from what the player typed.
|
||||
// from what the player typed. By is set in an Engine game, Seat in a Match.
|
||||
type Move struct {
|
||||
By Side
|
||||
Seat int
|
||||
Word string
|
||||
Syllables int
|
||||
Points int
|
||||
At time.Time
|
||||
}
|
||||
|
||||
// Engine is one game. Not safe for concurrent use; the caller serializes.
|
||||
// Engine is one game of the human against the bot. Not safe for concurrent
|
||||
// use; the caller serializes.
|
||||
type Engine struct {
|
||||
dict Dictionary
|
||||
opening string
|
||||
used map[string]struct{}
|
||||
current string
|
||||
turn Side
|
||||
turnLimit time.Duration
|
||||
grace time.Duration
|
||||
deadline time.Time
|
||||
history []Move
|
||||
score int
|
||||
board
|
||||
turn Side
|
||||
score int
|
||||
|
||||
over bool
|
||||
winner Side
|
||||
@@ -105,48 +96,16 @@ type Engine struct {
|
||||
// grace is extra time after the deadline during which a submission still
|
||||
// counts, to absorb network latency. It never earns speed points.
|
||||
func New(d Dictionary, opening string, turnLimit, grace time.Duration, now time.Time) (*Engine, error) {
|
||||
if d == nil {
|
||||
return nil, errors.New("engine: nil dictionary")
|
||||
b, err := newBoard(d, opening, turnLimit, grace, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if turnLimit <= 0 || grace < 0 {
|
||||
return nil, fmt.Errorf("engine: invalid turn limit %v / grace %v", turnLimit, grace)
|
||||
}
|
||||
canonical, ok := d.Resolve(opening)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("engine: opening word %q is not in the dictionary", opening)
|
||||
}
|
||||
last, _ := d.LastSyllable(canonical)
|
||||
e := &Engine{
|
||||
dict: d,
|
||||
opening: canonical,
|
||||
used: map[string]struct{}{canonical: {}},
|
||||
current: last,
|
||||
turn: Human,
|
||||
turnLimit: turnLimit,
|
||||
grace: grace,
|
||||
deadline: now.Add(turnLimit),
|
||||
}
|
||||
if !e.HasLegalMove() {
|
||||
return nil, fmt.Errorf("engine: opening word %q ends on %q, which starts no other word", canonical, last)
|
||||
}
|
||||
return e, nil
|
||||
return &Engine{board: b, turn: Human}, nil
|
||||
}
|
||||
|
||||
// Opening is the bot's opening word.
|
||||
func (e *Engine) Opening() string { return e.opening }
|
||||
|
||||
// Current is the syllable the next word must start with.
|
||||
func (e *Engine) Current() string { return e.current }
|
||||
|
||||
// Turn reports whose move it is.
|
||||
func (e *Engine) Turn() Side { return e.turn }
|
||||
|
||||
// Deadline is when the current turn ends, before the grace period.
|
||||
func (e *Engine) Deadline() time.Time { return e.deadline }
|
||||
|
||||
// TurnLimit is the length of one turn.
|
||||
func (e *Engine) TurnLimit() time.Duration { return e.turnLimit }
|
||||
|
||||
// Over reports whether the game has finished.
|
||||
func (e *Engine) Over() bool { return e.over }
|
||||
|
||||
@@ -159,23 +118,11 @@ func (e *Engine) EndReason() EndReason { return e.endReason }
|
||||
// Score is the human's total points.
|
||||
func (e *Engine) Score() int { return e.score }
|
||||
|
||||
// History returns the played moves after the opening word, oldest first.
|
||||
func (e *Engine) History() []Move { return slices.Clone(e.history) }
|
||||
|
||||
// ChainLength counts the words played, opening word included.
|
||||
func (e *Engine) ChainLength() int { return len(e.history) + 1 }
|
||||
|
||||
// Expired reports whether the current turn ran out, grace period included.
|
||||
func (e *Engine) Expired(now time.Time) bool {
|
||||
return now.After(e.deadline.Add(e.grace))
|
||||
}
|
||||
|
||||
// Submit validates a word from side and, when legal, plays it.
|
||||
//
|
||||
// Checks run in this order: game over, turn, expiry, syllable count,
|
||||
// dictionary, link, reuse. Resolving before the link check matters: the
|
||||
// canonical spelling can change the first syllable ("sỹ hai" → "sĩ hai").
|
||||
// An expired turn ends the game against the human (the bot never waits).
|
||||
// Checks run in this order: game over, turn, expiry, then the word rules
|
||||
// (see board.check). An expired turn ends the game against the human (the
|
||||
// bot never waits).
|
||||
func (e *Engine) Submit(side Side, raw string, now time.Time) (Move, Reason) {
|
||||
if e.over {
|
||||
return Move{}, ReasonGameOver
|
||||
@@ -187,46 +134,22 @@ func (e *Engine) Submit(side Side, raw string, now time.Time) (Move, Reason) {
|
||||
e.expire()
|
||||
return Move{}, ReasonTimeout
|
||||
}
|
||||
normalized, syllables, err := dict.Normalize(raw)
|
||||
if err != nil || !dict.HasEnoughSyllables(syllables) {
|
||||
return Move{}, ReasonTooFewSyllables
|
||||
move, last, reason := e.check(raw, now)
|
||||
if reason != ReasonNone {
|
||||
return Move{}, reason
|
||||
}
|
||||
canonical, ok := e.dict.Resolve(normalized)
|
||||
if !ok {
|
||||
return Move{}, ReasonNotInDictionary
|
||||
}
|
||||
first, _ := e.dict.FirstSyllable(canonical)
|
||||
if first != e.current {
|
||||
return Move{}, ReasonWrongLink
|
||||
}
|
||||
if _, played := e.used[canonical]; played {
|
||||
return Move{}, ReasonAlreadyUsed
|
||||
}
|
||||
last, _ := e.dict.LastSyllable(canonical)
|
||||
// Count the canonical word's syllables: a variant spelling never changes
|
||||
// the count, but the canonical form is what was played.
|
||||
n := len(syllablesOf(canonical))
|
||||
move := Move{By: side, Word: canonical, Syllables: n, Points: e.pointsFor(n, first, now), At: now}
|
||||
|
||||
e.used[canonical] = struct{}{}
|
||||
e.history = append(e.history, move)
|
||||
move.By = side
|
||||
e.play(move, last, now)
|
||||
if side == Human {
|
||||
e.score += move.Points
|
||||
}
|
||||
e.current = last
|
||||
e.turn = side.other()
|
||||
e.deadline = now.Add(e.turnLimit)
|
||||
// A dead end does not end the game here. The human keeps the turn and
|
||||
// loses it on the clock or claims it with NoMove; the caller settles the
|
||||
// bot's with BotStuck.
|
||||
return move, ReasonNone
|
||||
}
|
||||
|
||||
func syllablesOf(word string) []string {
|
||||
_, s, _ := dict.Normalize(word)
|
||||
return s
|
||||
}
|
||||
|
||||
// Timeout ends the game against the human when their turn has expired.
|
||||
// Reports whether it did.
|
||||
func (e *Engine) Timeout(now time.Time) bool {
|
||||
@@ -294,52 +217,3 @@ func (e *Engine) end(winner Side, reason EndReason) {
|
||||
e.winner = winner
|
||||
e.endReason = reason
|
||||
}
|
||||
|
||||
// Used reports whether a canonical word has been played.
|
||||
func (e *Engine) Used(word string) bool {
|
||||
_, ok := e.used[word]
|
||||
return ok
|
||||
}
|
||||
|
||||
// WordsStartingWith iterates the dictionary words starting with syllable,
|
||||
// played ones included.
|
||||
func (e *Engine) WordsStartingWith(syllable string) iter.Seq[string] {
|
||||
return e.dict.WordsStartingWith(syllable)
|
||||
}
|
||||
|
||||
// LastSyllable reports the last syllable of a canonical word.
|
||||
func (e *Engine) LastSyllable(word string) (string, bool) {
|
||||
return e.dict.LastSyllable(word)
|
||||
}
|
||||
|
||||
// LegalMoves lists every unused word that answers the current syllable.
|
||||
func (e *Engine) LegalMoves() []string {
|
||||
var out []string
|
||||
for w := range e.dict.WordsStartingWith(e.current) {
|
||||
if !e.Used(w) {
|
||||
out = append(out, w)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// HasLegalMove reports whether the side to act has anything to play.
|
||||
func (e *Engine) HasLegalMove() bool {
|
||||
for w := range e.dict.WordsStartingWith(e.current) {
|
||||
if !e.Used(w) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Suggestions lists up to n words the side to act could still play, sorted.
|
||||
// Shown to a player who lost; empty means the position was a dead end.
|
||||
func (e *Engine) Suggestions(n int) []string {
|
||||
if n <= 0 {
|
||||
return nil
|
||||
}
|
||||
moves := e.LegalMoves()
|
||||
slices.Sort(moves)
|
||||
return moves[:min(n, len(moves))]
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MinSeats and MaxSeats bound the players in one Match.
|
||||
const (
|
||||
MinSeats = 2
|
||||
MaxSeats = 8
|
||||
)
|
||||
|
||||
// Match is one game between people taking turns in seat order, ported from
|
||||
// the multiplayer rules of tiennm99/noitu server/internal/game/engine.go.
|
||||
//
|
||||
// A player who fails their turn (the clock runs out, or they give up) is
|
||||
// eliminated, and the rest play on from the same syllable against the same
|
||||
// used words; the last one standing wins. Words, checks and scoring are the
|
||||
// board's, the same as in a game against the bot.
|
||||
//
|
||||
// Not safe for concurrent use; the caller serializes.
|
||||
type Match struct {
|
||||
board
|
||||
// alive is per seat. An eliminated seat keeps its score, its words and
|
||||
// its place in the turn order.
|
||||
alive []bool
|
||||
aliveN int
|
||||
turn int
|
||||
scores []int
|
||||
outOrder []int
|
||||
outReason []EndReason
|
||||
|
||||
over bool
|
||||
winner int
|
||||
endReason EndReason
|
||||
}
|
||||
|
||||
// NewMatch opens a game for seats players on opening. Seat 0 moves first,
|
||||
// with a full turn from now.
|
||||
func NewMatch(d Dictionary, seats int, opening string, turnLimit, grace time.Duration, now time.Time) (*Match, error) {
|
||||
if seats < MinSeats || seats > MaxSeats {
|
||||
return nil, fmt.Errorf("engine: a match needs %d to %d players, got %d", MinSeats, MaxSeats, seats)
|
||||
}
|
||||
b, err := newBoard(d, opening, turnLimit, grace, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := &Match{
|
||||
board: b,
|
||||
alive: make([]bool, seats),
|
||||
aliveN: seats,
|
||||
scores: make([]int, seats),
|
||||
outReason: make([]EndReason, seats),
|
||||
winner: -1,
|
||||
}
|
||||
for i := range m.alive {
|
||||
m.alive[i] = true
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Seats is the number of players the match started with.
|
||||
func (m *Match) Seats() int { return len(m.alive) }
|
||||
|
||||
// Turn is the seat to act: always a seat still in the game, and the winner
|
||||
// once the match is over.
|
||||
func (m *Match) Turn() int { return m.turn }
|
||||
|
||||
// Alive reports whether seat is still in the game.
|
||||
func (m *Match) Alive(seat int) bool { return m.valid(seat) && m.alive[seat] }
|
||||
|
||||
// Score is seat's points over the words it played.
|
||||
func (m *Match) Score(seat int) int {
|
||||
if !m.valid(seat) {
|
||||
return 0
|
||||
}
|
||||
return m.scores[seat]
|
||||
}
|
||||
|
||||
// OutReason reports why seat was eliminated, EndNone while it is still in.
|
||||
func (m *Match) OutReason(seat int) EndReason {
|
||||
if !m.valid(seat) {
|
||||
return EndNone
|
||||
}
|
||||
return m.outReason[seat]
|
||||
}
|
||||
|
||||
// Over reports whether the match has finished.
|
||||
func (m *Match) Over() bool { return m.over }
|
||||
|
||||
// Winner is the winning seat once the match is over, -1 before.
|
||||
func (m *Match) Winner() int { return m.winner }
|
||||
|
||||
// EndReason is the reason of the elimination that ended the match, or
|
||||
// EndMaxMoves.
|
||||
func (m *Match) EndReason() EndReason { return m.endReason }
|
||||
|
||||
// Standings lists the seats best first once the match is over: the winner,
|
||||
// then the eliminated seats, last out first. Outlasting a player beats them,
|
||||
// whatever the scores.
|
||||
func (m *Match) Standings() []int {
|
||||
if !m.over {
|
||||
return nil
|
||||
}
|
||||
out := make([]int, 0, len(m.alive))
|
||||
out = append(out, m.winner)
|
||||
// Only a max-moves end leaves other seats standing. They survived, so
|
||||
// they rank ahead of every eliminated seat, by points, the earlier seat
|
||||
// on a tie, the same order that picked the winner.
|
||||
var survivors []int
|
||||
for s, alive := range m.alive {
|
||||
if alive && s != m.winner {
|
||||
survivors = append(survivors, s)
|
||||
}
|
||||
}
|
||||
slices.SortStableFunc(survivors, func(a, b int) int { return m.scores[b] - m.scores[a] })
|
||||
out = append(out, survivors...)
|
||||
for i := len(m.outOrder) - 1; i >= 0; i-- {
|
||||
out = append(out, m.outOrder[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Submit validates a word from seat and, when legal, plays it. Checks run in
|
||||
// this order: game over, turn, expiry, then the word rules (see board.check).
|
||||
// An expired turn eliminates the seat and reports ReasonTimeout. A rejected
|
||||
// word keeps the turn and the clock.
|
||||
func (m *Match) Submit(seat int, raw string, now time.Time) (Move, Reason) {
|
||||
if m.over {
|
||||
return Move{}, ReasonGameOver
|
||||
}
|
||||
if seat != m.turn {
|
||||
return Move{}, ReasonNotYourTurn
|
||||
}
|
||||
if m.Expired(now) {
|
||||
m.expire(m.deadline.Add(m.grace))
|
||||
return Move{}, ReasonTimeout
|
||||
}
|
||||
move, last, reason := m.check(raw, now)
|
||||
if reason != ReasonNone {
|
||||
return Move{}, reason
|
||||
}
|
||||
move.Seat = seat
|
||||
m.play(move, last, now)
|
||||
m.scores[seat] += move.Points
|
||||
m.advance()
|
||||
// A dead end does not end anything here: the next player gets the turn
|
||||
// and loses it on the clock (or by giving up), and settle then takes out
|
||||
// everyone behind them who faces the same board.
|
||||
return move, ReasonNone
|
||||
}
|
||||
|
||||
// Timeout eliminates the seat whose turn expired and reports whether it did.
|
||||
// The next turn starts when the expired one ended, grace included, not at
|
||||
// now: a caller that settles late (a poll, a sweep) then replays the clock
|
||||
// as a timer would have, and one call per expired turn catches up.
|
||||
func (m *Match) Timeout(now time.Time) bool {
|
||||
if m.over || !m.Expired(now) {
|
||||
return false
|
||||
}
|
||||
m.expire(m.deadline.Add(m.grace))
|
||||
return true
|
||||
}
|
||||
|
||||
// Resign eliminates seat, which need not be the seat to act: a player may
|
||||
// leave while somebody else is thinking. On their own turn in a dead end it
|
||||
// counts as no legal move rather than giving up. The clock restarts only when
|
||||
// the turn moved on. Reports whether it changed anything.
|
||||
func (m *Match) Resign(seat int, now time.Time) bool {
|
||||
if m.over || !m.Alive(seat) {
|
||||
return false
|
||||
}
|
||||
before := m.turn
|
||||
reason := EndGaveUp
|
||||
if seat == m.turn && !m.HasLegalMove() {
|
||||
reason = EndNoLegalMove
|
||||
}
|
||||
m.eliminate(seat, reason)
|
||||
if !m.over && m.turn != before {
|
||||
m.settle()
|
||||
if !m.over {
|
||||
m.deadline = now.Add(m.turnLimit)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// EndMaxMoves ends the match once the chain reached the cap the caller
|
||||
// enforces. Every seat still in survived; the one with the most points wins,
|
||||
// the earlier seat on a tie.
|
||||
func (m *Match) EndMaxMoves() bool {
|
||||
if m.over {
|
||||
return false
|
||||
}
|
||||
best := -1
|
||||
for s, alive := range m.alive {
|
||||
if alive && (best < 0 || m.scores[s] > m.scores[best]) {
|
||||
best = s
|
||||
}
|
||||
}
|
||||
m.over, m.winner, m.endReason = true, best, EndMaxMoves
|
||||
m.turn = best
|
||||
return true
|
||||
}
|
||||
|
||||
// expire eliminates the seat to act, whose turn ended at end: as no legal
|
||||
// move when the position was a dead end, so the result does not blame the
|
||||
// clock, otherwise as a timeout.
|
||||
func (m *Match) expire(end time.Time) {
|
||||
reason := EndTimeout
|
||||
if !m.HasLegalMove() {
|
||||
reason = EndNoLegalMove
|
||||
}
|
||||
m.eliminate(m.turn, reason)
|
||||
m.settle()
|
||||
if !m.over {
|
||||
m.deadline = end.Add(m.turnLimit)
|
||||
}
|
||||
}
|
||||
|
||||
// settle takes out everyone a dead end leaves with nothing. The first player
|
||||
// to face it lost it on their own turn; everyone behind them has seen that
|
||||
// board, so making each sit out a turn they cannot use would only stall a
|
||||
// decided game. The player who closed the position is left standing.
|
||||
func (m *Match) settle() {
|
||||
for !m.over && !m.HasLegalMove() {
|
||||
m.eliminate(m.turn, EndNoLegalMove)
|
||||
}
|
||||
}
|
||||
|
||||
// eliminate takes seat out and ends the match when one seat is left.
|
||||
func (m *Match) eliminate(seat int, reason EndReason) {
|
||||
m.alive[seat] = false
|
||||
m.aliveN--
|
||||
m.outOrder = append(m.outOrder, seat)
|
||||
m.outReason[seat] = reason
|
||||
m.endReason = reason
|
||||
if m.turn == seat {
|
||||
m.advance()
|
||||
}
|
||||
if m.aliveN <= 1 {
|
||||
m.over = true
|
||||
m.winner = m.turn
|
||||
}
|
||||
}
|
||||
|
||||
// advance moves the turn to the next seat still in the game.
|
||||
func (m *Match) advance() {
|
||||
for range m.alive {
|
||||
m.turn = (m.turn + 1) % len(m.alive)
|
||||
if m.alive[m.turn] {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Match) valid(seat int) bool { return seat >= 0 && seat < len(m.alive) }
|
||||
@@ -0,0 +1,230 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newMatch(t *testing.T, seats int) *Match {
|
||||
t.Helper()
|
||||
m, err := NewMatch(testDict(t), seats, "hoa hồng", limit, grace, t0)
|
||||
if err != nil {
|
||||
t.Fatalf("NewMatch: %v", err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// late is just past the first turn's deadline and grace.
|
||||
var late = t0.Add(limit + grace + time.Nanosecond)
|
||||
|
||||
func TestNewMatchSeatBounds(t *testing.T) {
|
||||
for _, n := range []int{0, 1, MaxSeats + 1} {
|
||||
if _, err := NewMatch(testDict(t), n, "hoa hồng", limit, grace, t0); err == nil {
|
||||
t.Errorf("NewMatch with %d seats accepted", n)
|
||||
}
|
||||
}
|
||||
if _, err := NewMatch(testDict(t), 2, "sự cố", limit, grace, t0); err == nil {
|
||||
t.Error("dead-end opening accepted")
|
||||
}
|
||||
m := newMatch(t, MaxSeats)
|
||||
if m.Seats() != MaxSeats || m.Turn() != 0 || m.Winner() != -1 || m.Over() {
|
||||
t.Fatalf("fresh match: seats %d turn %d winner %d over %v", m.Seats(), m.Turn(), m.Winner(), m.Over())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchWordRulesAreTheBoards(t *testing.T) {
|
||||
m := newMatch(t, 2)
|
||||
cases := []struct {
|
||||
seat int
|
||||
word string
|
||||
want Reason
|
||||
}{
|
||||
{1, "hồng hào", ReasonNotYourTurn},
|
||||
{0, "hồng", ReasonTooFewSyllables},
|
||||
{0, "hồng xyz", ReasonNotInDictionary},
|
||||
{0, "tâm sự", ReasonWrongLink},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if _, got := m.Submit(c.seat, c.word, t0); got != c.want {
|
||||
t.Errorf("Submit(%d, %q) = %q, want %q", c.seat, c.word, got, c.want)
|
||||
}
|
||||
}
|
||||
mv, r := m.Submit(0, "Hồng HÀO", t0)
|
||||
if r != ReasonNone || mv.Word != "hồng hào" || mv.Seat != 0 || m.Score(0) != mv.Points || m.Turn() != 1 {
|
||||
t.Fatalf("valid move = %+v %q; score %d turn %d", mv, r, m.Score(0), m.Turn())
|
||||
}
|
||||
m.Submit(1, "hào hoa", t0)
|
||||
if _, r := m.Submit(0, "hoa hồng", t0); r != ReasonAlreadyUsed {
|
||||
t.Fatalf("reused opening = %q", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchTimeoutEliminatesAndPlaysOn(t *testing.T) {
|
||||
m := newMatch(t, 3)
|
||||
if m.Timeout(t0.Add(limit + grace)) {
|
||||
t.Fatal("timeout inside the grace window")
|
||||
}
|
||||
if !m.Timeout(late) {
|
||||
t.Fatal("timeout did not fire")
|
||||
}
|
||||
if m.Over() || m.Alive(0) || m.OutReason(0) != EndTimeout || m.Turn() != 1 {
|
||||
t.Fatalf("after timeout: over %v alive0 %v reason %q turn %d", m.Over(), m.Alive(0), m.OutReason(0), m.Turn())
|
||||
}
|
||||
// The position survives the player: the next seat answers the same
|
||||
// syllable, with a full turn that starts when the expired one ended.
|
||||
if m.Current() != "hồng" {
|
||||
t.Errorf("current = %q, want hồng", m.Current())
|
||||
}
|
||||
if want := t0.Add(limit + grace + limit); !m.Deadline().Equal(want) {
|
||||
t.Errorf("deadline = %v, want %v", m.Deadline(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchSyllableAndUsedWordsSurviveAnEliminatedPlayer(t *testing.T) {
|
||||
m := newMatch(t, 3)
|
||||
m.Submit(0, "hồng tâm", t0)
|
||||
at := t0.Add(limit + grace + time.Nanosecond)
|
||||
if !m.Timeout(at) || m.Alive(1) {
|
||||
t.Fatal("seat 1 not eliminated")
|
||||
}
|
||||
if m.Turn() != 2 || m.Current() != "tâm" || !m.Used("hồng tâm") {
|
||||
t.Fatalf("turn %d current %q used %v", m.Turn(), m.Current(), m.Used("hồng tâm"))
|
||||
}
|
||||
if _, r := m.Submit(2, "tâm sự", at); r != ReasonNone {
|
||||
t.Fatalf("next seat answering the same syllable = %q", r)
|
||||
}
|
||||
if m.Score(0) == 0 {
|
||||
t.Fatal("seat 0's points were lost")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchTurnSkipsEliminatedSeats(t *testing.T) {
|
||||
m := newMatch(t, 3)
|
||||
m.Submit(0, "hồng tâm", t0)
|
||||
deadline := m.Deadline()
|
||||
// Seat 2 leaves while seat 1 is thinking: seat 1 keeps its turn and clock.
|
||||
if !m.Resign(2, t0.Add(time.Second)) || m.OutReason(2) != EndGaveUp {
|
||||
t.Fatal("out-of-turn resign failed")
|
||||
}
|
||||
if m.Turn() != 1 || !m.Deadline().Equal(deadline) {
|
||||
t.Fatalf("turn %d deadline %v, want 1 / %v", m.Turn(), m.Deadline(), deadline)
|
||||
}
|
||||
m.Submit(1, "tâm sự", t0.Add(2*time.Second))
|
||||
if m.Turn() != 0 {
|
||||
t.Fatalf("turn = %d, want 0: the eliminated seat 2 is skipped", m.Turn())
|
||||
}
|
||||
if _, r := m.Submit(2, "sự cố", t0.Add(3*time.Second)); r != ReasonNotYourTurn {
|
||||
t.Fatalf("eliminated seat submit = %q", r)
|
||||
}
|
||||
if m.Resign(2, t0) {
|
||||
t.Fatal("an eliminated seat resigned twice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchLastStandingWins(t *testing.T) {
|
||||
m := newMatch(t, 3)
|
||||
m.Submit(0, "hồng hào", t0)
|
||||
// Nobody else plays. One Timeout per expired turn catches up even when
|
||||
// the caller settles late.
|
||||
at := t0.Add(3 * (limit + grace))
|
||||
for m.Timeout(at) {
|
||||
}
|
||||
if !m.Over() || m.Winner() != 0 || m.Alive(1) || m.Alive(2) || m.Turn() != 0 {
|
||||
t.Fatalf("over %v winner %d", m.Over(), m.Winner())
|
||||
}
|
||||
if got := m.Standings(); !slices.Equal(got, []int{0, 2, 1}) {
|
||||
t.Fatalf("standings = %v, want [0 2 1]", got)
|
||||
}
|
||||
if _, r := m.Submit(0, "hào hoa", at); r != ReasonGameOver {
|
||||
t.Fatalf("move after the end = %q", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchDeadEndSettlesEveryoneBehind(t *testing.T) {
|
||||
m := newMatch(t, 3)
|
||||
m.Submit(0, "hồng tâm", t0)
|
||||
m.Submit(1, "tâm sự", t0)
|
||||
m.Submit(2, "sự cố", t0) // "cố" starts nothing
|
||||
if m.Over() || m.Turn() != 0 {
|
||||
t.Fatal("a dead end ended the match before the next seat had its turn")
|
||||
}
|
||||
if !m.Timeout(late) {
|
||||
t.Fatal("timeout did not fire")
|
||||
}
|
||||
if !m.Over() || m.Winner() != 2 || m.OutReason(0) != EndNoLegalMove || m.OutReason(1) != EndNoLegalMove {
|
||||
t.Fatalf("over %v winner %d reasons %q %q", m.Over(), m.Winner(), m.OutReason(0), m.OutReason(1))
|
||||
}
|
||||
if got := m.Standings(); !slices.Equal(got, []int{2, 1, 0}) {
|
||||
t.Fatalf("standings = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchResignInADeadEndIsNoLegalMove(t *testing.T) {
|
||||
m := newMatch(t, 2)
|
||||
m.Submit(0, "hồng tâm", t0)
|
||||
m.Submit(1, "tâm sự", t0)
|
||||
m.Submit(0, "sự cố", t0)
|
||||
if !m.Resign(1, t0) || m.OutReason(1) != EndNoLegalMove || m.Winner() != 0 || m.EndReason() != EndNoLegalMove {
|
||||
t.Fatalf("reason %q winner %d", m.OutReason(1), m.Winner())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchEndMaxMovesRanksSurvivorsByPoints(t *testing.T) {
|
||||
m := newMatch(t, 3)
|
||||
// Seat 0 answers on the buzzer, seats 1 and 2 at once, so the points run
|
||||
// against seat order: 2 > 1 > 0.
|
||||
at := t0.Add(limit - time.Second)
|
||||
m.Submit(0, "hồng tâm", at)
|
||||
m.Submit(1, "tâm sự", at)
|
||||
m.Submit(2, "sự cố", at)
|
||||
if m.Score(2) <= m.Score(1) || m.Score(1) <= m.Score(0) {
|
||||
t.Fatalf("scores %d %d %d do not run against seat order", m.Score(0), m.Score(1), m.Score(2))
|
||||
}
|
||||
if !m.EndMaxMoves() || m.Winner() != 2 || m.EndReason() != EndMaxMoves || m.Turn() != 2 {
|
||||
t.Fatalf("winner %d reason %q", m.Winner(), m.EndReason())
|
||||
}
|
||||
if got := m.Standings(); !slices.Equal(got, []int{2, 1, 0}) {
|
||||
t.Fatalf("standings = %v, want [2 1 0] by points", got)
|
||||
}
|
||||
if m.EndMaxMoves() {
|
||||
t.Fatal("ended twice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchLateSubmitStartsTheNextTurnWhenTheExpiredOneEnded(t *testing.T) {
|
||||
m := newMatch(t, 3)
|
||||
at := t0.Add(2 * time.Minute)
|
||||
if _, r := m.Submit(0, "hồng hào", at); r != ReasonTimeout || m.Alive(0) || m.Turn() != 1 {
|
||||
t.Fatalf("late submit = %q alive %v turn %d", r, m.Alive(0), m.Turn())
|
||||
}
|
||||
if want := t0.Add(limit + grace + limit); !m.Deadline().Equal(want) {
|
||||
t.Fatalf("deadline = %v, want %v", m.Deadline(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchOnTurnResignRestartsTheClock(t *testing.T) {
|
||||
m := newMatch(t, 3)
|
||||
at := t0.Add(10 * time.Second)
|
||||
if !m.Resign(0, at) || m.Turn() != 1 || m.OutReason(0) != EndGaveUp {
|
||||
t.Fatalf("turn %d reason %q", m.Turn(), m.OutReason(0))
|
||||
}
|
||||
if want := at.Add(limit); !m.Deadline().Equal(want) {
|
||||
t.Fatalf("deadline = %v, want %v", m.Deadline(), want)
|
||||
}
|
||||
}
|
||||
|
||||
// Only the seat facing a dead end can claim it; anyone else leaving gives up,
|
||||
// and the seat to act keeps its own turn.
|
||||
func TestMatchOutOfTurnResignInADeadEndIsGivingUp(t *testing.T) {
|
||||
m := newMatch(t, 3)
|
||||
m.Submit(0, "hồng tâm", t0)
|
||||
m.Submit(1, "tâm sự", t0)
|
||||
m.Submit(2, "sự cố", t0) // seat 0 faces "cố"
|
||||
if !m.Resign(1, t0) || m.OutReason(1) != EndGaveUp {
|
||||
t.Fatalf("out-of-turn reason = %q", m.OutReason(1))
|
||||
}
|
||||
if m.Over() || m.Turn() != 0 {
|
||||
t.Fatalf("over %v turn %d: the dead end was settled out of turn", m.Over(), m.Turn())
|
||||
}
|
||||
}
|
||||
@@ -26,13 +26,13 @@ const (
|
||||
|
||||
// pointsFor scores a word about to be played: syllables is its length, link
|
||||
// the syllable it answers, now when it was played.
|
||||
func (e *Engine) pointsFor(syllables int, link string, now time.Time) int {
|
||||
func (b *board) pointsFor(syllables int, link string, now time.Time) int {
|
||||
parts := []int{
|
||||
basePoints,
|
||||
chainBonus * min(e.ChainLength(), chainBonusWords),
|
||||
chainBonus * min(b.ChainLength(), chainBonusWords),
|
||||
syllableBonus * (syllables - dict.MinSyllables),
|
||||
e.speedPoints(now),
|
||||
e.rarityPoints(link),
|
||||
b.speedPoints(now),
|
||||
b.rarityPoints(link),
|
||||
}
|
||||
total := 0
|
||||
for _, p := range parts {
|
||||
@@ -43,19 +43,19 @@ func (e *Engine) pointsFor(syllables int, link string, now time.Time) int {
|
||||
|
||||
// speedPoints pays for the share of the turn left on the clock. A move in the
|
||||
// grace period is past the deadline and earns nothing.
|
||||
func (e *Engine) speedPoints(now time.Time) int {
|
||||
remaining := e.deadline.Sub(now)
|
||||
func (b *board) speedPoints(now time.Time) int {
|
||||
remaining := b.deadline.Sub(now)
|
||||
if remaining <= 0 {
|
||||
return 0
|
||||
}
|
||||
remaining = min(remaining, e.turnLimit)
|
||||
return int(int64(speedBonus) * int64(remaining) / int64(e.turnLimit))
|
||||
remaining = min(remaining, b.turnLimit)
|
||||
return int(int64(speedBonus) * int64(remaining) / int64(b.turnLimit))
|
||||
}
|
||||
|
||||
// rarityPoints pays for how few corpus words answer link, spent ones
|
||||
// included: the reward is a property of the dictionary, not of this game.
|
||||
func (e *Engine) rarityPoints(link string) int {
|
||||
options := e.dict.OutDegree(link)
|
||||
func (b *board) rarityPoints(link string) int {
|
||||
options := b.dict.OutDegree(link)
|
||||
if options < 1 {
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules"
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules/noitu/dict"
|
||||
"github.com/tiennm99/tiennm99bot/internal/storage"
|
||||
)
|
||||
|
||||
var testKey = []byte("0123456789abcdef0123456789abcdef")
|
||||
@@ -49,12 +50,47 @@ type reportCall struct {
|
||||
}
|
||||
|
||||
type fakeReporter struct {
|
||||
mu sync.Mutex
|
||||
calls []reportCall
|
||||
err error
|
||||
mu sync.Mutex
|
||||
calls []reportCall
|
||||
err error
|
||||
announced []string
|
||||
cards []claims
|
||||
// beforeReport, when set before any game ends, runs at the start of
|
||||
// every Report, outside the mutex, so a test can hold a report.
|
||||
beforeReport func(claims)
|
||||
}
|
||||
|
||||
func (r *fakeReporter) Announce(_ context.Context, card claims, text string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.announced = append(r.announced, text)
|
||||
r.cards = append(r.cards, card)
|
||||
return r.err
|
||||
}
|
||||
|
||||
// announcedOn counts the announcements made on the card with message msg.
|
||||
func (r *fakeReporter) announcedOn(msg int) int {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
n := 0
|
||||
for _, c := range r.cards {
|
||||
if c.MessageID == msg {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (r *fakeReporter) announcements() []string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]string(nil), r.announced...)
|
||||
}
|
||||
|
||||
func (r *fakeReporter) Report(_ context.Context, c claims, score int) error {
|
||||
if r.beforeReport != nil {
|
||||
r.beforeReport(c)
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.calls = append(r.calls, reportCall{c, score})
|
||||
@@ -90,12 +126,14 @@ func newHarnessWithStore(t *testing.T, store *dict.Store) *harness {
|
||||
h := &harness{t: t, clock: &fakeClock{t: time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC)}, reporter: &fakeReporter{}}
|
||||
seed := uint64(0)
|
||||
cfg := config{
|
||||
baseURL: testBase,
|
||||
key: testKey,
|
||||
now: h.clock.now,
|
||||
newRNG: func() *rand.Rand { seed++; return rand.New(rand.NewPCG(seed, 42)) },
|
||||
reporter: h.reporter,
|
||||
loadDict: func() (*dict.Store, error) { return store, nil },
|
||||
baseURL: testBase,
|
||||
key: testKey,
|
||||
now: h.clock.now,
|
||||
newRNG: func() *rand.Rand { seed++; return rand.New(rand.NewPCG(seed, 42)) },
|
||||
reporter: h.reporter,
|
||||
announcer: h.reporter,
|
||||
cards: storage.Typed[pvpCard](storage.NewMemoryProvider().Collection("noitu")),
|
||||
loadDict: func() (*dict.Store, error) { return store, nil },
|
||||
}
|
||||
h.svc = newService(cfg)
|
||||
h.mod = h.svc.module()
|
||||
@@ -131,6 +169,16 @@ func (h *harness) post(path, body string) *httptest.ResponseRecorder {
|
||||
return rec
|
||||
}
|
||||
|
||||
// postJSON marshals v and posts it.
|
||||
func (h *harness) postJSON(path string, v any) *httptest.ResponseRecorder {
|
||||
h.t.Helper()
|
||||
body, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
return h.post(path, string(body))
|
||||
}
|
||||
|
||||
// call posts v and decodes the answer into out, failing on an unexpected status.
|
||||
func (h *harness) call(path string, v any, wantStatus int, out any) {
|
||||
h.t.Helper()
|
||||
|
||||
@@ -28,6 +28,8 @@ type apiError struct {
|
||||
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."}
|
||||
@@ -87,6 +89,11 @@ func (s *service) handler() http.Handler {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -142,13 +149,12 @@ func (s *service) apiStart(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
now := s.cfg.now()
|
||||
c, err := verifyToken(s.cfg.key, req.Token, now)
|
||||
switch {
|
||||
case errors.Is(err, errTokenExpired):
|
||||
writeError(w, errTokenExpAPI)
|
||||
c, ok := s.verifyRequestToken(w, req.Token, now)
|
||||
if !ok {
|
||||
return
|
||||
case err != nil:
|
||||
writeError(w, errBadTokenAPI)
|
||||
}
|
||||
if c.PvP {
|
||||
writeError(w, errPvPSoloAPI)
|
||||
return
|
||||
}
|
||||
difficulty, ok := opponent.ParseDifficulty(req.Difficulty)
|
||||
@@ -375,9 +381,14 @@ func (s *service) botMove(sess *session, now time.Time) (string, error) {
|
||||
}
|
||||
|
||||
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", sess.eng.Current(), 1)
|
||||
msg = strings.Replace(msg, "%s", current, 1)
|
||||
}
|
||||
return moveResult{Reason: string(reason), Message: msg}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Package noitu is the "nối từ" Telegram HTML5 game: the /noitu command sends
|
||||
// the BotFather game, the Play button opens a page this bot serves, and the
|
||||
// player chains Vietnamese words against the bot opponent there.
|
||||
// player chains Vietnamese words against the bot opponent there. /noitupvp
|
||||
// sends a card whose Play button opens a room where the chat's members chain
|
||||
// words against each other.
|
||||
//
|
||||
// The server owns the game. The page only sends words; validation, the turn
|
||||
// timer, the bot's replies and the score all happen here, and the score is
|
||||
@@ -22,6 +24,7 @@ import (
|
||||
"github.com/tiennm99/tiennm99bot/internal/log"
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules"
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules/noitu/dict"
|
||||
"github.com/tiennm99/tiennm99bot/internal/storage"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -42,12 +45,14 @@ const (
|
||||
|
||||
// config is everything New reads from the environment, injectable in tests.
|
||||
type config struct {
|
||||
baseURL string // public https base without trailing slash; "" disables
|
||||
key []byte // token HMAC key; empty disables
|
||||
now func() time.Time
|
||||
newRNG func() *mrand.Rand
|
||||
reporter scoreReporter // nil disables
|
||||
loadDict func() (*dict.Store, error)
|
||||
baseURL string // public https base without trailing slash; "" disables
|
||||
key []byte // token HMAC key; empty disables
|
||||
now func() time.Time
|
||||
newRNG func() *mrand.Rand
|
||||
reporter scoreReporter // nil disables
|
||||
announcer announcer // nil skips room result messages
|
||||
cards storage.DocStore[pvpCard]
|
||||
loadDict func() (*dict.Store, error)
|
||||
}
|
||||
|
||||
// New builds the module from the environment. The game is enabled only when
|
||||
@@ -63,6 +68,10 @@ func New(deps modules.Deps) modules.Module {
|
||||
}
|
||||
if deps.Bot != nil {
|
||||
cfg.reporter = botReporter{b: deps.Bot}
|
||||
cfg.announcer = botReporter{b: deps.Bot}
|
||||
}
|
||||
if deps.Store != nil {
|
||||
cfg.cards = storage.Typed[pvpCard](deps.Store)
|
||||
}
|
||||
return newWithConfig(cfg)
|
||||
}
|
||||
@@ -73,7 +82,7 @@ func newWithConfig(cfg config) modules.Module { return newService(cfg).module()
|
||||
// newService loads the dictionary when the game is configured. A load failure
|
||||
// leaves the game disabled rather than failing startup for every module.
|
||||
func newService(cfg config) *service {
|
||||
svc := &service{cfg: cfg, sessions: newSessionStore()}
|
||||
svc := &service{cfg: cfg, sessions: newSessionStore(), rooms: newRoomStore()}
|
||||
if cfg.baseURL != "" && len(cfg.key) > 0 && cfg.reporter != nil {
|
||||
words, err := cfg.loadDict()
|
||||
if err != nil {
|
||||
@@ -88,7 +97,8 @@ func newService(cfg config) *service {
|
||||
|
||||
// module describes the service to the registry. Routes and the sweep cron
|
||||
// exist only when the game is enabled, so a disabled game exposes nothing
|
||||
// over HTTP.
|
||||
// over HTTP. The card cleanup runs whenever cards can be stored, because
|
||||
// /noitupvp registers cards even while the game is disabled.
|
||||
func (svc *service) module() modules.Module {
|
||||
mod := modules.Module{
|
||||
Commands: []modules.Command{{
|
||||
@@ -96,6 +106,11 @@ func (svc *service) module() modules.Module {
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Chơi nối từ với bot",
|
||||
Handler: svc.handleCommand,
|
||||
}, {
|
||||
Name: pvpCommand,
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Chơi nối từ với các thành viên trong nhóm",
|
||||
Handler: svc.handlePvPCommand,
|
||||
}},
|
||||
Games: []modules.Game{{
|
||||
ShortName: ShortName,
|
||||
@@ -107,6 +122,9 @@ func (svc *service) module() modules.Module {
|
||||
mod.HTTP = []modules.Route{{Pattern: routePrefix, Handler: svc.handler()}}
|
||||
mod.Crons = []modules.Cron{{Schedule: "* * * * *", Name: "noitu_sweep", Handler: svc.sweepCron}}
|
||||
}
|
||||
if svc.cfg.cards != nil {
|
||||
mod.Crons = append(mod.Crons, modules.Cron{Schedule: cardCleanupSchedule, Name: "noitu_pvp_cards", Handler: svc.cleanupCards})
|
||||
}
|
||||
return mod
|
||||
}
|
||||
|
||||
|
||||
@@ -109,12 +109,24 @@ func TestNew_DisabledWithoutBaseURL(t *testing.T) {
|
||||
if len(mod.HTTP) != 0 || len(mod.Crons) != 0 {
|
||||
t.Fatalf("disabled game exposes routes %v / crons %v", mod.HTTP, mod.Crons)
|
||||
}
|
||||
if len(mod.Commands) != 1 || mod.Commands[0].Name != "noitu" || mod.Commands[0].Parameters != "" {
|
||||
if len(mod.Commands) != 2 || mod.Commands[0].Name != "noitu" || mod.Commands[1].Name != "noitupvp" ||
|
||||
mod.Commands[0].Parameters != "" || mod.Commands[1].Parameters != "" {
|
||||
t.Fatalf("commands = %+v", mod.Commands)
|
||||
}
|
||||
for _, c := range mod.Commands {
|
||||
if c.Visibility != modules.VisibilityPublic || c.Description == "" {
|
||||
t.Fatalf("command %+v is not a described public command", c)
|
||||
}
|
||||
}
|
||||
if len(mod.Games) != 1 || mod.Games[0].ShortName != "noitu" {
|
||||
t.Fatalf("games = %+v", mod.Games)
|
||||
}
|
||||
// /noitupvp registers cards even while the game is disabled, so their
|
||||
// cleanup still runs; nothing else does.
|
||||
mod = New(modules.Deps{Bot: rb.Bot, Store: storage.NewMemoryProvider().Collection("noitu")})
|
||||
if len(mod.HTTP) != 0 || len(mod.Crons) != 1 || mod.Crons[0].Name != "noitu_pvp_cards" {
|
||||
t.Fatalf("disabled game with a store: routes %v / crons %+v", mod.HTTP, mod.Crons)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_EnabledFromEnv(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package noitu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/tiennm99bot/internal/log"
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules"
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules/util/chathelper"
|
||||
"github.com/tiennm99/tiennm99bot/internal/storage"
|
||||
)
|
||||
|
||||
const (
|
||||
// pvpCommand sends a game card that the chat's members play together.
|
||||
pvpCommand = "noitupvp"
|
||||
|
||||
cardKeyPrefix = "pvp:"
|
||||
// cardTTL is how long a card stays a room after its last Play press;
|
||||
// after that the daily cleanup forgets it and it plays solo.
|
||||
cardTTL = 30 * 24 * time.Hour
|
||||
// cardTouchEvery throttles the Play-time refresh of a card's age to one
|
||||
// write a day.
|
||||
cardTouchEvery = 24 * time.Hour
|
||||
// cardCleanupSchedule runs at 03:30 ICT.
|
||||
cardCleanupSchedule = "30 20 * * *"
|
||||
|
||||
msgPvPNeedsGroup = "Chơi nối từ cùng nhau cần một nhóm. Hãy dùng /noitupvp trong nhóm, hoặc /noitu để chơi với bot."
|
||||
msgPvPFail = "Không tạo được phòng nối từ. Thử lại sau nhé."
|
||||
msgCardLookup = "Không mở được trò chơi lúc này. Thử lại sau nhé."
|
||||
)
|
||||
|
||||
// pvpCard records a game message /noitupvp sent. A Play press on it opens the
|
||||
// card's room; any other game message, a forwarded copy of a card included,
|
||||
// plays against the bot.
|
||||
type pvpCard struct {
|
||||
ChatID int64 `bson:"chatId"`
|
||||
MessageID int `bson:"messageId"`
|
||||
ThreadID int `bson:"threadId"` // forum topic; 0 outside forums
|
||||
TouchedAt int64 `bson:"touchedAt"` // unix seconds of creation or a recent Play
|
||||
}
|
||||
|
||||
func cardKey(chatID int64, messageID int) string {
|
||||
return cardKeyPrefix + strconv.FormatInt(chatID, 10) + ":" + strconv.Itoa(messageID)
|
||||
}
|
||||
|
||||
// handlePvPCommand sends the game and registers it as a room card. Like
|
||||
// /noitu it works even when the game is disabled; Play explains why. Rooms
|
||||
// are for a group's members, so private chats and channels are refused.
|
||||
func (s *service) handlePvPCommand(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg.Chat.Type != models.ChatTypeGroup && msg.Chat.Type != models.ChatTypeSupergroup {
|
||||
return chathelper.Reply(ctx, b, msg, msgPvPNeedsGroup)
|
||||
}
|
||||
if s.cfg.cards == nil {
|
||||
_ = chathelper.Reply(ctx, b, msg, msgPvPFail)
|
||||
return errors.New("noitu: no storage for pvp cards")
|
||||
}
|
||||
sent, err := b.SendGame(ctx, &bot.SendGameParams{
|
||||
ChatID: msg.Chat.ID,
|
||||
MessageThreadID: msg.MessageThreadID,
|
||||
GameShorName: ShortName, // the library's field name is misspelled
|
||||
})
|
||||
if err != nil {
|
||||
_ = chathelper.Reply(ctx, b, msg, msgSendGameFail)
|
||||
return err
|
||||
}
|
||||
card := pvpCard{ChatID: msg.Chat.ID, MessageID: sent.ID, ThreadID: msg.MessageThreadID, TouchedAt: s.cfg.now().Unix()}
|
||||
if err := s.cfg.cards.Put(ctx, cardKey(card.ChatID, card.MessageID), card); err != nil {
|
||||
// An unregistered card would silently play solo; take it back.
|
||||
if _, derr := b.DeleteMessage(ctx, &bot.DeleteMessageParams{ChatID: card.ChatID, MessageID: card.MessageID}); derr != nil {
|
||||
log.Warn("noitu: delete unregistered pvp card failed", "err", derr)
|
||||
}
|
||||
_ = chathelper.Reply(ctx, b, msg, msgPvPFail)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// lookupCard reports whether the chat message is a registered card, and
|
||||
// refreshes its age at most once a day so a card in use never expires.
|
||||
func (s *service) lookupCard(ctx context.Context, chatID int64, messageID int) (pvpCard, bool, error) {
|
||||
if s.cfg.cards == nil {
|
||||
return pvpCard{}, false, nil
|
||||
}
|
||||
key := cardKey(chatID, messageID)
|
||||
card, _, err := s.cfg.cards.Get(ctx, key)
|
||||
if errors.Is(err, storage.ErrNotFound) {
|
||||
return pvpCard{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return pvpCard{}, false, err
|
||||
}
|
||||
now := s.cfg.now().Unix()
|
||||
if now-card.TouchedAt >= int64(cardTouchEvery/time.Second) {
|
||||
card.TouchedAt = now
|
||||
if err := s.cfg.cards.Put(ctx, key, card); err != nil {
|
||||
log.Warn("noitu: refresh pvp card failed", "err", err)
|
||||
}
|
||||
}
|
||||
return card, true, nil
|
||||
}
|
||||
|
||||
// cleanupCards forgets cards nobody played for cardTTL.
|
||||
func (s *service) cleanupCards(ctx context.Context, _ modules.Deps) error {
|
||||
if s.cfg.cards == nil {
|
||||
return nil
|
||||
}
|
||||
docs, err := s.cfg.cards.Scan(ctx, cardKeyPrefix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cutoff := s.cfg.now().Add(-cardTTL).Unix()
|
||||
var errs []error
|
||||
for _, d := range docs {
|
||||
if d.Val.TouchedAt < cutoff {
|
||||
if err := s.cfg.cards.Delete(ctx, d.ID); err != nil && !errors.Is(err, storage.ErrNotFound) {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package noitu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules"
|
||||
"github.com/tiennm99/tiennm99bot/internal/storage"
|
||||
"github.com/tiennm99/tiennm99bot/internal/testutil"
|
||||
)
|
||||
|
||||
func TestPvPCommand_RegistersCardKeepingTopic(t *testing.T) {
|
||||
h := newHarness(t, testCorpus)
|
||||
rb := installModule(t, h.mod)
|
||||
update := testutil.NewSupergroupMessage(-100777, 42, "/noitupvp")
|
||||
update.Message.MessageThreadID = 12
|
||||
rb.Bot.ProcessUpdate(context.Background(), update)
|
||||
|
||||
last := rb.LastSent()
|
||||
if last.Method != "sendGame" || last.Form["game_short_name"] != "noitu" || last.ChatID() != "-100777" || last.Form["message_thread_id"] != "12" {
|
||||
t.Fatalf("sent %+v", rb.Sent())
|
||||
}
|
||||
card, _, err := h.svc.cfg.cards.Get(context.Background(), cardKey(-100777, 1))
|
||||
if err != nil {
|
||||
t.Fatalf("card not registered: %v", err)
|
||||
}
|
||||
want := pvpCard{ChatID: -100777, MessageID: 1, ThreadID: 12, TouchedAt: h.clock.now().Unix()}
|
||||
if card != want {
|
||||
t.Fatalf("card = %+v, want %+v", card, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPvPCommand_RefusesPrivateChatsAndChannels(t *testing.T) {
|
||||
h := newHarness(t, testCorpus)
|
||||
for name, update := range map[string]*models.Update{
|
||||
"private": testutil.NewPrivateMessage(42, "/noitupvp"),
|
||||
"channel": testutil.NewChannelMessage(-100888, "/noitupvp"),
|
||||
} {
|
||||
rb := installModule(t, h.mod)
|
||||
rb.Bot.ProcessUpdate(context.Background(), update)
|
||||
if last := rb.LastSent(); last.Method != "sendMessage" || last.Text() != msgPvPNeedsGroup || len(rb.Sent()) != 1 {
|
||||
t.Errorf("%s: sent %+v", name, rb.Sent())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A card that cannot be recorded would play solo while looking like a room,
|
||||
// so it is deleted again and the chat is told.
|
||||
func TestPvPCommand_StoreFailureTakesTheCardBack(t *testing.T) {
|
||||
h := newHarness(t, testCorpus)
|
||||
h.svc.cfg.cards = storage.Typed[pvpCard](storage.NewMemoryProvider().Collection("Not A Valid Name"))
|
||||
rb := installModule(t, h.svc.module())
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewGroupMessage(-100777, 42, "/noitupvp"))
|
||||
var methods []string
|
||||
for _, c := range rb.Sent() {
|
||||
methods = append(methods, c.Method)
|
||||
}
|
||||
if fmt.Sprint(methods) != "[sendGame deleteMessage sendMessage]" || rb.LastSent().Text() != msgPvPFail {
|
||||
t.Fatalf("sent %+v", rb.Sent())
|
||||
}
|
||||
|
||||
rb = installModule(t, h.svc.module())
|
||||
rb.FailMethodCode("sendGame", 400, "Bad Request: GAME_SHORTNAME_INVALID")
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewGroupMessage(-100777, 42, "/noitupvp"))
|
||||
if last := rb.LastSent(); last.Method != "sendMessage" || last.Text() != msgSendGameFail {
|
||||
t.Fatalf("sendGame failure: sent %+v", rb.Sent())
|
||||
}
|
||||
}
|
||||
|
||||
// playToken presses Play on update and returns the claims of the answered URL.
|
||||
func (h *harness) playToken(update *models.Update) claims {
|
||||
h.t.Helper()
|
||||
rb := installModule(h.t, h.mod)
|
||||
rb.Bot.ProcessUpdate(context.Background(), update)
|
||||
u, err := url.Parse(lastAnswer(h.t, rb)["url"])
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
c, err := verifyToken(testKey, u.Query().Get("t"), h.clock.now())
|
||||
if err != nil {
|
||||
h.t.Fatalf("token: %v", err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func TestPlay_OnACardIssuesAPvPToken(t *testing.T) {
|
||||
h := newHarness(t, testCorpus)
|
||||
ctx := context.Background()
|
||||
if err := h.svc.cfg.cards.Put(ctx, cardKey(-100500, 7), pvpCard{ChatID: -100500, MessageID: 7, ThreadID: 3, TouchedAt: h.clock.now().Unix()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := h.playToken(testutil.NewGameCallback(42, -100500, 7, ShortName))
|
||||
if !c.PvP || c.ThreadID != 3 || c.ChatID != -100500 || c.MessageID != 7 || c.UserID != 42 {
|
||||
t.Fatalf("card claims = %+v", c)
|
||||
}
|
||||
// A forwarded copy is another message, and an inline game is never a
|
||||
// card: both play solo.
|
||||
if c := h.playToken(testutil.NewGameCallback(42, -100500, 8, ShortName)); c.PvP {
|
||||
t.Fatalf("unregistered message gave a pvp token: %+v", c)
|
||||
}
|
||||
if c := h.playToken(testutil.NewInlineGameCallback(42, "AgAAAInline", ShortName)); c.PvP {
|
||||
t.Fatalf("inline message gave a pvp token: %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlay_CardLookupFailureAnswersAlert(t *testing.T) {
|
||||
h := newHarness(t, testCorpus)
|
||||
h.svc.cfg.cards = storage.Typed[pvpCard](storage.NewMemoryProvider().Collection("Not A Valid Name"))
|
||||
rb := installModule(t, h.svc.module())
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewGameCallback(42, -100500, 7, ShortName))
|
||||
if a := lastAnswer(t, rb); a["text"] != msgCardLookup || a["show_alert"] != "true" || a["url"] != "" {
|
||||
t.Fatalf("answer = %v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPvPToken_NeedsAChatMessageAndIsRefusedForSolo(t *testing.T) {
|
||||
h := newHarness(t, testCorpus)
|
||||
exp := h.clock.now().Add(time.Hour).Unix()
|
||||
inline, _ := signToken(testKey, claims{UserID: 1, InlineID: "x", PvP: true, Expiry: exp})
|
||||
if _, err := verifyToken(testKey, inline, h.clock.now()); err == nil {
|
||||
t.Fatal("a pvp token on an inline message verified")
|
||||
}
|
||||
rec := h.post("start", fmt.Sprintf(`{"token":%q}`, h.pvpToken(42, "An")))
|
||||
if rec.Code != http.StatusBadRequest || h.apiError(rec) != "bad_request" {
|
||||
t.Fatalf("solo start with a pvp token: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCards_PlayRefreshesAndCleanupForgetsStaleCards(t *testing.T) {
|
||||
h := newHarness(t, testCorpus)
|
||||
ctx := context.Background()
|
||||
cards := h.svc.cfg.cards
|
||||
now := h.clock.now()
|
||||
old := now.Add(-cardTTL - time.Hour).Unix()
|
||||
for _, c := range []pvpCard{
|
||||
{ChatID: -1, MessageID: 1, TouchedAt: old},
|
||||
{ChatID: -1, MessageID: 2, TouchedAt: old},
|
||||
{ChatID: -1, MessageID: 3, TouchedAt: now.Unix()},
|
||||
} {
|
||||
if err := cards.Put(ctx, cardKey(c.ChatID, c.MessageID), c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// Playing card 2 keeps it alive.
|
||||
if c := h.playToken(testutil.NewGameCallback(42, -1, 2, ShortName)); !c.PvP {
|
||||
t.Fatal("stale card no longer a card before cleanup")
|
||||
}
|
||||
if err := h.svc.cleanupCards(ctx, modules.Deps{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keys, err := cards.List(ctx, cardKeyPrefix)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fmt.Sprint(keys) != "[pvp:-1:2 pvp:-1:3]" {
|
||||
t.Fatalf("cards after cleanup = %v", keys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotReporter_AnnounceRepliesToTheCardInItsTopic(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
r := botReporter{b: rb.Bot}
|
||||
if err := r.Announce(context.Background(), claims{ChatID: cardChat, MessageID: cardMsg, ThreadID: cardThread}, "<b>An</b> thắng"); err != nil {
|
||||
t.Fatalf("Announce: %v", err)
|
||||
}
|
||||
last := rb.LastSent()
|
||||
if last.Method != "sendMessage" || last.ChatID() != "-100500" || last.Form["message_thread_id"] != "3" ||
|
||||
last.Text() != "<b>An</b> thắng" || last.Form["parse_mode"] != "" ||
|
||||
!strings.Contains(last.Form["reply_parameters"], `"message_id":7`) {
|
||||
t.Fatalf("sendMessage form = %v", last.Form)
|
||||
}
|
||||
}
|
||||
|
||||
// Cards live only in groups, so a private chat never touches the card store
|
||||
// and a storage fault cannot block its solo game.
|
||||
func TestPlay_PrivateChatSkipsTheCardLookup(t *testing.T) {
|
||||
h := newHarness(t, testCorpus)
|
||||
h.svc.cfg.cards = storage.Typed[pvpCard](storage.NewMemoryProvider().Collection("Not A Valid Name"))
|
||||
h.mod = h.svc.module()
|
||||
if c := h.playToken(testutil.NewGameCallback(42, 42, 7, ShortName)); c.PvP || c.ChatID != 42 {
|
||||
t.Fatalf("private chat claims = %+v", c)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
package noitu
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tiennm99/tiennm99bot/internal/log"
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules/noitu/engine"
|
||||
)
|
||||
|
||||
var (
|
||||
errNoRoomAPI = apiError{http.StatusNotFound, "no_room", "Phòng chơi không còn nữa. Hãy bấm Chơi trên thẻ trò chơi để vào lại."}
|
||||
errNotPvPAPI = apiError{http.StatusBadRequest, "bad_request", "Liên kết này không mở phòng chơi nhóm."}
|
||||
errPvPSoloAPI = apiError{http.StatusBadRequest, "bad_request", "Liên kết này mở phòng chơi nhóm, không dùng để chơi với bot."}
|
||||
errRoomFullAPI = apiError{http.StatusConflict, "room_full", "Phòng đã đủ người."}
|
||||
errNotHostAPI = apiError{http.StatusForbidden, "not_host", "Chỉ chủ phòng mới bắt đầu được ván chơi."}
|
||||
errNotEnoughAPI = apiError{http.StatusConflict, "not_enough_players", "Cần ít nhất 2 người để bắt đầu."}
|
||||
errGameRunningAPI = apiError{http.StatusConflict, "game_running", "Ván chơi đang diễn ra."}
|
||||
errNoGameAPI = apiError{http.StatusConflict, "no_game", "Ván chơi chưa bắt đầu hoặc đã kết thúc."}
|
||||
errNotInGameAPI = apiError{http.StatusConflict, "not_in_game", "Bạn không chơi trong ván này."}
|
||||
errChatRoomsAPI = apiError{http.StatusConflict, "too_many_rooms", "Nhóm này đang có quá nhiều phòng chơi. Hãy chơi trên một thẻ đang mở."}
|
||||
|
||||
msgWatching = "Bạn đang xem ván này."
|
||||
msgEliminated = "Bạn đã bị loại khỏi ván này."
|
||||
msgNotYetTurn = "Chưa đến lượt bạn."
|
||||
)
|
||||
|
||||
// roomPlayerView is one player as the page shows them: a lobby member, or a
|
||||
// seat of the running game.
|
||||
type roomPlayerView struct {
|
||||
Name string `json:"name"`
|
||||
You bool `json:"you"`
|
||||
Host bool `json:"host"`
|
||||
Score int `json:"score"`
|
||||
Alive bool `json:"alive"`
|
||||
OutReason string `json:"out_reason"`
|
||||
}
|
||||
|
||||
// roomView is the room state the page renders. See docs/noitu-game.md.
|
||||
type roomView struct {
|
||||
Member string `json:"member"`
|
||||
Version int64 `json:"version"`
|
||||
Status string `json:"status"` // lobby | playing
|
||||
Game int `json:"game"`
|
||||
Host bool `json:"host"`
|
||||
Seat int `json:"seat"` // your seat in the running game, -1 otherwise
|
||||
Turn int `json:"turn"` // seat to act, -1 in the lobby
|
||||
Players []roomPlayerView `json:"players"`
|
||||
Watchers int `json:"watchers"`
|
||||
MinPlayers int `json:"min_players"`
|
||||
MaxPlayers int `json:"max_players"`
|
||||
Current string `json:"current"`
|
||||
Chain []roomEntry `json:"chain"`
|
||||
TurnLimitMS int64 `json:"turn_limit_ms"`
|
||||
DeadlineMS int64 `json:"deadline_ms"`
|
||||
ServerNowMS int64 `json:"server_now_ms"`
|
||||
Result *roomResult `json:"result"`
|
||||
}
|
||||
|
||||
// roomPing answers a poll whose version is current.
|
||||
type roomPing struct {
|
||||
Version int64 `json:"version"`
|
||||
Unchanged bool `json:"unchanged"`
|
||||
ServerNowMS int64 `json:"server_now_ms"`
|
||||
}
|
||||
|
||||
type roomMoveResponse struct {
|
||||
Result moveResult `json:"result"`
|
||||
State roomView `json:"state"`
|
||||
}
|
||||
|
||||
type joinRequest struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type memberRequest struct {
|
||||
Member string `json:"member"`
|
||||
}
|
||||
|
||||
type roomStateRequest struct {
|
||||
Member string `json:"member"`
|
||||
Version int64 `json:"version"`
|
||||
}
|
||||
|
||||
type roomMoveRequest struct {
|
||||
Member string `json:"member"`
|
||||
Word string `json:"word"`
|
||||
}
|
||||
|
||||
// roomReply is an answer built under the room lock and written after it is
|
||||
// released, so a slow client never holds up the room.
|
||||
type roomReply struct {
|
||||
status int
|
||||
body any
|
||||
}
|
||||
|
||||
func okReply(v any) roomReply { return roomReply{http.StatusOK, v} }
|
||||
func errReply(e apiError) roomReply { return roomReply{e.status, e} }
|
||||
func (r roomReply) write(w http.ResponseWriter) { writeJSON(w, r.status, r.body) }
|
||||
|
||||
func (s *service) apiRoomJoin(w http.ResponseWriter, r *http.Request) {
|
||||
var req joinRequest
|
||||
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, errNotPvPAPI)
|
||||
return
|
||||
}
|
||||
if !s.sessions.allowStart(c.UserID, now) {
|
||||
writeError(w, errTooFast)
|
||||
return
|
||||
}
|
||||
s.sweepRooms(now)
|
||||
// A sweep can close the room between open and lock; open then replaces
|
||||
// the closed room, so the second try gets a live one.
|
||||
for range 2 {
|
||||
rm, err := s.rooms.open(c)
|
||||
if err != nil {
|
||||
var apiErr apiError
|
||||
if errors.As(err, &apiErr) {
|
||||
writeError(w, apiErr)
|
||||
return
|
||||
}
|
||||
writeError(w, errBusyAPI)
|
||||
return
|
||||
}
|
||||
reply, retry := s.joinRoom(rm, c, now)
|
||||
if !retry {
|
||||
reply.write(w)
|
||||
return
|
||||
}
|
||||
}
|
||||
writeError(w, errBusyAPI)
|
||||
}
|
||||
|
||||
// joinRoom adds c's user to rm under its lock. retry reports that the sweep
|
||||
// closed rm after open returned it, so the caller must open again.
|
||||
func (s *service) joinRoom(rm *room, c claims, now time.Time) (reply roomReply, retry bool) {
|
||||
rm.mu.Lock()
|
||||
defer rm.mu.Unlock()
|
||||
if rm.closed.Load() {
|
||||
return roomReply{}, true
|
||||
}
|
||||
s.settleRoom(rm, now)
|
||||
m, err := rm.join(c, now)
|
||||
if err != nil {
|
||||
var apiErr apiError
|
||||
if errors.As(err, &apiErr) {
|
||||
return errReply(apiErr), false
|
||||
}
|
||||
log.Error("noitu room join failed", "err", err)
|
||||
return errReply(errInternalAPI), false
|
||||
}
|
||||
return okReply(s.roomView(rm, m, now)), false
|
||||
}
|
||||
|
||||
// verifyRequestToken checks a page token, answering the error itself.
|
||||
func (s *service) verifyRequestToken(w http.ResponseWriter, token string, now time.Time) (claims, bool) {
|
||||
c, err := verifyToken(s.cfg.key, token, now)
|
||||
switch {
|
||||
case errors.Is(err, errTokenExpired):
|
||||
writeError(w, errTokenExpAPI)
|
||||
return claims{}, false
|
||||
case err != nil:
|
||||
writeError(w, errBadTokenAPI)
|
||||
return claims{}, false
|
||||
}
|
||||
return c, true
|
||||
}
|
||||
|
||||
// withRoom finds and locks the room and member a bearer names, catches the
|
||||
// room up with the clock, runs fn under the lock and writes its answer after
|
||||
// unlocking. expired tells fn that the member held a turn that had run out
|
||||
// before the catch-up, which therefore eliminated them. An unknown member,
|
||||
// a closed room, or a member the catch-up dropped answers no_room.
|
||||
func (s *service) withRoom(w http.ResponseWriter, bearer string, now time.Time, fn func(rm *room, m *roomMember, expired bool) roomReply) {
|
||||
id, secret, found := strings.Cut(bearer, ".")
|
||||
rm := s.rooms.get(id)
|
||||
if !found || rm == nil {
|
||||
writeError(w, errNoRoomAPI)
|
||||
return
|
||||
}
|
||||
func() roomReply {
|
||||
rm.mu.Lock()
|
||||
defer rm.mu.Unlock()
|
||||
m := rm.member(secret)
|
||||
if m == nil || rm.closed.Load() {
|
||||
return errReply(errNoRoomAPI)
|
||||
}
|
||||
expired := false
|
||||
if rm.playing() {
|
||||
seat := rm.seatOf(m)
|
||||
expired = seat != noSeat && rm.match.Turn() == seat && rm.match.Expired(now)
|
||||
}
|
||||
s.settleRoom(rm, now)
|
||||
// The catch-up drops members who stopped polling; they rejoin.
|
||||
if rm.member(secret) != m {
|
||||
return errReply(errNoRoomAPI)
|
||||
}
|
||||
m.lastSeen = now
|
||||
return fn(rm, m, expired)
|
||||
}().write(w)
|
||||
}
|
||||
|
||||
func (s *service) apiRoomState(w http.ResponseWriter, r *http.Request) {
|
||||
var req roomStateRequest
|
||||
if !decode(w, r, &req) {
|
||||
return
|
||||
}
|
||||
now := s.cfg.now()
|
||||
s.withRoom(w, req.Member, now, func(rm *room, m *roomMember, _ bool) roomReply {
|
||||
if req.Version == rm.version {
|
||||
return okReply(roomPing{Version: rm.version, Unchanged: true, ServerNowMS: now.UnixMilli()})
|
||||
}
|
||||
return okReply(s.roomView(rm, m, now))
|
||||
})
|
||||
}
|
||||
|
||||
func (s *service) apiRoomStart(w http.ResponseWriter, r *http.Request) {
|
||||
var req memberRequest
|
||||
if !decode(w, r, &req) {
|
||||
return
|
||||
}
|
||||
now := s.cfg.now()
|
||||
s.withRoom(w, req.Member, now, func(rm *room, m *roomMember, _ bool) roomReply {
|
||||
switch {
|
||||
case rm.playing():
|
||||
return errReply(errGameRunningAPI)
|
||||
case rm.members[0] != m:
|
||||
return errReply(errNotHostAPI)
|
||||
case !rm.lastStart.IsZero() && now.Sub(rm.lastStart) < roomStartInterval:
|
||||
return errReply(errTooFast)
|
||||
case !s.sessions.allowStart(m.userID, now):
|
||||
return errReply(errTooFast)
|
||||
}
|
||||
if err := s.startGame(rm, now); err != nil {
|
||||
var apiErr apiError
|
||||
if errors.As(err, &apiErr) {
|
||||
return errReply(apiErr)
|
||||
}
|
||||
log.Error("noitu room start failed", "err", err)
|
||||
return errReply(errInternalAPI)
|
||||
}
|
||||
return okReply(s.roomView(rm, m, now))
|
||||
})
|
||||
}
|
||||
|
||||
func (s *service) apiRoomMove(w http.ResponseWriter, r *http.Request) {
|
||||
var req roomMoveRequest
|
||||
if !decode(w, r, &req) {
|
||||
return
|
||||
}
|
||||
now := s.cfg.now()
|
||||
s.withRoom(w, req.Member, now, func(rm *room, m *roomMember, expired bool) roomReply {
|
||||
if expired {
|
||||
// The word came after the deadline plus grace: the clock took
|
||||
// the player out before it could count.
|
||||
return okReply(roomMoveResponse{Result: rejectedFor(engine.ReasonTimeout, ""), State: s.roomView(rm, m, now)})
|
||||
}
|
||||
if !rm.playing() {
|
||||
return errReply(errNoGameAPI)
|
||||
}
|
||||
if now.Sub(m.lastMove) < moveInterval {
|
||||
return errReply(errTooFast)
|
||||
}
|
||||
m.lastMove = now
|
||||
if wordTooLong(req.Word) {
|
||||
return errReply(errWordTooLong)
|
||||
}
|
||||
seat := rm.seatOf(m)
|
||||
var result moveResult
|
||||
switch {
|
||||
case seat == noSeat:
|
||||
result = moveResult{Reason: string(engine.ReasonNotYourTurn), Message: msgWatching}
|
||||
case !rm.match.Alive(seat):
|
||||
result = moveResult{Reason: string(engine.ReasonNotYourTurn), Message: msgEliminated}
|
||||
case rm.match.Turn() != seat:
|
||||
result = moveResult{Reason: string(engine.ReasonNotYourTurn), Message: msgNotYetTurn}
|
||||
default:
|
||||
result = s.playRoomWord(rm, seat, req.Word, now)
|
||||
}
|
||||
return okReply(roomMoveResponse{Result: result, State: s.roomView(rm, m, now)})
|
||||
})
|
||||
}
|
||||
|
||||
func (s *service) apiRoomGiveUp(w http.ResponseWriter, r *http.Request) {
|
||||
var req memberRequest
|
||||
if !decode(w, r, &req) {
|
||||
return
|
||||
}
|
||||
now := s.cfg.now()
|
||||
s.withRoom(w, req.Member, now, func(rm *room, m *roomMember, _ bool) roomReply {
|
||||
if !rm.playing() {
|
||||
return errReply(errNoGameAPI)
|
||||
}
|
||||
seat := rm.seatOf(m)
|
||||
if seat == noSeat || !rm.match.Alive(seat) {
|
||||
return errReply(errNotInGameAPI)
|
||||
}
|
||||
s.resignRoom(rm, seat, now)
|
||||
return okReply(s.roomView(rm, m, now))
|
||||
})
|
||||
}
|
||||
|
||||
// roomView renders the room for member m. Caller holds rm.mu. The view
|
||||
// shares no mutable state with the room, so it may be encoded after the
|
||||
// lock is released: the chain is only ever appended to or replaced.
|
||||
func (s *service) roomView(rm *room, m *roomMember, now time.Time) roomView {
|
||||
v := roomView{
|
||||
Member: rm.id + "." + m.secret,
|
||||
Version: rm.version,
|
||||
Status: roomLobby,
|
||||
Game: rm.game,
|
||||
Host: len(rm.members) > 0 && rm.members[0] == m,
|
||||
Seat: noSeat,
|
||||
Turn: noSeat,
|
||||
MinPlayers: engine.MinSeats,
|
||||
MaxPlayers: engine.MaxSeats,
|
||||
Chain: rm.chain,
|
||||
TurnLimitMS: turnLimit.Milliseconds(),
|
||||
ServerNowMS: now.UnixMilli(),
|
||||
}
|
||||
if v.Chain == nil {
|
||||
v.Chain = []roomEntry{}
|
||||
}
|
||||
if rm.playing() {
|
||||
v.Status = roomPlaying
|
||||
v.Seat = rm.seatOf(m)
|
||||
v.Turn = rm.match.Turn()
|
||||
v.Current = rm.match.Current()
|
||||
v.DeadlineMS = rm.match.Deadline().UnixMilli()
|
||||
for i, p := range rm.seats {
|
||||
v.Players = append(v.Players, roomPlayerView{
|
||||
Name: p.name,
|
||||
You: p == m,
|
||||
Score: rm.match.Score(i),
|
||||
Alive: rm.match.Alive(i),
|
||||
OutReason: string(rm.match.OutReason(i)),
|
||||
})
|
||||
}
|
||||
// Members are only dropped in the lobby, so every seat is a member.
|
||||
v.Watchers = len(rm.members) - len(rm.seats)
|
||||
return v
|
||||
}
|
||||
for i, p := range rm.members[:min(len(rm.members), engine.MaxSeats)] {
|
||||
v.Players = append(v.Players, roomPlayerView{Name: p.name, You: p == m, Host: i == 0, Alive: true})
|
||||
}
|
||||
v.Watchers = max(len(rm.members)-engine.MaxSeats, 0)
|
||||
if rm.result != nil {
|
||||
res := *rm.result
|
||||
res.Standings = make([]roomStanding, len(rm.result.Standings))
|
||||
for i, st := range rm.result.Standings {
|
||||
st.You = st.userID == m.userID
|
||||
res.Standings[i] = st
|
||||
}
|
||||
v.Result = &res
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,655 @@
|
||||
package noitu
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules"
|
||||
)
|
||||
|
||||
const (
|
||||
cardChat = -100500
|
||||
cardMsg = 7
|
||||
cardThread = 3
|
||||
)
|
||||
|
||||
// pvpToken signs a Play token for user on the test card.
|
||||
func (h *harness) pvpToken(user int64, name string) string {
|
||||
h.t.Helper()
|
||||
return h.pvpTokenOn(user, name, cardMsg)
|
||||
}
|
||||
|
||||
func (h *harness) pvpTokenOn(user int64, name string, msg int) string {
|
||||
h.t.Helper()
|
||||
return h.pvpTokenAt(user, name, cardChat, msg)
|
||||
}
|
||||
|
||||
func (h *harness) pvpTokenAt(user int64, name string, chat int64, msg int) string {
|
||||
h.t.Helper()
|
||||
tok, err := signToken(testKey, claims{UserID: user, Name: name, ChatID: chat, MessageID: msg, ThreadID: cardThread, PvP: true, Expiry: h.clock.now().Add(tokenTTL).Unix()})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
return tok
|
||||
}
|
||||
|
||||
func (h *harness) join(user int64, name string) roomView {
|
||||
h.t.Helper()
|
||||
var v roomView
|
||||
h.call("room/join", map[string]string{"token": h.pvpToken(user, name)}, http.StatusOK, &v)
|
||||
return v
|
||||
}
|
||||
|
||||
func (h *harness) roomState(member string) roomView {
|
||||
h.t.Helper()
|
||||
var v roomView
|
||||
h.call("room/state", map[string]any{"member": member}, http.StatusOK, &v)
|
||||
return v
|
||||
}
|
||||
|
||||
func (h *harness) roomStart(member string, wantStatus int) roomView {
|
||||
h.t.Helper()
|
||||
var v roomView
|
||||
h.call("room/start", map[string]string{"member": member}, wantStatus, &v)
|
||||
return v
|
||||
}
|
||||
|
||||
// roomMove advances the clock past the per-member move limit, then plays.
|
||||
func (h *harness) roomMove(member, word string) roomMoveResponse {
|
||||
h.t.Helper()
|
||||
h.clock.advance(time.Second)
|
||||
var r roomMoveResponse
|
||||
h.call("room/move", map[string]string{"member": member, "word": word}, http.StatusOK, &r)
|
||||
return r
|
||||
}
|
||||
|
||||
func (h *harness) roomErr(path string, body any) (int, string) {
|
||||
h.t.Helper()
|
||||
rec := h.postJSON(path, body)
|
||||
return rec.Code, h.apiError(rec)
|
||||
}
|
||||
|
||||
// waitRoomReport polls until the finished game's score report settles.
|
||||
func (h *harness) waitRoomReport(member string) roomView {
|
||||
h.t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
v := h.roomState(member)
|
||||
if v.Result == nil {
|
||||
h.t.Fatalf("no result: %+v", v)
|
||||
}
|
||||
if v.Result.ScoreReported != reportPending || time.Now().After(deadline) {
|
||||
return v
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoom_JoinStartPlayAndReport(t *testing.T) {
|
||||
h := newHarness(t, testCorpus)
|
||||
a := h.join(1, "An")
|
||||
if a.Status != roomLobby || !a.Host || len(a.Players) != 1 || !a.Players[0].You || a.Seat != noSeat {
|
||||
t.Fatalf("first join = %+v", a)
|
||||
}
|
||||
if code, e := h.roomErr("room/start", map[string]string{"member": a.Member}); code != http.StatusConflict || e != "not_enough_players" {
|
||||
t.Fatalf("start alone: %d %s", code, e)
|
||||
}
|
||||
b := h.join(2, "Bình")
|
||||
if b.Host || len(b.Players) != 2 || b.Players[0].Name != "An" || !b.Players[0].Host || !b.Players[1].You {
|
||||
t.Fatalf("second join = %+v", b)
|
||||
}
|
||||
if code, e := h.roomErr("room/start", map[string]string{"member": b.Member}); code != http.StatusForbidden || e != "not_host" {
|
||||
t.Fatalf("start by guest: %d %s", code, e)
|
||||
}
|
||||
|
||||
v := h.roomStart(a.Member, http.StatusOK)
|
||||
if v.Status != roomPlaying || v.Seat != 0 || v.Turn != 0 || v.Current != "hồng" || v.Game != 1 || len(v.Chain) != 1 || v.Chain[0].Seat != noSeat {
|
||||
t.Fatalf("started = %+v", v)
|
||||
}
|
||||
if v.DeadlineMS-v.ServerNowMS != turnLimit.Milliseconds() {
|
||||
t.Fatalf("deadline %d now %d", v.DeadlineMS, v.ServerNowMS)
|
||||
}
|
||||
if code, e := h.roomErr("room/start", map[string]string{"member": a.Member}); code != http.StatusConflict || e != "game_running" {
|
||||
t.Fatalf("second start: %d %s", code, e)
|
||||
}
|
||||
|
||||
// Playing out of turn is refused and changes nothing.
|
||||
r := h.roomMove(b.Member, "hồng tâm")
|
||||
if r.Result.Accepted || r.Result.Reason != "not_your_turn" || r.Result.Message != msgNotYetTurn || len(r.State.Chain) != 1 {
|
||||
t.Fatalf("out of turn = %+v", r)
|
||||
}
|
||||
r = h.roomMove(a.Member, "hồng tâm")
|
||||
if !r.Result.Accepted || r.State.Turn != 1 || r.State.Chain[1].Name != "An" || r.State.Chain[1].Seat != 0 {
|
||||
t.Fatalf("a's move = %+v", r)
|
||||
}
|
||||
if r = h.roomMove(b.Member, "hồng hào"); r.Result.Accepted || r.Result.Reason != "wrong_link" || !strings.Contains(r.Result.Message, "“tâm”") {
|
||||
t.Fatalf("wrong link = %+v", r.Result)
|
||||
}
|
||||
h.roomMove(b.Member, "tâm sự")
|
||||
r = h.roomMove(a.Member, "sự cố") // leaves b a dead end
|
||||
if r.State.Status != roomPlaying || r.State.Turn != 1 {
|
||||
t.Fatalf("dead end ended the game early: %+v", r.State)
|
||||
}
|
||||
aScore := r.State.Players[0].Score
|
||||
bScore := r.State.Players[1].Score
|
||||
|
||||
var end roomView
|
||||
h.call("room/give-up", map[string]string{"member": b.Member}, http.StatusOK, &end)
|
||||
if end.Status != roomLobby || end.Result == nil || end.Result.Winner != "An" || end.Result.Words != 3 || end.Result.EndReason != "no_legal_move" {
|
||||
t.Fatalf("after give up = %+v / %+v", end, end.Result)
|
||||
}
|
||||
st := end.Result.Standings
|
||||
if len(st) != 2 || st[0].Name != "An" || st[0].Score != aScore+winnerBonus || st[0].Bonus != winnerBonus || st[0].Words != 2 ||
|
||||
st[1].Name != "Bình" || !st[1].You || st[1].Score != bScore || st[1].OutReason != "no_legal_move" {
|
||||
t.Fatalf("standings = %+v", st)
|
||||
}
|
||||
|
||||
if got := h.waitRoomReport(a.Member); got.Result.ScoreReported != reportOK || !got.Result.Standings[0].You {
|
||||
t.Fatalf("report = %+v", got.Result)
|
||||
}
|
||||
calls := h.reporter.snapshot()
|
||||
if len(calls) != 2 || calls[0].score != aScore+winnerBonus || calls[0].claims.UserID != 1 || calls[1].claims.UserID != 2 {
|
||||
t.Fatalf("reports = %+v", calls)
|
||||
}
|
||||
for _, c := range calls {
|
||||
if c.claims.ChatID != cardChat || c.claims.MessageID != cardMsg || c.claims.PvP {
|
||||
t.Fatalf("report not on the card: %+v", c.claims)
|
||||
}
|
||||
}
|
||||
if ann := h.reporter.announcements(); len(ann) != 1 || ann[0] != "Ván nối từ kết thúc: An thắng sau 3 từ!" {
|
||||
t.Fatalf("announcements = %q", ann)
|
||||
}
|
||||
if h.reporter.cards[0].ThreadID != cardThread || h.reporter.cards[0].MessageID != cardMsg {
|
||||
t.Fatalf("announced on %+v", h.reporter.cards[0])
|
||||
}
|
||||
|
||||
// An unchanged poll is cheap; the same card plays again.
|
||||
final := h.roomState(a.Member)
|
||||
var ping roomPing
|
||||
h.call("room/state", map[string]any{"member": a.Member, "version": final.Version}, http.StatusOK, &ping)
|
||||
if !ping.Unchanged || ping.Version != final.Version {
|
||||
t.Fatalf("unchanged poll = %+v", ping)
|
||||
}
|
||||
again := h.roomStart(a.Member, http.StatusOK)
|
||||
if again.Status != roomPlaying || again.Game != 2 || len(again.Chain) != 1 || again.Result != nil || again.Version <= final.Version {
|
||||
t.Fatalf("second game = %+v", again)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoom_NonMembersAndWrongModeAreRejected(t *testing.T) {
|
||||
h := newHarness(t, testCorpus)
|
||||
a := h.join(1, "An")
|
||||
id, _, _ := strings.Cut(a.Member, ".")
|
||||
for _, member := range []string{"", "nope", id, id + ".forged", "x." + strings.Repeat("0", 32)} {
|
||||
for _, path := range []string{"room/state", "room/start", "room/move", "room/give-up"} {
|
||||
if code, e := h.roomErr(path, map[string]string{"member": member}); code != http.StatusNotFound || e != "no_room" {
|
||||
t.Errorf("%s with %q: %d %s", path, member, code, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
if code, e := h.roomErr("room/join", map[string]string{"token": h.token(1)}); code != http.StatusBadRequest || e != "bad_request" {
|
||||
t.Fatalf("join with a solo token: %d %s", code, e)
|
||||
}
|
||||
tok := h.pvpToken(1, "An")
|
||||
if code, e := h.roomErr("room/join", map[string]string{"token": tok[:len(tok)-2] + "xx"}); code != http.StatusUnauthorized || e != "bad_token" {
|
||||
t.Fatalf("tampered token: %d %s", code, e)
|
||||
}
|
||||
if rec := h.post("room/join", `{"token":"x","room":"y"}`); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("unknown field: %d", rec.Code)
|
||||
}
|
||||
// Moves and give-ups need a running game.
|
||||
if code, e := h.roomErr("room/give-up", map[string]string{"member": a.Member}); code != http.StatusConflict || e != "no_game" {
|
||||
t.Fatalf("give up in the lobby: %d %s", code, e)
|
||||
}
|
||||
h.clock.advance(time.Second)
|
||||
if code, e := h.roomErr("room/move", map[string]string{"member": a.Member, "word": "hồng hào"}); code != http.StatusConflict || e != "no_game" {
|
||||
t.Fatalf("move in the lobby: %d %s", code, e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoom_RejoinKeepsTheMembershipAndLateJoinersWatch(t *testing.T) {
|
||||
h := newHarness(t, testCorpus)
|
||||
a := h.join(1, "An")
|
||||
b := h.join(2, "Bình")
|
||||
if again := h.join(1, "An"); again.Member != a.Member || !again.Host || len(again.Players) != 2 {
|
||||
t.Fatalf("rejoin = %+v", again)
|
||||
}
|
||||
h.roomStart(a.Member, http.StatusOK)
|
||||
w := h.join(3, "<b>Chi</b>")
|
||||
if w.Status != roomPlaying || w.Seat != noSeat || w.Watchers != 1 || len(w.Players) != 2 {
|
||||
t.Fatalf("late joiner = %+v", w)
|
||||
}
|
||||
if r := h.roomMove(w.Member, "hồng hào"); r.Result.Accepted || r.Result.Message != msgWatching {
|
||||
t.Fatalf("watcher move = %+v", r.Result)
|
||||
}
|
||||
if code, e := h.roomErr("room/give-up", map[string]string{"member": w.Member}); code != http.StatusConflict || e != "not_in_game" {
|
||||
t.Fatalf("watcher give up: %d %s", code, e)
|
||||
}
|
||||
// Leaving mid-game out of turn: the turn and its clock stay with a.
|
||||
var v roomView
|
||||
h.call("room/give-up", map[string]string{"member": b.Member}, http.StatusOK, &v)
|
||||
if v.Status != roomLobby || v.Result.Winner != "An" || v.Result.Standings[1].OutReason != "gave_up" {
|
||||
t.Fatalf("after b leaves = %+v / %+v", v, v.Result)
|
||||
}
|
||||
// Back in the lobby, the watcher is a player for the next game, and the
|
||||
// name comes back exactly as given: the page escapes it.
|
||||
if v.Watchers != 0 || len(v.Players) != 3 || v.Players[2].Name != "<b>Chi</b>" {
|
||||
t.Fatalf("lobby after the game = %+v", v.Players)
|
||||
}
|
||||
// Nobody played a word: no bonus, no report and no announcement.
|
||||
if v.Result.Standings[0].Bonus != 0 || v.Result.Standings[0].Score != 0 || v.Result.Words != 0 || v.Result.ScoreReported != reportSkipped {
|
||||
t.Fatalf("zero-word result = %+v", v.Result)
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond) // a wrongly started publisher would have run by now
|
||||
if calls, ann := h.reporter.snapshot(), h.reporter.announcements(); len(calls) != 0 || len(ann) != 0 {
|
||||
t.Fatalf("zero-word game reported %+v / announced %q", calls, ann)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoom_TimeoutEliminatesAndALateWordCountsAsTimeout(t *testing.T) {
|
||||
h := newHarness(t, testCorpus)
|
||||
a := h.join(1, "An")
|
||||
b := h.join(2, "Bình")
|
||||
c := h.join(3, "Chi")
|
||||
h.roomStart(a.Member, http.StatusOK)
|
||||
|
||||
h.clock.advance(turnLimit + turnGrace + time.Millisecond)
|
||||
v := h.roomState(c.Member)
|
||||
if v.Players[0].Alive || v.Players[0].OutReason != "timeout" || v.Turn != 1 || v.Status != roomPlaying || v.Current != "hồng" {
|
||||
t.Fatalf("after a's timeout = %+v", v)
|
||||
}
|
||||
// b's turn began when a's ended, grace included.
|
||||
if want := h.clock.now().Add(turnLimit - time.Millisecond); v.DeadlineMS != want.UnixMilli() {
|
||||
t.Fatalf("b's deadline = %d, want %d", v.DeadlineMS, want.UnixMilli())
|
||||
}
|
||||
if r := h.roomMove(a.Member, "hồng hào"); r.Result.Accepted || r.Result.Message != msgEliminated {
|
||||
t.Fatalf("eliminated move = %+v", r.Result)
|
||||
}
|
||||
|
||||
h.clock.advance(turnLimit + turnGrace)
|
||||
var r roomMoveResponse
|
||||
h.call("room/move", map[string]string{"member": b.Member, "word": "hồng hào"}, http.StatusOK, &r)
|
||||
if r.Result.Accepted || r.Result.Reason != "timeout" || r.State.Status != roomLobby || r.State.Result.Winner != "Chi" {
|
||||
t.Fatalf("late move = %+v / %+v", r.Result, r.State.Result)
|
||||
}
|
||||
// Nobody played a word, so nothing is reported.
|
||||
if r.State.Result.ScoreReported != reportSkipped || r.State.Result.Standings[0].Bonus != 0 {
|
||||
t.Fatalf("zero-word result = %+v", r.State.Result)
|
||||
}
|
||||
}
|
||||
|
||||
// A game whose players all closed the page still ends on the clock through
|
||||
// the sweep, and is still reported and announced.
|
||||
func TestRoom_SweepSettlesAnAbandonedGame(t *testing.T) {
|
||||
h := newHarness(t, testCorpus)
|
||||
a := h.join(1, "An")
|
||||
h.join(2, "Bình")
|
||||
h.roomStart(a.Member, http.StatusOK)
|
||||
h.roomMove(a.Member, "hồng hào")
|
||||
h.clock.advance(5 * time.Minute)
|
||||
if err := h.svc.sweepCron(t.Context(), modules.Deps{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitCalls(t, h.reporter, 1)
|
||||
calls := h.reporter.snapshot()
|
||||
if len(calls) != 1 || calls[0].claims.UserID != 1 {
|
||||
t.Fatalf("reports = %+v", calls)
|
||||
}
|
||||
if ann := h.reporter.announcements(); len(ann) != 1 || !strings.Contains(ann[0], "An thắng sau 1 từ") {
|
||||
t.Fatalf("announcements = %q", ann)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoom_ExpiryAndStaleMembers(t *testing.T) {
|
||||
h := newHarness(t, testCorpus)
|
||||
a := h.join(1, "An")
|
||||
b := h.join(2, "Bình")
|
||||
// a keeps polling; b's page closed. b's own late poll is what drops b,
|
||||
// and it must not be answered as a member.
|
||||
h.clock.advance(memberStaleTTL / 2)
|
||||
h.roomState(a.Member)
|
||||
h.clock.advance(memberStaleTTL/2 + time.Second)
|
||||
if code, e := h.roomErr("room/state", map[string]string{"member": b.Member}); code != http.StatusNotFound || e != "no_room" {
|
||||
t.Fatalf("stale member poll: %d %s", code, e)
|
||||
}
|
||||
if v := h.roomState(a.Member); len(v.Players) != 1 || !v.Host {
|
||||
t.Fatalf("lobby after b went stale = %+v", v.Players)
|
||||
}
|
||||
|
||||
// A lobby somebody polls lives on, however long nobody starts a game.
|
||||
for range 30 {
|
||||
h.clock.advance(memberStaleTTL - time.Second)
|
||||
h.roomState(a.Member)
|
||||
h.svc.sweepRooms(h.clock.now())
|
||||
}
|
||||
if n := len(h.svc.rooms.all()); n != 1 {
|
||||
t.Fatalf("polled lobby dropped: %d rooms", n)
|
||||
}
|
||||
|
||||
// Once nobody polls, the room goes with its last member.
|
||||
h.clock.advance(memberStaleTTL + time.Second)
|
||||
h.svc.sweepRooms(h.clock.now())
|
||||
if n := len(h.svc.rooms.all()); n != 0 {
|
||||
t.Fatalf("%d rooms left", n)
|
||||
}
|
||||
if code, e := h.roomErr("room/state", map[string]string{"member": a.Member}); code != http.StatusNotFound || e != "no_room" {
|
||||
t.Fatalf("expired room: %d %s", code, e)
|
||||
}
|
||||
// Joining again opens a fresh room on the same card.
|
||||
if v := h.join(1, "An"); v.Member == a.Member || !v.Host {
|
||||
t.Fatalf("rejoin after expiry = %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// A running game keeps every member, polling or not: only the lobby drops
|
||||
// the absent.
|
||||
func TestRoom_RunningGameNeverDropsMembers(t *testing.T) {
|
||||
h := newHarness(t, testCorpus)
|
||||
a := h.join(1, "An")
|
||||
b := h.join(2, "Bình")
|
||||
h.roomStart(a.Member, http.StatusOK)
|
||||
w := h.join(3, "Chi") // watches, then stops polling
|
||||
for _, mv := range []struct{ member, word string }{
|
||||
{a.Member, "hồng tâm"}, {b.Member, "tâm sự"}, {a.Member, "sự cố"},
|
||||
} {
|
||||
h.clock.advance(turnLimit - 5*time.Second)
|
||||
if r := h.roomMove(mv.member, mv.word); !r.Result.Accepted {
|
||||
t.Fatalf("%s: %+v", mv.word, r.Result)
|
||||
}
|
||||
}
|
||||
// b now faces a dead end; the game is still running past memberStaleTTL.
|
||||
v := h.roomState(w.Member)
|
||||
if v.Status != roomPlaying || v.Watchers != 1 || v.Seat != noSeat {
|
||||
t.Fatalf("watcher after %v = %+v", 3*(turnLimit-4*time.Second), v)
|
||||
}
|
||||
}
|
||||
|
||||
// A room the sweep has closed answers no_room even before it leaves the
|
||||
// store, and a join that races the removal gets a fresh room, not busy.
|
||||
func TestRoom_ClosedRoomIsReplacedNotJoined(t *testing.T) {
|
||||
h := newHarness(t, testCorpus)
|
||||
a := h.join(1, "An")
|
||||
rooms := h.svc.rooms.all()
|
||||
if len(rooms) != 1 {
|
||||
t.Fatalf("%d rooms", len(rooms))
|
||||
}
|
||||
rooms[0].closed.Store(true) // the sweep's first step, before remove
|
||||
if code, e := h.roomErr("room/state", map[string]string{"member": a.Member}); code != http.StatusNotFound || e != "no_room" {
|
||||
t.Fatalf("closed room poll: %d %s", code, e)
|
||||
}
|
||||
v := h.join(1, "An")
|
||||
if v.Member == a.Member || !v.Host {
|
||||
t.Fatalf("join on a closed room = %+v", v)
|
||||
}
|
||||
if got := h.svc.rooms.all(); len(got) != 1 || got[0] == rooms[0] {
|
||||
t.Fatal("the closed room was not replaced")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoom_GiveUpByAnEliminatedPlayerIsRefused(t *testing.T) {
|
||||
h := newHarness(t, testCorpus)
|
||||
a := h.join(1, "An")
|
||||
h.join(2, "Bình")
|
||||
h.join(3, "Chi")
|
||||
h.roomStart(a.Member, http.StatusOK)
|
||||
h.clock.advance(turnLimit + turnGrace + time.Millisecond)
|
||||
if code, e := h.roomErr("room/give-up", map[string]string{"member": a.Member}); code != http.StatusConflict || e != "not_in_game" {
|
||||
t.Fatalf("eliminated give up: %d %s", code, e)
|
||||
}
|
||||
if v := h.roomState(a.Member); v.Status != roomPlaying || v.Players[0].OutReason != "timeout" {
|
||||
t.Fatalf("state = %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoom_MaxChainEndsTheGame(t *testing.T) {
|
||||
h := newHarness(t, testCorpus)
|
||||
a := h.join(1, "An")
|
||||
h.join(2, "Bình")
|
||||
h.roomStart(a.Member, http.StatusOK)
|
||||
rm := h.svc.rooms.all()[0]
|
||||
rm.mu.Lock()
|
||||
for len(rm.chain) < maxChain-1 {
|
||||
rm.chain = append(rm.chain, rm.chain[0])
|
||||
}
|
||||
rm.mu.Unlock()
|
||||
r := h.roomMove(a.Member, "hồng hào")
|
||||
if !r.Result.Accepted || r.State.Status != roomLobby || r.State.Result == nil || r.State.Result.EndReason != "max_moves" || r.State.Result.Winner != "An" {
|
||||
t.Fatalf("max chain = %+v / %+v", r.Result, r.State.Result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoom_FailedReportShowsFailed(t *testing.T) {
|
||||
h := newHarness(t, testCorpus)
|
||||
h.reporter.err = fmt.Errorf("Forbidden")
|
||||
a := h.join(1, "An")
|
||||
b := h.join(2, "Bình")
|
||||
h.roomStart(a.Member, http.StatusOK)
|
||||
h.roomMove(a.Member, "hồng hào")
|
||||
h.call("room/give-up", map[string]string{"member": b.Member}, http.StatusOK, nil)
|
||||
if got := h.waitRoomReport(a.Member); got.Result.ScoreReported != reportFailed {
|
||||
t.Fatalf("score_reported = %q", got.Result.ScoreReported)
|
||||
}
|
||||
}
|
||||
|
||||
// Starts are limited per room and per host, so a start/give-up loop cannot
|
||||
// flood the chat or the card.
|
||||
func TestRoom_StartRateLimits(t *testing.T) {
|
||||
h := newHarness(t, testCorpus)
|
||||
a := h.join(1, "An")
|
||||
b := h.join(2, "Bình")
|
||||
h.roomStart(a.Member, http.StatusOK)
|
||||
h.call("room/give-up", map[string]string{"member": b.Member}, http.StatusOK, nil)
|
||||
if code, e := h.roomErr("room/start", map[string]string{"member": a.Member}); code != http.StatusTooManyRequests || e != "too_fast" {
|
||||
t.Fatalf("immediate restart: %d %s", code, e)
|
||||
}
|
||||
starts := 1
|
||||
for {
|
||||
h.clock.advance(roomStartInterval)
|
||||
rec := h.postJSON("room/start", map[string]string{"member": a.Member})
|
||||
if rec.Code != http.StatusOK {
|
||||
if rec.Code != http.StatusTooManyRequests || h.apiError(rec) != "too_fast" {
|
||||
t.Fatalf("start %d: %d %s", starts+1, rec.Code, rec.Body.String())
|
||||
}
|
||||
break
|
||||
}
|
||||
starts++
|
||||
h.call("room/give-up", map[string]string{"member": b.Member}, http.StatusOK, nil)
|
||||
}
|
||||
// The host's join counted as one of their startsPerMinute.
|
||||
if starts != startsPerMinute-1 {
|
||||
t.Fatalf("%d starts in a minute, want %d", starts, startsPerMinute-1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoom_Limits(t *testing.T) {
|
||||
h := newHarness(t, testCorpus)
|
||||
for user := int64(1); user <= maxRoomMembers; user++ {
|
||||
h.join(user, "P")
|
||||
}
|
||||
if code, e := h.roomErr("room/join", map[string]string{"token": h.pvpToken(maxRoomMembers+1, "P")}); code != http.StatusConflict || e != "room_full" {
|
||||
t.Fatalf("full room: %d %s", code, e)
|
||||
}
|
||||
// Only the first MaxSeats members play; the rest wait in the lobby.
|
||||
v := h.join(1, "P")
|
||||
if len(v.Players) != 8 || v.Watchers != maxRoomMembers-8 {
|
||||
t.Fatalf("lobby = %d players / %d watchers", len(v.Players), v.Watchers)
|
||||
}
|
||||
started := h.roomStart(v.Member, http.StatusOK)
|
||||
if len(started.Players) != 8 || started.Watchers != maxRoomMembers-8 {
|
||||
t.Fatalf("game = %d players / %d watchers", len(started.Players), started.Watchers)
|
||||
}
|
||||
|
||||
// The room cap answers busy.
|
||||
h2 := newHarness(t, testCorpus)
|
||||
for msg := 1; msg <= maxRooms; msg++ {
|
||||
h2.call("room/join", map[string]string{"token": h2.pvpTokenAt(int64(msg), "P", -int64(msg), msg)}, http.StatusOK, nil)
|
||||
}
|
||||
tok := h2.pvpTokenAt(maxRooms+1, "P", -maxRooms-1, maxRooms+1)
|
||||
if code, e := h2.roomErr("room/join", map[string]string{"token": tok}); code != http.StatusServiceUnavailable || e != "busy" {
|
||||
t.Fatalf("room cap: %d %s", code, e)
|
||||
}
|
||||
|
||||
// One chat holds at most maxChatRooms live rooms; other chats are free.
|
||||
h4 := newHarness(t, testCorpus)
|
||||
for msg := 1; msg <= maxChatRooms; msg++ {
|
||||
h4.call("room/join", map[string]string{"token": h4.pvpTokenOn(int64(msg), "P", msg)}, http.StatusOK, nil)
|
||||
}
|
||||
if code, e := h4.roomErr("room/join", map[string]string{"token": h4.pvpTokenOn(99, "P", 99)}); code != http.StatusConflict || e != "too_many_rooms" {
|
||||
t.Fatalf("chat room cap: %d %s", code, e)
|
||||
}
|
||||
h4.call("room/join", map[string]string{"token": h4.pvpTokenAt(99, "P", -1, 99)}, http.StatusOK, nil)
|
||||
|
||||
// Joins share the per-user start limit.
|
||||
h3 := newHarness(t, testCorpus)
|
||||
for range startsPerMinute {
|
||||
h3.join(1, "P")
|
||||
}
|
||||
if code, e := h3.roomErr("room/join", map[string]string{"token": h3.pvpToken(1, "P")}); code != http.StatusTooManyRequests || e != "too_fast" {
|
||||
t.Fatalf("join flood: %d %s", code, e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoom_MoveRateLimitAndWordLength(t *testing.T) {
|
||||
h := newHarness(t, testCorpus)
|
||||
a := h.join(1, "An")
|
||||
h.join(2, "Bình")
|
||||
h.roomStart(a.Member, http.StatusOK)
|
||||
h.roomMove(a.Member, "hồng xyz")
|
||||
if code, e := h.roomErr("room/move", map[string]string{"member": a.Member, "word": "hồng hào"}); code != http.StatusTooManyRequests || e != "too_fast" {
|
||||
t.Fatalf("fast move: %d %s", code, e)
|
||||
}
|
||||
h.clock.advance(time.Second)
|
||||
if code, _ := h.roomErr("room/move", map[string]string{"member": a.Member, "word": strings.Repeat("a ", 9)}); code != http.StatusBadRequest {
|
||||
t.Fatalf("long word: %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// The sweep marks a room it drops as closed, so a request already holding
|
||||
// the room sees it gone.
|
||||
func TestRoom_SweepClosesTheRoomItDrops(t *testing.T) {
|
||||
h := newHarness(t, testCorpus)
|
||||
h.join(1, "An")
|
||||
held := h.svc.rooms.all()[0]
|
||||
h.clock.advance(memberStaleTTL + time.Second)
|
||||
h.svc.sweepRooms(h.clock.now())
|
||||
if !held.closed.Load() {
|
||||
t.Fatal("the swept room is not marked closed")
|
||||
}
|
||||
if n := len(h.svc.rooms.all()); n != 0 {
|
||||
t.Fatalf("%d rooms left", n)
|
||||
}
|
||||
}
|
||||
|
||||
// A join that got a room from open before the sweep closed it adds nobody
|
||||
// to it and asks for a retry.
|
||||
func TestRoom_JoinRetriesARoomClosedAfterOpen(t *testing.T) {
|
||||
h := newHarness(t, testCorpus)
|
||||
now := h.clock.now()
|
||||
c := claims{UserID: 1, Name: "An", ChatID: cardChat, MessageID: cardMsg, ThreadID: cardThread, PvP: true}
|
||||
rm, err := h.svc.rooms.open(c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rm.closed.Store(true) // the sweep ran between open and lock
|
||||
if _, retry := h.svc.joinRoom(rm, c, now); !retry {
|
||||
t.Fatal("join on a room closed after open did not retry")
|
||||
}
|
||||
rm.mu.Lock()
|
||||
n := len(rm.members)
|
||||
rm.mu.Unlock()
|
||||
if n != 0 {
|
||||
t.Fatalf("closed room gained %d members", n)
|
||||
}
|
||||
fresh, err := h.svc.rooms.open(c)
|
||||
if err != nil || fresh == rm {
|
||||
t.Fatalf("retry open = %p, %v; want a new room", fresh, err)
|
||||
}
|
||||
if reply, retry := h.svc.joinRoom(fresh, c, now); retry || reply.status != http.StatusOK {
|
||||
t.Fatalf("retry join = %+v, %v", reply, retry)
|
||||
}
|
||||
}
|
||||
|
||||
// Results on one card publish one game after another: while the first
|
||||
// game's report is held, a second game on the same card announces nothing,
|
||||
// but a game on another card publishes freely.
|
||||
func TestRoom_PublishingIsSerializedPerCard(t *testing.T) {
|
||||
h := newHarness(t, testCorpus)
|
||||
const otherMsg = cardMsg + 1
|
||||
entered := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
var once sync.Once
|
||||
h.reporter.beforeReport = func(c claims) {
|
||||
if c.MessageID != cardMsg {
|
||||
return
|
||||
}
|
||||
once.Do(func() {
|
||||
close(entered)
|
||||
<-release
|
||||
})
|
||||
}
|
||||
released := false
|
||||
defer func() {
|
||||
if !released {
|
||||
close(release)
|
||||
}
|
||||
}()
|
||||
|
||||
// playOut runs one game on the card a and b joined: a plays a word, b
|
||||
// gives up, so a wins with one word.
|
||||
playOut := func(a, b roomView) {
|
||||
h.roomStart(a.Member, http.StatusOK)
|
||||
if r := h.roomMove(a.Member, "hồng hào"); !r.Result.Accepted {
|
||||
t.Fatalf("move: %+v", r.Result)
|
||||
}
|
||||
h.call("room/give-up", map[string]string{"member": b.Member}, http.StatusOK, nil)
|
||||
}
|
||||
|
||||
a := h.join(1, "An")
|
||||
b := h.join(2, "Bình")
|
||||
playOut(a, b)
|
||||
select {
|
||||
case <-entered:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("first report never started")
|
||||
}
|
||||
|
||||
// A second game on the same card ends while the first still reports.
|
||||
h.clock.advance(roomStartInterval)
|
||||
playOut(a, b)
|
||||
|
||||
// A game on another card publishes while the first card is held.
|
||||
var c, d roomView
|
||||
h.call("room/join", map[string]string{"token": h.pvpTokenOn(3, "Chi", otherMsg)}, http.StatusOK, &c)
|
||||
h.call("room/join", map[string]string{"token": h.pvpTokenOn(4, "Dũng", otherMsg)}, http.StatusOK, &d)
|
||||
playOut(c, d)
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for h.reporter.announcedOn(otherMsg) == 0 {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("the other card's result was blocked by the first card")
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
if calls := waitCalls(t, h.reporter, 1); len(calls) != 1 || calls[0].claims.MessageID != otherMsg {
|
||||
t.Fatalf("reports while the first card is held = %+v", calls)
|
||||
}
|
||||
if n := h.reporter.announcedOn(cardMsg); n != 1 {
|
||||
t.Fatalf("%d announcements on the held card, want only the first game's", n)
|
||||
}
|
||||
|
||||
close(release)
|
||||
released = true
|
||||
deadline = time.Now().Add(2 * time.Second)
|
||||
for h.reporter.announcedOn(cardMsg) != 2 {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("the second game never published after the first finished")
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
if calls := waitCalls(t, h.reporter, 3); len(calls) != 3 {
|
||||
t.Fatalf("reports = %+v, want 3", calls)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
package noitu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/tiennm99/tiennm99bot/internal/log"
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules/noitu/engine"
|
||||
)
|
||||
|
||||
const (
|
||||
maxRooms = 500
|
||||
maxChatRooms = 3 // live rooms per chat, so one group cannot fill maxRooms
|
||||
maxRoomMembers = 32 // MaxSeats players plus watchers
|
||||
// memberStaleTTL drops a lobby member whose page stopped polling, so a
|
||||
// closed page does not hold a seat, and a lobby nobody polls is dropped
|
||||
// with its last member. A running game never drops anyone: an absent
|
||||
// player loses their turns on the clock instead.
|
||||
memberStaleTTL = time.Minute
|
||||
// roomStartInterval is the shortest gap between two games in one room.
|
||||
// Starts also count against the host's startsPerMinute.
|
||||
roomStartInterval = 3 * time.Second
|
||||
// winnerBonus is added to the winner's points.
|
||||
winnerBonus = 50
|
||||
noSeat = -1
|
||||
)
|
||||
|
||||
// Room statuses shown on the page.
|
||||
const (
|
||||
roomLobby = "lobby"
|
||||
roomPlaying = "playing"
|
||||
)
|
||||
|
||||
// roomMember is one person who joined a room through the card's Play button.
|
||||
// secret is their bearer credential; it is only ever sent to them.
|
||||
type roomMember struct {
|
||||
secret string
|
||||
userID int64
|
||||
name string
|
||||
lastSeen time.Time
|
||||
lastMove time.Time
|
||||
}
|
||||
|
||||
// roomEntry is one word of a room's chain. Seat indexes the game's players;
|
||||
// the opening word is the bot's, with seat -1.
|
||||
type roomEntry struct {
|
||||
chainEntry
|
||||
Seat int `json:"seat"`
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
// roomStanding is one player's line in a finished game's table.
|
||||
type roomStanding struct {
|
||||
userID int64
|
||||
Name string `json:"name"`
|
||||
Rank int `json:"rank"`
|
||||
Score int `json:"score"` // word points plus the winner bonus
|
||||
Bonus int `json:"bonus"`
|
||||
Words int `json:"words"`
|
||||
OutReason string `json:"out_reason"`
|
||||
You bool `json:"you"`
|
||||
}
|
||||
|
||||
// roomReport is one score to report on the card.
|
||||
type roomReport struct {
|
||||
claims claims
|
||||
score int
|
||||
}
|
||||
|
||||
// roomResult is the last finished game, shown in the lobby until the next
|
||||
// game starts.
|
||||
type roomResult struct {
|
||||
Winner string `json:"winner"`
|
||||
Words int `json:"words"` // words played, opening excluded
|
||||
EndReason string `json:"end_reason"`
|
||||
Standings []roomStanding `json:"standings"`
|
||||
ScoreReported string `json:"score_reported"`
|
||||
}
|
||||
|
||||
// room is the live game of one /noitupvp card. Its mutex guards every field
|
||||
// below it; the room store's mutex is never taken while holding it.
|
||||
type room struct {
|
||||
id string
|
||||
card claims // ChatID, MessageID and ThreadID of the card
|
||||
// closed marks a room the sweep dropped. It is atomic so the store can
|
||||
// replace a closed room without taking the room's mutex.
|
||||
closed atomic.Bool
|
||||
|
||||
mu sync.Mutex
|
||||
version int64
|
||||
members []*roomMember // join order; members[0] hosts the lobby
|
||||
game int // games started, so the page can tell chains apart
|
||||
match *engine.Match // nil before the first game; kept after one ends
|
||||
seats []*roomMember // the match's players, by seat
|
||||
chain []roomEntry
|
||||
result *roomResult
|
||||
lastStart time.Time
|
||||
}
|
||||
|
||||
// playing reports whether a game is in progress. Caller holds r.mu.
|
||||
func (r *room) playing() bool { return r.match != nil && !r.match.Over() }
|
||||
|
||||
func (r *room) changed() { r.version++ }
|
||||
|
||||
// member finds the member holding secret. Caller holds r.mu.
|
||||
func (r *room) member(secret string) *roomMember {
|
||||
for _, m := range r.members {
|
||||
if m.secret == secret {
|
||||
return m
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// seatOf is m's seat in the current or last game, noSeat when m watches.
|
||||
// Caller holds r.mu.
|
||||
func (r *room) seatOf(m *roomMember) int {
|
||||
for i, s := range r.seats {
|
||||
if s == m {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return noSeat
|
||||
}
|
||||
|
||||
// expired reports whether the room can be dropped: no game running and
|
||||
// nobody left, every member having stopped polling. Caller holds r.mu.
|
||||
func (r *room) expired() bool {
|
||||
return !r.playing() && len(r.members) == 0
|
||||
}
|
||||
|
||||
// roomStore indexes live rooms by card and by id.
|
||||
type roomStore struct {
|
||||
mu sync.Mutex
|
||||
byCard map[string]*room
|
||||
byID map[string]*room
|
||||
}
|
||||
|
||||
func newRoomStore() *roomStore {
|
||||
return &roomStore{byCard: map[string]*room{}, byID: map[string]*room{}}
|
||||
}
|
||||
|
||||
func roomCardKey(c claims) string { return cardKey(c.ChatID, c.MessageID) }
|
||||
|
||||
// open returns the card's room, creating it when there is none, or only a
|
||||
// closed one the sweep has not removed yet, and the room caps allow.
|
||||
func (st *roomStore) open(card claims) (*room, error) {
|
||||
st.mu.Lock()
|
||||
defer st.mu.Unlock()
|
||||
key := roomCardKey(card)
|
||||
if r := st.byCard[key]; r != nil {
|
||||
if !r.closed.Load() {
|
||||
return r, nil
|
||||
}
|
||||
st.removeLocked(r)
|
||||
}
|
||||
if len(st.byID) >= maxRooms {
|
||||
return nil, errBusy
|
||||
}
|
||||
inChat := 0
|
||||
for _, r := range st.byID {
|
||||
if r.card.ChatID == card.ChatID && !r.closed.Load() {
|
||||
inChat++
|
||||
}
|
||||
}
|
||||
if inChat >= maxChatRooms {
|
||||
return nil, errChatRoomsAPI
|
||||
}
|
||||
id, err := newSessionID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r := &room{
|
||||
id: id,
|
||||
card: claims{ChatID: card.ChatID, MessageID: card.MessageID, ThreadID: card.ThreadID},
|
||||
version: 1,
|
||||
}
|
||||
st.byCard[key] = r
|
||||
st.byID[id] = r
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (st *roomStore) get(id string) *room {
|
||||
st.mu.Lock()
|
||||
defer st.mu.Unlock()
|
||||
return st.byID[id]
|
||||
}
|
||||
|
||||
func (st *roomStore) remove(r *room) {
|
||||
st.mu.Lock()
|
||||
defer st.mu.Unlock()
|
||||
st.removeLocked(r)
|
||||
}
|
||||
|
||||
func (st *roomStore) removeLocked(r *room) {
|
||||
if st.byID[r.id] == r {
|
||||
delete(st.byID, r.id)
|
||||
}
|
||||
if key := roomCardKey(r.card); st.byCard[key] == r {
|
||||
delete(st.byCard, key)
|
||||
}
|
||||
}
|
||||
|
||||
func (st *roomStore) all() []*room {
|
||||
st.mu.Lock()
|
||||
defer st.mu.Unlock()
|
||||
out := make([]*room, 0, len(st.byID))
|
||||
for _, r := range st.byID {
|
||||
out = append(out, r)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sweepRooms settles expired turns in rooms nobody is polling, so their
|
||||
// games still end and report, and drops idle rooms.
|
||||
func (s *service) sweepRooms(now time.Time) {
|
||||
for _, r := range s.rooms.all() {
|
||||
r.mu.Lock()
|
||||
s.settleRoom(r, now)
|
||||
drop := r.expired()
|
||||
if drop {
|
||||
r.closed.Store(true)
|
||||
}
|
||||
r.mu.Unlock()
|
||||
if drop {
|
||||
s.rooms.remove(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// settleRoom catches the room up with the clock: every turn that ran out
|
||||
// eliminates its player, and lobby members who stopped polling leave.
|
||||
// Caller holds r.mu.
|
||||
func (s *service) settleRoom(r *room, now time.Time) {
|
||||
if r.playing() {
|
||||
timedOut := false
|
||||
for r.match.Timeout(now) {
|
||||
timedOut = true
|
||||
}
|
||||
if timedOut {
|
||||
r.changed()
|
||||
if r.match.Over() {
|
||||
s.endRoomGame(r, now)
|
||||
}
|
||||
}
|
||||
}
|
||||
if r.playing() {
|
||||
return
|
||||
}
|
||||
kept := r.members[:0]
|
||||
for _, m := range r.members {
|
||||
if now.Sub(m.lastSeen) <= memberStaleTTL {
|
||||
kept = append(kept, m)
|
||||
}
|
||||
}
|
||||
if len(kept) != len(r.members) {
|
||||
clear(r.members[len(kept):])
|
||||
r.members = kept
|
||||
r.changed()
|
||||
}
|
||||
}
|
||||
|
||||
// join adds the user to the room, or returns their existing membership so a
|
||||
// reloaded page or a second Play press keeps its place. Caller holds r.mu.
|
||||
func (r *room) join(c claims, now time.Time) (*roomMember, error) {
|
||||
name := c.Name
|
||||
if name == "" {
|
||||
name = "Người chơi"
|
||||
}
|
||||
for _, m := range r.members {
|
||||
if m.userID == c.UserID {
|
||||
m.lastSeen = now
|
||||
if m.name != name {
|
||||
m.name = name
|
||||
r.changed()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
if len(r.members) >= maxRoomMembers {
|
||||
return nil, errRoomFullAPI
|
||||
}
|
||||
secret, err := newSessionID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := &roomMember{secret: secret, userID: c.UserID, name: name, lastSeen: now}
|
||||
r.members = append(r.members, m)
|
||||
r.changed()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// startGame seats the first MaxSeats members in join order and opens a
|
||||
// match. Caller holds r.mu and has checked the lobby and the host.
|
||||
func (s *service) startGame(r *room, now time.Time) error {
|
||||
seats := r.members[:min(len(r.members), engine.MaxSeats)]
|
||||
if len(seats) < engine.MinSeats {
|
||||
return errNotEnoughAPI
|
||||
}
|
||||
rng := s.cfg.newRNG()
|
||||
match, err := engine.NewMatch(s.words, len(seats), s.words.RandomOpening(rng, minOpeningOut), turnLimit, turnGrace, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.match = match
|
||||
r.seats = append([]*roomMember(nil), seats...)
|
||||
r.chain = []roomEntry{{chainEntry: s.entry(match.Opening(), "bot", 0), Seat: noSeat}}
|
||||
r.result = nil
|
||||
r.game++
|
||||
r.lastStart = now
|
||||
for _, m := range r.seats {
|
||||
m.lastMove = time.Time{}
|
||||
}
|
||||
r.changed()
|
||||
return nil
|
||||
}
|
||||
|
||||
// playRoomWord submits seat's word. Caller holds r.mu and has checked that a
|
||||
// game runs.
|
||||
func (s *service) playRoomWord(r *room, seat int, word string, now time.Time) moveResult {
|
||||
mv, reason := r.match.Submit(seat, word, now)
|
||||
if reason != engine.ReasonNone {
|
||||
if r.match.Over() || reason == engine.ReasonTimeout {
|
||||
r.changed()
|
||||
}
|
||||
if r.match.Over() {
|
||||
s.endRoomGame(r, now)
|
||||
}
|
||||
return rejectedFor(reason, r.match.Current())
|
||||
}
|
||||
r.chain = append(r.chain, roomEntry{chainEntry: s.entry(mv.Word, "player", mv.Points), Seat: seat, Name: r.seats[seat].name})
|
||||
if len(r.chain) >= maxChain {
|
||||
r.match.EndMaxMoves()
|
||||
}
|
||||
r.changed()
|
||||
if r.match.Over() {
|
||||
s.endRoomGame(r, now)
|
||||
}
|
||||
return moveResult{Accepted: true, PlayerWord: mv.Word}
|
||||
}
|
||||
|
||||
// resignRoom takes seat out of the running game. Caller holds r.mu.
|
||||
func (s *service) resignRoom(r *room, seat int, now time.Time) {
|
||||
if !r.match.Resign(seat, now) {
|
||||
return
|
||||
}
|
||||
r.changed()
|
||||
if r.match.Over() {
|
||||
s.endRoomGame(r, now)
|
||||
}
|
||||
}
|
||||
|
||||
// endRoomGame records the finished game's table, returns the room to the
|
||||
// lobby, then posts the result and reports every positive score in the
|
||||
// background. Caller holds r.mu.
|
||||
func (s *service) endRoomGame(r *room, now time.Time) {
|
||||
words := make([]int, len(r.seats))
|
||||
for _, e := range r.chain {
|
||||
if e.Seat >= 0 {
|
||||
words[e.Seat]++
|
||||
}
|
||||
}
|
||||
res := &roomResult{
|
||||
Winner: r.seats[r.match.Winner()].name,
|
||||
Words: len(r.chain) - 1,
|
||||
EndReason: string(r.match.EndReason()),
|
||||
}
|
||||
var reports []roomReport
|
||||
for rank, seat := range r.match.Standings() {
|
||||
m := r.seats[seat]
|
||||
st := roomStanding{
|
||||
userID: m.userID,
|
||||
Name: m.name,
|
||||
Rank: rank + 1,
|
||||
Score: r.match.Score(seat),
|
||||
Words: words[seat],
|
||||
OutReason: string(r.match.OutReason(seat)),
|
||||
}
|
||||
// A game nobody played a word in (everyone left at once) earns
|
||||
// nothing: no bonus, no score report and no announcement.
|
||||
if seat == r.match.Winner() && res.Words > 0 {
|
||||
st.Bonus = winnerBonus
|
||||
st.Score += winnerBonus
|
||||
}
|
||||
res.Standings = append(res.Standings, st)
|
||||
if st.Score > 0 {
|
||||
reports = append(reports, roomReport{claims: claims{UserID: m.userID, ChatID: r.card.ChatID, MessageID: r.card.MessageID}, score: st.Score})
|
||||
}
|
||||
}
|
||||
res.ScoreReported = reportPending
|
||||
if len(reports) == 0 || s.cfg.reporter == nil {
|
||||
res.ScoreReported = reportSkipped
|
||||
}
|
||||
r.result = res
|
||||
for _, m := range r.members {
|
||||
// Back in the lobby, the clock for leaving starts now, not at the
|
||||
// last poll before a long game.
|
||||
if m.lastSeen.Before(now) {
|
||||
m.lastSeen = now
|
||||
}
|
||||
}
|
||||
r.changed()
|
||||
if res.Words == 0 {
|
||||
return
|
||||
}
|
||||
text := fmt.Sprintf("Ván nối từ kết thúc: %s thắng sau %d từ!", res.Winner, res.Words)
|
||||
go s.publishRoomResult(r, res, text, reports, res.ScoreReported == reportPending) //nolint:gosec // G118: the report must outlive the request that ended the game
|
||||
}
|
||||
|
||||
// publishRoomResult announces the result in the chat and reports the scores
|
||||
// one by one. Publishing holds the card's lock, so two games on one card
|
||||
// never edit its game message at the same time. Neither step is fatal to
|
||||
// the room.
|
||||
func (s *service) publishRoomResult(r *room, res *roomResult, text string, reports []roomReport, report bool) {
|
||||
defer s.publishing.Acquire(roomCardKey(r.card))()
|
||||
if s.cfg.announcer != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), reportTimeout)
|
||||
if err := s.cfg.announcer.Announce(ctx, r.card, text); err != nil {
|
||||
log.Warn("noitu room result message failed", "err", err)
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
if !report {
|
||||
return
|
||||
}
|
||||
status := reportOK
|
||||
for _, rep := range reports {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), reportTimeout)
|
||||
err := s.cfg.reporter.Report(ctx, rep.claims, rep.score)
|
||||
cancel()
|
||||
if err != nil && !scoreNotModified(err) {
|
||||
status = reportFailed
|
||||
log.Warn("noitu room setGameScore failed", "err", err)
|
||||
}
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
res.ScoreReported = status
|
||||
if r.result == res {
|
||||
r.changed()
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/tiennm99bot/internal/telegram"
|
||||
)
|
||||
@@ -34,6 +35,23 @@ func (r botReporter) Report(ctx context.Context, c claims, score int) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// announcer posts a finished room game's result into the card's chat.
|
||||
type announcer interface {
|
||||
Announce(ctx context.Context, card claims, text string) error
|
||||
}
|
||||
|
||||
// Announce replies to the card in its chat and forum topic, as plain text so
|
||||
// player names need no escaping.
|
||||
func (r botReporter) Announce(ctx context.Context, card claims, text string) error {
|
||||
_, err := r.b.SendMessage(ctx, &bot.SendMessageParams{
|
||||
ChatID: card.ChatID,
|
||||
MessageThreadID: card.ThreadID,
|
||||
Text: text,
|
||||
ReplyParameters: &models.ReplyParameters{MessageID: card.MessageID, AllowSendingWithoutReply: true},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// scoreNotModified reports Telegram's answer to a score that does not beat the
|
||||
// player's best. With force=false that is the expected outcome, not a failure.
|
||||
func scoreNotModified(err error) bool {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/tiennm99/tiennm99bot/internal/keylock"
|
||||
"github.com/tiennm99/tiennm99bot/internal/log"
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules"
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules/noitu/dict"
|
||||
@@ -197,6 +198,10 @@ type service struct {
|
||||
cfg config
|
||||
words *dict.Store // nil when the game is disabled
|
||||
sessions *sessionStore
|
||||
rooms *roomStore
|
||||
// publishing serializes each card's room results: its announcements and
|
||||
// setGameScore edits.
|
||||
publishing keylock.Map
|
||||
}
|
||||
|
||||
func (s *service) enabled() bool { return s.words != nil }
|
||||
@@ -218,7 +223,9 @@ func (s *service) sweep(now time.Time) {
|
||||
}
|
||||
|
||||
func (s *service) sweepCron(context.Context, modules.Deps) error {
|
||||
s.sweep(s.cfg.now())
|
||||
now := s.cfg.now()
|
||||
s.sweep(now)
|
||||
s.sweepRooms(now)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -26,12 +26,18 @@ var (
|
||||
// claims is what a game token asserts: who pressed Play, on which game
|
||||
// message, until when. Exactly one address is set: ChatID+MessageID for a
|
||||
// message the bot sent, or InlineID for one sent via the bot.
|
||||
//
|
||||
// PvP marks a Play press on a card /noitupvp registered: the page then joins
|
||||
// the card's room instead of playing the bot. Only a chat message can be a
|
||||
// card, and ThreadID is its forum topic, where the room posts its results.
|
||||
type claims struct {
|
||||
UserID int64 `json:"u"`
|
||||
Name string `json:"n,omitempty"`
|
||||
ChatID int64 `json:"c,omitempty"`
|
||||
MessageID int `json:"m,omitempty"`
|
||||
InlineID string `json:"i,omitempty"`
|
||||
PvP bool `json:"p,omitempty"`
|
||||
ThreadID int `json:"t,omitempty"`
|
||||
Expiry int64 `json:"e"`
|
||||
}
|
||||
|
||||
@@ -41,6 +47,9 @@ func (c claims) valid() bool {
|
||||
}
|
||||
chat := c.ChatID != 0 && c.MessageID != 0
|
||||
inline := c.InlineID != ""
|
||||
if c.PvP && !chat {
|
||||
return false
|
||||
}
|
||||
return chat != inline
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
--accent-text: #ffffff;
|
||||
--bot: #eef1f5;
|
||||
--player: #e3f0fb;
|
||||
--other: #fbf1e3;
|
||||
--error: #c0392b;
|
||||
--ok: #1e8e3e;
|
||||
--warn: #d68910;
|
||||
@@ -25,6 +26,7 @@
|
||||
--accent-text: #0e1621;
|
||||
--bot: #263342;
|
||||
--player: #1d3b55;
|
||||
--other: #3d3423;
|
||||
--error: #ff7b6b;
|
||||
--ok: #6fcf87;
|
||||
--warn: #f5b041;
|
||||
@@ -125,7 +127,8 @@ button.ghost { background: transparent; color: var(--muted); align-self: center;
|
||||
.chain > li { border-radius: 10px; padding: 8px 12px; background: var(--bot); }
|
||||
.chain > li.player { background: var(--player); }
|
||||
.chain .row { display: flex; align-items: center; gap: 8px; }
|
||||
.chain .who { font-size: .8rem; color: var(--muted); min-width: 34px; }
|
||||
.chain .who { font-size: .8rem; color: var(--muted); min-width: 34px; max-width: 35%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.chain > li.other { background: var(--other); }
|
||||
.chain .word { font-weight: 600; flex: 1; overflow-wrap: anywhere; }
|
||||
.chain .points { font-size: .85rem; color: var(--ok); }
|
||||
.chain .toggle { padding: 2px 8px; min-height: 30px; font-size: .8rem; }
|
||||
@@ -134,6 +137,23 @@ button.ghost { background: transparent; color: var(--muted); align-self: center;
|
||||
.chain > li.fresh { animation: pop .35s ease-out; }
|
||||
@keyframes pop { from { transform: scale(.97); opacity: .4; } to { transform: none; opacity: 1; } }
|
||||
|
||||
.players { list-style: none; margin: 12px 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.players > li { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 10px; background: var(--bot); }
|
||||
.players > li.you { background: var(--player); }
|
||||
.players .name { flex: 1; min-width: 0; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.players .tag { font-size: .8rem; color: var(--muted); }
|
||||
.players .score { font-variant-numeric: tabular-nums; color: var(--ok); }
|
||||
.players.compact { flex-direction: row; flex-wrap: wrap; margin: 0; }
|
||||
.players.compact > li { padding: 4px 10px; font-size: .9rem; max-width: 100%; }
|
||||
.players.compact > li.turn { outline: 2px solid var(--accent); }
|
||||
.players > li.out { opacity: .55; }
|
||||
.players > li.out .name { text-decoration: line-through; }
|
||||
.turn-line { margin: 0; font-weight: 600; }
|
||||
.turn-line.mine { color: var(--accent); }
|
||||
.room-result { border-bottom: 1px solid var(--border); margin-bottom: 12px; padding-bottom: 8px; }
|
||||
.standings { padding-left: 22px; margin: 8px 0; }
|
||||
.standings li.you { font-weight: 600; }
|
||||
|
||||
.final-score { font-size: 1.2rem; }
|
||||
.suggestions { padding-left: 20px; }
|
||||
.actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
|
||||
|
||||
@@ -14,6 +14,17 @@
|
||||
* score_reported: string
|
||||
* }} SessionView
|
||||
* @typedef {{accepted: boolean, reason: string, message: string, player_word: string, bot_word: string}} MoveResult
|
||||
* @typedef {{name: string, you: boolean, host: boolean, score: number, alive: boolean, out_reason: string}} RoomPlayer
|
||||
* @typedef {{name: string, rank: number, score: number, bonus: number, words: number, out_reason: string, you: boolean}} RoomStanding
|
||||
* @typedef {{winner: string, words: number, end_reason: string, standings: RoomStanding[], score_reported: string}} RoomResult
|
||||
* @typedef {ChainEntry & {seat: number, name?: string}} RoomEntry
|
||||
* @typedef {{
|
||||
* member: string, version: number, status: 'lobby'|'playing', game: number,
|
||||
* host: boolean, seat: number, turn: number, players: RoomPlayer[],
|
||||
* watchers: number, min_players: number, max_players: number,
|
||||
* current: string, chain: RoomEntry[], turn_limit_ms: number,
|
||||
* deadline_ms: number, server_now_ms: number, result: RoomResult|null
|
||||
* }} RoomView
|
||||
*/
|
||||
|
||||
/** Grace the server allows after the deadline, plus a little slack. */
|
||||
@@ -22,6 +33,9 @@
|
||||
var THINK_MS = 350;
|
||||
var REPORT_POLL_MS = 1500;
|
||||
var REPORT_POLLS = 10;
|
||||
/** Room state polling while the page is visible, and while it is hidden. */
|
||||
var POLL_MS = 1000;
|
||||
var POLL_HIDDEN_MS = 4000;
|
||||
|
||||
/** @param {string} id @returns {HTMLElement} */
|
||||
function $(id) {
|
||||
@@ -33,6 +47,7 @@
|
||||
var screens = {
|
||||
missing: $('screen-missing'),
|
||||
start: $('screen-start'),
|
||||
lobby: $('screen-lobby'),
|
||||
game: $('screen-game'),
|
||||
end: $('screen-end')
|
||||
};
|
||||
@@ -48,6 +63,8 @@
|
||||
|
||||
var TOKEN_KEY = 'noitu-token';
|
||||
var token = readToken();
|
||||
/** A token from a /noitupvp card opens the card's room instead of a game vs the bot. */
|
||||
var roomMode = isRoomToken(token);
|
||||
|
||||
/**
|
||||
* Takes the signed token from the URL, then strips it from the address bar
|
||||
@@ -68,8 +85,35 @@
|
||||
try { return window.sessionStorage.getItem(TOKEN_KEY) || ''; } catch (e) { return ''; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the PvP flag from the token's payload. The payload is signed, not
|
||||
* secret; the server checks the flag again, so this only picks the screen.
|
||||
* @param {string} t @returns {boolean}
|
||||
*/
|
||||
function isRoomToken(t) {
|
||||
try {
|
||||
var body = t.split('.')[0].replace(/-/g, '+').replace(/_/g, '/');
|
||||
while (body.length % 4) body += '=';
|
||||
return JSON.parse(window.atob(body)).p === true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {SessionView|null} */
|
||||
var view = null;
|
||||
/** @type {RoomView|null} */
|
||||
var room = null;
|
||||
var pollHandle = 0;
|
||||
var rejoined = false;
|
||||
/** Game number whose chain is on screen, so a new game clears it. */
|
||||
var shownGame = -1;
|
||||
/** Game and deadline of the room turn on screen, so a new turn clears the message. */
|
||||
var shownTurn = '';
|
||||
/** Set while a room error shows a lost connection, so the next good poll clears it. */
|
||||
var offlineShown = false;
|
||||
/** Turn length of the countdown on screen. */
|
||||
var turnLimitMs = 30000;
|
||||
/** Local wall-clock time at which the current turn ends. */
|
||||
var deadlineLocal = 0;
|
||||
var timerHandle = 0;
|
||||
@@ -85,7 +129,7 @@
|
||||
Object.keys(screens).forEach(function (key) {
|
||||
screens[/** @type {keyof typeof screens} */ (key)].hidden = key !== name;
|
||||
});
|
||||
chainBox.hidden = !(name === 'game' || name === 'end');
|
||||
chainBox.hidden = !(name === 'game' || name === 'end' || (name === 'lobby' && chainList.children.length > 0));
|
||||
}
|
||||
|
||||
/** @param {number} ms @returns {Promise<void>} */
|
||||
@@ -123,16 +167,31 @@
|
||||
message.className = 'message' + (kind ? ' ' + kind : '');
|
||||
}
|
||||
|
||||
/** @param {ChainEntry} entry @param {boolean} fresh @returns {HTMLLIElement} */
|
||||
/**
|
||||
* Who played a chain word: the bot or "Bạn" in a game vs the bot; in a
|
||||
* room, the opening, "Bạn", or the player's name.
|
||||
* @param {ChainEntry|RoomEntry} entry @returns {{label: string, cls: string}}
|
||||
*/
|
||||
function author(entry) {
|
||||
if (!roomMode) return entry.by === 'bot' ? { label: 'Bot', cls: 'bot' } : { label: 'Bạn', cls: 'player' };
|
||||
var e = /** @type {RoomEntry} */ (entry);
|
||||
if (e.by === 'bot') return { label: 'Mở đầu', cls: 'bot' };
|
||||
if (room && room.seat >= 0 && e.seat === room.seat) return { label: 'Bạn', cls: 'player' };
|
||||
return { label: e.name || '', cls: 'other' };
|
||||
}
|
||||
|
||||
/** @param {ChainEntry|RoomEntry} entry @param {boolean} fresh @returns {HTMLLIElement} */
|
||||
function chainItem(entry, fresh) {
|
||||
var li = document.createElement('li');
|
||||
li.className = entry.by + (fresh ? ' fresh' : '');
|
||||
var by = author(entry);
|
||||
li.className = by.cls + (fresh ? ' fresh' : '');
|
||||
|
||||
var row = document.createElement('div');
|
||||
row.className = 'row';
|
||||
var who = document.createElement('span');
|
||||
who.className = 'who';
|
||||
who.textContent = entry.by === 'bot' ? 'Bot' : 'Bạn';
|
||||
who.textContent = by.label;
|
||||
who.title = by.label;
|
||||
var word = document.createElement('span');
|
||||
word.className = 'word';
|
||||
word.textContent = entry.word;
|
||||
@@ -181,7 +240,7 @@
|
||||
return li;
|
||||
}
|
||||
|
||||
/** @param {ChainEntry[]} chain */
|
||||
/** @param {Array<ChainEntry|RoomEntry>} chain */
|
||||
function renderChain(chain) {
|
||||
var before = chainList.children.length;
|
||||
if (chain.length < before) {
|
||||
@@ -201,9 +260,8 @@
|
||||
}
|
||||
|
||||
function tick() {
|
||||
if (!view) return;
|
||||
var left = Math.max(0, deadlineLocal - Date.now());
|
||||
var ratio = view.turn_limit_ms > 0 ? left / view.turn_limit_ms : 0;
|
||||
var ratio = turnLimitMs > 0 ? left / turnLimitMs : 0;
|
||||
timerBar.style.transform = 'scaleX(' + Math.min(1, ratio).toFixed(3) + ')';
|
||||
var low = left <= 5000;
|
||||
timerBar.classList.toggle('low', low);
|
||||
@@ -216,16 +274,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** Starts the countdown from the server's deadline, not the local clock. */
|
||||
function startCountdown() {
|
||||
/**
|
||||
* Starts the countdown from the server's deadline, not the local clock.
|
||||
* Once the grace period has passed, onExpire asks the server to settle it.
|
||||
* @param {{deadline_ms: number, server_now_ms: number, turn_limit_ms: number}} v
|
||||
* @param {() => void} onExpire
|
||||
*/
|
||||
function startCountdown(v, onExpire) {
|
||||
stopTimers();
|
||||
if (!view) return;
|
||||
var left = view.deadline_ms - view.server_now_ms;
|
||||
var left = v.deadline_ms - v.server_now_ms;
|
||||
turnLimitMs = v.turn_limit_ms;
|
||||
deadlineLocal = Date.now() + left;
|
||||
tick();
|
||||
timerHandle = window.setInterval(tick, 100);
|
||||
// Once the grace period has passed, ask the server to settle the timeout.
|
||||
expiryHandle = window.setTimeout(refresh, Math.max(0, left) + GRACE_MS);
|
||||
expiryHandle = window.setTimeout(onExpire, Math.max(0, left) + GRACE_MS);
|
||||
}
|
||||
|
||||
/** @param {SessionView} next */
|
||||
@@ -237,7 +299,7 @@
|
||||
renderChain(next.chain);
|
||||
if (next.status === 'playing') {
|
||||
show('game');
|
||||
startCountdown();
|
||||
startCountdown(next, refresh);
|
||||
return;
|
||||
}
|
||||
stopTimers();
|
||||
@@ -344,6 +406,10 @@
|
||||
function submitWord(event) {
|
||||
event.preventDefault();
|
||||
var word = wordInput.value.trim();
|
||||
if (roomMode) {
|
||||
submitRoomWord(word);
|
||||
return;
|
||||
}
|
||||
if (!view || busy || word === '') return;
|
||||
busy = true;
|
||||
sendBtn.disabled = true;
|
||||
@@ -367,7 +433,7 @@
|
||||
}
|
||||
|
||||
function giveUp() {
|
||||
if (!view || busy) return;
|
||||
if ((roomMode ? !room : !view) || busy) return;
|
||||
if (!giveUpArmed) {
|
||||
giveUpBtn.textContent = 'Bấm lần nữa để chịu thua';
|
||||
giveUpArmed = window.setTimeout(function () {
|
||||
@@ -379,7 +445,282 @@
|
||||
clearTimeout(giveUpArmed);
|
||||
giveUpArmed = 0;
|
||||
giveUpBtn.textContent = 'Chịu thua';
|
||||
api('give-up', { session: view.session }).then(render, handleError);
|
||||
if (roomMode) {
|
||||
if (room) api('room/give-up', { member: room.member }).then(renderRoom, roomError);
|
||||
return;
|
||||
}
|
||||
if (view) api('give-up', { session: view.session }).then(render, handleError);
|
||||
}
|
||||
|
||||
// Room mode: the members of a chat play each other on one /noitupvp card.
|
||||
// The page polls the room's state; the server owns the turns and the clock.
|
||||
|
||||
var roomStartBtn = /** @type {HTMLButtonElement} */ ($('room-start'));
|
||||
var turnLine = $('turn-line');
|
||||
|
||||
var outText = {
|
||||
timeout: 'hết giờ',
|
||||
no_legal_move: 'hết từ để nối',
|
||||
gave_up: 'chịu thua'
|
||||
};
|
||||
|
||||
/** @param {number} [ms] */
|
||||
function schedulePoll(ms) {
|
||||
clearTimeout(pollHandle);
|
||||
var wait = ms === undefined ? (document.hidden ? POLL_HIDDEN_MS : POLL_MS) : ms;
|
||||
pollHandle = window.setTimeout(poll, wait);
|
||||
}
|
||||
|
||||
function poll() {
|
||||
if (!room) return;
|
||||
api('room/state', { member: room.member, version: room.version }).then(function (res) {
|
||||
if (offlineShown) {
|
||||
offlineShown = false;
|
||||
$('lobby-error').textContent = '';
|
||||
say('');
|
||||
}
|
||||
if (!res.unchanged) renderRoom(res);
|
||||
}, roomError).then(function () {
|
||||
if (room) schedulePoll();
|
||||
});
|
||||
}
|
||||
|
||||
/** Joins the card's room with the page token; a reload rejoins the same seat. */
|
||||
function joinRoom() {
|
||||
stopTimers();
|
||||
clearTimeout(pollHandle);
|
||||
show('lobby');
|
||||
roomStartBtn.hidden = true;
|
||||
$('lobby-error').textContent = '';
|
||||
$('lobby-wait').textContent = 'Đang vào phòng…';
|
||||
return api('room/join', { token: token }).then(function (v) {
|
||||
rejoined = false;
|
||||
renderRoom(v);
|
||||
schedulePoll();
|
||||
}, function (err) {
|
||||
room = null;
|
||||
$('lobby-wait').textContent = '';
|
||||
$('lobby-error').textContent = err.message;
|
||||
roomStartBtn.textContent = 'Vào lại phòng';
|
||||
roomStartBtn.disabled = false;
|
||||
roomStartBtn.hidden = err.code === 'bad_token' || err.code === 'token_expired';
|
||||
});
|
||||
}
|
||||
|
||||
/** @param {{code: string, message: string}} err */
|
||||
function roomError(err) {
|
||||
if (err.code === 'no_room' && !rejoined) {
|
||||
// The room expired or dropped this member; the token still names the card.
|
||||
rejoined = true;
|
||||
room = null;
|
||||
return joinRoom();
|
||||
}
|
||||
if (err.code === 'no_room') {
|
||||
room = null;
|
||||
stopTimers();
|
||||
show('lobby');
|
||||
$('lobby-wait').textContent = '';
|
||||
$('lobby-error').textContent = err.message;
|
||||
return;
|
||||
}
|
||||
// A rate-limited move retries on the next tap; a rate-limited start says so.
|
||||
if (err.code === 'too_fast' && room && room.status === 'playing') return;
|
||||
offlineShown = err.code === 'network';
|
||||
if (err.code === 'no_game' || err.code === 'game_running' || err.code === 'not_in_game') {
|
||||
schedulePoll(0);
|
||||
}
|
||||
if (room && room.status === 'playing') {
|
||||
say(err.message, 'error');
|
||||
} else {
|
||||
$('lobby-error').textContent = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {RoomPlayer} p @param {string} tag
|
||||
* @returns {HTMLLIElement}
|
||||
*/
|
||||
function playerItem(p, tag) {
|
||||
var li = document.createElement('li');
|
||||
if (p.you) li.className = 'you';
|
||||
var name = document.createElement('span');
|
||||
name.className = 'name';
|
||||
name.textContent = p.name + (p.you ? ' (bạn)' : '');
|
||||
li.appendChild(name);
|
||||
if (tag) {
|
||||
var t = document.createElement('span');
|
||||
t.className = 'tag';
|
||||
t.textContent = tag;
|
||||
li.appendChild(t);
|
||||
}
|
||||
return li;
|
||||
}
|
||||
|
||||
/** @param {RoomView} v */
|
||||
function renderRoom(v) {
|
||||
room = v;
|
||||
if (v.game !== shownGame) {
|
||||
chainList.textContent = '';
|
||||
expanded = Object.create(null);
|
||||
$('lobby-error').textContent = '';
|
||||
shownGame = v.game;
|
||||
}
|
||||
renderChain(v.chain);
|
||||
/** @type {RoomPlayer|null} */
|
||||
var me = null;
|
||||
v.players.forEach(function (p) { if (p.you) me = p; });
|
||||
$('player').textContent = me ? me.name : '';
|
||||
if (v.status === 'playing') {
|
||||
renderRoomGame(v, me);
|
||||
} else {
|
||||
renderLobby(v, me);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {RoomView} v @param {RoomPlayer|null} me */
|
||||
function renderLobby(v, me) {
|
||||
stopTimers();
|
||||
show('lobby');
|
||||
var list = $('lobby-players');
|
||||
list.textContent = '';
|
||||
v.players.forEach(function (p) {
|
||||
list.appendChild(playerItem(p, p.host ? 'chủ phòng' : ''));
|
||||
});
|
||||
var watchers = $('lobby-watchers');
|
||||
watchers.hidden = v.watchers === 0;
|
||||
watchers.textContent = 'Thêm ' + v.watchers + ' người đang chờ: mỗi ván có tối đa ' + v.max_players + ' người chơi.';
|
||||
var enough = v.players.length >= v.min_players;
|
||||
roomStartBtn.textContent = 'Bắt đầu';
|
||||
roomStartBtn.hidden = !v.host;
|
||||
roomStartBtn.disabled = busy || !enough;
|
||||
var wait = '';
|
||||
if (v.host && !enough) {
|
||||
wait = 'Cần ít nhất ' + v.min_players + ' người. Mời mọi người bấm Chơi trên thẻ trò chơi.';
|
||||
} else if (!v.host) {
|
||||
wait = me ? 'Chờ chủ phòng bắt đầu…' : 'Phòng đã đủ người chơi; bạn sẽ được xem ván tiếp theo.';
|
||||
}
|
||||
$('lobby-wait').textContent = wait;
|
||||
renderResult(v.result);
|
||||
}
|
||||
|
||||
/** @param {RoomResult|null} res */
|
||||
function renderResult(res) {
|
||||
var box = $('room-result');
|
||||
if (!res) {
|
||||
box.hidden = true;
|
||||
return;
|
||||
}
|
||||
box.hidden = false;
|
||||
$('room-result-title').textContent = res.winner + ' thắng!';
|
||||
$('room-result-detail').textContent = 'Chuỗi có ' + res.words + ' từ.' +
|
||||
(res.end_reason === 'max_moves' ? ' Chuỗi đã đủ dài, người nhiều điểm nhất thắng.' : '');
|
||||
var list = $('room-standings');
|
||||
list.textContent = '';
|
||||
res.standings.forEach(function (st) {
|
||||
var li = document.createElement('li');
|
||||
if (st.you) li.className = 'you';
|
||||
var why = outText[/** @type {keyof typeof outText} */ (st.out_reason)];
|
||||
li.textContent = st.name + (st.you ? ' (bạn)' : '') + ': ' + st.score + ' điểm, ' + st.words + ' từ' +
|
||||
(st.bonus > 0 ? ' (thưởng ' + st.bonus + ')' : '') + (why ? ' — ' + why : '');
|
||||
list.appendChild(li);
|
||||
});
|
||||
var report = $('room-report');
|
||||
report.textContent = reportText[/** @type {keyof typeof reportText} */ (res.score_reported)] || '';
|
||||
report.className = res.score_reported === 'ok' ? 'report-ok' : res.score_reported === 'failed' ? 'report-failed' : 'muted';
|
||||
}
|
||||
|
||||
/** @param {RoomView} v @param {RoomPlayer|null} me */
|
||||
function renderRoomGame(v, me) {
|
||||
var wasMine = !wordInput.disabled && !screens.game.hidden;
|
||||
show('game');
|
||||
var strip = $('room-players');
|
||||
strip.hidden = false;
|
||||
strip.textContent = '';
|
||||
v.players.forEach(function (p, i) {
|
||||
var li = playerItem(p, p.alive ? String(p.score) : (outText[/** @type {keyof typeof outText} */ (p.out_reason)] || 'bị loại'));
|
||||
if (i === v.turn) li.classList.add('turn');
|
||||
if (!p.alive) li.classList.add('out');
|
||||
strip.appendChild(li);
|
||||
});
|
||||
var mine = v.seat >= 0 && v.seat === v.turn;
|
||||
// A new turn clears the last one's message, such as "Hết giờ…"; a
|
||||
// re-render within the turn keeps it, such as a rejected word's reason.
|
||||
var turnKey = v.game + ':' + v.deadline_ms;
|
||||
if (turnKey !== shownTurn) {
|
||||
shownTurn = turnKey;
|
||||
say('');
|
||||
}
|
||||
var turnName = v.players[v.turn] ? v.players[v.turn].name : '';
|
||||
turnLine.hidden = false;
|
||||
turnLine.className = 'turn-line' + (mine ? ' mine' : '');
|
||||
if (mine) {
|
||||
turnLine.textContent = 'Đến lượt bạn!';
|
||||
} else if (!me) {
|
||||
turnLine.textContent = 'Bạn đang xem. Lượt của ' + turnName + '.';
|
||||
} else if (!me.alive) {
|
||||
turnLine.textContent = 'Bạn đã bị loại. Lượt của ' + turnName + '.';
|
||||
} else {
|
||||
turnLine.textContent = 'Lượt của ' + turnName + '.';
|
||||
}
|
||||
$('score').textContent = String(me ? me.score : 0);
|
||||
$('current').textContent = v.current;
|
||||
wordInput.disabled = !mine;
|
||||
sendBtn.disabled = busy || !mine;
|
||||
giveUpBtn.hidden = !(me && me.alive);
|
||||
if (mine && !wasMine) wordInput.focus();
|
||||
startCountdown(v, function () { schedulePoll(0); });
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends a room request: clears busy and re-enables the control the room now
|
||||
* needs, which a render during the request left disabled.
|
||||
*/
|
||||
function finishRoomAction() {
|
||||
busy = false;
|
||||
if (!room) return;
|
||||
if (room.status === 'playing') {
|
||||
if (room.seat >= 0 && room.seat === room.turn) {
|
||||
sendBtn.disabled = false;
|
||||
wordInput.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
roomStartBtn.disabled = room.players.length < room.min_players;
|
||||
}
|
||||
|
||||
/** @param {string} word */
|
||||
function submitRoomWord(word) {
|
||||
if (!room || busy || word === '') return;
|
||||
busy = true;
|
||||
sendBtn.disabled = true;
|
||||
say('');
|
||||
api('room/move', { member: room.member, word: word }).then(function (res) {
|
||||
// Render first: a late word moves the turn on, and the new turn
|
||||
// clears the message.
|
||||
renderRoom(res.state);
|
||||
if (res.result.accepted) {
|
||||
wordInput.value = '';
|
||||
} else {
|
||||
say(res.result.message || 'Từ không hợp lệ.', 'error');
|
||||
wordInput.select();
|
||||
}
|
||||
}, roomError).then(finishRoomAction);
|
||||
}
|
||||
|
||||
function startRoomGame() {
|
||||
if (busy) return;
|
||||
if (!room) {
|
||||
joinRoom();
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
roomStartBtn.disabled = true;
|
||||
$('lobby-error').textContent = '';
|
||||
api('room/start', { member: room.member }).then(function (v) {
|
||||
say('');
|
||||
wordInput.value = '';
|
||||
renderRoom(v);
|
||||
}, roomError).then(finishRoomAction);
|
||||
}
|
||||
|
||||
function share() {
|
||||
@@ -398,10 +739,22 @@
|
||||
show('start');
|
||||
});
|
||||
$('share').addEventListener('click', share);
|
||||
roomStartBtn.addEventListener('click', startRoomGame);
|
||||
// A tab that slept (phone locked) has a stale countdown; resync on return.
|
||||
document.addEventListener('visibilitychange', function () {
|
||||
if (!document.hidden && view && view.status === 'playing') refresh();
|
||||
if (document.hidden) return;
|
||||
if (roomMode && room) {
|
||||
schedulePoll(0);
|
||||
} else if (view && view.status === 'playing') {
|
||||
refresh();
|
||||
}
|
||||
});
|
||||
|
||||
show(token ? 'start' : 'missing');
|
||||
if (!token) {
|
||||
show('missing');
|
||||
} else if (roomMode) {
|
||||
joinRoom();
|
||||
} else {
|
||||
show('start');
|
||||
}
|
||||
})();
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
<section id="screen-missing" class="card" hidden>
|
||||
<p class="lead">Hãy mở trò chơi từ Telegram.</p>
|
||||
<p class="muted">Gửi <code>/noitu</code> cho bot rồi bấm nút Chơi.</p>
|
||||
<p class="muted">Gửi <code>/noitu</code> cho bot rồi bấm nút Chơi. Để chơi cùng bạn bè, gửi <code>/noitupvp</code> trong nhóm.</p>
|
||||
</section>
|
||||
|
||||
<section id="screen-start" class="card" hidden>
|
||||
@@ -37,7 +37,30 @@
|
||||
<p id="start-error" class="error" role="alert"></p>
|
||||
</section>
|
||||
|
||||
<section id="screen-lobby" class="card" hidden>
|
||||
<div id="room-result" class="room-result" hidden>
|
||||
<h2 id="room-result-title"></h2>
|
||||
<p id="room-result-detail" class="muted"></p>
|
||||
<ol id="room-standings" class="standings"></ol>
|
||||
<p id="room-report" class="muted"></p>
|
||||
</div>
|
||||
<h2>Phòng chơi nhóm</h2>
|
||||
<p class="lead">Mọi người bấm Chơi trên cùng thẻ trò chơi để vào phòng, rồi lần lượt nối từ theo thứ tự vào phòng.</p>
|
||||
<ul class="rules">
|
||||
<li>Mỗi lượt có 30 giây. Hết giờ hoặc chịu thua là bị loại.</li>
|
||||
<li>Người còn lại cuối cùng thắng và được thêm 50 điểm.</li>
|
||||
<li>Từ 2 đến 8 người chơi; người vào sau xem ván đang diễn ra.</li>
|
||||
</ul>
|
||||
<ul id="lobby-players" class="players"></ul>
|
||||
<p id="lobby-watchers" class="muted" hidden></p>
|
||||
<button type="button" id="room-start" class="primary" hidden>Bắt đầu</button>
|
||||
<p id="lobby-wait" class="muted"></p>
|
||||
<p id="lobby-error" class="error" role="alert"></p>
|
||||
</section>
|
||||
|
||||
<section id="screen-game" hidden>
|
||||
<ul id="room-players" class="players compact" hidden></ul>
|
||||
<p id="turn-line" class="turn-line" hidden></p>
|
||||
<div class="scoreline">
|
||||
<span>Điểm: <strong id="score">0</strong></span>
|
||||
<span id="timer-text" class="timer-text">30</span>
|
||||
|
||||
Reference in new issue
Block a user