mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-11 03:13:45 +00:00
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.
121 lines
3.0 KiB
Go
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
|
|
}
|