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