Files
noitu/server/internal/game/engine.go
T
tiennm99 ca01145d06 feat(game): add noi tu rules engine and bot opponent
The engine is transport-free: no sockets, no protobuf, and no clock of its
own. Callers pass the current time in and read the deadline back, so every
rule is testable without a timer. One goroutine owns a game.

Validation resolves the word before checking the chain link. Roughly a
third of dictionary aliases move the first syllable, so "sy hai" resolves
to "si hai"; checking the link against what the player typed would reject
legal moves. The used-word set is keyed on the canonical form, which also
stops the same word being played twice under two spellings.

An opening whose last syllable starts nothing is refused. It would hand
the first player a game already lost, with no move and no reason, that
resolves only when the turn timer expires and then reports a timeout.

Three bot difficulties, separated by how far they look ahead rather than
by how willing they are to win: random, one-ply greedy, and depth-limited
negamax with alpha-beta. Strategies see a read-only view of the board, so
a bot cannot bypass the same validation a human's move goes through.

Simulation on the real corpus, alternating sides across 60 games per
pairing: hard beats easy 98%, medium beats easy 87%, hard beats medium
65%. Decisions take at most 19ms against a 150ms budget.

Three defects surfaced only under simulation. Withholding the winning
move from the middle bot, as first designed, made it lose to the random
bot 97% of the time. The search evaluated leaves with an inverted sign,
so it hunted for positions where it was about to be trapped. And the
rate limit on taking an instant win did nothing, because declining the
shortcut let the search rediscover the same move.

Games against the hard bot end after about three moves versus fifteen
for two random bots: with 1,814 dead-end syllables an instant win is
usually available. Tunable, and flagged for playtesting.
2026-09-04 17:29:47 +07:00

302 lines
8.7 KiB
Go

// Package game implements the nối từ rules: what counts as a legal move, whose
// turn it is, and when a game is over.
//
// It is transport-free by design. No WebSocket, no protobuf, no wall clock: the
// turn deadline is data the caller supplies and reads back, so every rule can
// be tested without a timer or a network. The room in the wsapi layer owns an
// Engine and is the only goroutine that touches it.
package game
import (
"errors"
"fmt"
"time"
"github.com/tiennm99dev/noitu/server/internal/vietnamese"
)
// Scoring. Longer words are worth more, which gives players a reason to reach
// for three- and four-syllable compounds rather than always playing the
// shortest legal word.
const (
basePoints = 10
chainBonus = 2 // per word already played
syllableBonus = 5 // per syllable beyond the minimum
maxPointsPerWord = 100
)
// Engine holds one game.
//
// Not safe for concurrent use. Exactly one goroutine owns an Engine — in the
// server that is the room goroutine, which serializes every input through a
// single channel.
type Engine struct {
dict Dictionary
players []PlayerID
used map[string]struct{}
current string
turnIndex int
turnLimit time.Duration
deadline time.Time
history []Move
scores map[PlayerID]int
over bool
winner PlayerID
endReason EndReason
}
// New starts a game from an opening word.
//
// The opening word counts as played: it seeds the used set and fixes the
// syllable the first player must link from.
func New(dict Dictionary, players []PlayerID, opening string, turnLimit time.Duration, now time.Time) (*Engine, error) {
if dict == nil {
return nil, errors.New("game: nil dictionary")
}
if len(players) < 2 {
return nil, fmt.Errorf("game: need at least 2 players, got %d", len(players))
}
if turnLimit <= 0 {
return nil, fmt.Errorf("game: turn limit must be positive, got %v", turnLimit)
}
seen := make(map[PlayerID]struct{}, len(players))
for _, p := range players {
if _, dup := seen[p]; dup {
return nil, fmt.Errorf("game: duplicate player %q", p)
}
seen[p] = struct{}{}
}
canonical, ok := dict.Resolve(opening)
if !ok {
return nil, fmt.Errorf("game: opening word %q is not in the dictionary", opening)
}
last, ok := dict.LastSyllable(canonical)
if !ok {
return nil, fmt.Errorf("game: opening word %q has no last syllable", canonical)
}
e := &Engine{
dict: dict,
players: append([]PlayerID{}, players...),
used: map[string]struct{}{canonical: {}},
current: last,
turnLimit: turnLimit,
deadline: now.Add(turnLimit),
scores: make(map[PlayerID]int, len(players)),
}
for _, p := range players {
e.scores[p] = 0
}
// An opening whose last syllable starts nothing hands the first player a
// game they have already lost, with no move to make and no reason given —
// it would resolve only when the turn timer expired, reported as a timeout.
// Refuse it here so the caller picks another opening.
if !e.HasLegalMove() {
return nil, fmt.Errorf("game: opening word %q ends on %q, which starts no other word", canonical, last)
}
return e, nil
}
// Dict returns the dictionary this game is played against, so a bot searches
// the same word graph that Submit validates against.
func (e *Engine) Dict() Dictionary { return e.dict }
// Turn reports whose move it is.
func (e *Engine) Turn() PlayerID { return e.players[e.turnIndex] }
// Current reports the syllable the next word must start with.
func (e *Engine) Current() string { return e.current }
// Deadline reports when the current turn expires.
func (e *Engine) Deadline() time.Time { return e.deadline }
// Over reports whether the game has finished.
func (e *Engine) Over() bool { return e.over }
// Winner reports the winner. Meaningless while the game is in play.
func (e *Engine) Winner() PlayerID { return e.winner }
// ChainLength reports how many words have been played, opening word included.
func (e *Engine) ChainLength() int { return len(e.history) + 1 }
// Submit validates a player's word and, if legal, plays it.
//
// The returned Move carries the canonical spelling; on rejection the reason
// says which rule failed.
//
// Validation order is turn, then length, then dictionary, then link, then
// reuse. Resolving before checking the link is not optional: canonicalization
// can move the first syllable ("sỹ hai" resolves to "sĩ hai"), so a link check
// against what the player typed would reject legal moves.
//
// raw is untrusted input and is normalized before use, but its length is not
// bounded here: the transport layer caps message size before a word reaches
// this point.
func (e *Engine) Submit(p PlayerID, raw string, now time.Time) (Move, RejectReason) {
if e.over {
return Move{}, ReasonGameOver
}
if p != e.Turn() {
return Move{}, ReasonNotYourTurn
}
if e.IsExpired(now) {
e.finish(e.opponentOf(p), EndTimeout)
return Move{}, ReasonTimeout
}
normalized, syllables, err := vietnamese.Normalize(raw)
if err != nil || !vietnamese.HasEnoughSyllables(syllables) {
return Move{}, ReasonTooFewSyllables
}
canonical, ok := e.dict.Resolve(normalized)
if !ok {
return Move{}, ReasonNotInDictionary
}
first, ok := e.dict.FirstSyllable(canonical)
if !ok {
return Move{}, ReasonNotInDictionary
}
if first != e.current {
return Move{}, ReasonWrongLink
}
if _, played := e.used[canonical]; played {
return Move{}, ReasonAlreadyUsed
}
last, ok := e.dict.LastSyllable(canonical)
if !ok {
return Move{}, ReasonNotInDictionary
}
move := Move{
Player: p,
Word: canonical,
Typed: raw,
First: first,
Last: last,
Syllables: len(syllables),
Points: e.pointsFor(len(syllables)),
At: now,
}
e.used[canonical] = struct{}{}
e.history = append(e.history, move)
e.scores[p] += move.Points
e.current = last
e.turnIndex = (e.turnIndex + 1) % len(e.players)
e.deadline = now.Add(e.turnLimit)
// The player who now has to move may have nothing to play.
if !e.HasLegalMove() {
e.finish(p, EndNoLegalMove)
}
return move, ReasonNone
}
// pointsFor scores a word about to be played. The chain term counts the words
// already down, opening word included, which is what ChainLength reports.
func (e *Engine) pointsFor(syllables int) int {
points := basePoints + chainBonus*e.ChainLength() + syllableBonus*(syllables-vietnamese.MinSyllables)
return min(points, maxPointsPerWord)
}
// LegalMoves lists every word the player to act may play.
//
// Allocates, so the bot's search uses HasLegalMove and iterates directly rather
// than calling this per node.
func (e *Engine) LegalMoves() []string {
var moves []string
for word := range e.dict.WordsStartingWith(e.current) {
if _, played := e.used[word]; !played {
moves = append(moves, word)
}
}
return moves
}
// HasLegalMove reports whether the player to act has anything to play. It stops
// at the first unused candidate instead of building the whole list.
func (e *Engine) HasLegalMove() bool {
for word := range e.dict.WordsStartingWith(e.current) {
if _, played := e.used[word]; !played {
return true
}
}
return false
}
// IsExpired reports whether the current turn's deadline has passed.
func (e *Engine) IsExpired(now time.Time) bool {
return now.After(e.deadline)
}
// Timeout ends the game against the player whose turn expired. The caller
// drives this from its own timer; the engine never reads the clock itself.
func (e *Engine) Timeout(now time.Time) bool {
if e.over || !e.IsExpired(now) {
return false
}
e.finish(e.opponentOf(e.Turn()), EndTimeout)
return true
}
// Resign ends the game against the player who gave up.
func (e *Engine) Resign(p PlayerID) bool {
if e.over {
return false
}
e.finish(e.opponentOf(p), EndResigned)
return true
}
func (e *Engine) finish(winner PlayerID, reason EndReason) {
e.over = true
e.winner = winner
e.endReason = reason
}
// opponentOf returns the other player. With more than two seats it returns the
// next one, which keeps the two-player case exact and the rest sane.
func (e *Engine) opponentOf(p PlayerID) PlayerID {
for i, candidate := range e.players {
if candidate == p {
return e.players[(i+1)%len(e.players)]
}
}
return p
}
// Used reports whether a canonical word has already been played.
func (e *Engine) Used(word string) bool {
_, played := e.used[word]
return played
}
// Snapshot copies the observable state for the transport layer.
func (e *Engine) Snapshot() State {
scores := make(map[PlayerID]int, len(e.scores))
for p, s := range e.scores {
scores[p] = s
}
return State{
Current: e.current,
Turn: e.Turn(),
Deadline: e.deadline,
History: append([]Move{}, e.history...),
Scores: scores,
ChainLength: e.ChainLength(),
Over: e.over,
Winner: e.winner,
EndReason: e.endReason,
}
}