Files
noitu/server/internal/game/state.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

121 lines
3.0 KiB
Go

package game
import (
"iter"
"time"
)
// PlayerID identifies a seat at the table. The engine never learns anything
// else about a player: no name, no connection, no session.
type PlayerID string
// Dictionary is the slice of the word store the engine needs.
//
// An interface rather than *dictionary.Store so the engine can be tested
// against a hand-built word graph with no SQLite involved, and so the bot's
// search can be exercised on boards small enough to reason about.
type Dictionary interface {
// Resolve maps a normalized word to its canonical form.
Resolve(word string) (string, bool)
// FirstSyllable and LastSyllable report the ends of a canonical word.
// The engine must use these rather than splitting the player's input:
// canonicalization can move either end.
FirstSyllable(word string) (string, bool)
LastSyllable(word string) (string, bool)
// WordsStartingWith iterates the words that may follow a syllable.
WordsStartingWith(syllable string) iter.Seq[string]
// OutDegree reports how many words start with a syllable.
OutDegree(syllable string) (int, error)
}
// RejectReason says why a submission was not accepted. Callers map these to
// player-facing messages, so each one has to be specific enough to act on.
type RejectReason int
const (
ReasonNone RejectReason = iota
ReasonNotYourTurn
ReasonTooFewSyllables
ReasonNotInDictionary
ReasonWrongLink
ReasonAlreadyUsed
ReasonTimeout
ReasonGameOver
)
func (r RejectReason) String() string {
switch r {
case ReasonNone:
return "accepted"
case ReasonNotYourTurn:
return "not your turn"
case ReasonTooFewSyllables:
return "fewer than two syllables"
case ReasonNotInDictionary:
return "not in dictionary"
case ReasonWrongLink:
return "wrong first syllable"
case ReasonAlreadyUsed:
return "already used"
case ReasonTimeout:
return "turn expired"
case ReasonGameOver:
return "game already over"
}
return "unknown"
}
// Move is one accepted word.
//
// Word is always the canonical spelling, which may differ from what the player
// typed. Typed records the raw input so the UI can show that a correction
// happened rather than silently replacing the player's text.
type Move struct {
Player PlayerID
Word string
Typed string
First string
Last string
Syllables int
Points int
At time.Time
}
// EndReason says how a finished game ended.
type EndReason int
const (
EndNone EndReason = iota
EndTimeout
EndNoLegalMove
EndResigned
)
func (r EndReason) String() string {
switch r {
case EndNone:
return "in play"
case EndTimeout:
return "timeout"
case EndNoLegalMove:
return "no legal move"
case EndResigned:
return "resigned"
}
return "unknown"
}
// State is a snapshot for the transport layer to render. It copies everything
// it exposes, so a caller can hold it without touching engine state.
type State struct {
Current string
Turn PlayerID
Deadline time.Time
History []Move
Scores map[PlayerID]int
ChainLength int
Over bool
Winner PlayerID
EndReason EndReason
}