mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-11 03:13:45 +00:00
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.
This commit is contained in:
1 parent
442ced32cc
commit
90cd679639
34 files changed
+2238
-108
No files matched your search
@@ -0,0 +1,176 @@
|
||||
# Web improvement batch: rules page, chat UX leftovers, ESLint+JSDoc
|
||||
|
||||
Scope: `web/` only, plus `.github/workflows/ci.yml` (one line in the `web` job), `Makefile`,
|
||||
`README.md`. Did not touch `server/`, `proto/`, or `web/src/lib/proto/`. Another agent's
|
||||
concurrent server work is visible in `git status` (server/*, proto/*, a new `metrics.go`) —
|
||||
untouched by this session, called out so it isn't mistaken for mine.
|
||||
|
||||
## Files changed
|
||||
|
||||
New:
|
||||
- `web/src/routes/rules/+page.svelte` — the `/rules` route
|
||||
- `web/eslint.config.js` — flat ESLint config
|
||||
|
||||
Edited (feature work):
|
||||
- `web/src/lib/i18n/vi.js` — all `/rules` copy plus `rulesLink`/`titleRules`
|
||||
- `web/src/routes/+page.svelte` — landing-page "Luật chơi" link
|
||||
- `web/src/lib/components/GameBoard.svelte` — rules link + hoisted chat-unread pill in `.top`
|
||||
- `web/src/lib/components/Lobby.svelte` — rules link in `.top`
|
||||
- `web/src/lib/components/ChatPanel.svelte` — `folded`/`unread` made `$bindable` so a control
|
||||
outside the panel can unfold it and read its count
|
||||
- `web/src/routes/online/+page.svelte` — lifted chat fold/unread state, `openChat()` scrolls
|
||||
`.pane.talk` into view, `collapsible={playing && !wide}` → `collapsible={!wide}`
|
||||
- `web/src/app.css` — shared `.rules-link` utility class
|
||||
|
||||
Edited (lint setup + real findings):
|
||||
- `web/package.json` — devDependencies (eslint, @eslint/js, eslint-plugin-svelte,
|
||||
eslint-plugin-jsdoc, globals), `allowScripts`, new `lint` script
|
||||
- `web/package-lock.json` — regenerated by npm
|
||||
- `.github/workflows/ci.yml` — one `Lint` step in the `web` job, after type check
|
||||
- `Makefile`, `README.md` — `web-lint` target, doc mentions
|
||||
- `web/src/lib/components/ChainHistory.svelte`, `ChatPanel.svelte`, `GameOverPanel.svelte` —
|
||||
added `{#each ... (key)}` keys (word / gloss / message identity)
|
||||
- `web/src/lib/stores/game.svelte.js` — scoped `eslint-disable-next-line` on a static lookup
|
||||
`Set` that is never mutated or read reactively (false positive for
|
||||
`svelte/prefer-svelte-reactivity`)
|
||||
- `web/src/lib/components/GameBoard.svelte`, `Lobby.svelte`, `RoomCodePanel.svelte` — timer
|
||||
handle `@type {any}` → `@type {ReturnType<typeof setTimeout>}`
|
||||
- Auto-fixed across most of `web/src` and `web/tests` (and `web/e2e`): `jsdoc/tag-lines`
|
||||
(blank line before a tag block), a pure whitespace-in-comments change, `eslint --fix`
|
||||
|
||||
## Deliverable 1 — `/rules`
|
||||
|
||||
Single page, seven anchored sections (chain, clock, dead end, elimination, scoring, room
|
||||
codes, reconnect), a pill nav at the top, all copy in `vi.js`. Scoring section states the
|
||||
actual formula and constants read from `server/internal/game/engine.go` (`pointsFor`,
|
||||
`speedPoints`, `rarityPoints`, read-only): base 10, +2/word up to the first 15, +5/syllable
|
||||
past the 2-syllable minimum, up to +10 for speed (full instantly, linear to 0 at the buzzer),
|
||||
up to +15 for rarity (−3 per doubling of the syllable's option count), capped at 100/word. The
|
||||
constants live in a small `SCORING` object in the route with a comment naming the source
|
||||
functions, since the page is static and these are compile-time constants, not something the
|
||||
wire sends.
|
||||
|
||||
Linked from the landing page (own line under the two big actions, not a third button) and from
|
||||
`GameBoard`'s `.top` row and `Lobby`'s `.top` row via a shared `.rules-link` class in
|
||||
`app.css`, so it appears in both the bot game, the online game, and the lobby without three
|
||||
separate style blocks.
|
||||
|
||||
## Deliverable 2 — UX leftovers (C1)
|
||||
|
||||
**2a — hoist the chat toggle.** Followed the 2026-09-10 report's own recommendation (option
|
||||
a, not the DOM-reorder option it called "wrong on a phone"): a pill button now sits in
|
||||
`GameBoard`'s `.top` row, next to the connection badge, showing the unread count and — on
|
||||
click — unfolding the chat panel and scrolling `.pane.talk` into view. `ChatPanel`'s `folded`
|
||||
and `unread` became `$bindable` props so `online/+page.svelte` can drive them from the pill
|
||||
without the two components importing each other's internals. The pill is bot-mode-safe: it
|
||||
only renders when `onchatopen` is passed, which `online/+page.svelte` does not do on wide
|
||||
screens (chat already has its own column there) and `/play` never does at all (no chat in a
|
||||
bot game).
|
||||
|
||||
**2b — lobby unread badge.** `collapsible={playing && !wide}` → `collapsible={!wide}`: the
|
||||
panel now folds on any narrow screen, lobby included, so the existing unread badge in
|
||||
`ChatPanel`'s own header (built for the game view) now also appears in the lobby. No new
|
||||
store state — reused the same `chatCount`/`seenAt` mechanism the panel already had.
|
||||
|
||||
## Deliverable 3 — ESLint + JSDoc (F1)
|
||||
|
||||
Flat config (`eslint.config.js`): `@eslint/js` recommended, `eslint-plugin-svelte`'s
|
||||
`flat/recommended`, `eslint-plugin-jsdoc`'s `flat/recommended-typescript-flavor` (fits a
|
||||
JS+JSDoc project better than plain `recommended`: it turns off `no-undefined-types`, which
|
||||
otherwise misreads this codebase's TS-flavoured JSDoc generics). `globals.browser` for
|
||||
`src/`, `globals.node` for `*.config.js`/`tests/**`/`e2e/**`. `src/lib/proto/**` is excluded
|
||||
(generated, not ours to fix).
|
||||
|
||||
Two narrow, justified rule overrides, both with an inline comment in `eslint.config.js`:
|
||||
|
||||
- `svelte/no-navigation-without-resolve`: off. This app has no base path (adapter-static,
|
||||
served from `/` by the Go binary) and several of its own links append a query string at the
|
||||
call site (`/play?difficulty=`, `/online?code=`) — a shape the rule's `resolve()` cannot
|
||||
express (confirmed via the plugin's own open issues on search-param hrefs). Adopting
|
||||
`resolve()` would work around a limitation for a base path this app will never have.
|
||||
- `jsdoc/require-jsdoc`, `require-param`, `require-param-description`, `require-param-type`,
|
||||
`require-property*`, `require-returns*`: off. This codebase types function/component
|
||||
inputs with one `@type {{ ... }}` object-shape block (every component's props, e.g.
|
||||
`GameBoard.svelte`) rather than per-parameter `@param name description` lines, which is
|
||||
what these rules expect; turning them on flagged ~400 warnings that were the codebase's
|
||||
actual documentation style, not missing documentation. Left everything else on, including
|
||||
`check-tag-names`, `check-param-names`, `check-types`, and `reject-any-type`.
|
||||
|
||||
Ran it, fixed what was real:
|
||||
- `svelte/require-each-key` (4 sites) — added keys: chain entries by `word` (unique, no word
|
||||
is ever reused), a word's senses by `gloss`, chat messages by `playerId + atMs`, suggestion
|
||||
words by the word itself.
|
||||
- `svelte/prefer-svelte-reactivity` (1 site) — `game.svelte.js`'s `kept` is a static lookup
|
||||
`Set`, built once, never mutated, never read by a template; a scoped disable with a comment
|
||||
rather than converting it to `SvelteSet` for reactivity it will never use.
|
||||
- `jsdoc/reject-any-type`, 3 of 32 sites — the three that were plainly a timer handle
|
||||
(`GameBoard.svelte`, `Lobby.svelte`, `RoomCodePanel.svelte`'s `armTimer`/`clearTimer`) got
|
||||
`ReturnType<typeof setTimeout>`; verified with `npm run check` (still 0 errors).
|
||||
- `jsdoc/tag-lines` (blank line before a tag block) — ran `eslint --fix`; purely
|
||||
whitespace-in-comments, touched most files under `src/`, `tests/`, and `e2e/`.
|
||||
|
||||
`npm install --save-dev` with caret ranges (`eslint@^10.11.0`, `@eslint/js@^10.0.1`,
|
||||
`eslint-plugin-svelte@^3.23.0`, `eslint-plugin-jsdoc@^64.5.4`, `globals@^17.12.0`), all
|
||||
compatible with the pinned `eslint`/`svelte`/node peer ranges. `npm approve-scripts esbuild`
|
||||
wrote `package.json#allowScripts` (npm 11 feature, confirmed present); the postinstall warning
|
||||
is gone on a clean `npm install`. `npm run check` stayed type-check only; added `npm run lint`
|
||||
= `eslint .`.
|
||||
|
||||
## Deliverable 4 — CI
|
||||
|
||||
One line added to the `web` job in `.github/workflows/ci.yml`, right after `Type check`:
|
||||
```yaml
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
working-directory: web
|
||||
```
|
||||
Nothing else in that file touched — Go job and triggers left alone for the other agent.
|
||||
|
||||
## Deliverable 5 — README / Makefile
|
||||
|
||||
- `Makefile`: new `web-lint` target (`cd web && npm run lint`), listed in `help` and
|
||||
`.PHONY`.
|
||||
- `README.md`: "The frontend" section now mentions `/rules`; the Make targets table and the
|
||||
"Without `make`" section both gained the lint line.
|
||||
|
||||
## Verification (tail)
|
||||
|
||||
```
|
||||
$ npm run check
|
||||
1789926960919 START ".../web"
|
||||
1789926960922 COMPLETED 380 FILES 0 ERRORS 0 WARNINGS 0 FILES_WITH_PROBLEMS
|
||||
|
||||
$ npm run lint
|
||||
✖ 29 problems (0 errors, 29 warnings)
|
||||
|
||||
$ npm test
|
||||
Test Files 12 passed (12)
|
||||
Tests 190 passed (190)
|
||||
```
|
||||
All three commands the task requires (`npm run check && npm test`, plus `npm run lint`) exit 0.
|
||||
|
||||
## Deferred items
|
||||
|
||||
- **29 remaining `jsdoc/reject-any-type` warnings** (not errors — `npm run lint` still exits
|
||||
0). All are decoded-wire-payload shapes: `game.svelte.js`'s `toSenses`/`toScore`/chat and
|
||||
room-player mappers, `client.js`'s message handlers, `connection.svelte.js`'s `send`, a few
|
||||
test files mapping over generated proto arrays. Precisely typing these means importing the
|
||||
specific generated `oneof` message type from `game_pb.js` at each of ~15 call sites —
|
||||
legitimate follow-up, but a larger, separate change than this batch's scope, and higher risk
|
||||
of a `svelte-check` regression than the task's time budget covers. Left as visible warnings
|
||||
rather than silenced.
|
||||
- Did not add a Playwright spec for `/rules` — the task allowed this but flagged e2e as
|
||||
unverifiable here (no browser); a minimal smoke spec would be unverified guesswork, so
|
||||
skipped rather than adding untested test code.
|
||||
|
||||
## Unresolved questions
|
||||
|
||||
None — the two design calls with real judgment involved (chat-pill placement mechanism, the
|
||||
`no-navigation-without-resolve` exception) are documented above and in the code comments where
|
||||
made, per "verified decisions" / "threat model" guidance: both are backed by a source
|
||||
(the 2026-09-10 UX report's own recommendation; the plugin's own documented limitation), not
|
||||
an abstract preference.
|
||||
|
||||
Status: DONE
|
||||
Summary: Added `/rules` (single anchored page, all copy in vi.js, scoring sourced from engine.go constants), hoisted the online chat-unread pill into GameBoard's top row and fixed the lobby's missing fold/badge, and wired up ESLint+JSDoc (flat config, `npm run lint`, CI step, Makefile target, README) with real findings fixed (each-key, one Set-reactivity false positive, three `any`→`ReturnType<typeof setTimeout>`) and two narrow, justified rule exceptions.
|
||||
Concerns/Blockers: 29 jsdoc/reject-any-type warnings remain as documented backlog (non-blocking); no e2e coverage added for /rules since Playwright can't run in this environment.
|
||||
@@ -34,7 +34,6 @@ for (const line of lines) {
|
||||
/**
|
||||
* The first sense of a fixture word, rendered as the chain renders it:
|
||||
* `(pos) gloss`, or the gloss alone. Undefined for a word without one.
|
||||
*
|
||||
* @param {string} word
|
||||
*/
|
||||
export function renderedSense(word) {
|
||||
@@ -52,7 +51,6 @@ for (const word of words) {
|
||||
|
||||
/**
|
||||
* A legal continuation of `syllable` that has not been played yet.
|
||||
*
|
||||
* @param {string} syllable
|
||||
* @param {Set<string>} used
|
||||
* @returns {string | undefined}
|
||||
|
||||
@@ -24,7 +24,6 @@ export function board(page) {
|
||||
|
||||
/**
|
||||
* Waits until it is this player's turn to move.
|
||||
*
|
||||
* @param {import('@playwright/test').Page} page
|
||||
*/
|
||||
export async function waitForMyTurn(page) {
|
||||
@@ -39,7 +38,6 @@ export async function waitForMyTurn(page) {
|
||||
* has to ask rather than assume the room's owner. It polls the indicators
|
||||
* together instead of waiting on one page, because the state being waited for
|
||||
* belongs to the room and not to any single player.
|
||||
*
|
||||
* @param {import('@playwright/test').Page[]} pages
|
||||
* @returns {Promise<{lead: import('@playwright/test').Page, waits: import('@playwright/test').Page[]}>}
|
||||
*/
|
||||
@@ -64,7 +62,6 @@ export async function awaitTurn(...pages) {
|
||||
/**
|
||||
* Types a word and sends it. The field is uncontrolled on purpose, so this
|
||||
* fills and submits exactly as a player would.
|
||||
*
|
||||
* @param {import('@playwright/test').Page} page
|
||||
* @param {string} word
|
||||
*/
|
||||
@@ -77,7 +74,6 @@ export async function submitWord(page, word) {
|
||||
/**
|
||||
* Plays one legal move for whatever syllable is currently required, and returns
|
||||
* the word it played.
|
||||
*
|
||||
* @param {import('@playwright/test').Page} page
|
||||
* @param {Set<string>} used
|
||||
* @returns {Promise<string>}
|
||||
@@ -97,7 +93,6 @@ export async function playLegalMove(page, used) {
|
||||
|
||||
/**
|
||||
* The chat panel's parts, for the tests that drive a conversation.
|
||||
*
|
||||
* @param {import('@playwright/test').Page} page
|
||||
*/
|
||||
export function chat(page) {
|
||||
@@ -111,7 +106,6 @@ export function chat(page) {
|
||||
|
||||
/**
|
||||
* Types a line and sends it, the way a player does.
|
||||
*
|
||||
* @param {import('@playwright/test').Page} page
|
||||
* @param {string} text
|
||||
*/
|
||||
@@ -125,7 +119,6 @@ export async function say(page, text) {
|
||||
* Takes a seated pair from their lobby into a game: the guest readies, the
|
||||
* owner starts. Nothing begins on its own now, so every online test that is
|
||||
* about a game goes through here.
|
||||
*
|
||||
* @param {import('@playwright/test').Page} owner
|
||||
* @param {import('@playwright/test').Page} guest
|
||||
*/
|
||||
@@ -142,7 +135,6 @@ export async function readyAndStart(owner, ...guests) {
|
||||
* The seat rows in the lobby, one per player who is actually in the room. The
|
||||
* free seats are drawn too, so counting rows would count the room's size
|
||||
* rather than its occupants.
|
||||
*
|
||||
* @param {import('@playwright/test').Page} page
|
||||
*/
|
||||
export function seats(page) {
|
||||
@@ -154,7 +146,6 @@ export function seats(page) {
|
||||
*
|
||||
* The radio itself is visually hidden so the platform supplies arrow-key
|
||||
* behaviour, so the card is what a player actually clicks.
|
||||
*
|
||||
* @param {import('@playwright/test').Page} page
|
||||
* @param {string | RegExp} label
|
||||
*/
|
||||
@@ -165,7 +156,6 @@ export async function chooseDifficulty(page, label) {
|
||||
|
||||
/**
|
||||
* Sets the nickname on the home screen.
|
||||
*
|
||||
* @param {import('@playwright/test').Page} page
|
||||
* @param {string} name
|
||||
*/
|
||||
@@ -180,7 +170,6 @@ export async function setNickname(page, name) {
|
||||
* native confirm() blocks the frame loop the countdown ring runs on and could
|
||||
* cost the turn it was protecting. The accessible name still contains "Đầu
|
||||
* hàng" in both states, so one locator drives both presses.
|
||||
*
|
||||
* @param {import('@playwright/test').Page} page
|
||||
*/
|
||||
export async function resign(page) {
|
||||
@@ -191,7 +180,6 @@ export async function resign(page) {
|
||||
|
||||
/**
|
||||
* Puts a player out of the room. Two presses, for the same reason as resign.
|
||||
*
|
||||
* @param {import('@playwright/test').Locator} kick
|
||||
*/
|
||||
export async function confirmKick(kick) {
|
||||
@@ -201,7 +189,6 @@ export async function confirmKick(kick) {
|
||||
|
||||
/**
|
||||
* Reads the words currently in the chain, opening word included.
|
||||
*
|
||||
* @param {import('@playwright/test').Page} page
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
@@ -211,7 +198,6 @@ export async function chainWords(page) {
|
||||
|
||||
/**
|
||||
* Reads the words whose meaning panel is open.
|
||||
*
|
||||
* @param {import('@playwright/test').Page} page
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
|
||||
@@ -23,7 +23,6 @@ import { cuttableSocket } from './socket-cut.js';
|
||||
* The pair comes back twice over: as owner and joiner, which is what the seat
|
||||
* ids and the names follow, and as lead and second, which is who the first
|
||||
* turn was drawn for.
|
||||
*
|
||||
* @param {import('@playwright/test').Browser} browser
|
||||
*/
|
||||
async function pvpRoom(browser) {
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
* window may never start. That makes this the right tool for what the client
|
||||
* does about a dead socket, and the wrong one for what the server does about a
|
||||
* missing player. For that, take the page away instead.
|
||||
*
|
||||
* @param {import('@playwright/test').Page} page
|
||||
*/
|
||||
export async function cuttableSocket(page) {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import js from '@eslint/js';
|
||||
import jsdoc from 'eslint-plugin-jsdoc';
|
||||
import svelte from 'eslint-plugin-svelte';
|
||||
import globals from 'globals';
|
||||
|
||||
/**
|
||||
* Flat config. Type-checking already lives in `svelte-check` via the JSDoc
|
||||
* annotations in `jsconfig.json`'s `checkJs`, so this is style and correctness
|
||||
* only — there is no TypeScript project for a type-aware rule set to attach
|
||||
* to.
|
||||
*
|
||||
* `src/lib/proto/` is generated by `buf` from `proto/noitu/v1/game.proto` and
|
||||
* committed as-is; linting it would flag code nobody here is meant to edit.
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
ignores: ['build/**', '.svelte-kit/**', 'static/**', 'src/lib/proto/**']
|
||||
},
|
||||
js.configs.recommended,
|
||||
...svelte.configs['flat/recommended'],
|
||||
jsdoc.configs['flat/recommended-typescript-flavor'],
|
||||
{
|
||||
languageOptions: {
|
||||
ecmaVersion: 2024,
|
||||
sourceType: 'module',
|
||||
globals: {
|
||||
...globals.browser
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
// The build config, the test config and everything under test/ and e2e/
|
||||
// run under Node rather than in a browser.
|
||||
files: ['*.config.js', 'tests/**/*.js', 'e2e/**/*.js'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
// The word field and the chat field are deliberately uncontrolled —
|
||||
// see WordInput.svelte and ChatPanel.svelte — so an unused event
|
||||
// parameter kept for its shape (a composition handler ignoring its
|
||||
// event, say) is a documented choice, not dead code. Leading
|
||||
// underscore still flags a genuine oversight.
|
||||
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||
|
||||
// This app has no base path — adapter-static builds to the root the
|
||||
// Go binary serves everything from — and several of its own links
|
||||
// carry a query string built at the call site (`/play?difficulty=`,
|
||||
// `/online?code=`), a shape the rule does not recognise as resolved.
|
||||
// Adopting resolve() here would be for a base path this app will
|
||||
// never have, working around a limitation in exactly the links this
|
||||
// app uses.
|
||||
'svelte/no-navigation-without-resolve': 'off',
|
||||
|
||||
// require-jsdoc and the param/returns completeness rules assume
|
||||
// per-parameter @param/@returns prose; this codebase instead types a
|
||||
// function's inputs with one @type {{ ... }} object-shape block
|
||||
// (see any component's props, e.g. GameBoard.svelte) and skips a
|
||||
// comment on a name that already says what it is. Turning these off
|
||||
// keeps the rules that check what is actually written — tag names,
|
||||
// param names, and no bare `any` — without demanding a second prose
|
||||
// copy of every signature already typed.
|
||||
'jsdoc/require-jsdoc': 'off',
|
||||
'jsdoc/require-param': 'off',
|
||||
'jsdoc/require-param-description': 'off',
|
||||
'jsdoc/require-param-type': 'off',
|
||||
'jsdoc/require-property': 'off',
|
||||
'jsdoc/require-property-description': 'off',
|
||||
'jsdoc/require-returns': 'off',
|
||||
'jsdoc/require-returns-description': 'off',
|
||||
'jsdoc/require-returns-type': 'off'
|
||||
}
|
||||
}
|
||||
];
|
||||
Generated
+1597
File diff suppressed because it is too large.
Load diff
@@ -8,6 +8,7 @@
|
||||
"build": "vite build",
|
||||
"prepare": "svelte-kit sync",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
|
||||
"lint": "eslint .",
|
||||
"test": "vite build && vitest run",
|
||||
"test:e2e": "vite build && playwright test"
|
||||
},
|
||||
@@ -16,15 +17,23 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@bufbuild/protoc-gen-es": "^2.14.1",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.63.0",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.70.3",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@types/node": "^26.4.1",
|
||||
"eslint": "^10.11.0",
|
||||
"eslint-plugin-jsdoc": "^64.5.4",
|
||||
"eslint-plugin-svelte": "^3.23.0",
|
||||
"globals": "^17.12.0",
|
||||
"jsdom": "^27.4.0",
|
||||
"svelte": "^5.57.0",
|
||||
"svelte-check": "^4.7.6",
|
||||
"vite": "^7.3.6",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"allowScripts": {
|
||||
"esbuild@0.28.2": true
|
||||
}
|
||||
}
|
||||
@@ -161,6 +161,22 @@ input {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/*
|
||||
* A small link to the rules, used wherever a screen has room for one but not
|
||||
* for a button that would compete with the primary action beside it: the
|
||||
* landing page, the game header and the lobby all point at the same route
|
||||
* through the same class, so it reads as one control rather than three.
|
||||
*/
|
||||
.rules-link {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-4);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.rules-link:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Visible only to assistive technology. */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
Safari with VoiceOver, and "3 trong 24" is most of what the chain
|
||||
tells somebody listening to it. -->
|
||||
<ol class="rows" class:more role="list" bind:this={list} onscroll={measure}>
|
||||
{#each entries as entry, index}
|
||||
{#each entries as entry, index (entry.word)}
|
||||
<!-- The panel id comes from the row's place in the chain, not the
|
||||
word, so two rows can never share one. -->
|
||||
{@const open = game.isExpanded(entry.word)}
|
||||
@@ -111,7 +111,7 @@
|
||||
stripped the wiki markup and nothing here re-interprets it. -->
|
||||
{#if entry.meanings.length}
|
||||
<ol class="meanings" id={panelId}>
|
||||
{#each entry.meanings as sense}
|
||||
{#each entry.meanings as sense (sense.gloss)}
|
||||
<li>{sense.pos ? `(${sense.pos}) ` : ''}{sense.gloss}</li>
|
||||
{/each}
|
||||
</ol>
|
||||
|
||||
@@ -20,13 +20,24 @@
|
||||
* that produced them, which is where the lobby now draws them: down here
|
||||
* they were below the fold on a phone, and "Bắt đầu" looked broken.
|
||||
*
|
||||
* `folded` and `unread` are bindable so a control drawn outside this panel
|
||||
* — the pill hoisted into the board's top row — can unfold it and read its
|
||||
* count without this component knowing that control exists.
|
||||
* @type {{
|
||||
* collapsible?: boolean,
|
||||
* column?: boolean,
|
||||
* onsend: (text: string) => void
|
||||
* onsend: (text: string) => void,
|
||||
* folded?: boolean,
|
||||
* unread?: number
|
||||
* }}
|
||||
*/
|
||||
let { collapsible = false, column = false, onsend } = $props();
|
||||
let {
|
||||
collapsible = false,
|
||||
column = false,
|
||||
onsend,
|
||||
folded = $bindable(true),
|
||||
unread = $bindable(0)
|
||||
} = $props();
|
||||
|
||||
/** @type {HTMLElement | undefined} */
|
||||
let list = $state();
|
||||
@@ -35,9 +46,8 @@
|
||||
|
||||
let draft = $state('');
|
||||
let composing = $state(false);
|
||||
// Folded is the toggle's own state; open is what the panel is, which a
|
||||
// panel that cannot fold always is.
|
||||
let folded = $state(true);
|
||||
// Open is what the panel is; folded is only the toggle's own state, which a
|
||||
// panel that cannot fold ignores entirely.
|
||||
const open = $derived(!collapsible || !folded);
|
||||
|
||||
// Where this reader had got to, counted against the store's running total
|
||||
@@ -48,7 +58,12 @@
|
||||
let seenAt = $state(game.state.chatCount);
|
||||
|
||||
const messages = $derived(game.state.chat);
|
||||
const unread = $derived(open ? 0 : Math.max(0, game.state.chatCount - seenAt));
|
||||
|
||||
// Written rather than derived: `unread` is bindable, and a bindable prop is
|
||||
// assigned to, not declared with $derived.
|
||||
$effect(() => {
|
||||
unread = open ? 0 : Math.max(0, game.state.chatCount - seenAt);
|
||||
});
|
||||
|
||||
// What the server would be left with after sanitizing: whitespace gone and
|
||||
// the invisible characters that survive a trim stripped out. Matching the
|
||||
@@ -84,7 +99,6 @@
|
||||
* The colour a seat writes in, from the palette in app.css. A line whose
|
||||
* seat the server cleared has no colour of its own: it belongs to nobody,
|
||||
* and painting it as somebody would be a lie about who said it.
|
||||
*
|
||||
* @param {string} playerId
|
||||
* @returns {string}
|
||||
*/
|
||||
@@ -149,7 +163,7 @@
|
||||
aria-relevant="additions"
|
||||
data-testid="chat-log"
|
||||
>
|
||||
{#each messages as entry}
|
||||
{#each messages as entry (entry.playerId + "@" + entry.atMs)}
|
||||
<li style:color={colourOf(entry.playerId)}>
|
||||
<!-- An author the server cleared belongs to nobody: the seat
|
||||
they spoke from may be somebody else's now. -->
|
||||
|
||||
@@ -13,21 +13,36 @@
|
||||
* else; what a finished game offers differs between bot and online play, so
|
||||
* that arrives as a snippet rather than as a branch in here.
|
||||
*
|
||||
* `chatUnread` and `onchatopen` are online-only: a bot game has no chat, so
|
||||
* the caller simply never passes them, and the pill they draw does not
|
||||
* appear. Passing them is what puts a way to reach the conversation above
|
||||
* the chain rather than under it, which on a phone mid-game used to make
|
||||
* the unread count effectively unreachable.
|
||||
* @type {{
|
||||
* modeLabel?: string,
|
||||
* onsubmit: (word: string) => boolean,
|
||||
* onresign: () => void,
|
||||
* gameOver: import('svelte').Snippet,
|
||||
* banner?: import('svelte').Snippet
|
||||
* banner?: import('svelte').Snippet,
|
||||
* chatUnread?: number,
|
||||
* onchatopen?: () => void
|
||||
* }}
|
||||
*/
|
||||
let { modeLabel = '', onsubmit, onresign, gameOver, banner } = $props();
|
||||
let {
|
||||
modeLabel = '',
|
||||
onsubmit,
|
||||
onresign,
|
||||
gameOver,
|
||||
banner,
|
||||
chatUnread = 0,
|
||||
onchatopen
|
||||
} = $props();
|
||||
|
||||
/** How long an armed resign button waits before it goes back to being safe. */
|
||||
const ARM_MS = 4000;
|
||||
|
||||
let arming = $state(false);
|
||||
/** @type {any} */
|
||||
/** @type {ReturnType<typeof setTimeout>} */
|
||||
let armTimer;
|
||||
|
||||
// Whose turn it is, said by name. With four people at the table "the
|
||||
@@ -81,7 +96,29 @@
|
||||
<section class="board" data-phase={game.state.phase}>
|
||||
<div class="top">
|
||||
<ConnectionBadge />
|
||||
{#if modeLabel}<span class="mode">{modeLabel}</span>{/if}
|
||||
<div class="meta">
|
||||
{#if modeLabel}<span class="mode">{modeLabel}</span>{/if}
|
||||
<a class="rules-link" href="/rules">{t.rulesLink}</a>
|
||||
{#if onchatopen}
|
||||
<!-- Above the chain rather than below it, which is where this used
|
||||
to live: the chain grows a row per turn, and a badge under it
|
||||
was two screens down by the time a game was worth talking
|
||||
about. Scrolling the panel into view rather than opening it in
|
||||
place, since a folded panel scrolled here still shows its own
|
||||
badge and its own way to unfold. -->
|
||||
<button
|
||||
type="button"
|
||||
class="chat-pill"
|
||||
onclick={onchatopen}
|
||||
data-testid="chat-pill"
|
||||
>
|
||||
{t.chatTitle}
|
||||
{#if chatUnread > 0}
|
||||
<span class="pill-badge">{fill(t.chatUnread, { n: chatUnread })}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Not once the game is over: the result panel below carries the same
|
||||
@@ -193,11 +230,41 @@
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.mode {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-4);
|
||||
}
|
||||
|
||||
.chat-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 32px;
|
||||
padding: 4px var(--space-3);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--surface-alt);
|
||||
font-size: var(--text-3);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pill-badge {
|
||||
padding: 1px var(--space-2);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
font-size: var(--text-1);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.turn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
* onrematch is optional because the two modes differ: a bot always plays
|
||||
* again, so the button starts the next game, while online play agrees the
|
||||
* next one in the lobby this panel appears above.
|
||||
*
|
||||
* @type {{ isRecord: boolean, onrematch?: () => void, onhome: () => void }}
|
||||
*/
|
||||
let { isRecord, onrematch, onhome } = $props();
|
||||
@@ -101,7 +100,7 @@
|
||||
<div class="suggestions">
|
||||
<h3>{t.suggestionsTitle}:</h3>
|
||||
<ul>
|
||||
{#each elimination.suggestions as word}
|
||||
{#each elimination.suggestions as word (word)}
|
||||
<li>{word}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
* The callbacks report whether the request actually reached the server. A
|
||||
* socket that has just dropped answers `false`, and a button that silently
|
||||
* did nothing is the fastest way to make a room look dead.
|
||||
*
|
||||
* @type {{
|
||||
* compact?: boolean,
|
||||
* onready: (ready: boolean) => boolean,
|
||||
@@ -54,7 +53,7 @@
|
||||
|
||||
/** The seat whose kick button is armed, if any. */
|
||||
let armedKick = $state('');
|
||||
/** @type {any} */
|
||||
/** @type {ReturnType<typeof setTimeout>} */
|
||||
let armTimer;
|
||||
// Set when a request could not go out at all, which is a different thing
|
||||
// from the server refusing it — that arrives as game.state.error.
|
||||
@@ -88,6 +87,7 @@
|
||||
{#if !compact}
|
||||
<div class="top">
|
||||
<ConnectionBadge />
|
||||
<a class="rules-link" href="/rules">{t.rulesLink}</a>
|
||||
</div>
|
||||
<RoomCodePanel code={s.roomCode} />
|
||||
{:else}
|
||||
@@ -250,6 +250,7 @@
|
||||
.top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.count {
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
* The countdown is local because the server sends a duration, not a
|
||||
* deadline: it is a rough "how much longer" for the players watching, and
|
||||
* the server still decides when a seat is actually forfeit.
|
||||
*
|
||||
* @type {Record<string, number>}
|
||||
*/
|
||||
let endsAt = $state({});
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
* game: the full panel is a screen's worth of vertical space, but that is
|
||||
* exactly the moment a room wants to invite a replacement, so the code and a
|
||||
* copy button still have to be reachable.
|
||||
*
|
||||
* @type {{ code: string, compact?: boolean }}
|
||||
*/
|
||||
let { code, compact = false } = $props();
|
||||
@@ -16,7 +15,7 @@
|
||||
/** @type {'' | 'code' | 'link'} */
|
||||
let copied = $state('');
|
||||
let failed = $state(false);
|
||||
/** @type {any} */
|
||||
/** @type {ReturnType<typeof setTimeout>} */
|
||||
let clearTimer;
|
||||
/** @type {HTMLElement | undefined} */
|
||||
let codeEl = $state();
|
||||
|
||||
@@ -72,7 +72,6 @@
|
||||
* why this is not `disabled` or `readonly` — but a keystroke, a paste and
|
||||
* a drop all do nothing, so nobody spends their opponent's turn typing a
|
||||
* word that was never going to be sent.
|
||||
*
|
||||
* @param {Event} event
|
||||
*/
|
||||
function guardInput(event) {
|
||||
|
||||
@@ -11,7 +11,6 @@ export const SETTLE_MS = 300;
|
||||
|
||||
/**
|
||||
* Milliseconds left on the current turn, never negative.
|
||||
*
|
||||
* @param {number} deadlineMs - the server's absolute deadline
|
||||
* @param {number} serverNowMs - the server clock as this client estimates it
|
||||
* @param {number} [settleMs]
|
||||
@@ -28,7 +27,6 @@ export function remainingMs(deadlineMs, serverNowMs, settleMs = SETTLE_MS) {
|
||||
* A turn limit of zero means no timed turn is in progress, which is full
|
||||
* rather than empty: an empty ring would announce a timeout that is not
|
||||
* happening.
|
||||
*
|
||||
* @param {number} remaining
|
||||
* @param {number} limitMs
|
||||
* @returns {number}
|
||||
@@ -41,7 +39,6 @@ export function fraction(remaining, limitMs) {
|
||||
/**
|
||||
* Whole seconds to display. Rounded up, so the last second is shown as "1"
|
||||
* for its whole duration instead of flashing "0" while time remains.
|
||||
*
|
||||
* @param {number} remaining
|
||||
* @returns {number}
|
||||
*/
|
||||
|
||||
@@ -15,7 +15,6 @@ function pad(n) {
|
||||
/**
|
||||
* Local time, because the file is for the person who played the game and not
|
||||
* for a machine in another timezone.
|
||||
*
|
||||
* @param {Date} at
|
||||
* @returns {string}
|
||||
*/
|
||||
@@ -41,7 +40,6 @@ export function historyFilename(at = new Date()) {
|
||||
* `nameOf` resolves a seat id to the name that seat was playing under. It is
|
||||
* passed in rather than read from the store so the transcript can be built and
|
||||
* tested without one.
|
||||
*
|
||||
* @param {object} args
|
||||
* @param {import('$lib/stores/game.svelte.js').ChainEntry[]} args.chain
|
||||
* @param {{ iWon: boolean, myScore: number, chainLength: number } | null} [args.result]
|
||||
@@ -79,7 +77,6 @@ export function chainToText({ chain, result = null, nameOf = () => '', at = new
|
||||
|
||||
/**
|
||||
* Hands the text to the browser as a download.
|
||||
*
|
||||
* @param {string} filename
|
||||
* @param {string} text
|
||||
*/
|
||||
|
||||
+50
-6
@@ -147,12 +147,60 @@ export const t = {
|
||||
attributionIntro: 'Từ điển dựa trên',
|
||||
attributionSource: 'Wiktionary tiếng Việt',
|
||||
attributionLicense: 'giấy phép CC BY-SA 4.0',
|
||||
attributionMiddle: 'phát hành theo'
|
||||
attributionMiddle: 'phát hành theo',
|
||||
|
||||
// The /rules route. One page with anchors, not an accordion: the game has
|
||||
// one set of rules, not a set the reader has to tap open one at a time.
|
||||
rulesLink: 'Luật chơi',
|
||||
titleRules: 'Luật chơi · Nối Từ',
|
||||
rulesIntro: 'Từ lúc vào phòng đến lúc thắng cuộc, đây là toàn bộ luật chơi.',
|
||||
|
||||
rulesNavChain: 'Nối từ',
|
||||
rulesNavClock: 'Đồng hồ',
|
||||
rulesNavDeadEnd: 'Bí từ',
|
||||
rulesNavElimination: 'Bị loại',
|
||||
rulesNavScoring: 'Tính điểm',
|
||||
rulesNavRoom: 'Mã phòng',
|
||||
rulesNavReconnect: 'Mất kết nối',
|
||||
|
||||
rulesChainTitle: 'Nối từ',
|
||||
rulesChainBody:
|
||||
'Mỗi từ phải có ít nhất 2 tiếng. Tiếng đầu của từ bạn gõ phải trùng với tiếng cuối của từ trước đó, ví dụ “ngôn ngữ” → “ngữ pháp” → “pháp luật”. Một từ chỉ được dùng một lần trong cả ván đấu — dùng lại một từ đã nối bị coi như không nối được.',
|
||||
|
||||
rulesClockTitle: 'Đồng hồ',
|
||||
rulesClockBody:
|
||||
'Mỗi lượt có cùng một khoảng thời gian cho tất cả mọi người, hiển thị bằng vòng đếm ngược quanh khung nhập từ. Hết giờ mà chưa gửi từ coi như thua lượt đó, giống hệt như nối sai hay nối một từ đã dùng.',
|
||||
|
||||
rulesDeadEndTitle: 'Bí từ',
|
||||
rulesDeadEndBody:
|
||||
'Nối một từ khiến người kế tiếp không còn tiếng nào để nối không phải là thắng ngay lập tức: người bị bí vẫn được chơi lượt của mình và vẫn thua vào đồng hồ như bình thường, không phải thua ngay khi bí. Nếu họ không nối được, màn hình kết quả sẽ cho xem vài từ tiếng đó còn nối được — hoặc cho biết tiếng đó đã hết từ để nối.',
|
||||
|
||||
rulesEliminationTitle: 'Bị loại và người thắng cuộc',
|
||||
rulesEliminationBody:
|
||||
'Một phòng có từ hai đến bốn người chơi. Thua một lượt không kết thúc cả ván đấu: người đó bị loại khỏi ván, còn tiếng hiện tại và danh sách từ đã dùng vẫn giữ nguyên cho những người còn lại, và lượt chuyển cho người kế tiếp. Người trụ lại cuối cùng là người thắng cuộc — ván hai người chỉ là chính quy tắc này nhìn ở quy mô nhỏ nhất, nên không có luật riêng nào cho nó.',
|
||||
|
||||
rulesScoringTitle: 'Tính điểm',
|
||||
rulesScoringBase: 'Mỗi từ nối được cộng {base} điểm nền.',
|
||||
rulesScoringChain:
|
||||
'Cộng thêm {bonus} điểm cho mỗi từ đã nối trước đó trong chuỗi (kể cả từ mở đầu), tính tối đa {cap} từ đầu của chuỗi — chuỗi càng dài về sau không cộng thêm phần này nữa.',
|
||||
rulesScoringSyllable: 'Cộng thêm {bonus} điểm cho mỗi tiếng vượt quá mức tối thiểu {min} tiếng.',
|
||||
rulesScoringSpeed:
|
||||
'Cộng tối đa {bonus} điểm cho tốc độ trả lời: được cộng đủ nếu gửi từ gần như ngay lập tức, giảm dần đều về 0 khi gửi sát giờ hết lượt.',
|
||||
rulesScoringRarity:
|
||||
'Cộng tối đa {bonus} điểm nếu tiếng đó hiếm từ để nối: được cộng đủ khi từ điển chỉ có đúng một từ bắt đầu bằng tiếng đó, giảm {penalty} điểm mỗi khi số từ có thể nối tăng gấp đôi.',
|
||||
rulesScoringCap: 'Dù cộng đủ mọi phần, một từ không bao giờ được quá {cap} điểm.',
|
||||
|
||||
rulesRoomTitle: 'Mã phòng và sẵn sàng',
|
||||
rulesRoomBody:
|
||||
'Người tạo phòng nhận một mã sáu ký tự để gửi cho bạn bè — bảng chữ cái của mã bỏ 0/O và 1/I/L vì mã này thường được đọc thành tiếng cho nhau nghe. Phòng cần mọi người trừ chủ phòng bấm sẵn sàng rồi chủ phòng mới bấm bắt đầu — chủ phòng không có nút sẵn sàng riêng, vì bấm bắt đầu đã là lời xác nhận của họ.',
|
||||
|
||||
rulesReconnectTitle: 'Mất kết nối',
|
||||
rulesReconnectBody:
|
||||
'Rớt mạng giữa ván đấu không mất chỗ ngay: máy chủ giữ ghế lại trong một khoảng thời gian, quay lại trong lúc đó sẽ được vào lại đúng ván đang chơi, đúng vị trí cũ. Đồng hồ lượt vẫn chạy trong lúc mất kết nối — rớt mạng đúng vào lượt của mình vẫn thua lượt đó như thường, không được khoan.'
|
||||
};
|
||||
|
||||
/**
|
||||
* Bot difficulty labels, keyed by the proto enum.
|
||||
*
|
||||
* @type {Record<number, string>}
|
||||
*/
|
||||
export const difficultyLabels = {
|
||||
@@ -167,7 +215,6 @@ export const difficultyOrder = [Difficulty.EASY, Difficulty.MEDIUM, Difficulty.H
|
||||
/**
|
||||
* Why a word was refused. `{syllable}` is filled from the syllable the server
|
||||
* is currently asking for — the reason alone does not say which one it was.
|
||||
*
|
||||
* @type {Record<number, string>}
|
||||
*/
|
||||
export const rejectMessages = {
|
||||
@@ -183,7 +230,6 @@ export const rejectMessages = {
|
||||
|
||||
/**
|
||||
* How a finished game ended, phrased from the losing or winning side.
|
||||
*
|
||||
* @type {Record<number, string>}
|
||||
*/
|
||||
export const endReasonMessages = {
|
||||
@@ -198,7 +244,6 @@ export const endReasonMessages = {
|
||||
* ServerError.code is a UI key, so this is where those keys become sentences.
|
||||
* An unknown code falls back to `errorFallback` rather than showing the raw
|
||||
* key: a key leaking into the UI is a bug, not a message.
|
||||
*
|
||||
* @type {Record<string, string>}
|
||||
*/
|
||||
export const errorMessages = {
|
||||
@@ -244,7 +289,6 @@ export const errorFallback = 'Đã có lỗi xảy ra. Hãy thử lại.';
|
||||
/**
|
||||
* Fills `{name}` placeholders. Keeping interpolation here means a message can
|
||||
* gain a placeholder without every call site learning about it.
|
||||
*
|
||||
* @param {string} template
|
||||
* @param {Record<string, string | number>} [values]
|
||||
* @returns {string}
|
||||
|
||||
@@ -27,7 +27,6 @@ const SEPARATORS = /[\s._-]+/g;
|
||||
* instead would turn any sentence into six plausible letters, and the player
|
||||
* would be told the room does not exist rather than that they pasted the wrong
|
||||
* thing.
|
||||
*
|
||||
* @param {string} raw
|
||||
* @returns {string}
|
||||
*/
|
||||
@@ -43,7 +42,6 @@ export function normalizeRoomCode(raw) {
|
||||
* Whether a normalized code is worth sending. Checking here saves a round trip
|
||||
* and, more usefully, tells the player which of the two failures they have:
|
||||
* a malformed code, or a room that is not there.
|
||||
*
|
||||
* @param {string} code
|
||||
* @returns {boolean}
|
||||
*/
|
||||
|
||||
@@ -30,7 +30,6 @@ export function createBotSession({ start }) {
|
||||
/**
|
||||
* Queues a game. Nothing is sent until the socket is open, which is the
|
||||
* usual case on a fresh page load.
|
||||
*
|
||||
* @param {number} difficulty
|
||||
*/
|
||||
request(difficulty) {
|
||||
@@ -39,7 +38,6 @@ export function createBotSession({ start }) {
|
||||
|
||||
/**
|
||||
* Sends the queued request if there is one and the socket can carry it.
|
||||
*
|
||||
* @param {boolean} isOpen
|
||||
* @returns {boolean} whether a request was sent
|
||||
*/
|
||||
@@ -65,7 +63,6 @@ export function createBotSession({ start }) {
|
||||
* Identity of the result object is the guard rather than a boolean, so
|
||||
* re-entering the screen with the same result cannot score it twice and
|
||||
* a genuinely new result is never mistaken for the old one.
|
||||
*
|
||||
* @param {object | null} result - GameOver as the store holds it
|
||||
* @param {number} difficulty
|
||||
* @param {{ recordScore: (difficulty: number, score: number) => boolean }} settings
|
||||
|
||||
@@ -11,7 +11,6 @@ export const CHAT_WINDOW = 20;
|
||||
* decides whether a word is valid, whose turn it is, or who won — it renders
|
||||
* the last message it received. That is what makes the bot and the online
|
||||
* modes the same screen.
|
||||
*
|
||||
* @typedef {object} ChainEntry
|
||||
* @property {string} word - the canonical spelling
|
||||
* @property {string} typed - what the player actually typed, when it differed
|
||||
@@ -22,11 +21,9 @@ export const CHAT_WINDOW = 20;
|
||||
* @property {boolean} opening - the seed word, played by neither side
|
||||
* @property {Sense[]} meanings - what the word means, at most five; empty when
|
||||
* the dictionary has none
|
||||
*
|
||||
* @typedef {object} Sense
|
||||
* @property {string} pos - Vietnamese part-of-speech label, empty when unknown
|
||||
* @property {string} gloss - the definition, plain text
|
||||
*
|
||||
* @typedef {object} PlayerSlot
|
||||
* @property {string} playerId
|
||||
* @property {string} name
|
||||
@@ -35,7 +32,6 @@ export const CHAT_WINDOW = 20;
|
||||
* @property {boolean} ready
|
||||
* @property {boolean} connected
|
||||
* @property {number} wins - games won since the room opened
|
||||
*
|
||||
* @typedef {object} PlayerScore
|
||||
* @property {string} playerId
|
||||
* @property {string} name
|
||||
@@ -56,7 +52,6 @@ function initialState() {
|
||||
*
|
||||
* RoomState deliberately does not move this. A game running is what
|
||||
* the phase is about, and only GameStarted and GameOver know that.
|
||||
*
|
||||
* @type {'idle' | 'lobby' | 'playing' | 'over'}
|
||||
*/
|
||||
phase: 'idle',
|
||||
@@ -68,7 +63,6 @@ function initialState() {
|
||||
* and a click toggles any word, so any number may be open at once. A
|
||||
* list with set semantics rather than a Set, because $state proxies
|
||||
* arrays and not Sets.
|
||||
*
|
||||
* @type {string[]}
|
||||
*/
|
||||
expanded: [],
|
||||
@@ -89,7 +83,6 @@ function initialState() {
|
||||
*
|
||||
* The recipient's own row is in `roomPlayers` like everybody else's,
|
||||
* marked `isMe`, which is what the derived accessors below read.
|
||||
*
|
||||
* @type {PlayerSlot[]}
|
||||
*/
|
||||
roomPlayers: [],
|
||||
@@ -106,7 +99,6 @@ function initialState() {
|
||||
|
||||
/**
|
||||
* The table of a running game, in turn order, and who is on turn.
|
||||
*
|
||||
* @type {PlayerScore[]}
|
||||
*/
|
||||
gamePlayers: [],
|
||||
@@ -114,7 +106,6 @@ function initialState() {
|
||||
/**
|
||||
* The final table, best first: the player left standing, then the rest
|
||||
* in reverse order of elimination.
|
||||
*
|
||||
* @type {PlayerScore[]}
|
||||
*/
|
||||
standings: [],
|
||||
@@ -124,14 +115,12 @@ function initialState() {
|
||||
* the position still had when they lost it; empty means it was a dead
|
||||
* end, which is a different thing to say than "here is what you
|
||||
* missed".
|
||||
*
|
||||
* @type {{ playerId: string, name: string, reason: number, suggestions: string[] } | null}
|
||||
*/
|
||||
elimination: null,
|
||||
/**
|
||||
* The last player to go out, whoever they were. It is what a spectator
|
||||
* is shown; the client's own knockout is `elimination` above.
|
||||
*
|
||||
* @type {{ playerId: string, name: string, isMe: boolean, reason: number } | null}
|
||||
*/
|
||||
lastOut: null,
|
||||
@@ -140,7 +129,6 @@ function initialState() {
|
||||
* The room's conversation, oldest first, capped at CHAT_WINDOW. Chat
|
||||
* belongs to the room rather than to a game, so it survives reset();
|
||||
* leaving the room is what clears it.
|
||||
*
|
||||
* @type {{ fromMe: boolean, playerId: string, author: string, text: string, atMs: number }[]}
|
||||
*/
|
||||
chat: [],
|
||||
@@ -160,7 +148,6 @@ function initialState() {
|
||||
/**
|
||||
* The finished game, from this player's side. The table it came with
|
||||
* is `standings`; this is the part about them.
|
||||
*
|
||||
* @type {{ iWon: boolean, reason: number, myScore: number, chainLength: number } | null}
|
||||
*/
|
||||
result: null,
|
||||
@@ -171,7 +158,6 @@ function initialState() {
|
||||
|
||||
/**
|
||||
* Reads a word's senses off the wire.
|
||||
*
|
||||
* @param {any[] | undefined} senses
|
||||
* @returns {Sense[]}
|
||||
*/
|
||||
@@ -181,7 +167,6 @@ function toSenses(senses) {
|
||||
|
||||
/**
|
||||
* Reads one PlayerScore off the wire.
|
||||
*
|
||||
* @param {any} p
|
||||
* @returns {PlayerScore}
|
||||
*/
|
||||
@@ -208,7 +193,11 @@ export function createGameStore() {
|
||||
* Returns the model to its pre-game shape, keeping the identity fields and
|
||||
* the room. A game ending, or a new one starting, does not change which
|
||||
* room this is or who is in it — the server says so with its own message.
|
||||
*
|
||||
* A plain lookup table, built once and never mutated or read reactively —
|
||||
* SvelteSet is for state a template tracks, which this never is.
|
||||
*/
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const kept = new Set([
|
||||
'nickname',
|
||||
'roomCode',
|
||||
@@ -244,7 +233,6 @@ export function createGameStore() {
|
||||
* Applies one ServerMessage. Every arm of the oneof is handled here and
|
||||
* nowhere else, so adding a message to the protocol has exactly one place
|
||||
* in the client that has to learn about it.
|
||||
*
|
||||
* @param {any} msg - a decoded ServerMessage
|
||||
*/
|
||||
function apply(msg) {
|
||||
@@ -453,7 +441,6 @@ export function createGameStore() {
|
||||
* The recipient's own row in the room. Their role and their readiness
|
||||
* live there and nowhere else: a second copy alongside the list is a
|
||||
* second thing to keep in step with the server.
|
||||
*
|
||||
* @returns {PlayerSlot | null}
|
||||
*/
|
||||
get me() {
|
||||
@@ -482,7 +469,6 @@ export function createGameStore() {
|
||||
* How many games a seat has won since the room opened. Read off the
|
||||
* room rather than the game: the tally spans games, and the table of
|
||||
* the one on screen cannot carry it.
|
||||
*
|
||||
* @param {string} playerId
|
||||
* @returns {number}
|
||||
*/
|
||||
@@ -495,7 +481,6 @@ export function createGameStore() {
|
||||
* Which seat a player is in, 1-based, or 0 for nobody. It is what the
|
||||
* chat log colours a line by — the server says which seat spoke, so
|
||||
* the client never has to match display names.
|
||||
*
|
||||
* @param {string} playerId
|
||||
* @returns {number}
|
||||
*/
|
||||
@@ -509,7 +494,6 @@ export function createGameStore() {
|
||||
/**
|
||||
* Everybody whose reconnect window is currently running. The lobby and
|
||||
* the board both wait on the same list.
|
||||
*
|
||||
* @returns {PlayerSlot[]}
|
||||
*/
|
||||
get awayPlayers() {
|
||||
@@ -519,7 +503,6 @@ export function createGameStore() {
|
||||
* The name behind a seat id, for the chain and the board. Falls back to
|
||||
* the id's absence rather than inventing a label: an empty string is
|
||||
* something a caller can substitute its own copy for.
|
||||
*
|
||||
* @param {string} playerId
|
||||
* @returns {string}
|
||||
*/
|
||||
@@ -537,7 +520,6 @@ export function createGameStore() {
|
||||
},
|
||||
/**
|
||||
* Whether a word's meaning panel is open.
|
||||
*
|
||||
* @param {string} word
|
||||
*/
|
||||
isExpanded(word) {
|
||||
@@ -547,7 +529,6 @@ export function createGameStore() {
|
||||
* Opens a closed word's meaning or closes an open one. Every word in the
|
||||
* chain toggles, with or without a definition, so the chain behaves
|
||||
* the same for all of them.
|
||||
*
|
||||
* @param {string} word
|
||||
*/
|
||||
toggleMeaning(word) {
|
||||
|
||||
@@ -53,7 +53,6 @@ function write(key, value) {
|
||||
/**
|
||||
* Best scores are stored as one JSON object keyed by difficulty. Corrupt or
|
||||
* hand-edited JSON degrades to "no records yet" rather than throwing on load.
|
||||
*
|
||||
* @returns {Record<string, number>}
|
||||
*/
|
||||
function readBestScores() {
|
||||
@@ -141,7 +140,6 @@ export function createSettingsStore() {
|
||||
* Records a score and reports whether it beat the previous best, which
|
||||
* is what the game-over screen needs to show the record marker. Ties do
|
||||
* not count: matching your own record is not setting a new one.
|
||||
*
|
||||
* @param {number|string} difficulty
|
||||
* @param {number} score
|
||||
* @returns {boolean}
|
||||
|
||||
@@ -50,7 +50,6 @@ const RESUME_KEY = 'noitu.resumeToken';
|
||||
* one origin, so the client never has an environment-specific URL to get
|
||||
* wrong. That divergence is the one thing that would work under `npm run dev`
|
||||
* and break the moment the binary serves the bundle.
|
||||
*
|
||||
* @param {{ protocol: string, host: string }} loc
|
||||
* @returns {string}
|
||||
*/
|
||||
@@ -64,7 +63,6 @@ export function socketUrl(loc) {
|
||||
* two tabs sharing one token would fight over the same seat. Access is guarded
|
||||
* because a private-mode browser can throw on the property itself, not only on
|
||||
* the call.
|
||||
*
|
||||
* @returns {Storage | null}
|
||||
*/
|
||||
function safeSessionStorage() {
|
||||
@@ -81,7 +79,6 @@ function safeSessionStorage() {
|
||||
* A screen that would otherwise wait for the player to ask for something uses
|
||||
* this to reconnect straight away, so refreshing the page mid-game returns to
|
||||
* the game rather than to the lobby.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function hasStoredSession() {
|
||||
@@ -97,7 +94,6 @@ export function hasStoredSession() {
|
||||
*
|
||||
* Everything environment-shaped is injected, so the reconnect schedule and the
|
||||
* clock offset can be tested without a real socket or a real clock.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {() => string} options.nickname - read at each connect, so a name
|
||||
* changed between attempts is the one the server is told about
|
||||
@@ -270,7 +266,6 @@ export function createClient({
|
||||
* Two messages are the transport's own business before the UI sees them:
|
||||
* Welcome carries the token a reconnect needs, and Pong is the clock probe.
|
||||
* Both are still forwarded, because the UI shows the accepted nickname.
|
||||
*
|
||||
* @param {any} msg
|
||||
*/
|
||||
function intercept(msg) {
|
||||
@@ -321,7 +316,6 @@ export function createClient({
|
||||
* train's wifi and can see their turn running out. The attempt counter
|
||||
* is reset too: the player asking is new information about the network,
|
||||
* so the schedule starts over rather than continuing to assume the worst.
|
||||
*
|
||||
* @returns {boolean} whether an attempt was actually started
|
||||
*/
|
||||
reconnectNow() {
|
||||
|
||||
@@ -33,7 +33,6 @@ export function connect() {
|
||||
* Deliberately does not open the socket: a caller that has not connected yet
|
||||
* has nothing queued to resume, and auto-connecting here would reopen the
|
||||
* connection during teardown.
|
||||
*
|
||||
* @param {any} msg - a ClientMessage
|
||||
* @returns {boolean}
|
||||
*/
|
||||
@@ -47,7 +46,6 @@ export function send(msg) {
|
||||
* For the player looking at a "mất kết nối" banner with a turn timer running:
|
||||
* the schedule is tuned for a client nobody is watching, and this is the case
|
||||
* where somebody is.
|
||||
*
|
||||
* @returns {boolean} whether an attempt was actually started
|
||||
*/
|
||||
export function reconnectNow() {
|
||||
|
||||
@@ -26,7 +26,6 @@ export const PROTOCOL_VERSION = 2;
|
||||
* Thin builders, one per client message. They exist so no other module has to
|
||||
* know the shape of the `payload` oneof, and so a schema change breaks in one
|
||||
* file instead of across the UI.
|
||||
*
|
||||
* @param {{ nickname: string, resumeToken?: string }} args
|
||||
*/
|
||||
export function hello({ nickname, resumeToken = '' }) {
|
||||
@@ -69,7 +68,6 @@ export function joinRoom(roomCode) {
|
||||
* turnSeq is not decoration: the server refuses a submission tagged with a
|
||||
* turn that is no longer current, which is what makes a double-submit or a
|
||||
* word racing the timeout visible instead of silently applied.
|
||||
*
|
||||
* @param {string} word
|
||||
* @param {number} turnSeq
|
||||
*/
|
||||
@@ -82,7 +80,6 @@ export function submitWord(word, turnSeq) {
|
||||
/**
|
||||
* Declares this player ready for the next game, or takes it back. Only guests
|
||||
* have a readiness to declare: the owner's is Start itself.
|
||||
*
|
||||
* @param {boolean} ready
|
||||
*/
|
||||
export function setReady(ready) {
|
||||
@@ -101,7 +98,6 @@ export function startGame() {
|
||||
/**
|
||||
* Frees one named seat. Refused while that player is ready, and refused on the
|
||||
* owner's own seat — leaving is what an owner who wants out does.
|
||||
*
|
||||
* @param {string} playerId
|
||||
*/
|
||||
export function kickPlayer(playerId) {
|
||||
@@ -120,7 +116,6 @@ export function leaveRoom() {
|
||||
/**
|
||||
* One line of chat to the rest of the room. The server sanitizes and caps it, so
|
||||
* this sends what was typed and lets the copy that comes back be the truth.
|
||||
*
|
||||
* @param {string} text
|
||||
*/
|
||||
export function sendChat(text) {
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
<button type="button" class="primary" onclick={playBot}>{t.playBot}</button>
|
||||
<a class="secondary" href="/online">{t.playOnline}</a>
|
||||
</div>
|
||||
|
||||
<a class="rules-link" href="/rules">{t.rulesLink}</a>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
@@ -70,4 +72,10 @@
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
}
|
||||
|
||||
/* Its own line under the two big taps, not one more of them: this is a
|
||||
thing to read before playing, not a third way to start a game. */
|
||||
.rules-link {
|
||||
align-self: center;
|
||||
}
|
||||
</style>
|
||||
@@ -10,6 +10,7 @@
|
||||
import NicknameInput from '$lib/components/NicknameInput.svelte';
|
||||
import PlayerStatus from '$lib/components/PlayerStatus.svelte';
|
||||
import { fill, t } from '$lib/i18n/vi.js';
|
||||
import { scrollBehavior } from '$lib/motion.js';
|
||||
import { isRoomCode, normalizeRoomCode, ROOM_CODE_LENGTH } from '$lib/room-code.js';
|
||||
import { game } from '$lib/stores/game.svelte.js';
|
||||
import { settings } from '$lib/stores/settings.svelte.js';
|
||||
@@ -38,7 +39,6 @@
|
||||
* What the player asked for, held until the socket can carry it. Same shape
|
||||
* as the bot screen's request latch and for the same reason: a request is
|
||||
* something the player did, not a condition to be re-derived from the board.
|
||||
*
|
||||
* @type {{ kind: 'create' } | { kind: 'join', code: string } | null}
|
||||
*/
|
||||
let pending = $state(null);
|
||||
@@ -74,6 +74,19 @@
|
||||
return () => mq.removeEventListener('change', onChange);
|
||||
});
|
||||
|
||||
// Lifted out of ChatPanel so the pill in GameBoard's top row — reachable
|
||||
// above the chain rather than below it — can unfold the panel and read its
|
||||
// count without the two components knowing about each other beyond this.
|
||||
let chatFolded = $state(true);
|
||||
let chatUnread = $state(0);
|
||||
/** @type {HTMLElement | undefined} */
|
||||
let talkPane = $state();
|
||||
|
||||
function openChat() {
|
||||
chatFolded = false;
|
||||
talkPane?.scrollIntoView({ behavior: scrollBehavior(), block: 'start' });
|
||||
}
|
||||
|
||||
let codeInput = $state(normalizeRoomCode(page.url.searchParams.get('code') ?? ''));
|
||||
let codeError = $state('');
|
||||
// True while the only reason this screen has a socket is to reclaim a game
|
||||
@@ -266,7 +279,6 @@
|
||||
*
|
||||
* Each of these reports whether the request actually reached the server, so
|
||||
* the lobby can say so rather than looking like a button that does nothing.
|
||||
*
|
||||
* @param {boolean} ready
|
||||
* @returns {boolean}
|
||||
*/
|
||||
@@ -335,7 +347,13 @@
|
||||
would come back as unread mail. -->
|
||||
<div class="pane game">
|
||||
{#if playing}
|
||||
<GameBoard modeLabel={game.state.roomCode} onsubmit={play} onresign={giveUp}>
|
||||
<GameBoard
|
||||
modeLabel={game.state.roomCode}
|
||||
onsubmit={play}
|
||||
onresign={giveUp}
|
||||
chatUnread={wide ? 0 : chatUnread}
|
||||
onchatopen={wide ? undefined : openChat}
|
||||
>
|
||||
{#snippet banner()}
|
||||
<PlayerStatus />
|
||||
{/snippet}
|
||||
@@ -355,11 +373,17 @@
|
||||
<!-- errors are not routed here any more: the lobby draws its own, beside
|
||||
the button that produced them.
|
||||
|
||||
Folded only during a game on a narrow screen. In the lobby the log
|
||||
stays open: waiting in a room is mostly what the conversation is
|
||||
for, and the pane below keeps it on screen now. -->
|
||||
<div class="pane talk">
|
||||
<ChatPanel collapsible={playing && !wide} column={wide} onsend={say} />
|
||||
Folded on any narrow screen now, lobby included: a lobby that never
|
||||
folded never had a badge either, so chat arriving there was
|
||||
completely silent behind a log that was itself below the fold. -->
|
||||
<div class="pane talk" bind:this={talkPane}>
|
||||
<ChatPanel
|
||||
collapsible={!wide}
|
||||
column={wide}
|
||||
onsend={say}
|
||||
bind:folded={chatFolded}
|
||||
bind:unread={chatUnread}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<h1>{t.onlineTitle}</h1>
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
<script>
|
||||
import { fill, t } from '$lib/i18n/vi.js';
|
||||
|
||||
// Mirrors basePoints, chainBonus, chainBonusWords, syllableBonus,
|
||||
// vietnamese.MinSyllables, speedBonus, rarityBonus, rarityHalvingPenalty
|
||||
// and maxPointsPerWord in server/internal/game/engine.go. Copied rather than
|
||||
// asked for over the wire: these are constants, not something a game state
|
||||
// carries, and this page is static.
|
||||
const SCORING = {
|
||||
base: 10,
|
||||
chainBonus: 2,
|
||||
chainBonusWords: 15,
|
||||
syllableBonus: 5,
|
||||
minSyllables: 2,
|
||||
speedBonus: 10,
|
||||
rarityBonus: 15,
|
||||
rarityHalvingPenalty: 3,
|
||||
maxPointsPerWord: 100
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{t.titleRules}</title>
|
||||
</svelte:head>
|
||||
|
||||
<section class="rules">
|
||||
<h1>{t.rulesLink}</h1>
|
||||
<p class="intro">{t.rulesIntro}</p>
|
||||
|
||||
<nav class="toc" aria-label={t.rulesLink}>
|
||||
<a href="#chain">{t.rulesNavChain}</a>
|
||||
<a href="#clock">{t.rulesNavClock}</a>
|
||||
<a href="#dead-end">{t.rulesNavDeadEnd}</a>
|
||||
<a href="#elimination">{t.rulesNavElimination}</a>
|
||||
<a href="#scoring">{t.rulesNavScoring}</a>
|
||||
<a href="#room">{t.rulesNavRoom}</a>
|
||||
<a href="#reconnect">{t.rulesNavReconnect}</a>
|
||||
</nav>
|
||||
|
||||
<section id="chain">
|
||||
<h2>{t.rulesChainTitle}</h2>
|
||||
<p>{t.rulesChainBody}</p>
|
||||
</section>
|
||||
|
||||
<section id="clock">
|
||||
<h2>{t.rulesClockTitle}</h2>
|
||||
<p>{t.rulesClockBody}</p>
|
||||
</section>
|
||||
|
||||
<section id="dead-end">
|
||||
<h2>{t.rulesDeadEndTitle}</h2>
|
||||
<p>{t.rulesDeadEndBody}</p>
|
||||
</section>
|
||||
|
||||
<section id="elimination">
|
||||
<h2>{t.rulesEliminationTitle}</h2>
|
||||
<p>{t.rulesEliminationBody}</p>
|
||||
</section>
|
||||
|
||||
<section id="scoring">
|
||||
<h2>{t.rulesScoringTitle}</h2>
|
||||
<ul>
|
||||
<li>{fill(t.rulesScoringBase, { base: SCORING.base })}</li>
|
||||
<li>
|
||||
{fill(t.rulesScoringChain, { bonus: SCORING.chainBonus, cap: SCORING.chainBonusWords })}
|
||||
</li>
|
||||
<li>
|
||||
{fill(t.rulesScoringSyllable, {
|
||||
bonus: SCORING.syllableBonus,
|
||||
min: SCORING.minSyllables
|
||||
})}
|
||||
</li>
|
||||
<li>{fill(t.rulesScoringSpeed, { bonus: SCORING.speedBonus })}</li>
|
||||
<li>
|
||||
{fill(t.rulesScoringRarity, {
|
||||
bonus: SCORING.rarityBonus,
|
||||
penalty: SCORING.rarityHalvingPenalty
|
||||
})}
|
||||
</li>
|
||||
</ul>
|
||||
<p>{fill(t.rulesScoringCap, { cap: SCORING.maxPointsPerWord })}</p>
|
||||
</section>
|
||||
|
||||
<section id="room">
|
||||
<h2>{t.rulesRoomTitle}</h2>
|
||||
<p>{t.rulesRoomBody}</p>
|
||||
</section>
|
||||
|
||||
<section id="reconnect">
|
||||
<h2>{t.rulesReconnectTitle}</h2>
|
||||
<p>{t.rulesReconnectBody}</p>
|
||||
</section>
|
||||
|
||||
<a class="back" href="/">{t.back}</a>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.rules {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-5);
|
||||
padding-top: var(--space-3);
|
||||
padding-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: var(--text-8);
|
||||
}
|
||||
|
||||
.intro {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.toc {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.toc a {
|
||||
padding: 4px var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--surface-alt);
|
||||
color: var(--text);
|
||||
font-size: var(--text-3);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
section section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
/* Anchored to, not just linked from: a jump from the nav should not
|
||||
land the heading under the sticky-feeling top of a phone browser. */
|
||||
scroll-margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--text-7);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
ul {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
margin: 0;
|
||||
padding-left: 1.2em;
|
||||
}
|
||||
|
||||
li {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.back {
|
||||
align-self: flex-start;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-5);
|
||||
}
|
||||
</style>
|
||||
@@ -5,7 +5,6 @@ import adapter from '@sveltejs/adapter-static';
|
||||
* index.html for unknown paths, so every route is resolved in the browser.
|
||||
* Prerendering is disabled in src/routes/+layout.js rather than here, because
|
||||
* the game has nothing static to render — every screen depends on a socket.
|
||||
*
|
||||
* @type {import('@sveltejs/kit').Config}
|
||||
*/
|
||||
export default {
|
||||
|
||||
@@ -15,7 +15,6 @@ const srcDir = fileURLToPath(new URL('../src', import.meta.url));
|
||||
|
||||
/**
|
||||
* Every file in the built output, recursively.
|
||||
*
|
||||
* @param {string} dir
|
||||
* @returns {string[]}
|
||||
*/
|
||||
@@ -32,7 +31,6 @@ function walk(dir) {
|
||||
* A minifier configured to emit ASCII writes Vietnamese as escapes, and a
|
||||
* substring search over the raw bytes would then find nothing while the words
|
||||
* are plainly there in the shipped file.
|
||||
*
|
||||
* @param {string} text
|
||||
* @returns {string}
|
||||
*/
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
* Enumerates a generated enum's numeric values from its schema rather than
|
||||
* from the message table under test. Reading the table to check the table
|
||||
* would pass no matter what is missing.
|
||||
*
|
||||
* @param {{ values: { number: number }[] }} schema
|
||||
*/
|
||||
function valuesOf(schema) {
|
||||
|
||||
@@ -546,7 +546,6 @@ describe('a socket that dies without closing', () => {
|
||||
/**
|
||||
* Fires the ping timer `ticks` times, advancing the clock by one interval
|
||||
* each time — a healthy tab whose timers are running on schedule.
|
||||
*
|
||||
* @param {any} h
|
||||
* @param {number} ticks
|
||||
* @param {number} start
|
||||
|
||||
Reference in new issue
Block a user