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.
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.
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.
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.
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.
proto/noitu/v1/game.proto is the single source of truth for every WebSocket
message. buf generates Go types into server/gen and JavaScript types into
web/src/lib/proto; both trees are committed so building needs no codegen
toolchain.
The Go suite emits binary fixtures into proto/testdata and the JavaScript suite
decodes the same bytes, so the two generated clients are checked against one
artifact rather than against each other's assumptions. CI lints the schema,
rejects breaking changes against main, and fails when the committed generated
trees drift from the schema.
game.NumRejectReasons and game.NumEndReasons let the mapping tests prove every
engine reason has a wire value without guessing where the enum ends.