mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-15 00:17:51 +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
@@ -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