mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-17 10:14:35 +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 (
|
||||
"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() {
|
||||
|
||||
Reference in new issue
Block a user