16 Commits
Author SHA1 Message Date
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 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 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 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 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 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 11d716e68a feat(game): hold every player to their own turn, and let them leave
A turn belonged to one player, but the screen and the room both let
somebody else act inside it.

The word field took text out of turn — never disabled, so the on-screen
keyboard survives the handover — so a player could spend an opponent's
turn typing a word the submit button was never going to send. It now
refuses the text instead: beforeinput is cancelled, and the composed
character an input method lands past it is taken straight back out.

Giving up was open to any seat at any time. It is a move, played instead
of a word, so only the player to act may spend one; the board offers the
button on their turn and no other, and the room answers not_your_turn.
Somebody who wants out of a game they are not on turn in leaves the
room, which is now allowed mid-game: the seat is freed and the others
are told somebody left, exactly as a reconnect window running out
reports it. Going back to the homepage does that, taking the stored
session with it, so the player arrives back a stranger who can join
again rather than being resumed into the room they walked out of — and
a ready player's readiness is taken back first, or the seat would sit
in the lobby ready and empty until its window ran out.

Two things one player's exit used to cost the player still thinking: the
turn sequence moved even when the turn had not, refusing the word
already in flight, and a wordless turn update wiped the rejection they
were reading. The sequence now moves only when the turn does, and only
an accepted move answers a rejection.
2026-09-10 14:49:25 +07:00
tiennm99 e652f7764d feat(web): show each chain word's meaning, newest open, click to toggle
open set is client-only state; every word is a button; `Chưa có nghĩa` for a word without one.
2026-09-08 22:48:26 +07:00
tiennm99 e19b083dc0 feat(online): seat the chat beside the game and score the series
A room screen wide enough for it is two columns now: the game or the
lobby on one side and the room's conversation on the other, so neither
has to be scrolled past to reach the other. A phone keeps one column,
the game first, with the chat folded behind an unread count while a game
is on. One chat panel spans both phases by staying in the same place in
the markup — a remounted panel reopened having read nothing, and handed
the reader the lobby's conversation back as unread mail.

The room also keeps the score of the series. PlayerSlot carries the
games a seat has won since the room opened, credited before the lobby is
broadcast so the players see it the moment a game ends, and shown in the
lobby and beside each player's score on the board. It belongs to the
seat rather than to the room, because vacating one is exactly when the
name on it stops meaning the same person.

The conversation reads as a log — one line per message, "name: text",
each seat in its own colour — rather than as a stack of bubbles, which
stops being legible once four people are talking. ChatMessage carries
the seat that spoke, so a line is coloured by what the server said
rather than by matching display names, and a line whose seat has been
vacated is in nobody's colour.

The board's word button gets a test id of its own: it and the chat's
send button both read "Gửi", and the two are now on screen together.
2026-09-08 11:28:19 +07:00
tiennm99 4e2e9e1433 feat(online)!: seat two to four players in a room
A room holds up to four people and needs two to start. Both numbers are
server constants sent to the client in RoomState, so the lobby draws
whatever the server allows and widening a room is a server change alone.

Failing a turn eliminates that player rather than ending the game. The
syllable and the used words survive them, the turn passes to whoever is
next, and the last player standing wins. Two seats is that same rule seen
from close up, which is why there is one implementation of it and not two.

A dead end still costs the first player to face it their own clock, as
before: they get their turn, and lose it. Everyone behind them has already
seen that board, so they go out together rather than each sitting out a turn
limit they cannot use — which leaves the player who closed the position
standing, the same outcome two players get.

A player who is knocked out keeps their seat. They watch the rest of the
game, chat included, with only the word input gone, and everybody lands back
in the same lobby when it ends. The result screen is the whole table, ranked
by who outlasted whom, with each score reported beside the place rather than
deciding it.

The turn clock is deliberately not paused for a seat that has dropped, so a
player who loses their connection on their own turn loses it the way anybody
else would. Their reconnect window decides only whether they are still in the
game afterwards. Any number of windows can be open at once, settled by one
timer armed for the nearest of them.

Starting waits for every guest, not merely the first: a room of four that
began on one yes would have dealt three people a turn they never agreed to.
Kicking names a seat and is still refused on a player who is ready, and on
the owner's own — leaving is what an owner who wants out does, and it hands
the room on. Joining stays a lobby thing: a room with a game running turns a
latecomer away even with seats going spare, because there is no way to hand
somebody a game already in progress.

BREAKING CHANGE: RoomState, TurnUpdate and GameOver lose the fields that
could only ever describe a second player, OpponentLeft is retired in favour
of presence on RoomState, and suggestions move to the new PlayerEliminated —
they describe the position that beat a player, which by the end of a longer
game is nobody else's position. ProtocolVersion goes to 2, so a client built
against 1 is refused with a readable error rather than decoding a frame that
now means something else.
2026-09-08 10:56:14 +07:00
tiennm99 0b507c3537 feat(online): let the two people in a room talk to each other
Chat belongs to the room rather than to a game, so it works in the lobby
while they agree on one, during it, and in the lobby it ends in.

A player is replayed what was said while they held their seat. That is what
a refresh restores, and it is also the boundary: a room code is pasted into
group chats by design, so somebody who redeems one starts at silence rather
than reading what the last two people said. A seat records where the
conversation stood when it was filled; the room keeps twenty lines and no
more, so a room that lives all day cannot grow.

Text is untrusted input rendered in a stranger's browser, so it goes through
the filter nicknames already used — now with a cap on stacked combining
marks, which that filter admitted. Twenty runes made mark stacking a
curiosity; two hundred make it a glyph cluster tall enough to cover the
board, and it would sit in the history being replayed to everyone who
followed.

Talking is not playing. A chat message does not reset the room's idle clock,
or one open tab could hold a room and its code for the life of the process
by typing into it once every nine minutes. It does not spend the move budget
either, and a line to a player who cannot keep up is dropped rather than
allowed to close their session — losing a line is recoverable, losing a
session mid-game costs them the game. A history is not droppable that way:
it is the frame that corrects a whole panel, and there is nothing behind it.

When a seat is vacated its words stay and its author goes, name included,
and the player who stayed is re-synced rather than left holding a name that
the next person through the door could ask for.
2026-09-08 09:49:54 +07:00
tiennm99 d481a093ec feat(online): make a room a lobby with an owner and a ready-up
A room used to be a wrapper around one game: joining started it, and the
room died with it unless both players accepted a rematch inside thirty
seconds. It is now a lobby that outlives its games.

Whoever created the room owns it and the other seat is the guest. The guest
readies and the owner starts; the owner has no readiness of their own,
because starting is the same statement. A finished game returns both to the
lobby, where the next one is agreed exactly as the last was — the readiness
that started a game is spent with it.

A guest takes their readiness back before leaving, which is deliberate
friction: a player the owner is waiting on should have to say so before
walking away. The owner can free the seat of a guest who is not ready, and
not of one who is — readiness is a commitment, not an inconvenience. An
owner who leaves hands the room to whoever is left, unreadied, because they
are the one who starts now.

Something has to bound a room that outlives its games: the last player out
closes it, as does ten minutes in a lobby nobody started a game in. A
dropped connection is still not a player leaving — the seat is held for the
reconnect window in the lobby as well as mid-game, so a refresh no longer
costs somebody their room, and a resume lands in the lobby it left.

The rematch handshake is retired, and RoomCreated and RoomJoined go with it.
All three described part of what RoomState now describes in full, and three
messages for one lobby is three ways for a client to hold a view of it the
server never had. One snapshot per recipient, broadcast from the one place
that knows an input is finished, so no handler can forget to send it.
2026-09-07 16:32:14 +07:00
tiennm99 ef638e52fd feat(game): leave a dead end to the clock and show what was missed
Closing the position no longer wins the game on the spot. The player handed
a syllable that starts nothing keeps the turn they were given and loses it to
the clock, the way they lose any turn they cannot answer — the win used to
land before they had seen the board at all. A turn lost to a position nobody
could answer is reported as the dead end it was rather than as time spent
thinking.

The bot is the exception, and stays one: it has no clock to spend, so the
room settles its dead end the moment the search comes back empty. NoMove
does that without dressing it up as a resignation the bot never chose.

GameOver now carries a few of the words the position still had, filled for
the losing player only — the winner was not the one who was stuck. An empty
list on a loss is the other half of the message: nothing could have been
played, so the panel says so instead of listing nothing.
2026-09-07 15:51:22 +07:00
tiennm99 ffa13a0f6e feat(web): add online 1v1 play
Create a room and share a six-character code or an invite link; join by either.
The waiting room shows the code until an opponent arrives. During a game the
opponent's disconnection raises a banner with the grace countdown, and their
return clears it. When the game ends, the rematch prompt appears only while an
offer is actually open, so there is never a button that can only produce an
error.

The board is now one component both modes render, which is what makes online
play the same screen as the bot game rather than a second implementation of it.

Room codes are normalized by stripping separators and reading the code out of a
pasted invite link, and nothing else. Filtering out every character outside the
alphabet would turn any sentence into six plausible letters and send the player
to a room that was never theirs.

The socket now opens when the player asks for a room, not on arrival: the
handshake carries the nickname once, and a socket opened before they typed it
introduced them under whatever was stored beforehand. A tab that already holds
a session reconnects immediately instead, so refreshing mid-game returns to the
game. A resume the server cannot honour is dropped quietly rather than opening
the lobby with an error the player did nothing to cause.

The client also notices a socket that fails without closing. Nothing did
before, so the page kept showing a live connection and a running countdown over
a socket nothing could reach. A tick that fired late is ignored, because a
backgrounded tab has its timers throttled and would otherwise close a healthy
connection every time the player switched away.
2026-09-05 14:18:03 +07:00
tiennm99 64106735ec feat(web): add SvelteKit vs-bot frontend
The board, the home screen and the game-over panel, in Vietnamese, served by
the Go binary as a static single-page app.

The store is a reducer over ServerMessage and computes nothing. Validity, turn
order, scores and the result are read from the wire, which is what lets one
screen serve the bot now and online play later. Every Vietnamese string lives in
one module, including the map from RejectReason to a message, so the server can
send UI keys instead of prose.

The word field is uncontrolled. A Telex or VNI input method composes a diacritic
across several keystrokes, and writing the value back on each one cancels the
composition. It is read on submit and cleared only there.

The countdown is drawn against the server's clock, estimated from the ping round
trip, and settles 300ms early so the ring never claims more time than the server
allows.

The screen owns the socket and the game while it is mounted, and gives the
current game up on the way out. Asking for a game is stored intent rather than a
condition inferred from the board being empty: clearing the board for a rematch
is that same condition, so the inference sent a second StartBotGame and the
server built two rooms that then destroyed each other.

A test greps the built bundle for dictionary words and holds a size budget, so
the wordlist cannot reach the browser unnoticed. Another reads the error codes
out of the Go transport and fails when one has no Vietnamese message.
2026-09-05 12:55:02 +07:00