Commit Graph
123 Commits
Author SHA1 Message Date
tiennm99 4d12e283c6 ci: move actions to their Node 24 major versions 2026-10-10 17:25:26 +07:00
tiennm99 f7bbfe50ad ci(refresh-dictionary): target main now that the dev branch is gone 2026-10-10 16:47:00 +07:00
tiennm99 ad8a3686be docs(deployment): note Compose deploys replace the container without a rolling update 2026-10-07 09:07:45 +07:00
tiennm99 3b76d66e59 build: add compose file for Docker Compose deployments 2026-10-07 08:47:18 +07:00
tiennm99 f7509720fe build: drop the fixture dictionary option from the image 2026-10-02 14:48:35 +07:00
tiennm99 f37e3e69f4 build: ship the dictionary from a committed corpus, refreshed monthly by pull request 2026-10-02 13:46:25 +07:00
tiennm99 a947f5ce2e feat(build-dictionary): export accepted words as a text corpus and build from it 2026-10-02 13:46:25 +07:00
tiennm99 0cb1b74952 docs(reports): record the whole-project review and fixes 2026-09-29 20:33:16 +07:00
tiennm99 00d3dadad4 build: container health check, licence in the image, CI hardening
HEALTHCHECK via noitu-server -healthcheck; the root LICENSE ships next
to NOTICE and the CI image check requires it; .claude and .env files
stay out of the build context. CI uses go-version stable, runs
govulncheck and npm audit, checks out without persisted credentials, and
proto.yml moves off the archived buf-setup-action. dependabot.yml is
dropped; the audit steps are the dependency signal. deployment.md gains
the Coolify/Traefik recipe, the stop-grace rule, the hello deadline and
per-address room budget, and the suppressed-log counter.
2026-09-29 20:33:16 +07:00
tiennm99 1f2624c0e3 fix(web): clear stale rooms on refused resume, harden reconnect paths
A refused resume now leaves the room instead of showing a banner over a
dead board, and /play offers a fresh game there. Entering /play forgets
any stored token so it no longer races StartBotGame. An eliminated
player who reconnects stays a spectator. Held lobby actions wait for the
resume's RoomState; leaving while offline is not held. Chat clears its
draft only when the line was sent and cannot send while offline.

Also: away banners no longer announce every second; game over does not
steal focus from the chat field; the clock offset uses the lowest-RTT
pong; the history export numbers a resumed chain from its real length;
chat replay keeps existing ordinals; one .primary button class replaces
five copies; the footer links the data change list; the online heading
joins the type ramp. New component, connection and storage-guard tests.
2026-09-29 20:33:16 +07:00
tiennm99 81234b5ab9 fix(wsapi): stop a kicked token resuming into another seat, harden limits
A held seat now remembers which connection it waits for, so a token
from a kicked player is refused instead of landing in whoever took the
seat. A room answers every input left in its inbox when it exits,
disconnect notices no longer share the lossy inbox, quick-match no
longer leaves autoStart armed after a pairing that never started, only
lobby changes restart the idle window, and draining refuses new queue
entries.

Sockets that never send Hello close after ten seconds and the room
budget is charged per address, so one client cannot hold the global
caps. IPv6 limiter keys use the /64. Corpus log lines get a process-wide
rate limit with a suppressed counter. Responses carry nosniff,
frame-ancestors and referrer headers. Unicode spaces in a word become
spaces rather than vanishing, and blank-rendering letters are dropped
from names and chat. A resume into a lobby whose game ended during the
absence is replayed that seat's GameOver. Unknown payloads get
unknown_message.
2026-09-29 20:33:16 +07:00
tiennm99 7fb724d29e fix(server): drain live games on SIGTERM and honour a second signal
The wsapi server was built on the signal context, so SIGTERM cancelled
every room before StartDraining ran and the restart notice never went
out. Build it on a background context, release the signal context as
soon as it fires, and wait a bounded moment after Shutdown so the notice
reaches open sockets.

Also: Hard prefers the slower loss in lost positions; resigning out of
turn no longer settles a pending dead end on the spot; a self-closing
<ref> with a slash in its name no longer swallows definition text; the
store enforces builder_version; both listeners get an IdleTimeout; the
real-corpus ladder is reproducible from its seed; a -healthcheck flag
probes /healthz for the container health check.
2026-09-29 20:33:16 +07:00
tiennm99 d3eb13e36e docs(reports): record the dev branch review and refactor 2026-09-28 15:19:59 +07:00
tiennm99 9be86a3cad refactor(web): share alert banner and turn actions, fix restyle leaks
Add AlertBanner and turnActions to replace duplicated markup and
wrappers, and share component-test setup.

Fixes: oversized dismiss, help, rules and skip-link text after the type
ramp collapse; report button hover styled as submit; chat draft lost
when the panel folds; resign/claim/kick styles leaking app-wide.
2026-09-28 15:19:59 +07:00
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