mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-11 03:13:45 +00:00
feat(game): a score a player can see the cause of, and a dead end they can claim
pointsFor now returns the named terms behind a word's total alongside the total itself, trimmed from the trailing term when the maxPointsPerWord cap bites, so PlayedWord.parts always sums exactly to points. ClaimDeadEnd lets the player to act say a syllable has no answer instead of waiting out the clock; the room checks HasLegalMove and settles it exactly as a timeout would, or refuses with the clock untouched. dictionary.Store gains a diacritic-stripped index built at Open, so MoveRejected.suggestion can offer the one real word a typo differs from by tone marks alone. ReportWord is validated and rate-limited on the session, logged with the room's own context, and answered with WordReported.
This commit is contained in:
1 parent
865222d2cd
commit
16fd37978a
12 files changed
+905
-13
No files matched your search
@@ -24,9 +24,12 @@ import (
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"math/rand/v2"
|
||||
|
||||
"golang.org/x/text/unicode/norm"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
@@ -73,6 +76,11 @@ type Store struct {
|
||||
meanings map[string][]Sense
|
||||
meaningCount int
|
||||
|
||||
// stripped maps a word's diacritic-stripped form to every canonical word
|
||||
// that reduces to it, for NearMiss. Built once at Open from the same words
|
||||
// map, never mutated afterwards.
|
||||
stripped map[string][]string
|
||||
|
||||
license string
|
||||
}
|
||||
|
||||
@@ -131,6 +139,7 @@ func Open(path string) (*Store, error) {
|
||||
if err := s.validate(declaredWords, declaredMeanings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.buildStrippedIndex()
|
||||
|
||||
sort.SliceStable(s.openers, func(i, j int) bool {
|
||||
return s.openers[i].lastOutDegree > s.openers[j].lastOutDegree
|
||||
@@ -360,6 +369,69 @@ func (s *Store) Resolve(word string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// buildStrippedIndex populates the near-miss lookup from the words already
|
||||
// loaded. Run once at Open, after loadWords: every canonical word maps to the
|
||||
// bucket of every other canonical word that shares its diacritic-stripped
|
||||
// form.
|
||||
func (s *Store) buildStrippedIndex() {
|
||||
s.stripped = make(map[string][]string, len(s.words))
|
||||
for word := range s.words {
|
||||
key := stripDiacritics(word)
|
||||
s.stripped[key] = append(s.stripped[key], word)
|
||||
}
|
||||
}
|
||||
|
||||
// stripDiacritics reduces a word to the letters a Vietnamese input method
|
||||
// actually types, so two spellings that differ only in tone or vowel marks
|
||||
// compare equal. NFD decomposition drops every combining mark (Mn), and đ/Đ
|
||||
// are folded onto d/D by hand because they are single codepoints with no
|
||||
// canonical decomposition — a separate letter in the alphabet, but the same
|
||||
// key on a Telex or VNI keyboard as d.
|
||||
//
|
||||
// This is purely a typing-distance measure, never a vocabulary one: it is
|
||||
// used only to find a real dictionary word near a mistyped one, not to decide
|
||||
// whether a word is playable.
|
||||
func stripDiacritics(word string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(word))
|
||||
for _, r := range norm.NFD.String(word) {
|
||||
switch {
|
||||
case unicode.Is(unicode.Mn, r):
|
||||
continue
|
||||
case r == 'đ':
|
||||
r = 'd'
|
||||
case r == 'Đ':
|
||||
r = 'D'
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return strings.ToLower(b.String())
|
||||
}
|
||||
|
||||
// NearMiss finds the one real word a normalized submission is a diacritic
|
||||
// typo away from.
|
||||
//
|
||||
// It reports a match only when the stripped form has exactly one canonical
|
||||
// word behind it, other than normalized itself: an ambiguous stem ("ngu" for
|
||||
// both "ngữ" and "ngủ") corrects to nothing, since guessing which one the
|
||||
// player meant would be handing out a word they may not know, and normalized
|
||||
// is excluded so a word already in the dictionary is never "corrected" to
|
||||
// itself. Callers are expected to call this only after Resolve has already
|
||||
// failed.
|
||||
func (s *Store) NearMiss(normalized string) (string, bool) {
|
||||
match, count := "", 0
|
||||
for _, w := range s.stripped[stripDiacritics(normalized)] {
|
||||
if w == normalized {
|
||||
continue
|
||||
}
|
||||
match, count = w, count+1
|
||||
}
|
||||
if count != 1 {
|
||||
return "", false
|
||||
}
|
||||
return match, true
|
||||
}
|
||||
|
||||
// FirstSyllable returns the syllable a canonical word begins with: the syllable
|
||||
// it must link from. Reports false for an alias, which is not a playable entry.
|
||||
func (s *Store) FirstSyllable(word string) (string, bool) {
|
||||
|
||||
@@ -269,6 +269,95 @@ func TestResolveUnknown(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// nearMissFixtureAt builds a tiny dictionary purpose-built for NearMiss: one
|
||||
// word with a unique stripped form, one ambiguous pair that shares a stripped
|
||||
// form with each other, and nothing else that could coincidentally collide.
|
||||
func nearMissFixtureAt(tb testing.TB, dir string) string {
|
||||
tb.Helper()
|
||||
|
||||
path := filepath.Join(dir, "nearmiss.db")
|
||||
db, err := sql.Open("sqlite", "file:"+path)
|
||||
if err != nil {
|
||||
tb.Fatal(err)
|
||||
}
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
data := fixtureSchema + `
|
||||
INSERT INTO meta VALUES ('source_license','CC BY-SA 4.0'),('word_count','4'),('meaning_count','0');
|
||||
INSERT INTO words VALUES
|
||||
('bình yên','bình','yên',2),
|
||||
('an nhàn','an','nhàn',2),
|
||||
-- "ngữ" and "ngừ" both strip to "ngu": an ambiguous stem with two real
|
||||
-- candidates behind it.
|
||||
('ngữ nghĩa','ngữ','nghĩa',2),
|
||||
('ngừ nghĩa','ngừ','nghĩa',2);
|
||||
INSERT INTO syllables VALUES ('bình',1),('an',1),('ngữ',1),('ngừ',1);
|
||||
`
|
||||
if _, err := db.Exec(data); err != nil {
|
||||
tb.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func nearMissFixture(tb testing.TB) *Store {
|
||||
tb.Helper()
|
||||
|
||||
store, err := Open(nearMissFixtureAt(tb, tb.TempDir()))
|
||||
if err != nil {
|
||||
tb.Fatalf("Open: %v", err)
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
// stripDiacritics is the typing-distance normalization NearMiss is built on:
|
||||
// NFD plus dropping combining marks handles tones and vowel horns, but đ has
|
||||
// no canonical decomposition, so it needs its own fold onto d.
|
||||
func TestStripDiacriticsFoldsDBreve(t *testing.T) {
|
||||
if got := stripDiacritics("đông đảo"); got != "dong dao" {
|
||||
t.Errorf("stripDiacritics(%q) = %q, want %q", "đông đảo", got, "dong dao")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNearMissFindsAUniqueDiacriticTypo(t *testing.T) {
|
||||
s := nearMissFixture(t)
|
||||
|
||||
got, ok := s.NearMiss("binh yen")
|
||||
if !ok || got != "bình yên" {
|
||||
t.Errorf(`NearMiss("binh yen") = (%q, %v), want ("bình yên", true)`, got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// Two real words differing only by diacritics from each other must not
|
||||
// resolve to either: correcting to the wrong one would hand out a word the
|
||||
// player did not know, which is the one thing NearMiss must never do.
|
||||
func TestNearMissRefusesAnAmbiguousStem(t *testing.T) {
|
||||
s := nearMissFixture(t)
|
||||
|
||||
if got, ok := s.NearMiss("ngu nghia"); ok {
|
||||
t.Errorf(`NearMiss("ngu nghia") = (%q, true), want no match for an ambiguous stem`, got)
|
||||
}
|
||||
}
|
||||
|
||||
// A word already spelled correctly must never be offered as its own
|
||||
// suggestion — Resolve would already have accepted it, so reaching NearMiss
|
||||
// with it at all means something upstream skipped that check, and this is the
|
||||
// last line of defence against showing "ý bạn là bình yên?" under "bình yên".
|
||||
func TestNearMissNeverSuggestsTheWordItself(t *testing.T) {
|
||||
s := nearMissFixture(t)
|
||||
|
||||
if got, ok := s.NearMiss("bình yên"); ok {
|
||||
t.Errorf(`NearMiss("bình yên") = (%q, true), want no suggestion for an exact dictionary word`, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNearMissRefusesAnUnrelatedWord(t *testing.T) {
|
||||
s := nearMissFixture(t)
|
||||
|
||||
if got, ok := s.NearMiss("hoàn toàn"); ok {
|
||||
t.Errorf(`NearMiss("hoàn toàn") = (%q, true), want no match`, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstAndLastSyllable(t *testing.T) {
|
||||
s := fixture(t)
|
||||
|
||||
|
||||
@@ -231,6 +231,7 @@ func (e *Engine) Submit(p PlayerID, raw string, now time.Time) (Move, RejectReas
|
||||
return Move{}, ReasonNotInDictionary
|
||||
}
|
||||
|
||||
points, parts := e.pointsFor(len(syllables), first, now)
|
||||
move := Move{
|
||||
Player: p,
|
||||
Word: canonical,
|
||||
@@ -238,7 +239,8 @@ func (e *Engine) Submit(p PlayerID, raw string, now time.Time) (Move, RejectReas
|
||||
First: first,
|
||||
Last: last,
|
||||
Syllables: len(syllables),
|
||||
Points: e.pointsFor(len(syllables), first, now),
|
||||
Points: points,
|
||||
Parts: parts,
|
||||
At: now,
|
||||
}
|
||||
|
||||
@@ -262,13 +264,57 @@ func (e *Engine) Submit(p PlayerID, raw string, now time.Time) (Move, RejectReas
|
||||
// already down, opening word included, which is what ChainLength reports; link
|
||||
// is the syllable the word answers, and now is when it was played, so both the
|
||||
// speed and the rarity term have to be read before the move is applied.
|
||||
func (e *Engine) pointsFor(syllables int, link string, now time.Time) int {
|
||||
points := basePoints +
|
||||
chainBonus*min(e.ChainLength(), chainBonusWords) +
|
||||
syllableBonus*(syllables-vietnamese.MinSyllables) +
|
||||
e.speedPoints(now) +
|
||||
e.rarityPoints(link)
|
||||
return min(points, maxPointsPerWord)
|
||||
//
|
||||
// Returns the total alongside the named terms it is made of, capped together:
|
||||
// a client with no wordlist of its own cannot re-derive why a word scored what
|
||||
// it did, so the breakdown travels with the total rather than being dropped
|
||||
// once it agrees with it.
|
||||
func (e *Engine) pointsFor(syllables int, link string, now time.Time) (int, []PointPart) {
|
||||
parts := []PointPart{
|
||||
{Kind: PointKindBase, Value: basePoints},
|
||||
{Kind: PointKindChain, Value: chainBonus * min(e.ChainLength(), chainBonusWords)},
|
||||
{Kind: PointKindSyllables, Value: syllableBonus * (syllables - vietnamese.MinSyllables)},
|
||||
{Kind: PointKindSpeed, Value: e.speedPoints(now)},
|
||||
{Kind: PointKindRarity, Value: e.rarityPoints(link)},
|
||||
}
|
||||
parts = capParts(parts)
|
||||
|
||||
total := 0
|
||||
for _, p := range parts {
|
||||
total += p.Value
|
||||
}
|
||||
return total, parts
|
||||
}
|
||||
|
||||
// capParts trims a word's score down to maxPointsPerWord when the terms
|
||||
// pointsFor computed add up to more, and drops whatever term that leaves at
|
||||
// zero — a PointPart exists only for a term that actually contributed.
|
||||
//
|
||||
// Trimmed from the end: rarity first, then speed, then syllables, then chain.
|
||||
// Base and the chain term never need touching to make room — the chain term
|
||||
// is itself capped at chainBonusWords words (10 base + 2*15 chain = 40 at
|
||||
// most), well under the cap — so the loop always finds enough in the later
|
||||
// terms and stops before reaching them.
|
||||
func capParts(parts []PointPart) []PointPart {
|
||||
total := 0
|
||||
for _, p := range parts {
|
||||
total += p.Value
|
||||
}
|
||||
if overflow := total - maxPointsPerWord; overflow > 0 {
|
||||
for i := len(parts) - 1; i >= 0 && overflow > 0; i-- {
|
||||
cut := min(parts[i].Value, overflow)
|
||||
parts[i].Value -= cut
|
||||
overflow -= cut
|
||||
}
|
||||
}
|
||||
|
||||
kept := parts[:0]
|
||||
for _, p := range parts {
|
||||
if p.Value > 0 {
|
||||
kept = append(kept, p)
|
||||
}
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
// speedPoints pays for the share of the turn the player left on the clock.
|
||||
|
||||
@@ -399,6 +399,109 @@ func TestSubmitScoringStopsPayingForChainLength(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Parts must name every term the total is made of, with nothing left over and
|
||||
// nothing zero: a term that did not contribute has nothing to show a player.
|
||||
func TestSubmitScoringPartsMatchTheTotal(t *testing.T) {
|
||||
d := newDict("ngôn ngữ", "ngữ pháp", "pháp vô tuyến điện")
|
||||
e := newGame(t, d, "ngôn ngữ")
|
||||
|
||||
two, _ := e.Submit(alice, "ngữ pháp", t0)
|
||||
if want := []PointPart{
|
||||
{Kind: PointKindBase, Value: 10},
|
||||
{Kind: PointKindChain, Value: 2},
|
||||
{Kind: PointKindSpeed, Value: 10},
|
||||
{Kind: PointKindRarity, Value: 15},
|
||||
}; !slices.Equal(two.Parts, want) {
|
||||
t.Errorf("two-syllable word parts = %+v, want %+v", two.Parts, want)
|
||||
}
|
||||
if sum := sumParts(two.Parts); sum != two.Points {
|
||||
t.Errorf("parts sum to %d, want Points %d", sum, two.Points)
|
||||
}
|
||||
|
||||
// Four syllables is two past the minimum, so this word's syllable term is
|
||||
// the one difference from the case above and must appear rather than be
|
||||
// silently folded into the total.
|
||||
four, r := e.Submit(bob, "pháp vô tuyến điện", t0)
|
||||
if r != ReasonNone {
|
||||
t.Fatalf("four-syllable word rejected: %s", r)
|
||||
}
|
||||
if want := []PointPart{
|
||||
{Kind: PointKindBase, Value: 10},
|
||||
{Kind: PointKindChain, Value: 4},
|
||||
{Kind: PointKindSyllables, Value: 10},
|
||||
{Kind: PointKindSpeed, Value: 10},
|
||||
{Kind: PointKindRarity, Value: 15},
|
||||
}; !slices.Equal(four.Parts, want) {
|
||||
t.Errorf("four-syllable word parts = %+v, want %+v", four.Parts, want)
|
||||
}
|
||||
if sum := sumParts(four.Parts); sum != four.Points {
|
||||
t.Errorf("parts sum to %d, want Points %d", sum, four.Points)
|
||||
}
|
||||
}
|
||||
|
||||
// When the naive sum overruns maxPointsPerWord, the parts must still sum to
|
||||
// the capped total exactly — trimmed from the end (rarity, then speed, then
|
||||
// syllables) rather than the total being clamped while the breakdown still
|
||||
// claims the uncapped numbers.
|
||||
func TestSubmitScoringPartsAreTrimmedAtTheCap(t *testing.T) {
|
||||
// A 20-syllable word answering "ngữ", the only continuation the dictionary
|
||||
// offers there, so rarity pays its maximum: base 10 + chain 2 + syllables
|
||||
// 5*18=90 + speed 10 + rarity 15 = 127 raw, 27 over the cap.
|
||||
tokens := []string{"ngữ"}
|
||||
for i := range 18 {
|
||||
tokens = append(tokens, fmt.Sprintf("s%d", i))
|
||||
}
|
||||
tokens = append(tokens, "z")
|
||||
long := strings.Join(tokens, " ")
|
||||
|
||||
e := newGame(t, newDict("ngôn ngữ", long), "ngôn ngữ")
|
||||
move, r := e.Submit(alice, long, t0)
|
||||
if r != ReasonNone {
|
||||
t.Fatalf("long word rejected: %s", r)
|
||||
}
|
||||
if move.Points != maxPointsPerWord {
|
||||
t.Fatalf("Points = %d, want the cap %d", move.Points, maxPointsPerWord)
|
||||
}
|
||||
if sum := sumParts(move.Parts); sum != maxPointsPerWord {
|
||||
t.Errorf("parts sum to %d, want the cap %d", sum, maxPointsPerWord)
|
||||
}
|
||||
// Rarity and speed are trimmed away entirely (15 + 10 = 25 of the 27
|
||||
// overflow) before syllables gives up the remaining 2, so base and chain
|
||||
// are untouched and syllables lands at 88 rather than its raw 90.
|
||||
if want := []PointPart{
|
||||
{Kind: PointKindBase, Value: 10},
|
||||
{Kind: PointKindChain, Value: 2},
|
||||
{Kind: PointKindSyllables, Value: 88},
|
||||
}; !slices.Equal(move.Parts, want) {
|
||||
t.Errorf("trimmed parts = %+v, want %+v", move.Parts, want)
|
||||
}
|
||||
}
|
||||
|
||||
func sumParts(parts []PointPart) int {
|
||||
sum := 0
|
||||
for _, p := range parts {
|
||||
sum += p.Value
|
||||
}
|
||||
return sum
|
||||
}
|
||||
|
||||
func TestPointKindStrings(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for k := PointKind(0); k < NumPointKinds; k++ {
|
||||
s := k.String()
|
||||
if s == "" || s == "unknown" {
|
||||
t.Errorf("PointKind(%d).String() = %q", k, s)
|
||||
}
|
||||
if seen[s] {
|
||||
t.Errorf("duplicate description %q", s)
|
||||
}
|
||||
seen[s] = true
|
||||
}
|
||||
if got := PointKind(99).String(); got != "unknown" {
|
||||
t.Errorf("unknown PointKind string = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegalMovesAndHasLegalMove(t *testing.T) {
|
||||
e := newGame(t, standardDict(), "ngôn ngữ")
|
||||
|
||||
|
||||
@@ -86,7 +86,55 @@ type Move struct {
|
||||
Last string
|
||||
Syllables int
|
||||
Points int
|
||||
At time.Time
|
||||
// Parts is how Points was arrived at: one entry per non-zero term, summing
|
||||
// exactly to Points even when the maxPointsPerWord cap trimmed them. See
|
||||
// pointsFor.
|
||||
Parts []PointPart
|
||||
At time.Time
|
||||
}
|
||||
|
||||
// PointKind names one term of a word's score. PointKindNone is the zero value
|
||||
// and never appears in a PointPart — it exists only so the wire mapping in
|
||||
// wsapi/convert.go has somewhere unreachable to send an unmapped value, the
|
||||
// same shape RejectReason and EndReason already use.
|
||||
type PointKind int
|
||||
|
||||
const (
|
||||
PointKindNone PointKind = iota
|
||||
PointKindBase
|
||||
PointKindChain
|
||||
PointKindSyllables
|
||||
PointKindSpeed
|
||||
PointKindRarity
|
||||
|
||||
// NumPointKinds is one past the last defined kind. See NumRejectReasons
|
||||
// for why the count is a constant rather than a walk.
|
||||
NumPointKinds
|
||||
)
|
||||
|
||||
func (k PointKind) String() string {
|
||||
switch k {
|
||||
case PointKindNone:
|
||||
return "none"
|
||||
case PointKindBase:
|
||||
return "base"
|
||||
case PointKindChain:
|
||||
return "chain"
|
||||
case PointKindSyllables:
|
||||
return "syllables"
|
||||
case PointKindSpeed:
|
||||
return "speed"
|
||||
case PointKindRarity:
|
||||
return "rarity"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// PointPart is one named term of a word's score: how many points it
|
||||
// contributed, and which of pointsFor's terms it was.
|
||||
type PointPart struct {
|
||||
Kind PointKind
|
||||
Value int
|
||||
}
|
||||
|
||||
// EndReason says how a finished game ended.
|
||||
|
||||
@@ -59,9 +59,19 @@ func welcomeMsg(sessionID, resumeToken, nickname string) *noituv1.ServerMessage
|
||||
}}}
|
||||
}
|
||||
|
||||
func moveRejectedMsg(reason noituv1.RejectReason, word string, turnSeq uint32) *noituv1.ServerMessage {
|
||||
// suggestion is REJECT_REASON_NOT_IN_DICTIONARY only: the one real word the
|
||||
// submission differs from by diacritics alone, or empty when none applies.
|
||||
func moveRejectedMsg(reason noituv1.RejectReason, word string, turnSeq uint32, suggestion string) *noituv1.ServerMessage {
|
||||
return &noituv1.ServerMessage{Payload: &noituv1.ServerMessage_MoveRejected{
|
||||
MoveRejected: &noituv1.MoveRejected{Reason: reason, Word: word, TurnSeq: turnSeq},
|
||||
MoveRejected: &noituv1.MoveRejected{Reason: reason, Word: word, TurnSeq: turnSeq, Suggestion: suggestion},
|
||||
}}
|
||||
}
|
||||
|
||||
// wordReportedMsg acknowledges a ReportWord, echoing the word as the server
|
||||
// recorded it so the player sees that it was heard.
|
||||
func wordReportedMsg(word string) *noituv1.ServerMessage {
|
||||
return &noituv1.ServerMessage{Payload: &noituv1.ServerMessage_WordReported{
|
||||
WordReported: &noituv1.WordReported{Word: word},
|
||||
}}
|
||||
}
|
||||
|
||||
|
||||
@@ -76,6 +76,40 @@ func EndReason(r game.EndReason) noituv1.GameEndReason {
|
||||
return noituv1.GameEndReason_GAME_END_REASON_UNSPECIFIED
|
||||
}
|
||||
|
||||
// PointKind maps an engine score term onto the wire enum.
|
||||
//
|
||||
// game.PointKindNone has no wire counterpart: a PointPart exists only for a
|
||||
// term that actually contributed, so no move ever carries one and this arm is
|
||||
// reached only by the exhaustiveness test.
|
||||
func PointKind(k game.PointKind) noituv1.PointKind {
|
||||
switch k {
|
||||
case game.PointKindNone:
|
||||
return noituv1.PointKind_POINT_KIND_UNSPECIFIED
|
||||
case game.PointKindBase:
|
||||
return noituv1.PointKind_POINT_KIND_BASE
|
||||
case game.PointKindChain:
|
||||
return noituv1.PointKind_POINT_KIND_CHAIN
|
||||
case game.PointKindSyllables:
|
||||
return noituv1.PointKind_POINT_KIND_SYLLABLES
|
||||
case game.PointKindSpeed:
|
||||
return noituv1.PointKind_POINT_KIND_SPEED
|
||||
case game.PointKindRarity:
|
||||
return noituv1.PointKind_POINT_KIND_RARITY
|
||||
}
|
||||
log.Printf("wsapi: no wire mapping for game.PointKind(%d) %q", int(k), k)
|
||||
return noituv1.PointKind_POINT_KIND_UNSPECIFIED
|
||||
}
|
||||
|
||||
// PointParts renders a move's score breakdown for the wire, in the order the
|
||||
// engine produced them.
|
||||
func PointParts(parts []game.PointPart) []*noituv1.PointPart {
|
||||
out := make([]*noituv1.PointPart, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
out = append(out, &noituv1.PointPart{Kind: PointKind(p.Kind), Value: uint32(p.Value)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Difficulty maps a wire difficulty onto a bot strategy selector.
|
||||
//
|
||||
// This one runs client to server, so an unrecognized value is untrusted input
|
||||
@@ -113,6 +147,7 @@ func PlayedWord(m game.Move, byMe bool, meanings []dictionary.Sense) *noituv1.Pl
|
||||
Points: uint32(m.Points),
|
||||
Syllables: uint32(m.Syllables),
|
||||
Meanings: Senses(meanings),
|
||||
Parts: PointParts(m.Parts),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -127,6 +127,85 @@ func TestEndReasonMappingIsExhaustive(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func enginePointKinds() []game.PointKind {
|
||||
out := make([]game.PointKind, 0, game.NumPointKinds)
|
||||
for i := game.PointKind(0); i < game.NumPointKinds; i++ {
|
||||
out = append(out, i)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestPointKindMappingIsExhaustive mirrors the reject-reason walk. Only
|
||||
// PointKindNone is allowed to land on UNSPECIFIED: a PointPart exists only for
|
||||
// a term that contributed, so no real move ever carries that value.
|
||||
func TestPointKindMappingIsExhaustive(t *testing.T) {
|
||||
kinds := enginePointKinds()
|
||||
if len(kinds) < 2 {
|
||||
t.Fatalf("enumerated %d engine point kinds, expected the full set", len(kinds))
|
||||
}
|
||||
|
||||
seen := make(map[noituv1.PointKind]game.PointKind, len(kinds))
|
||||
for _, k := range kinds {
|
||||
got := PointKind(k)
|
||||
if k == game.PointKindNone {
|
||||
if got != noituv1.PointKind_POINT_KIND_UNSPECIFIED {
|
||||
t.Errorf("PointKindNone should map to UNSPECIFIED, got %v", got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if got == noituv1.PointKind_POINT_KIND_UNSPECIFIED {
|
||||
t.Errorf("game.PointKind(%d) %q has no wire mapping", int(k), k)
|
||||
continue
|
||||
}
|
||||
if prev, dup := seen[got]; dup {
|
||||
t.Errorf("%v is produced by both %q and %q", got, prev, k)
|
||||
}
|
||||
seen[got] = k
|
||||
}
|
||||
|
||||
for _, v := range enumValues(noituv1.PointKind_POINT_KIND_UNSPECIFIED.Descriptor()) {
|
||||
w := noituv1.PointKind(v)
|
||||
if w == noituv1.PointKind_POINT_KIND_UNSPECIFIED {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[w]; !ok {
|
||||
t.Errorf("wire value %v is unreachable: no engine point kind maps to it", w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlayedWordCarriesItsScoreBreakdown guards the invariant the client
|
||||
// relies on: the parts it draws beside the total must sum to it exactly.
|
||||
func TestPlayedWordCarriesItsScoreBreakdown(t *testing.T) {
|
||||
m := game.Move{
|
||||
Player: "p1",
|
||||
Word: "bình yên",
|
||||
Points: 25,
|
||||
Parts: []game.PointPart{
|
||||
{Kind: game.PointKindBase, Value: 10},
|
||||
{Kind: game.PointKindSpeed, Value: 15},
|
||||
},
|
||||
}
|
||||
|
||||
rendered := PlayedWord(m, true, nil)
|
||||
if len(rendered.GetParts()) != 2 {
|
||||
t.Fatalf("rendered %d parts, want 2", len(rendered.GetParts()))
|
||||
}
|
||||
sum := uint32(0)
|
||||
for _, p := range rendered.GetParts() {
|
||||
sum += p.GetValue()
|
||||
}
|
||||
if sum != rendered.GetPoints() {
|
||||
t.Errorf("parts sum to %d, want points %d", sum, rendered.GetPoints())
|
||||
}
|
||||
if rendered.GetParts()[0].GetKind() != noituv1.PointKind_POINT_KIND_BASE {
|
||||
t.Errorf("first part kind = %v, want BASE", rendered.GetParts()[0].GetKind())
|
||||
}
|
||||
if rendered.GetParts()[1].GetKind() != noituv1.PointKind_POINT_KIND_SPEED {
|
||||
t.Errorf("second part kind = %v, want SPEED", rendered.GetParts()[1].GetKind())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDifficultyMapping checks each level and, more importantly, that an
|
||||
// unknown wire difficulty is refused rather than defaulted. Difficulty arrives
|
||||
// from the client, so silently treating garbage as Easy would let a malformed
|
||||
|
||||
@@ -42,6 +42,13 @@ type metricSet struct {
|
||||
|
||||
chatLines *expvar.Int
|
||||
|
||||
// deadEndClaims is keyed "true" or "false": whether the claimed position
|
||||
// actually had no legal move.
|
||||
deadEndClaims *expvar.Map
|
||||
// wordsReported counts accepted ReportWord messages — never the refused
|
||||
// ones, which cost the sender an error and nothing else.
|
||||
wordsReported *expvar.Int
|
||||
|
||||
// joinsRefused is keyed by the same UI error code the client is sent —
|
||||
// "too_many_attempts", "room_not_found" or "room_full" — so a reader
|
||||
// checking this counter against the frontend copy is checking against the
|
||||
@@ -74,6 +81,8 @@ func newMetricSet() *metricSet {
|
||||
wordsRejected: expvar.NewMap("noitu_words_rejected"),
|
||||
eliminations: expvar.NewMap("noitu_eliminations"),
|
||||
chatLines: expvar.NewInt("noitu_chat_lines"),
|
||||
deadEndClaims: expvar.NewMap("noitu_dead_end_claims"),
|
||||
wordsReported: expvar.NewInt("noitu_words_reported"),
|
||||
joinsRefused: expvar.NewMap("noitu_joins_refused"),
|
||||
botMoves: expvar.NewMap("noitu_bot_moves"),
|
||||
resumesAttempted: expvar.NewInt("noitu_resumes_attempted"),
|
||||
|
||||
@@ -156,6 +156,22 @@ type resignInput struct {
|
||||
player game.PlayerID
|
||||
}
|
||||
|
||||
// claimDeadEndInput is the player to act saying the syllable has no answer
|
||||
// left. Carries the connection, not just the claimed seat, for the same
|
||||
// reason resignInput does.
|
||||
type claimDeadEndInput struct {
|
||||
sess *session
|
||||
player game.PlayerID
|
||||
}
|
||||
|
||||
// reportWordInput is a word the session has already validated as reportable —
|
||||
// long enough, and within its own per-session cap — waiting only on the room
|
||||
// for the context a report is logged with: the syllable in play, if any.
|
||||
type reportWordInput struct {
|
||||
sess *session
|
||||
word string
|
||||
}
|
||||
|
||||
type disconnectInput struct {
|
||||
player game.PlayerID
|
||||
// sess identifies which connection dropped. A player who already
|
||||
@@ -303,6 +319,9 @@ type Dictionary interface {
|
||||
RandomOpeningWord(minOutDegree int) (string, error)
|
||||
// Meanings returns a canonical word's senses in order, nil for none.
|
||||
Meanings(word string) []dictionary.Sense
|
||||
// NearMiss finds the one real word a normalized submission differs from by
|
||||
// diacritics alone, reported only when exactly one such word exists.
|
||||
NearMiss(normalized string) (string, bool)
|
||||
}
|
||||
|
||||
func newRoom(h *hub, code string, turnLimit, graceFor, idleFor time.Duration, mode string) *room {
|
||||
@@ -464,6 +483,11 @@ func (r *room) run() {
|
||||
idleActivity = false
|
||||
case resignInput:
|
||||
r.handleResign(m)
|
||||
case claimDeadEndInput:
|
||||
r.handleClaimDeadEnd(m)
|
||||
case reportWordInput:
|
||||
r.handleReportWord(m)
|
||||
idleActivity = false
|
||||
case disconnectInput:
|
||||
// A dropped connection is not a player leaving: the seat is
|
||||
// held for the reconnect window whether a game is running or
|
||||
@@ -573,6 +597,43 @@ func (r *room) handleResign(m resignInput) {
|
||||
}
|
||||
}
|
||||
|
||||
// handleClaimDeadEnd is the player to act saying the syllable in play has no
|
||||
// answer left, checked rather than trusted.
|
||||
//
|
||||
// A true claim takes them out at once with EndNoLegalMove — exactly what the
|
||||
// clock would eventually rule, so the game's own outcome is unchanged and
|
||||
// only the wait is gone. A false claim changes nothing at all: the clock
|
||||
// keeps running and the claimant is simply told a word exists, which is hint
|
||||
// enough to be the whole cost of asking wrongly.
|
||||
func (r *room) handleClaimDeadEnd(m claimDeadEndInput) {
|
||||
if !r.occupies(m.sess, m.player) {
|
||||
m.sess.send(errorMsg("not_your_seat"))
|
||||
return
|
||||
}
|
||||
if r.engine == nil {
|
||||
m.sess.send(errorMsg("game_not_started"))
|
||||
return
|
||||
}
|
||||
if r.engine.Over() {
|
||||
return
|
||||
}
|
||||
if r.engine.Turn() != m.player {
|
||||
m.sess.send(errorMsg("not_your_turn"))
|
||||
return
|
||||
}
|
||||
if r.engine.HasLegalMove() {
|
||||
metrics.deadEndClaims.Add("false", 1)
|
||||
m.sess.send(errorMsg("not_a_dead_end"))
|
||||
return
|
||||
}
|
||||
|
||||
metrics.deadEndClaims.Add("true", 1)
|
||||
before := r.mark()
|
||||
if r.engine.NoMove(time.Now()) {
|
||||
r.applyEliminations(before)
|
||||
}
|
||||
}
|
||||
|
||||
// handleStartBot seats a bot opposite the player and begins immediately.
|
||||
func (r *room) handleStartBot(m startBotInput) {
|
||||
strategy, err := bot.New(m.difficulty, rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64())))
|
||||
@@ -860,7 +921,7 @@ func (r *room) handleSubmit(m submitInput) {
|
||||
metrics.wordsSubmitted.Add(1)
|
||||
|
||||
if m.turnSeq != r.turnSeq {
|
||||
r.sendTo(m.player, moveRejectedMsg(noituv1.RejectReason_REJECT_REASON_NOT_YOUR_TURN, m.word, r.turnSeq))
|
||||
r.sendTo(m.player, moveRejectedMsg(noituv1.RejectReason_REJECT_REASON_NOT_YOUR_TURN, m.word, r.turnSeq, ""))
|
||||
r.recordRejection(game.ReasonNotYourTurn, m.word)
|
||||
return
|
||||
}
|
||||
@@ -874,7 +935,7 @@ func (r *room) handleSubmit(m submitInput) {
|
||||
before := r.mark()
|
||||
move, reason := r.engine.Submit(m.player, word, time.Now())
|
||||
if reason != game.ReasonNone {
|
||||
r.sendTo(m.player, moveRejectedMsg(RejectReason(reason), word, m.turnSeq))
|
||||
r.sendTo(m.player, moveRejectedMsg(RejectReason(reason), word, m.turnSeq, r.nearMissFor(reason, word)))
|
||||
r.recordRejection(reason, word)
|
||||
// A rejection for an expired turn also took this player out of the
|
||||
// game, and everybody has to be told which.
|
||||
@@ -890,6 +951,25 @@ func (r *room) handleSubmit(m submitInput) {
|
||||
r.maybeScheduleBot()
|
||||
}
|
||||
|
||||
// nearMissFor finds a diacritic-typo suggestion for a word the dictionary
|
||||
// refused. Only for REJECT_REASON_NOT_IN_DICTIONARY: every other rejection
|
||||
// means the word IS in the dictionary and was refused for some other reason,
|
||||
// where a spelling suggestion would be misleading rather than helpful.
|
||||
func (r *room) nearMissFor(reason game.RejectReason, raw string) string {
|
||||
if reason != game.ReasonNotInDictionary {
|
||||
return ""
|
||||
}
|
||||
normalized, _, err := vietnamese.Normalize(raw)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
suggestion, ok := r.dict.NearMiss(normalized)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return suggestion
|
||||
}
|
||||
|
||||
// recordRejection counts one rejected submission and logs it at Info.
|
||||
//
|
||||
// This is the corpus feedback loop the improvement report calls the input to
|
||||
@@ -922,6 +1002,24 @@ func (r *room) recordRejection(reason game.RejectReason, raw string) {
|
||||
)
|
||||
}
|
||||
|
||||
// handleReportWord logs one report with this room's context.
|
||||
//
|
||||
// The session has already checked the word is long enough and within its own
|
||||
// per-session cap before routing it here — this is only about what to log,
|
||||
// and the syllable in play, this room's mode and its code are all room
|
||||
// goroutine state that only the room may read. Never the reporting player's
|
||||
// seat or name: recordRejection keeps the same information out of the corpus
|
||||
// feedback loop for the same reason.
|
||||
func (r *room) handleReportWord(m reportWordInput) {
|
||||
link := ""
|
||||
if r.engine != nil {
|
||||
link = r.engine.Current()
|
||||
}
|
||||
metrics.wordsReported.Add(1)
|
||||
slog.Info("word_reported", "word", m.word, "link", link, "mode", r.mode, "room", r.code)
|
||||
m.sess.send(wordReportedMsg(m.word))
|
||||
}
|
||||
|
||||
// handleBotMove applies what the worker chose.
|
||||
func (r *room) handleBotMove(m botMoveInput) {
|
||||
if r.engine == nil || r.engine.Over() {
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/coder/websocket"
|
||||
noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1"
|
||||
"github.com/tiennm99dev/noitu/server/internal/game"
|
||||
"github.com/tiennm99dev/noitu/server/internal/vietnamese"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -49,6 +50,13 @@ const (
|
||||
joinsPerSecond = 1
|
||||
joinBurst = 5
|
||||
|
||||
// maxWordReportsPerSession bounds how many distinct words one session may
|
||||
// file with ReportWord. A duplicate report of a word already filed does
|
||||
// not count against it — it costs nothing new to acknowledge again — but
|
||||
// an unbounded stream of distinct ones would turn the corpus feedback loop
|
||||
// into a log-filling vector.
|
||||
maxWordReportsPerSession = 20
|
||||
|
||||
// Room creation is far more expensive than a join: each one is a
|
||||
// goroutine, an engine and a registry entry held until the game ends.
|
||||
roomsPerSecond = 0.2
|
||||
@@ -121,6 +129,13 @@ type session struct {
|
||||
chatLimiter *bucket
|
||||
frameLimiter *bucket
|
||||
|
||||
// reportedWords is every distinct word this session has filed with
|
||||
// ReportWord, capped at maxWordReportsPerSession. Touched only from
|
||||
// dispatch, which is the sole reader of this connection's frames, so it
|
||||
// needs no lock of its own — unlike nick/room/playerID above, nothing else
|
||||
// ever reads or writes it.
|
||||
reportedWords map[string]struct{}
|
||||
|
||||
// greeted marks the handshake done. It is a one-shot transition: a second
|
||||
// Hello would re-register the session and rewrite its nickname mid-game.
|
||||
greeted bool
|
||||
@@ -147,6 +162,7 @@ func newSession(ctx context.Context, conn *websocket.Conn, h *hub, remoteIP stri
|
||||
roomLimiter: newBucket(roomsPerSecond, roomBurst, time.Now()),
|
||||
chatLimiter: newBucket(chatsPerSecond, chatBurst, time.Now()),
|
||||
frameLimiter: newBucket(framesPerSecond, frameBurst, time.Now()),
|
||||
reportedWords: make(map[string]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -476,6 +492,24 @@ func (s *session) dispatch(msg *noituv1.ClientMessage) error {
|
||||
s.send(errorMsg("not_in_a_game"))
|
||||
}
|
||||
|
||||
case *noituv1.ClientMessage_ClaimDeadEnd:
|
||||
// Rate-limited on the same budget as a submission: a claim is the
|
||||
// alternative to playing a word, not a second action alongside it.
|
||||
if !s.submitLimiter.allow(time.Now()) {
|
||||
s.send(errorMsg("too_fast"))
|
||||
return nil
|
||||
}
|
||||
if r, id := s.currentRoom(); r != nil {
|
||||
if !r.send(claimDeadEndInput{sess: s, player: id}) {
|
||||
s.send(errorMsg("busy"))
|
||||
}
|
||||
} else {
|
||||
s.send(errorMsg("not_in_a_game"))
|
||||
}
|
||||
|
||||
case *noituv1.ClientMessage_ReportWord:
|
||||
s.handleReportWord(p.ReportWord)
|
||||
|
||||
case *noituv1.ClientMessage_SetReady:
|
||||
s.toRoom(lobbyInput{sess: s, action: lobbyReady, ready: p.SetReady.GetReady()})
|
||||
|
||||
@@ -623,6 +657,49 @@ func (s *session) handleSubmit(w *noituv1.SubmitWord) {
|
||||
}
|
||||
}
|
||||
|
||||
// handleReportWord validates a word report and, once it is worth logging,
|
||||
// hands it to the current room for the context only the room goroutine may
|
||||
// read — the syllable in play, and the room's own mode and code.
|
||||
//
|
||||
// Validation happens here rather than in the room because it is entirely
|
||||
// about this connection: its own rate budget, and its own running count of
|
||||
// distinct words already filed. Neither needs the room at all, and a session
|
||||
// playing no game — smoke-testing the wire directly, per the README — can
|
||||
// still file a report, acknowledged with mode "none" and no link.
|
||||
func (s *session) handleReportWord(m *noituv1.ReportWord) {
|
||||
if !s.chatLimiter.allow(time.Now()) {
|
||||
s.send(errorMsg("too_fast"))
|
||||
return
|
||||
}
|
||||
|
||||
word, syllables, err := vietnamese.Normalize(sanitizeText(m.GetWord(), maxWordRunes, maxNicknameMarks))
|
||||
if err != nil || !vietnamese.HasEnoughSyllables(syllables) {
|
||||
s.send(errorMsg("word_report_refused"))
|
||||
return
|
||||
}
|
||||
|
||||
if _, already := s.reportedWords[word]; !already {
|
||||
if len(s.reportedWords) >= maxWordReportsPerSession {
|
||||
s.send(errorMsg("word_report_limit"))
|
||||
return
|
||||
}
|
||||
s.reportedWords[word] = struct{}{}
|
||||
}
|
||||
|
||||
// currentRoom's player id is not needed here: the log line is about the
|
||||
// word and the room's context, never about who filed it.
|
||||
if r, _ := s.currentRoom(); r != nil {
|
||||
if !r.send(reportWordInput{sess: s, word: word}) {
|
||||
s.send(errorMsg("busy"))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
metrics.wordsReported.Add(1)
|
||||
slog.Info("word_reported", "word", word, "link", "", "mode", "none", "room", "")
|
||||
s.send(wordReportedMsg(word))
|
||||
}
|
||||
|
||||
// leaveRoom tells the room this connection is gone, so the seat enters its
|
||||
// grace window rather than the game simply stalling.
|
||||
func (s *session) leaveRoom() {
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
@@ -19,6 +20,7 @@ import (
|
||||
"github.com/tiennm99dev/noitu/server/internal/bot"
|
||||
"github.com/tiennm99dev/noitu/server/internal/dictionary"
|
||||
"github.com/tiennm99dev/noitu/server/internal/game"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
@@ -90,6 +92,41 @@ func (d *testDict) OutDegree(syllable string) (int, error) {
|
||||
|
||||
func (d *testDict) RandomOpeningWord(int) (string, error) { return d.opening, nil }
|
||||
|
||||
// NearMiss strips tone marks and the đ/d distinction by hand — the same
|
||||
// typing-distance fold dictionary.stripDiacritics performs — since this test
|
||||
// graph is built by hand rather than through the real Store.
|
||||
func (d *testDict) NearMiss(normalized string) (string, bool) {
|
||||
strip := func(s string) string {
|
||||
s = norm.NFD.String(s)
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case unicode.Is(unicode.Mn, r):
|
||||
continue
|
||||
case r == 'đ':
|
||||
r = 'd'
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
key := strip(normalized)
|
||||
match, count := "", 0
|
||||
for w := range d.words {
|
||||
if w == normalized {
|
||||
continue
|
||||
}
|
||||
if strip(w) == key {
|
||||
match, count = w, count+1
|
||||
}
|
||||
}
|
||||
if count != 1 {
|
||||
return "", false
|
||||
}
|
||||
return match, true
|
||||
}
|
||||
|
||||
func maps(m map[string][2]string) iter.Seq[string] {
|
||||
return func(yield func(string) bool) {
|
||||
for k := range m {
|
||||
@@ -277,6 +314,8 @@ func payloadCase(m *noituv1.ServerMessage) string {
|
||||
return "chat_message"
|
||||
case *noituv1.ServerMessage_ChatHistory:
|
||||
return "chat_history"
|
||||
case *noituv1.ServerMessage_WordReported:
|
||||
return "word_reported"
|
||||
}
|
||||
// Named rather than empty: a missing arm here makes every await for that
|
||||
// message time out with nothing to say about why.
|
||||
@@ -1156,6 +1195,20 @@ func (c *testClient) resign() {
|
||||
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Resign{Resign: &noituv1.Resign{}}})
|
||||
}
|
||||
|
||||
func (c *testClient) claimDeadEnd() {
|
||||
c.t.Helper()
|
||||
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_ClaimDeadEnd{
|
||||
ClaimDeadEnd: &noituv1.ClaimDeadEnd{},
|
||||
}})
|
||||
}
|
||||
|
||||
func (c *testClient) reportWord(word string) {
|
||||
c.t.Helper()
|
||||
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_ReportWord{
|
||||
ReportWord: &noituv1.ReportWord{Word: word},
|
||||
}})
|
||||
}
|
||||
|
||||
// resignFrom has one player of a two-player game give up, whichever of them
|
||||
// drew the first turn.
|
||||
//
|
||||
@@ -2076,6 +2129,15 @@ func offlineSession(t *testing.T, capacity int) *session {
|
||||
cancel: cancel,
|
||||
out: make(chan []byte, capacity),
|
||||
flushed: make(chan struct{}),
|
||||
// Generous rather than zero: most callers exercise a handler directly
|
||||
// and never touch a limiter, but one that does — handleReportWord — must
|
||||
// not panic on a nil bucket, and a test about something else has no
|
||||
// reason to also be a test of rate limiting.
|
||||
submitLimiter: newBucket(1000, 1000, time.Now()),
|
||||
roomLimiter: newBucket(1000, 1000, time.Now()),
|
||||
chatLimiter: newBucket(1000, 1000, time.Now()),
|
||||
frameLimiter: newBucket(1000, 1000, time.Now()),
|
||||
reportedWords: make(map[string]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2292,3 +2354,167 @@ func TestLobbyActionsAreRateLimited(t *testing.T) {
|
||||
}
|
||||
t.Error("a burst of lobby actions was never refused")
|
||||
}
|
||||
|
||||
// --- dead-end claims ---------------------------------------------------
|
||||
|
||||
// TestClaimDeadEndEliminatesImmediately walks a player into a real dead end
|
||||
// and has them claim it rather than wait out the clock. The outcome must be
|
||||
// exactly what a timeout would have produced: NO_LEGAL_MOVE, no answerable
|
||||
// suggestions.
|
||||
func TestClaimDeadEndEliminatesImmediately(t *testing.T) {
|
||||
// "b" starts nothing after "b c" is played, so whoever inherits "c" has no
|
||||
// move at all.
|
||||
_, url := newTestServer(t, newTestDict("a b", "b c"), Config{})
|
||||
host, guest, start := pvpRoom(t, url)
|
||||
lead, stuck := host, guest
|
||||
if !start.GetMyTurn() {
|
||||
lead, stuck = guest, host
|
||||
}
|
||||
|
||||
lead.submit("b c", start.GetTurnSeq())
|
||||
lead.await("turn_update")
|
||||
turn := stuck.await("turn_update").GetTurnUpdate()
|
||||
if turn.GetCurrentSyllable() != "c" {
|
||||
t.Fatalf("current syllable = %q, want %q", turn.GetCurrentSyllable(), "c")
|
||||
}
|
||||
if !turn.GetMyTurn() {
|
||||
t.Fatal("the player left with the dead end should be on turn")
|
||||
}
|
||||
|
||||
stuck.claimDeadEnd()
|
||||
|
||||
out := stuck.await("player_eliminated").GetPlayerEliminated()
|
||||
if !out.GetIsMe() {
|
||||
t.Error("the claimant should be the one eliminated")
|
||||
}
|
||||
if out.GetReason() != noituv1.GameEndReason_GAME_END_REASON_NO_LEGAL_MOVE {
|
||||
t.Errorf("reason = %v, want NO_LEGAL_MOVE", out.GetReason())
|
||||
}
|
||||
if len(out.GetSuggestions()) != 0 {
|
||||
t.Errorf("suggestions = %v, want none for a genuine dead end", out.GetSuggestions())
|
||||
}
|
||||
|
||||
stuck.await("game_over")
|
||||
lead.await("game_over")
|
||||
}
|
||||
|
||||
// TestClaimDeadEndRefusedWhenAMoveExists checks the false claim costs nothing
|
||||
// but the answer: the clock is untouched, proven by the original turn_seq
|
||||
// still being accepted afterwards.
|
||||
func TestClaimDeadEndRefusedWhenAMoveExists(t *testing.T) {
|
||||
_, url := newTestServer(t, chainDict(), Config{})
|
||||
host, guest, start := pvpRoom(t, url)
|
||||
lead := host
|
||||
if !start.GetMyTurn() {
|
||||
lead = guest
|
||||
}
|
||||
|
||||
lead.claimDeadEnd()
|
||||
if code := lead.await("error").GetError().GetCode(); code != "not_a_dead_end" {
|
||||
t.Errorf("code = %q, want not_a_dead_end", code)
|
||||
}
|
||||
|
||||
// The turn_seq the claim was answered on is still the current one: a
|
||||
// submission stamped with it is still accepted rather than refused as
|
||||
// stale.
|
||||
lead.submit("b c", start.GetTurnSeq())
|
||||
lead.await("turn_update")
|
||||
}
|
||||
|
||||
// TestClaimDeadEndOutOfTurnRefused: only the player to act may spend a claim,
|
||||
// exactly as only they may spend a resignation.
|
||||
func TestClaimDeadEndOutOfTurnRefused(t *testing.T) {
|
||||
_, url := newTestServer(t, chainDict(), Config{})
|
||||
host, guest, start := pvpRoom(t, url)
|
||||
waits := guest
|
||||
if !start.GetMyTurn() {
|
||||
waits = host
|
||||
}
|
||||
|
||||
waits.claimDeadEnd()
|
||||
if code := waits.await("error").GetError().GetCode(); code != "not_your_turn" {
|
||||
t.Errorf("code = %q, want not_your_turn", code)
|
||||
}
|
||||
}
|
||||
|
||||
// --- near-miss suggestions ----------------------------------------------
|
||||
|
||||
// TestNearMissSuggestionOnWire is the end-to-end proof that a diacritic typo
|
||||
// carries a suggestion: the dictionary layer is unit-tested on its own, but
|
||||
// only this shows the room actually wires MoveRejected.suggestion up.
|
||||
func TestNearMissSuggestionOnWire(t *testing.T) {
|
||||
_, url := newTestServer(t, newTestDict("ngôn ngữ", "ngữ pháp"), Config{})
|
||||
c := dial(t, url)
|
||||
c.hello("Người chơi")
|
||||
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_StartBotGame{
|
||||
StartBotGame: &noituv1.StartBotGame{Difficulty: noituv1.Difficulty_DIFFICULTY_EASY},
|
||||
}})
|
||||
started := c.await("game_started").GetGameStarted()
|
||||
|
||||
// Typed with no diacritics at all, which the dictionary does not know as a
|
||||
// word but which strips to exactly one real one.
|
||||
c.submit("ngu phap", started.GetTurnSeq())
|
||||
|
||||
rejected := c.await("move_rejected").GetMoveRejected()
|
||||
if rejected.GetReason() != noituv1.RejectReason_REJECT_REASON_NOT_IN_DICTIONARY {
|
||||
t.Fatalf("reason = %v, want NOT_IN_DICTIONARY", rejected.GetReason())
|
||||
}
|
||||
if rejected.GetSuggestion() != "ngữ pháp" {
|
||||
t.Errorf("suggestion = %q, want %q", rejected.GetSuggestion(), "ngữ pháp")
|
||||
}
|
||||
}
|
||||
|
||||
// --- word reports ---------------------------------------------------------
|
||||
|
||||
// TestReportWordAcceptsAndEchoes needs no room at all: filing a report is a
|
||||
// session-level fact, which is also what a direct wire client per the README
|
||||
// would exercise.
|
||||
func TestReportWordAcceptsAndEchoes(t *testing.T) {
|
||||
_, url := newTestServer(t, chainDict(), Config{})
|
||||
c := dial(t, url)
|
||||
c.hello("Người chơi")
|
||||
|
||||
c.reportWord("bình tâm")
|
||||
got := c.await("word_reported").GetWordReported()
|
||||
if got.GetWord() != "bình tâm" {
|
||||
t.Errorf("echoed word = %q, want %q", got.GetWord(), "bình tâm")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportWordRefusesOneSyllable(t *testing.T) {
|
||||
_, url := newTestServer(t, chainDict(), Config{})
|
||||
c := dial(t, url)
|
||||
c.hello("Người chơi")
|
||||
|
||||
c.reportWord("một")
|
||||
if code := c.await("error").GetError().GetCode(); code != "word_report_refused" {
|
||||
t.Errorf("code = %q, want word_report_refused", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReportWordEnforcesPerSessionCap drives the cap directly against the
|
||||
// session rather than through a real socket: 21 reports through the chat rate
|
||||
// limiter (chatBurst=5) would be a test of two budgets fighting each other
|
||||
// rather than of the cap itself.
|
||||
func TestReportWordEnforcesPerSessionCap(t *testing.T) {
|
||||
s := offlineSession(t, maxWordReportsPerSession+8)
|
||||
|
||||
for i := range maxWordReportsPerSession {
|
||||
s.handleReportWord(&noituv1.ReportWord{Word: fmt.Sprintf("từ số %d", i)})
|
||||
}
|
||||
accepted := queued(t, s)
|
||||
if len(accepted) != maxWordReportsPerSession {
|
||||
t.Fatalf("got %d replies for %d distinct reports, want one each", len(accepted), maxWordReportsPerSession)
|
||||
}
|
||||
for _, m := range accepted {
|
||||
if m.GetWordReported() == nil {
|
||||
t.Errorf("a report inside the cap was refused: %+v", m)
|
||||
}
|
||||
}
|
||||
|
||||
s.handleReportWord(&noituv1.ReportWord{Word: "một từ khác nữa"})
|
||||
overflow := queued(t, s)
|
||||
if len(overflow) != 1 || overflow[0].GetError().GetCode() != "word_report_limit" {
|
||||
t.Fatalf("the report past the cap = %+v, want a single word_report_limit error", overflow)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user