// Pure model for the cold-start loading splash: a small Scrabble crossword that lays the // words ЭРУДИТ / ЗАГРУЗКА / ОЖИДАНИЕ out of tiles, letter by letter, until the lobby is // ready. Kept free of any DOM or reactive state so it unit-tests in the node environment; // components/Splash.svelte wraps it with the reactive reveal set and the timer driver. // // The tile point values are hardcoded from the Эрудит ruleset (scrabble-solver // rules.Erudit). They cannot come from alphabet.valueForLetter() here: the splash renders on // a cold start, before the server has sent the variant's alphabet table that lookup reads. /** * ERUDIT_VALUES is the per-letter point value (вес) for every letter the splash words use, * taken from the Эрудит ruleset. Letters are upper-cased, matching the rest of the UI. */ export const ERUDIT_VALUES: Record = { А: 1, Г: 3, Д: 2, Е: 1, Ж: 5, З: 5, И: 1, К: 2, Н: 1, О: 1, Р: 2, Т: 2, У: 3, Э: 10, }; /** SplashWord names the three crossword words, used to group tiles for the reveal sequence. */ export type SplashWord = 'erudit' | 'zagruzka' | 'ozhidanie'; /** SplashTile is one placed tile on the 6×8 splash grid. */ export interface SplashTile { /** row is the grid row, 0..7 (ЭРУДИТ sits on row 3). */ row: number; /** col is the grid column, 0..5. */ col: number; /** letter is the upper-cased Cyrillic glyph shown on the tile. */ letter: string; /** value is the tile's Эрудит point value. */ value: number; /** word groups the tile by its crossword word. */ word: SplashWord; /** key is the stable `"row-col"` identifier used by the renderer's reveal set. */ key: string; } /** COLS and ROWS are the splash grid dimensions (6 wide, 8 tall). */ export const COLS = 6; export const ROWS = 8; function tile(row: number, col: number, letter: string, word: SplashWord): SplashTile { return { row, col, letter, value: ERUDIT_VALUES[letter] ?? 0, word, key: `${row}-${col}` }; } // ЭРУДИТ — horizontal on row 3, columns 0..5 (laid left → right). const ERUDIT_TILES: SplashTile[] = [...'ЭРУДИТ'].map((l, i) => tile(3, i, l, 'erudit')); // ЗАГРУЗКА — vertical in column 1, rows 0..7 (laid top → bottom). Its row-3 letter (Р) is the // tile already placed by ЭРУДИТ, so it is dropped here ("через имеющиеся буквы"). const ZAGRUZKA_TILES: SplashTile[] = [...'ЗАГРУЗКА'] .map((l, r) => tile(r, 1, l, 'zagruzka')) .filter((t) => t.row !== 3); // ОЖИДАНИЕ — vertical in column 3, rows 0..7. Its row-3 letter (Д) is shared with ЭРУДИТ and // likewise dropped. const OZHIDANIE_TILES: SplashTile[] = [...'ОЖИДАНИЕ'] .map((l, r) => tile(r, 3, l, 'ozhidanie')) .filter((t) => t.row !== 3); /** SPLASH_TILES is every unique tile on the grid (ЭРУДИТ owns the two shared crossings). */ export const SPLASH_TILES: SplashTile[] = [...ERUDIT_TILES, ...ZAGRUZKA_TILES, ...OZHIDANIE_TILES]; /** ERUDIT_KEYS is the set of ЭРУДИТ tile keys — the tiles kept on screen across a clear. */ export const ERUDIT_KEYS: ReadonlySet = new Set(ERUDIT_TILES.map((t) => t.key)); /** WORD_MS is the time to lay one whole word; PAUSE_MS the hold after each word — so the word * stays readable before the next word starts or the splash dismisses into the lobby. */ export const WORD_MS = 1000; export const PAUSE_MS = 250; /** SplashStep is one scheduled action: reveal a tile, check whether the lobby is ready (and * if so dismiss), or clear every non-ЭРУДИТ tile before the cycle repeats. atMs is relative * to the start of its segment (prefix or cycle). */ export interface SplashStep { atMs: number; kind: 'reveal' | 'check' | 'clear'; /** key is the tile to reveal, set only on a 'reveal' step. */ key?: string; } /** SplashSchedule is the timed plan the renderer drives: the one-time prefix (ЭРУДИТ) and the * repeating cycle (ЗАГРУЗКА → ОЖИДАНИЕ → clear). The driver plays the prefix once, then loops * the cycle every cycleMs until the splash is dismissed. */ export interface SplashSchedule { prefix: SplashStep[]; cycle: SplashStep[]; prefixMs: number; cycleMs: number; } function revealSteps(tiles: SplashTile[], base: number, perTile: number): SplashStep[] { return tiles.map((t, i) => ({ atMs: Math.round(base + i * perTile), kind: 'reveal', key: t.key })); } /** * splashSchedule builds the reveal plan. Each word is laid over wordMs (so its per-tile * stagger is wordMs divided by the word's tile count), then **held for pauseMs** so it stays * readable; only after that hold does a readiness check land — so the splash never dismisses * (nor starts the next word) the instant a word finishes. The next word begins right at the * check. After ОЖИДАНИЕ the cycle clears the two vertical words and restarts (ЭРУДИТ persists). * wordMs and pauseMs are overridable for tuning/tests. */ export function splashSchedule(opts: { wordMs?: number; pauseMs?: number } = {}): SplashSchedule { const wordMs = opts.wordMs ?? WORD_MS; const pauseMs = opts.pauseMs ?? PAUSE_MS; const sV = wordMs / ZAGRUZKA_TILES.length; const hold = wordMs + pauseMs; // lay the word, then hold it readable before the check // Prefix: ЭРУДИТ left → right, held, then a readiness check. const prefix: SplashStep[] = [ ...revealSteps(ERUDIT_TILES, 0, wordMs / ERUDIT_TILES.length), { atMs: hold, kind: 'check' }, ]; // Cycle: ЗАГРУЗКА, hold, check, ОЖИДАНИЕ, hold, check, clear. Each word starts at the previous // word's check (no leading gap); the hold lives at the end of every word. const endAt = 2 * hold; const cycle: SplashStep[] = [ ...revealSteps(ZAGRUZKA_TILES, 0, sV), { atMs: hold, kind: 'check' }, ...revealSteps(OZHIDANIE_TILES, hold, sV), { atMs: endAt, kind: 'check' }, { atMs: endAt, kind: 'clear' }, ]; return { prefix, cycle, prefixMs: hold, cycleMs: endAt }; }