Commit Graph
9 Commits
Author SHA1 Message Date
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 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 80f216d56b feat(dict): build the corpus and word meanings from the Wikimedia viwiktionary dump
reader for both wikitext dialects; `meanings(word, ord, pos, gloss)` table; `meaning_count`/`words_with_meaning`/`source_pages` in meta, `source_rows` gone, builder_version 5; `--dump`/`--min-pages` replace `--kaikki`; attribution names the dump and the definition excerpts; 36,200 words, 96.9% with a meaning, every kaikki word kept.
2026-09-08 22:48:26 +07:00
tiennm99 805cbca39b feat(server): give each turn 30 seconds by default
NOITU_TURN_LIMIT still overrides it; the docs follow.
2026-09-08 17:18:34 +07:00
tiennm99 0a5c5b06fe feat(dict): derive the corpus from kaikki.org's Wiktionary tiếng Việt export
Replace the pinned 2018 undertheseanlp wordlist with kaikki.org's current
wiktextract export of the Vietnamese Wiktionary, read with --kaikki. The
same authors and website, eight years fresher: 34,813 words instead of
26,845, with every graph metric up and bot game length unchanged.

The export is fetched fresh for every build and is not pinned, by the
owner's decision: kaikki keeps no dated snapshots, so a checksum would
break weekly. The builder therefore hashes the file as it streams it and
records source_sha256, source_rows and source_fetched_at in meta; the
fetch downloads to a .part name and renames on success; a truncated or
non-JSON body fails the build, and the --min-words floor rises to 30,000.
DICT_SHA256 and verify-dict are gone; the Makefile, Dockerfile and the
builder's URL constant are held in agreement by a test.

Current Wiktionary text is CC BY-SA 4.0, so the data licence returns to
4.0: data/LICENSE is restored, and NOTICE, ATTRIBUTION, README, the image
docs and the in-game footer credit Wiktionary tiếng Việt's contributors
and wiktextract/kaikki.org. builder_version becomes 3 for the changed
meta contract.
2026-09-08 17:17:58 +07:00
tiennm99 39ef45730f feat(dict): derive the corpus from undertheseanlp's wiktionary rows
Replace the 179 MB minhqnd SQLite aggregate with the 4.8 MB
undertheseanlp/dictionary JSONL, pinned by commit and SHA-256, reading
only rows tagged "wiktionary". The two other wordlists in that file are
never read: hongocduc is GPL and would force a relicense, tudientv is an
unlicensed derivative of a commercial dictionary.

build-dictionary gains --merged and --sources (names validated, default
wiktionary), a shared finish() tail, and meta rows for the source commit
and the sources kept and excluded. The SQLite --in path, its schema
auto-detection and their tests are removed. Fixture builds now record
that they carry no upstream data instead of inheriting a licence string.
The --min-words floor moves from 40,000 to 20,000; the corpus is 26,845
words, down from 48,216, all of the loss being words absent from the
2018 Wiktionary scrape. Capitalization is not a filter.

The data licence follows the source text: CC BY-SA 3.0 Unported, which
is what vi.wiktionary.org carried in 2018. LICENSE, NOTICE, ATTRIBUTION,
the README, the image docs, the builder's meta string and the in-game
footer all name Wiktionary tiếng Việt's contributors as the authors and
undertheseanlp as the intermediary. The Makefile/Dockerfile pin test now
also checks the commit the builder stamps into the database.
2026-09-08 16:37:34 +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 b2cad42b0c build: package the game as a container image and wire CI
One distroless image of about 25 MB carries the binary, the built frontend and
the derived dictionary. The 179 MB upstream release is downloaded in a builder
stage and never reaches the final image; the derived wordlist is copied in as
its own layer alongside its licence, attribution and notice, because CC BY-SA
4.0 applies wherever that data is distributed and an image is distribution.

FIXTURE_DICT=1 builds the same Dockerfile against the checked-in word sample,
so the image is built and smoke-tested on every push rather than only at
release. An image built only at release time is an image that breaks at release
time.

CI runs the Go suite under race detection, the frontend type check and tests,
the browser suite, and the image with its licence assertions. The wire contract
keeps its own workflow; the test steps it duplicated were removed from it.

docs/deployment.md covers configuration, the reverse-proxy settings that each
break the game in a way that looks like something else, and what a restart
costs.
2026-09-05 14:18:18 +07:00