Commit Graph
88 Commits
Author SHA1 Message Date
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 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 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 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 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
tiennm99 442ced32cc fix(server): name the player on every played word, and bound the process
PlayedWord.player_id was declared and read by the client but never set by
the server, so a room of three or four never showed who played each word.
The chain byline now comes from the room, with a producer-side test.

The process also gains the ceilings it was missing: a cap on live rooms and
on open sockets, a per-connection frame-rate limit so a payload-less frame
is no longer free, and an opt-in trusted-proxy list so the join limiter can
tell players apart behind the documented reverse proxy instead of putting
them in one bucket. The typed word is sanitized before the engine stores it,
since every seat is shown it; a room exiting on its idle clock releases the
sessions still bound to it; the dictionary builder escapes its SQLite path
like the store does and renames over the old database instead of deleting
it first.
2026-09-21 00:38:09 +07:00
tiennm99 a5052fca3f docs(plans): research, brainstorm and health-scan reports with synthesis brief 2026-09-21 00:30:10 +07:00
tiennm99 dd3b46397f fix(web): give the chain three rows to be read in
Its floor was a row and a half, which is what a finished game left it:
the panel and the lobby took the column and the transcript became a
sliver with the top card sliced. A row and a half of history is not
history.

Three rows now, and the page grows past the window rather than taking a
fourth off the list. To pay for them the compact room code under a
result is a line instead of a card — its border and padding were 30px
of the same screen, and the code and its copy button are all that line
was ever carrying.

Measured with two players in a room, board and chat side by side: in a
game the chain shows every word played at 1080, 900 and 768 tall. After
one, 1080 still shows them all with nothing scrolling, and a shorter
window keeps its three rows and scrolls for the rest. Four seats, a
loser's suggestions and a full lobby come to 989 against 979 — ten
pixels, where before these two changes it was 300.
2026-09-10 15:46:59 +07:00
tiennm99 aec9d4ad6f fix(web): fade the chain where it runs under the fold
The list is given whatever height the board has left, which is never a
whole number of rows, so the last word was sliced square by the bottom
edge — which reads as broken rather than as scrollable, and left the
history looking cut off rather than continued.

That row now fades out, and only while something really is below it: at
the true end of the chain there is nothing to promise. Scrolling settles
on whole rows rather than mid-card, and the list keeps its scrolling to
itself instead of carrying on into the page once it reaches the end.
2026-09-10 15:36:14 +07:00
tiennm99 e5f4dbdd70 fix(web): stop the off-turn word field looking like it wants a word
It keeps focus through the handover so a phone's keyboard survives it,
which left a caret blinking in a field that takes no text and an I-beam
over it inviting one. The caret is transparent and the pointer is a
plain arrow while the turn is somebody else's.

Its placeholder was a sentence — "Đến lượt Minh…", "Mất kết nối, đang
thử lại…" — in a box that shares its row with the send button, so the
end of it was cut off against the edge. The board's turn line above says
who is thinking and the badge says the connection is down; the field
only has to say that it is not yours yet, so it says that, and anything
that still does not fit ends in an ellipsis.
2026-09-10 15:36:14 +07:00
tiennm99 644379fa06 style(web): fit a finished game and its lobby on one screen
After a game the buttons that start the next one were under the fold on
a 1080p screen: the result panel needed 206px more than the column had,
and 300 with four players and a list of words the loser missed. Every
game ended with a scroll to find "Sẵn sàng".

The duplication went first. The board's scoreboard and the result's
standings are the same names and the same scores, so the board's stops
at the final whistle and the standings are shown for two players as well
as for four. Under a result the lobby lists the seats that exist rather
than three "Còn trống" rows the count line above already accounts for,
the two stat tiles are one line of small type — the score is in the
table now — and the words the loser could have played sit on the line of
their own label rather than under it.

The rest is chrome giving back pixels: a shorter header and footer, a
40px theme button, tighter seat rows and card padding, and the panel's
"Về trang chủ" and "Tải chuỗi từ" side by side instead of stacked, which
wrap back to one each on a phone. Readying, starting and playing a word
all keep their 44px.

Four seats, a loser's suggestions and a full lobby now come to exactly
the height of a 1080p column, with the chain scrolling inside what is
left. A phone still scrolls, which is what a phone is for.
2026-09-10 15:16:25 +07:00
tiennm99 a0029ba5aa style(web): shrink the resign button to secondary weight
At 44px and the same weight as "Gửi" it read as one of the two things to
do on your turn. It is neither: nobody is aiming for it, and it takes
two presses to do anything. Smaller and lighter, still its own outlined
block in danger colour on the right, so it stays easy to find.
2026-09-10 15:02:45 +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 db7093bbe5 fix(web): make the game comfortable to play on a phone and in both themes
Applies the findings of the three UI/UX reviews. The two that cost a
player something every single turn:

- The countdown reddened during the opponent's turn. It read the deadline,
  which the server rewrites on every handover, and never asked whose turn
  it was — a panic spike, every turn, about nothing the player could act
  on. It now mutes when the clock is not theirs, and signals urgency by
  thickening as well as reddening, because green to red is the pair a
  red-green colour blindness cannot separate.

- On a phone the keyboard closed on every turn and could not be reopened.
  `disabled` on the focused word field blurs it, and the recovering
  focus() ran on a WebSocket message, which iOS will not open a keyboard
  for. The field stays editable and the submit button carries the turn
  instead; focus is asked for inside the gesture that submitted. A draft
  aimed at a syllable the game has moved past is now corrected rather
  than kept.

The board was also not holding together as the game grew. `min-height`
on the shell left nothing with a definite height to shrink against, so
the chain kept its full height and the page got taller every turn,
walking the word field and the chat off the bottom of the screen. The
shell now has a real height, the chain gives up its space first with a
floor so it cannot vanish, and `main` scrolls only as a last resort.

Online play had one true dead end: nothing on the pre-room screen showed
the connection at all, so a server that was down left an enabled button
and a screen that never changed — while each impatient tap sent a real
CreateRoom until the rate limiter answered. The badge is there now,
requests disable their button, a stall says so, and the lobby draws the
room's refusals beside the button that produced them instead of in a
chat panel below the fold. A dropped player gets a retry rather than
waiting out the backoff. An invite link asks a first-time guest for a
name instead of seating them as "Người chơi" with no way to fix it.

Accessibility and palette:

- The turn change is announced. It is the state the whole game hangs on
  and it reached nobody who was not looking at that line.
- --accent darkened to #12692f: accent text on its own tint measured
  4.29:1. Every existing pair improves, white-on-accent included.
- --border-strong added for the boundary of anything you type into or
  press, which needs 3:1 and had 1.47:1 light, 1.78:1 dark.
- One focus ring, one 44px icon-button recipe, spacing and type scales,
  safe-area gutters, per-route titles, a heading per screen, a skip
  link, and reduced motion that stops the connection dot pulsing rather
  than flickering it a hundred thousand times a frame.

Tests follow the two interactions that changed shape: resign and kick
confirm inline with a second press, because confirm() blocks the frame
loop the countdown runs on and hesitating could cost the turn it was
protecting. joinRoomSeated waits for the seat before anybody speaks —
the server replays a seat only the conversation from when it joined, so
the old helper was racing the handshake.

Verified: 189 unit tests, 40 end-to-end, no type errors, and the
palette and layout measured at 360/400/768/1280 in both themes.
2026-09-10 13:04:13 +07:00
tiennm99 b65b4eade1 docs(reports): record the UI/UX review of the game's layout and comfort
Three read-only reviews, one per surface: the solo turn loop, the online
room, and the global layout with its tokens and accessibility.

They are stateful records of what was found on 2026-09-10, not evergreen
authority — the contrast tables and the drift inventory describe the
palette as it was measured, before the fixes that follow.
2026-09-10 13:03:52 +07:00
tiennm99 b1204e270f docs(plans): drop delivered plan phases, keep journals and reports 2026-09-10 09:56:31 +07:00
tiennm99 2f893c9877 docs(reports): record why Hoàng Phê cannot be shipped as data 2026-09-10 09:51:58 +07:00
tiennm99 7644a0745f style(web): repaint the game in word-game green
The accent was Claude's terracotta, which the board reads as text in
four places — the clock, a word's points, the connection badge, a
record — at 2.6:1 on white. It also could not carry white text, so
every primary button wore dark ink and read as a secondary one.

Green at #15803d is 5.02:1 against white, which is the same number in
both directions: the accent passes as text and as a button with a white
label. The neutrals take a faint green cast so they belong to it, and
amber and red are pulled to matching weights. Every text pair now
clears 4.5:1 on every surface in both themes.

No seat wears the accent's green any more. A player badge in the game's
own colour says nothing about whose word it is, so seat one takes the
blue and a cyan takes the fourth chair.
2026-09-09 00:36:04 +07:00
tiennm99 f49b906519 fix(web): keep the whole board on screen as the chain grows
Giving up was a button under the chain, and the chain is the one part of
the board with no ceiling: the longer a game ran, the further off the
bottom of the screen it went — worst exactly when a player wants it.

It sits above the list now, on the right, away from the field a player
is typing into, in the danger colour and at a full 44px. The chain takes
the height that is left and scrolls inside itself, so the syllable, the
clock and the word field stay where they are however long it gets.

The error banner's dismiss cross gets the same 44px, negative-margined
back out so the banner keeps its height.
2026-09-09 00:36:04 +07:00
tiennm99 3212d8fc1a feat(web): toggle a word's meaning from anywhere on its card
Aiming at the word to read its meaning is a small target on a phone.
The button is the whole row now, and the card's padding belongs to it,
so a click lands wherever the finger does.

The panel and the correction note stay outside the button — a button
cannot hold the panel's list — and carry the padding themselves.
2026-09-09 00:12:53 +07:00
tiennm99 af08df74a9 feat(game): price a word on speed and how rare its link is
A word was worth its length and the length of the chain it extended.
Two more terms: the share of the turn the player left on the clock, and
how few words the corpus offers for the syllable they answered.

The chain term now stops counting at 15 words. Uncapped it grew without
bound, so a total grew with the square of how long a player survived,
which drowned everything the move itself earned and pinned every late
word to the per-word ceiling.

Rarity is a ladder of 3 points per doubling of the answers available
rather than a straight line. Option counts are heavy tailed — the
syllable a player is handed has a median of 14 answers and a maximum of
195 — so a line spends the bonus across the middle, where a straight
count of options is not what a player feels as rare.
2026-09-09 00:12:53 +07:00
tiennm99 3ba99ec107 docs(reports): record the codebase cleanup review 2026-09-08 23:10:44 +07:00
tiennm99 e29c06d6f0 refactor: move test-only helpers out of production code and drop stale comments
bot.BoardFor and hub.roomCount were reachable from tests only and now live beside them; session.serve drops an always-true nil check (readLoop never returns nil); invisible characters in test literals become escapes; comments no longer cite plan phases; `.dockerignore` keeps the dump out of the build context. No behaviour change; vet, deadcode, staticcheck, go test -race, svelte-check and vitest green.
2026-09-08 23:10:39 +07:00
tiennm99 f00d0ef974 docs(plans): record the dump corpus plan, measurement, review and journal 2026-09-08 22:48:26 +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 9e74f6f959 feat(wire): carry a word's senses on PlayedWord and GameStarted
`message Sense {pos, gloss}`; fields 7 and 9, wire-compatible; `wsapi.Dictionary` gains `Meanings`; looked up once per move.
2026-09-08 22:48:26 +07:00