Commit Graph
109 Commits
Author SHA1 Message Date
tiennm99 55abdb36c1 refactor(wsapi): dedupe routing and seating, fix ordering races
Route room inputs through one toRoom helper, iterate live seats with
connected(), and share seating and reconnect-window code. Declare every
error code once in errcodes.go, which the web vocabulary test now reads.

Fixes:
- a kicked player is released before being told, so their next action
  is answered not_in_a_room
- a rejected word is counted before move_rejected is sent
- room code draw and registration share one lock hold
- two concurrent resumes with one token can no longer both take a seat
- multiplayer tests ready guests one at a time instead of racing

Behaviour changes:
- opening or joining another room while a game is running is refused
  with already_in_a_game
- resigning in the lobby answers game_not_started
2026-09-28 15:19:59 +07:00
tiennm99 5848751c6b refactor(server): share env parsing, shutdown and score capping helpers
Collapse the three env parsers into one generic helper, the duplicated
graceful-shutdown block into shutdownServer, and the score cap into
pointsFor. Behaviour is unchanged.
2026-09-28 15:19:59 +07:00
tiennm99 97a352f9f5 test(e2e): a rung with no record describes the bot, not a missing record 2026-09-21 17:07:08 +07:00
tiennm99 80ef632598 Merge branch 'worktree-agent-a4990d1e576b04b80' into dev 2026-09-21 17:02:02 +07:00
tiennm99 007f50cbba docs(reports): record the whole-game UX pass implementation 2026-09-21 17:01:24 +07:00
tiennm99 d5f89665d8 test(web): cover the difficulty-hint table and the last-difficulty setting
difficultyHints is walked against the Difficulty schema the same way
difficultyLabels already is, and the settings store gets coverage for
lastDifficulty: starts unset, persists across a reload, ignores a corrupt
stored value, and stays usable in memory when storage is hostile.
2026-09-21 16:59:39 +07:00
tiennm99 2a94e05752 style(web): remap the remaining components onto the collapsed type ramp
Mechanical follow-through on the app.css ramp collapse: every var(--text-N)
reference in these files moves to the step that carries the same size it did
under the old nine-step scale, and the remaining 6/10/14px spacing literals
become --space tokens. Accent hover/pressed tokens land on the two primary
buttons here (Lobby's "Bắt đầu"/"Sẵn sàng", the chat panel's "Gửi") for the
same reason the other primary buttons got them.
2026-09-21 16:59:33 +07:00
tiennm99 2a7a2a7d53 feat(web): take the quick-match wait's seconds out of the live region
The waiting panel's whole role="status" region re-announced itself every
second because the seconds counter lived inside it; the counter now sits
outside the live text, which only changes (and is only spoken) once. The
nudge that offers a bot game while waiting now says it leaves the queue, and
its link keeps the difficulty last picked rather than resetting to the
ladder's default. The two ways into an online game get sub-labels saying
what each actually starts.
2026-09-21 16:59:26 +07:00
tiennm99 a95938d611 feat(web): make the rejection row's suggestion and report real touch targets
The suggestion and report pills sat at 2px padding on 0.8rem text, under
WCAG's 24px minimum, on the two taps most worth making under a running
clock. They move onto their own line at a 36px floor, and the suggestion
reads as primary weight since it is almost certainly the word the player
meant. The fill-only behaviour and the field's uncontrolled-input invariant
are unchanged.
2026-09-21 16:59:19 +07:00
tiennm99 4fe7ef4f11 feat(web): slim the board header and stop the claim/resign row from churning
At 360px the header used to open on two rows of chrome: the connection badge
said "Đã kết nối" in full while it was true, "Luật chơi" was a full-width
text link, and the room code sat crowded in between. The badge now shows
only its dot once open, the rules link is a 44px icon with an aria-label,
and the room code moves under the turn prompt it used to sit beside. "Bí từ"
used to mount and unmount every handover, shifting the input under a
player's thumb each turn; it now sits in one persistent row with "Đầu hàng",
both built on ArmedButton and disabled off-turn rather than unmounted.

A resign or claim raced by the turn moving on now answers beside the button
that sent it instead of the top banner, matching how a false dead-end claim
was already handled. Knockout stops saying "you're out" twice — once in a
banner above the scoreboard, once in the spectating box below it — and the
elimination suggestions a spectator used to wait for the game-over screen to
see now show up the moment they're eliminated, keyed to the syllable they
were actually stuck on rather than whatever the game has moved on to since.
2026-09-21 16:59:13 +07:00
tiennm99 81d99bb0cf feat(web): teach the chain rule on landing and remember the last difficulty
A newcomer used to reach the first running clock never having been told what
nối từ requires; the landing screen now states the rule with a worked example
before the nickname field, and the chain's own opening row gets a caption
explaining why it starts the way it does. The difficulty ladder's record line
falls back to a truthful one-line description of what the bot actually does
at that rung instead of "chưa có", and the rung a player picks now survives
across visits in the settings store, so a bot game reached from elsewhere
(the quick-match nudge, next commit) can resume it.
2026-09-21 16:59:02 +07:00
tiennm99 d94c2a105c feat(web): apply the whole-game UX review's copy proposals
Rewrites the strings the review flagged as unclear or misleading: the
not_your_turn and not_a_dead_end messages now describe the race or the
position they actually answer, need_more_players drops a server constant
that had leaked into prose, the per-game score and the room's series score
get separate labels instead of sharing "Tỉ số", the opponent-turn fallback
reads neutral rather than assuming one opponent, a chat author who left
reads as a name rather than a sentence, too_fast drops its odd-one-out
"nhé", and the meta description stops promising 1v1 for a 2-4 person game.
2026-09-21 16:58:50 +07:00
tiennm99 18170aad15 style(web): collapse the type ramp, add accent hover tokens, and lift the page background
Nine font-size steps down to seven, mapped so no component reads smaller or
larger than it did before except the two the review named on purpose: the
syllable grows into the room a hero glyph deserves, and the ring's resting
value moves onto the ramp. Hover and pressed accent tokens replace the grey
--surface-alt every primary button used to borrow, and the light page
background lifts a step so a card reads as a card again rather than the same
paper as the page under it.
2026-09-21 16:58:38 +07:00
tiennm99 85199df079 Merge branch 'worktree-agent-a38a77d87cb0d7781' into dev 2026-09-21 16:37:00 +07:00
tiennm99 fbb06ad100 docs(reports): record the server review implementation
Branch base, per-deliverable changes, verification tail, deferred
items and unresolved questions for today's server architecture review.
2026-09-21 16:35:49 +07:00
tiennm99 f4acc12668 refactor(wsapi): split wsapi_test.go by topic, delete hub_test.go
wsapi_test.go was 2688 lines and 82 tests spanning every topic in the
package. Pure moves: wsapi_test.go keeps the harness (the hand-built
dictionary, the server-over-a-real-socket helpers, and the client-side
driving methods every topic file below uses); game_test.go,
presence_test.go, resume_test.go, lobby_test.go, protocol_test.go,
ratelimit_test.go, bot_board_test.go, chat_test.go, deadend_test.go
and report_test.go each hold one topic's tests. Three tests about
sanitizing and distinguishing nicknames moved into the existing
nickname_test.go alongside its fuzz target.

hub_test.go was three lines of comment pointing at hub.go; the note
now lives there instead, next to roomCount.

Verified by counting: the wsapi package's declaration count is
unchanged, `go test -list` finds the same tests it did before the
split, and go build/vet/test -race and golangci-lint stay clean.
2026-09-21 16:32:57 +07:00
tiennm99 8c5bb8bef4 Merge branch 'worktree-agent-ae280f2081bd718f6' into dev 2026-09-21 16:32:13 +07:00
tiennm99 df2ad83770 docs(reports): record the web review actions implementation
Also keep a copy of the architecture review this work implements, alongside
the report — it was untracked in the checkout this branch forked from.
2026-09-21 16:31:02 +07:00
tiennm99 cf48d270de test(web): mount WordInput and GameBoard under jsdom
WordInput: the turn seed fires once per turn and is gated on the connection
(regression test for C6), a reconnect blip mid-turn does not reseed over
what the player typed, a rejection's suggestion fills the field on click,
and the player's own in-turn composition is left alone.

GameBoard: the chat pill renders only when onchatopen is passed, which is
the actual contract between the bot and online routes — a bot game simply
never passes it.
2026-09-21 16:28:36 +07:00
tiennm99 8f739e02ae refactor(wsapi): split room.go and session.go along their seams
room.go was 1919 lines with every room concern in one file. Pure
moves, no signature or behaviour changes: room.go keeps the struct,
its constructor, the input loop and the small seat-authority helpers;
room_inputs.go the message types; room_lobby.go seating and the lobby
between games; room_game.go everything that touches a running game;
room_presence.go the reconnect window and resume; room_chat.go the
room's own conversation; bot_board.go the bot's frozen view of a
position.

session.go was two unrelated halves in one 762-line file: the socket
(session.go, kept) and the protocol (dispatch.go, new) — dispatch and
the handshake/resume flow it routes into.

Verified with go build, go vet, golangci-lint and go test -race, and
by counting: every one of the 89 room.go and 27 session.go top-level
declarations appears in the split exactly once.
2026-09-21 16:25:20 +07:00
tiennm99 69310a3b93 test(web): mount ChatPanel under jsdom for fold and unread accounting
Cover the fold toggle, the unread badge counting only while folded and
clearing on open, the recount after a fold-and-reread cycle, and send/clear.
Needed a resolve.conditions fix in vite.config.js gated on process.env.VITEST
so Vitest picks svelte's client runtime instead of its SSR one for mount();
vite dev and vite build are untouched.
2026-09-21 16:24:20 +07:00
tiennm99 f0bcb6084a refactor(web): extract ArmedButton for the three press-twice controls
Resign, claim-dead-end and kick each duplicated the same arm/disarm timer
and disarm-on-disable effect. ArmedButton.svelte owns that once, plus the
a11y gap none of the three closed: aria-pressed carries the armed state to
assistive tech, since a screen reader announces a control's name on focus,
not on the in-place label swap the first press used to be silent about.

Kick arms per seat now rather than sharing one Lobby-level slot, which was
an implementation detail of the old shared state rather than a stated rule.
2026-09-21 16:21:40 +07:00
tiennm99 f0c2334228 test(web): cover the room-session request machine
Join/create latch and retry-on-refusal, the resume latch clearing on
noteRoom() and on noteResumeFailed() (both the explicit-error and the
time-box paths), quick-match queued-to-seated, and the held-action slot for
cancelQueue/leave/ready/start/kick — replace-not-queue semantics, retry on
refusal, and drop on teardown.
2026-09-21 16:18:53 +07:00
tiennm99 8008bf6318 docs: document the per-IP cap and record two open decisions
NOITU_MAX_CONNECTIONS_PER_IP is now in both env tables, with the
warning that it must stay off behind a proxy unless
NOITU_TRUSTED_PROXIES names it, since every player otherwise shares
one address.

Also records two decisions the review flagged as undocumented rather
than broken: a second tab presenting a live resume token takes the
seat on purpose, and /debug/vars carries the process's argv and heap
stats because expvar always publishes them, which is why it lives on
its own address.
2026-09-21 16:18:31 +07:00
tiennm99 eace5940da refactor(web): extract the online screen's request machine into a store
Move the join/resume/quick-match/leave state machine out of
routes/online/+page.svelte into stores/room-session.svelte.js, modelled on
bot-session.svelte.js: no DOM, no runes beyond $state, so the resume time-box
is something Vitest can drive directly. The page keeps layout, timers and
wiring; the store keeps what the player asked for.

fix(web): time-box the resume latch and stop leaking a left room's session

A stale resume token used to get silence from the server, leaving `resuming`
stuck true and every button on the join form disabled with no way out but a
reload. `resuming` now clears five seconds after the socket opens if nothing
has answered by then, same as it already does on an explicit error (which
also covers a server new enough to send `session_not_resumable` instead of
staying quiet).

leave() now calls forgetSession(), matching the page-teardown path: without
it, deliberately leaving a room left the token behind, and the next load of
/online tried to resume into the room the player had just walked out of.

fix(web): retry cancelQueue, leave and lobby actions instead of dropping them

send() returns false while the socket is down, and cancelQueue/leave ignored
that return value while ready/start/kick only reported it as a dead-looking
button. All five now hold the request and resend it once the socket reopens,
the way join/create already do, via room-session's held-action slot. Lobby's
"reconnecting" banner is now driven by that held action instead of a local
flag that never noticed a background retry had succeeded.

Also: the lobby's chat panel now reopens once a game ends (phase 'over')
instead of staying folded for the rest of the room's life after the first
game, since phase never actually revisits 'lobby' on its own.
2026-09-21 16:17:23 +07:00
tiennm99 2fece2fcea build(docker): track the moving golang and alpine majors
golang:1.25-alpine and alpine:3.22 were exact-minor pins generating
the churn the moving-major house rule exists to avoid; node:24-alpine
and the distroless base already followed it. dependabot.yml needs no
change: it still covers the same four ecosystems, and will simply have
less to propose now that these two also float.
2026-09-21 16:17:03 +07:00
tiennm99 5c6421a011 test(server): cover the env parsers and the drain-wait loop
cmd/noitu-server had no tests at all: every env* helper is pure, and
config parsing is exactly where a production misconfiguration hides.
waitForGamesToFinish narrows its *wsapi.Server parameter to the single
method it calls, so the poll-then-check timing can be driven by a fake
without a live server behind it.
2026-09-21 16:16:23 +07:00
tiennm99 b1b6e4ba92 test(wsapi): cover ghost seats, drain refusals and a silent resume
Direct room-level tests for the torn-down-connection race on create,
join, quick match's auto-start, start-bot and resume, plus the
non-resumable-token answer and freezeBoard's new signature after
UsedWords replaced its history reconstruction.
2026-09-21 16:14:12 +07:00
tiennm99 8b3e8f7e67 fix(wsapi): guard room seating races, refuse games during drain, and drop the history copy
A session can tear down between the hub handing a room its seating
message and the room goroutine draining it off the queue. Nothing else
ever learns that, since leaveRoom only notifies a room the session had
already attached to. Left seated as connected, allConnected() could
report true and let quick match auto-start a game against a dead
socket. handleCreate, handleJoin, handleStartBot and handleResume now
check the connection's context and either reopen the grace window the
disconnect would have, or cancel the room when there is no lobby left
to hold one open.

beginGame could also raise the live-game count after the drain
decision: an existing lobby's StartGame, and quick match's own
auto-start, now refuse with server_restarting once the hub is
draining, matching the refusal newRegisteredRoom already gives a
brand-new room.

Engine.Snapshot() copied the whole move history on every broadcast for
two callers that only ever wanted the last move or the played-word
set. Engine.LastMove and Engine.UsedWords answer both directly off the
engine's own state, State.History is gone, and Standings is only
computed once the game is actually over, which is the only time it is
meaningful.
2026-09-21 16:13:52 +07:00
tiennm99 eae8d42f25 fix(wsapi): loosen the join limiter and answer a non-resumable token
joinsPerSecond/joinBurst at 1/5 was tight enough to refuse a whole
NAT egress sharing one address, not just a room-code brute force.
Raised to 5/20, which still takes centuries to walk the 31^6 room
code space.

handleHello stayed silent on a resume token that did not resolve to a
live session, leaving a client's resume latch waiting forever. It now
answers session_not_resumable and carries on as a fresh session.
2026-09-21 16:13:30 +07:00
tiennm99 e3efadfd63 fix(server): cap concurrent connections per client IP
One host could otherwise hold every socket the global connection cap
allows. The cap defaults off and must stay off behind a proxy unless
NOITU_TRUSTED_PROXIES is set, since every player then shares one
address.
2026-09-21 16:13:11 +07:00
tiennm99 70ae09d16b refactor(web): type the wire instead of passing any across it
Use the generated ServerMessage/ClientMessage union everywhere a decoded
message crosses a function boundary, so a typo in a payload field is a build
error instead of undefined at runtime. Clears every jsdoc/reject-any-type
warning in src/ and all but one in tests/ — room-code.test.js keeps one to
deliberately call normalizeRoomCode(undefined) against its documented
string-only signature, which is the point of that test.

Also fixes the flaky e2e helper: playingPair now waits for the guest's seat
(joinRoomSeated) before readyAndStart, and readyAndStart asserts the guest's
own ready row before touching Start, since a SetReady send can silently drop
while the socket is not open and nothing retries it.
2026-09-21 16:07:02 +07:00
tiennm99 c4502f2793 refactor(web): split the game store into shape, apply and store files
Type GameState for real instead of returning any from initialState(), which
made every game.state.* read in every component unchecked. apply() now
switches on payload.case so the oneof narrows, and is wrapped in try/catch so
a throw partway through a case cannot leave a half-mutated snapshot on
screen. Also key chain meanings by position instead of gloss, since the
dictionary gives no gloss-uniqueness guarantee, and gate the word field's
turn-seed effect on the same connection check `enabled` already uses so a
reconnect cannot seed a field the player cannot submit from.
2026-09-21 16:03:03 +07:00
tiennm99 e4f9917312 docs(plans): whole-project server, web and UX reviews 2026-09-21 16:01:54 +07:00
tiennm99 3ef9f48df0 fix(web): open the in-room rules link in a new tab
The board and lobby screens resign or leave the room when they unmount,
so an in-page navigation to the rules forfeited the game being played.
2026-09-21 15:42:14 +07:00
tiennm99 a198922ad0 test(e2e): reach the chat panel's toggle by its own handle
A role query by name matched both the panel toggle and the board's pill,
since the pill's label contains the panel's. The spec now names the
control it means.
2026-09-21 15:41:03 +07:00
tiennm99 8c3d8f415b fix(web): give the board's chat pill its own accessible name
The panel's fold toggle and the header pill both read "Trò chuyện", so
a role query, or a screen reader, found two controls with one name.
2026-09-21 15:36:15 +07:00
tiennm99 5178a97520 fix(web): open the lobby chat on a phone and fold it when the game starts
Making the lobby panel collapsible left it folded from the start, with
the input hidden behind a badge before anyone had spoken, which the
browser suite caught. It now opens in the lobby and folds itself on the
first turn, which is the shape the suite and the prior UX report describe.
2026-09-21 15:32:16 +07:00
tiennm99 1358c3d136 fix: close the review findings on the improvement branch
Chat lines are keyed by a client ordinal rather than author plus
timestamp, which one seat sending a burst could duplicate within a
millisecond and turn into a Svelte runtime error in every tab. The nginx
snippet now sets X-Forwarded-For, without which naming that proxy as
trusted would let each client pick its own limiter key. Quick-match skips
a waiter whose connection has already ended instead of seating a ghost,
and a match the server could not open clears the waiting panel. A
near-miss suggestion is withheld when the word would not link or is
already used, so taking it cannot earn a second refusal. The builder's
delete-then-rename fallback is confined to Windows, where the rename over
an existing file fails; elsewhere a failed rename is reported, not made
worse by deleting the good database.
2026-09-21 02:20:16 +07:00
tiennm99 6a7cb34897 Merge branch 'worktree-agent-a582df720cc6e3dea' into dev
# Conflicts:
#	server/internal/wsapi/wsapi_test.go
#	web/src/lib/ws/messages.js
#	web/src/routes/online/+page.svelte
2026-09-21 01:58:02 +07:00
tiennm99 2adab1318b docs(plans): record the in-game feedback implementation report 2026-09-21 01:55:46 +07:00
tiennm99 e52b0ef2c3 docs: record the score breakdown, dead-end claim and word report
README's frontend and rules paragraphs mention the visible score breakdown
and the dead-end claim button. deployment.md documents word_reported next to
word_rejected, and the new dead-end-claim and words-reported counters.
2026-09-21 01:55:10 +07:00
tiennm99 0003a4510b feat(web): show why a word scored what it did, and let a dead end be claimed
The chain shows each word's score breakdown as small chips beside the total.
A "Bí từ" button next to the input claims a dead end on the player's own
turn, armed the same way resign is; a false claim is answered inline near
the input rather than in the general error banner. A rejection for a word
not in the dictionary offers the one real word a diacritic typo differs
from, filling and focusing the field on click, and a button to report the
word as real, acknowledged with a confirmation once the server has heard it.
2026-09-21 01:55:04 +07:00
tiennm99 16fd37978a 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.
2026-09-21 01:54:58 +07:00
tiennm99 36915a67bd docs(reports): record the quick-match implementation 2026-09-21 01:40:23 +07:00
tiennm99 e6f2d1922c feat(web): a play-now button that skips the room code
QuickMatch/CancelQuickMatch join and leave the pairing queue; the
store tracks the wait as `queued` and clears it the moment a RoomState
seats the connection. The /online screen shows a waiting panel with
elapsed time, a cancel button, and — past 20 seconds — a nudge toward
the bot; leaving the page while queued cancels it. Adds the two new
error codes and a sentence to the room-code rules copy.
2026-09-21 01:39:54 +07:00
tiennm99 4a78b8aad5 feat(server): pair strangers with quick-match
The hub keeps a FIFO of waiting sessions; a second QuickMatch pops the
first, opens a room via the existing createInput/joinInput path, and
marks it to begin its own first game once both seats are connected in
the lobby. CancelQuickMatch, a dropped connection, and entering a room
by code all remove a session from the queue. Counts queued, cancelled
and matched pairings in metrics.
2026-09-21 01:39:44 +07:00
tiennm99 865222d2cd feat(proto): a dead-end claim, a word report, quick-match and a scored breakdown
Four client messages and two server ones, all additive. ClaimDeadEnd lets
the player to act say the syllable has no answer instead of waiting out
the clock; the server verifies. ReportWord files a refused word for the
maintainers and is acknowledged with WordReported. QuickMatch and
CancelQuickMatch join and leave a pairing queue, answered by
QuickMatchStatus. PlayedWord gains parts, the named terms that sum to
its points, and MoveRejected a suggestion for a word that differs from a
real one by diacritics alone. Generated code and cross-language fixtures
regenerated.
2026-09-21 01:20:22 +07:00
tiennm99 8223f40b16 feat(server): counters, readiness, drain mode and a version stamp
expvar counters for connections, rooms, games, submissions by rejection
reason, eliminations, chat, joins and bot moves, served on a separate
debug address so they never sit on the public mux, plus one structured
word_rejected log line per refused word carrying the normalized word and
its link. GET /readyz flips to 503 while draining; SIGTERM stops new
rooms, waits up to NOITU_DRAIN_TIMEOUT for live games, then shuts down.
GET /version and the startup log carry the build's git describe.

Fuzz targets for the frame decoder, the text sanitizer and Vietnamese
normalization; the last one found that composing before lowercasing
could leave a non-NFC result, now recomposed after lowering.

CI runs on dev as well as main, gates gofmt and golangci-lint, tracks the
buf major instead of an exact pin, and dependabot watches every
ecosystem. The lint findings that had been hidden by the default
per-issue cap are fixed.
2026-09-21 01:17:52 +07:00
tiennm99 90cd679639 feat(web): state the rules, reach the chat from the top of the board, lint the tree
A /rules page says, in one place, what nothing in the app said before:
the chain rule, the clock, what a dead end costs, elimination and the last
player standing, how a word is scored and the reconnect window. Linked
from the landing page and from the board and lobby headers.

The chat control moves above the chain as a pill with the unread count,
where it can be reached on a phone mid-game, and the lobby's folded chat
now carries an unread badge too.

ESLint with the Svelte and JSDoc plugins, run in CI; the real findings
it turned up (missing each keys, untyped timer handles) are fixed.
2026-09-21 01:17:52 +07:00