From 32298595f27f483e8e6764b2051ee85e8d636856 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 6 Jul 2026 09:07:40 +0200 Subject: [PATCH] feat(offline): local game source (Phase B3.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A GatewayClient-shaped facade over the offline engine, so the same game screen can drive a local vs_ai game with no backend (the wiring into Game.svelte is B3.2). - source.ts: LocalSource implements the game-loop subset (GameLoopSource) for a local game id — gameState/gameHistory via replay, submitPlay/pass/exchange/resign apply the human move then run the robot (decide(generateMoves)) synchronously, persisting both and delivering the robot's move through a per-game event emitter (no live stream). hint is gated to >30 min since the robot's last move; evaluate/checkWord are local. It translates the UI's glyph space to the engine's index space with the static letters table. - ruleset.ts: add the static per-variant letters (glyphs), pinned to the Go alphabet — offline is now fully self-contained (no reliance on a warm server alphabet cache). - engine.ts: submitPlay (infers the direction like the server SubmitPlay), evaluatePlay + dictionaryHas for the move preview / word check, and record the main-word coordinate + the words on a play (for the history MoveRecord). - source.test.ts: create -> human pass -> synchronous robot reply via the event, the hint gate, decoded history, and a whole game driven to completion. Pure additive library code; no runtime behavior change (bundle unchanged). --- backend/cmd/movegen/main.go | 24 +- ui/src/lib/localgame/engine.ts | 33 ++ ui/src/lib/localgame/ruleset.parity.test.ts | 2 + ui/src/lib/localgame/ruleset.ts | 6 + ui/src/lib/localgame/source.test.ts | 96 +++++ ui/src/lib/localgame/source.ts | 400 ++++++++++++++++++++ ui/src/lib/localgame/testdata/rulesets.json | 98 +++++ 7 files changed, 652 insertions(+), 7 deletions(-) create mode 100644 ui/src/lib/localgame/source.test.ts create mode 100644 ui/src/lib/localgame/source.ts diff --git a/backend/cmd/movegen/main.go b/backend/cmd/movegen/main.go index 5e5dfe2..233e5cd 100644 --- a/backend/cmd/movegen/main.go +++ b/backend/cmd/movegen/main.go @@ -25,6 +25,7 @@ import ( "log" "os" "path/filepath" + "strings" "gitea.iliadenisov.ru/developer/scrabble-solver/board" "gitea.iliadenisov.ru/developer/scrabble-solver/rack" @@ -370,12 +371,13 @@ func russianRack(letters string, blanks int) genRack { // parity test can pin that hand-copied table to the Go rulesets (scrabble-solver/rules). func emitRulesets() { type rsFix struct { - Size int `json:"size"` - RackSize int `json:"rackSize"` - Bingo int `json:"bingo"` - Blanks int `json:"blanks"` - Values []int `json:"values"` - Counts []int `json:"counts"` + Size int `json:"size"` + RackSize int `json:"rackSize"` + Bingo int `json:"bingo"` + Blanks int `json:"blanks"` + Values []int `json:"values"` + Counts []int `json:"counts"` + Letters []string `json:"letters"` } out := map[string]rsFix{} for _, v := range []struct { @@ -386,7 +388,15 @@ func emitRulesets() { {"scrabble_ru", rules.RussianScrabble()}, {"erudit_ru", rules.Erudit()}, } { - out[v.name] = rsFix{Size: v.rs.Size(), RackSize: v.rs.RackSize, Bingo: v.rs.Bingo, Blanks: v.rs.Blanks, Values: v.rs.Values, Counts: v.rs.Counts} + letters := make([]string, v.rs.Size()) + for i := range letters { + ch, err := v.rs.Alphabet.Character(byte(i)) + if err != nil { + log.Fatalf("movegen: %s letter %d: %v", v.name, i, err) + } + letters[i] = strings.ToUpper(ch) + } + out[v.name] = rsFix{Size: v.rs.Size(), RackSize: v.rs.RackSize, Bingo: v.rs.Bingo, Blanks: v.rs.Blanks, Values: v.rs.Values, Counts: v.rs.Counts, Letters: letters} } dir := filepath.Join("ui", "src", "lib", "localgame", "testdata") if err := os.MkdirAll(dir, 0o755); err != nil { diff --git a/ui/src/lib/localgame/engine.ts b/ui/src/lib/localgame/engine.ts index 58fc872..a8fbca6 100644 --- a/ui/src/lib/localgame/engine.ts +++ b/ui/src/lib/localgame/engine.ts @@ -14,6 +14,7 @@ import { Bag } from './bag'; import { RULESETS } from './ruleset'; import { generateMoves, GenRack, Both } from '../dict/generate'; import { validatePlay, type Direction, type Placement, type Ruleset, type Move } from '../dict/validate'; +import { playDirection } from '../dict/direction'; import { premiumGrid, centre, BOARD_SIZE, RACK_SIZE, BINGO, type Premium } from '../premiums'; import { BLANK_INDEX } from '../alphabet'; import type { Dawg } from '../dict/dawg'; @@ -35,6 +36,12 @@ export interface LocalMove { action: 'play' | 'pass' | 'exchange' | 'resign'; dir?: Direction; tiles?: Placement[]; + /** ActionPlay only: the main word's first-letter coordinate. */ + mainRow?: number; + mainCol?: number; + /** ActionPlay only: the words formed as alphabet-index letter arrays — the main word first, then + * the cross words. Decoded to glyphs when building the history's MoveRecord. */ + words?: number[][]; /** ActionExchange only: number of tiles swapped. */ count?: number; /** ActionExchange only: the swapped tiles (alphabet-index bytes, BLANK_INDEX for a blank), @@ -252,8 +259,31 @@ export class LocalGame { return generateMoves(this.dawg, this.board, this.rackOf(this.toMove), this.vrs, Both); } + /** evaluatePlay scores a candidate placement for the current position without committing it, + * returning its legality (dictionary + connectivity), score, the words it forms (as alphabet-index + * arrays, main first) and the inferred direction. Backs the local move preview. */ + evaluatePlay(tiles: Placement[]): { legal: boolean; score: number; words: number[][]; dir: Direction } { + const dir = playDirection(this.board, this.vrs, this.dawg, tiles); + const res = validatePlay(this.board, this.vrs, this.dawg, dir, tiles); + if (!res.legal || !res.move) return { legal: false, score: 0, words: [], dir }; + const m = res.move; + return { legal: true, score: m.score, words: [m.main.letters, ...m.cross.map((w) => w.letters)], dir }; + } + + /** dictionaryHas reports whether the word (alphabet-index letters) is in the game's dictionary. */ + dictionaryHas(word: readonly number[]): boolean { + return this.dawg.indexOf(word) >= 0; + } + // --- turns ----------------------------------------------------------------- + /** submitPlay infers the play's orientation from the placement (like the server's SubmitPlay), + * then plays it — the client submits tiles without a direction and the engine resolves it. */ + submitPlay(tiles: Placement[]): LocalMove { + const dir = playDirection(this.board, this.vrs, this.dawg, tiles); + return this.play(dir, tiles); + } + /** play validates and applies the current player's placement, scores it, refills the rack and * advances the turn (or ends the game). Throws GameError on an illegal play. */ play(dir: Direction, tiles: Placement[]): LocalMove { @@ -277,6 +307,9 @@ export class LocalGame { action: 'play', dir, tiles: tiles.map((t) => ({ ...t })), + mainRow: move.main.row, + mainCol: move.main.col, + words: [move.main.letters, ...move.cross.map((w) => w.letters)], score: move.score, total: this.scores[player], }; diff --git a/ui/src/lib/localgame/ruleset.parity.test.ts b/ui/src/lib/localgame/ruleset.parity.test.ts index cb603ac..8455513 100644 --- a/ui/src/lib/localgame/ruleset.parity.test.ts +++ b/ui/src/lib/localgame/ruleset.parity.test.ts @@ -14,6 +14,7 @@ interface RulesetFix { blanks: number; values: number[]; counts: number[]; + letters: string[]; } const fx = JSON.parse( @@ -31,6 +32,7 @@ describe('offline ruleset table parity vs Go rules', () => { blanks: rs.blanks, values: [...rs.values], counts: [...rs.counts], + letters: [...rs.letters], }).toEqual(fx[variant]); }); } diff --git a/ui/src/lib/localgame/ruleset.ts b/ui/src/lib/localgame/ruleset.ts index 840ddd5..99d218f 100644 --- a/ui/src/lib/localgame/ruleset.ts +++ b/ui/src/lib/localgame/ruleset.ts @@ -22,6 +22,9 @@ export interface VariantRuleset { values: readonly number[]; /** Bag tile count per letter index (how many of each letter the bag holds). */ counts: readonly number[]; + /** Display glyph per letter index, upper-cased (matching lib/alphabet.ts). Offline needs these + * to translate the engine's index space to/from the glyphs the game UI shows, with no server. */ + letters: readonly string[]; } /** RULESETS is the static ruleset table, one entry per variant, mirrored from rules.go. */ @@ -34,6 +37,7 @@ export const RULESETS: Record = { // a b c d e f g h i j k l m n o p q r s t u v w x y z values: [1, 3, 3, 2, 1, 4, 2, 4, 1, 8, 5, 1, 3, 1, 1, 3, 10, 1, 1, 1, 1, 4, 4, 8, 4, 10], counts: [9, 2, 2, 4, 12, 2, 3, 2, 9, 1, 1, 4, 2, 6, 8, 2, 1, 6, 4, 6, 4, 2, 2, 1, 2, 1], + letters: [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'], }, scrabble_ru: { size: 33, @@ -43,6 +47,7 @@ export const RULESETS: Record = { // а б в г д е ё ж з и й к л м н о п р с т у ф х ц ч ш щ ъ ы ь э ю я values: [1, 3, 1, 3, 2, 1, 3, 5, 5, 1, 4, 2, 2, 2, 1, 1, 2, 1, 1, 1, 2, 10, 5, 5, 5, 8, 10, 10, 4, 3, 8, 8, 3], counts: [8, 2, 4, 2, 4, 8, 1, 1, 2, 5, 1, 4, 4, 3, 5, 10, 4, 5, 5, 5, 4, 1, 1, 1, 1, 1, 1, 1, 2, 2, 1, 1, 2], + letters: [...'АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ'], }, erudit_ru: { size: 33, @@ -52,5 +57,6 @@ export const RULESETS: Record = { // а б в г д е ё ж з и й к л м н о п р с т у ф х ц ч ш щ ъ ы ь э ю я values: [1, 3, 2, 3, 2, 1, 0, 5, 5, 1, 2, 2, 2, 2, 1, 1, 2, 2, 2, 2, 3, 10, 5, 10, 5, 10, 10, 10, 5, 5, 10, 10, 3], counts: [10, 3, 5, 3, 5, 9, 0, 2, 2, 8, 4, 6, 4, 5, 8, 10, 6, 6, 6, 5, 3, 1, 2, 1, 2, 1, 1, 1, 2, 2, 1, 1, 3], + letters: [...'АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ'], }, }; diff --git a/ui/src/lib/localgame/source.test.ts b/ui/src/lib/localgame/source.test.ts new file mode 100644 index 0000000..c9b4f94 --- /dev/null +++ b/ui/src/lib/localgame/source.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { PushEvent } from '../model'; + +// getDawg is mocked to the committed sample dictionary (the store's IndexedDB is absent under node, +// so games live in the source's in-memory cache — created once, then driven without reload). +vi.mock('../dict', () => ({ + getDawg: async () => { + const { Dawg } = await import('../dict/dawg'); + const { readFileSync } = await import('node:fs'); + return new Dawg(new Uint8Array(readFileSync(new URL('../dict/testdata/sample_en.dawg', import.meta.url)))); + }, +})); + +import { LocalSource, isLocalGameId } from './source'; +import type { Seat } from './serialize'; + +const seats: Seat[] = [ + { kind: 'human', name: 'You' }, + { kind: 'robot', name: 'Robot' }, +]; + +async function newSource(id: string, seed: bigint): Promise { + const src = new LocalSource(); + await src.create({ id, variant: 'scrabble_en', dictVersion: 'sample', seed, multipleWords: true, seats }); + return src; +} + +describe('LocalSource', () => { + it('detects local game ids', () => { + expect(isLocalGameId('local:abc')).toBe(true); + expect(isLocalGameId('deadbeef')).toBe(false); + }); + + it('creates a vs_ai game with the human to move and a full rack', async () => { + const src = await newSource('local:g1', 42n); + const st = await src.gameState('local:g1'); + expect(st.game.vsAi).toBe(true); + expect(st.game.status).toBe('active'); + expect(st.seat).toBe(0); + expect(st.rack.length).toBe(7); + expect(st.game.seats.map((s) => s.displayName)).toEqual(['You', 'Robot']); + expect(st.game.toMove).toBe(0); + }); + + it('replies to a human pass with a synchronous robot move, delivered via the event', async () => { + const src = await newSource('local:g2', 999n); + const events: PushEvent[] = []; + src.events('local:g2', (e) => events.push(e)); + + const res = await src.pass('local:g2'); + expect(res.move.action).toBe('pass'); + expect(res.move.player).toBe(0); + + // The robot has already played (synchronously) — the state reflects both moves... + const mid = await src.gameState('local:g2'); + expect(mid.game.moveCount).toBeGreaterThanOrEqual(2); + // ...and its move is delivered to subscribers via the per-game event. + await Promise.resolve(); + expect(events.some((e) => e.kind === 'opponent_moved')).toBe(true); + }); + + it('gates the hint until 30 minutes since the robot last moved', async () => { + const src = await newSource('local:g3', 7n); + await expect(src.hint('local:g3')).rejects.toMatchObject({ code: 'hint_not_ready' }); + }); + + it('records decoded moves in the history', async () => { + const src = await newSource('local:g4', 55n); + await src.pass('local:g4'); + const h = await src.gameHistory('local:g4'); + expect(h.moves.length).toBeGreaterThanOrEqual(2); + expect(h.moves[0].action).toBe('pass'); + // the robot's reply, whatever it is, is decoded (a play carries glyph tiles + lower-case words) + const play = h.moves.find((m) => m.action === 'play'); + if (play) { + expect(play.tiles.every((t) => typeof t.letter === 'string' && t.letter.length >= 1)).toBe(true); + expect(play.words.every((w) => w === w.toLowerCase())).toBe(true); + } + }); + + it('drives a whole local game to completion', async () => { + const src = await newSource('local:g5', 2024n); + src.events('local:g5', () => {}); + let turns = 0; + while ((await src.gameState('local:g5')).game.status === 'active' && turns < 500) { + await src.pass('local:g5'); + await Promise.resolve(); + turns++; + } + const st = await src.gameState('local:g5'); + expect(st.game.status).toBe('finished'); + expect(turns).toBeLessThan(500); + const winners = st.game.seats.filter((s) => s.isWinner); + expect(winners.length).toBeLessThanOrEqual(1); + }); +}); diff --git a/ui/src/lib/localgame/source.ts b/ui/src/lib/localgame/source.ts new file mode 100644 index 0000000..721e329 --- /dev/null +++ b/ui/src/lib/localgame/source.ts @@ -0,0 +1,400 @@ +// The local game source: a GatewayClient-shaped facade over the offline engine, so the SAME game +// screen drives a local vs_ai game with no backend. It implements the game-loop subset of the +// gateway (GameLoopSource) for a local game id, backed by LocalGame + the IndexedDB store + the +// real robot (lib/robot/strategy). Unlike the network flow there is no live stream: after the +// human's move the robot plays synchronously in the same call, its move persisted atomically and +// delivered to the screen through this source's own per-game event emitter (events()). +// +// It translates between the UI's glyph space (PlacedTile.letter, rack strings) and the engine's +// alphabet-index space using the static letters table (ruleset.ts), so it needs no server alphabet. + +import { LocalGame, type LocalMove } from './engine'; +import { serializeGame, replayGame, type LocalGameRecord, type Seat as LocalSeat } from './serialize'; +import { getLocalGame, saveLocalGame } from './store'; +import { RULESETS } from './ruleset'; +import { getDawg } from '../dict'; +import { decide } from '../robot/strategy'; +import { GatewayError, type PlacedTile } from '../client'; +import type { + EvalResult, + GameView, + History, + HintResult, + MoveRecord, + MoveResult, + PushEvent, + Seat, + StateView, + Tile, + Variant, + WordCheckResult, +} from '../model'; +import type { Move } from '../dict/validate'; + +/** LOCAL_ID_PREFIX marks a game id as a local (offline) game, so the app dispatches it here. */ +export const LOCAL_ID_PREFIX = 'local:'; + +/** isLocalGameId reports whether an id belongs to a local game. */ +export function isLocalGameId(id: string): boolean { + return id.startsWith(LOCAL_ID_PREFIX); +} + +/** HINT_GATE_MS is the idle time since the robot's last move before an offline hint unlocks. */ +export const HINT_GATE_MS = 30 * 60 * 1000; + +/** + * GameLoopSource is the subset of GatewayClient the game screen calls to run a game. The real + * gateway satisfies it structurally; the local source implements it for offline play. + */ +export interface GameLoopSource { + gameState(gameId: string, includeAlphabet: boolean): Promise; + gameHistory(gameId: string): Promise; + submitPlay(gameId: string, tiles: PlacedTile[], variant: Variant): Promise; + pass(gameId: string): Promise; + exchange(gameId: string, tiles: string[], variant: Variant): Promise; + resign(gameId: string): Promise; + hint(gameId: string): Promise; + evaluate(gameId: string, tiles: PlacedTile[], variant: Variant, signal?: AbortSignal): Promise; + checkWord(gameId: string, word: string, variant: Variant): Promise; + draftGet(gameId: string): Promise; + draftSave(gameId: string, json: string): Promise; +} + +const BLANK_GLYPH = '?'; + +// letterToIndex builds a glyph -> index map for a variant's alphabet (upper-cased, matching the +// static letters table and lib/alphabet.ts). +const indexCache = new Map>(); +function letterToIndex(variant: Variant, glyph: string): number { + if (glyph === BLANK_GLYPH) return 255; // BLANK_INDEX + let m = indexCache.get(variant); + if (!m) { + m = new Map(); + RULESETS[variant].letters.forEach((l, i) => m!.set(l, i)); + indexCache.set(variant, m); + } + const i = m.get(glyph.toUpperCase()); + if (i === undefined) throw new GatewayError('illegal_play', `no index for "${glyph}" in ${variant}`); + return i; +} + +// indexToLetter decodes an alphabet index to its display glyph; the blank sentinel maps to "?". +function indexToLetter(variant: Variant, index: number): string { + if (index === 255) return BLANK_GLYPH; + return RULESETS[variant].letters[index] ?? ''; +} + +// encodePlacements turns submitted glyph tiles into the engine's index-space placements. +function encodePlacements(variant: Variant, tiles: PlacedTile[]): { row: number; col: number; letter: number; blank: boolean }[] { + return tiles.map((t) => ({ row: t.row, col: t.col, letter: letterToIndex(variant, t.letter), blank: t.blank })); +} + +// A live local game held in memory for the session, so calls do not re-replay from the store. +interface Live { + game: LocalGame; + record: LocalGameRecord; +} + +/** + * LocalSource runs local games. One instance backs every local game the app has open; it caches + * the live engine per id and persists after each move. + */ +export class LocalSource implements GameLoopSource { + private readonly live = new Map(); + private readonly listeners = new Map void>>(); + + /** create starts a new local vs_ai game, persists it and returns its state. */ + async create(opts: { + id: string; + variant: Variant; + dictVersion: string; + seed: bigint; + multipleWords: boolean; + seats: LocalSeat[]; + }): Promise { + const dawg = await getDawg(opts.variant, opts.dictVersion); + if (!dawg) throw new GatewayError('dict_unavailable'); + const game = new LocalGame({ + variant: opts.variant, + version: opts.dictVersion, + seed: opts.seed, + players: opts.seats.length, + dawg, + multipleWords: opts.multipleWords, + }); + const now = nowUnix(); + const record = serializeGame(game, { + id: opts.id, + seats: opts.seats.map((s) => ({ kind: s.kind, name: s.name })), + createdAtUnix: now, + updatedAtUnix: now, + robotLastMoveAtUnix: now, + }); + const entry: Live = { game, record }; + this.live.set(opts.id, entry); + await saveLocalGame(record); + return this.stateView(entry); + } + + async gameState(gameId: string): Promise { + return this.stateView(await this.load(gameId)); + } + + async gameHistory(gameId: string): Promise { + const { game, record } = await this.load(gameId); + return { gameId, moves: game.history.map((m) => this.moveRecord(record.variant, m)) }; + } + + async submitPlay(gameId: string, tiles: PlacedTile[], variant: Variant): Promise { + return this.humanMove(gameId, (g) => g.submitPlay(encodePlacements(variant, tiles))); + } + + async pass(gameId: string): Promise { + return this.humanMove(gameId, (g) => g.pass()); + } + + async exchange(gameId: string, tiles: string[], variant: Variant): Promise { + const idx = tiles.map((t) => letterToIndex(variant, t)); + return this.humanMove(gameId, (g) => g.exchange(idx)); + } + + async resign(gameId: string): Promise { + // A resignation ends a two-player game immediately; no robot reply follows. + const entry = await this.load(gameId); + const seat = entry.game.currentPlayer; + const move = entry.game.resign(); + await this.persist(entry); + return this.moveResult(entry, move, seat); + } + + async hint(gameId: string): Promise { + const entry = await this.load(gameId); + if (nowUnix() * 1000 - entry.record.robotLastMoveAtUnix * 1000 < HINT_GATE_MS) { + throw new GatewayError('hint_not_ready'); + } + const top = this.topMove(entry.game); + if (!top) throw new GatewayError('no_hint'); + return { move: this.moveFromGen(entry.record.variant, entry.game.currentPlayer, top), hintsRemaining: 1, walletBalance: 0 }; + } + + async evaluate(gameId: string, tiles: PlacedTile[], variant: Variant): Promise { + const { game } = await this.load(gameId); + const r = game.evaluatePlay(encodePlacements(variant, tiles)); + return { + legal: r.legal, + score: r.score, + words: r.words.map((w) => w.map((i) => indexToLetter(variant, i)).join('').toLowerCase()), + dir: r.legal ? (r.dir === 0 ? 'H' : 'V') : '', + }; + } + + async checkWord(gameId: string, word: string, variant: Variant): Promise { + const { game } = await this.load(gameId); + const idx = Array.from(word, (ch) => letterToIndex(variant, ch)); + return { word, legal: game.dictionaryHas(idx) }; + } + + // Drafts (the in-progress composition) are not persisted for local games — the arrangement is + // rebuilt from the rack on reopen. The screen tolerates an empty draft. + async draftGet(): Promise { + return ''; + } + async draftSave(): Promise { + /* no-op offline */ + } + + /** events subscribes to a local game's push events (the robot's reply, game over). Returns an + * unsubscribe. The network path uses the gateway's global stream instead. */ + events(gameId: string, onEvent: (e: PushEvent) => void): () => void { + let set = this.listeners.get(gameId); + if (!set) { + set = new Set(); + this.listeners.set(gameId, set); + } + set.add(onEvent); + return () => set!.delete(onEvent); + } + + // --- internals ------------------------------------------------------------- + + private async load(gameId: string): Promise { + const cached = this.live.get(gameId); + if (cached) return cached; + const record = await getLocalGame(gameId); + if (!record) throw new GatewayError('game_not_found'); + const dawg = await getDawg(record.variant, record.dictVersion); + if (!dawg) throw new GatewayError('dict_unavailable'); + const entry: Live = { game: replayGame(record, dawg), record }; + this.live.set(gameId, entry); + return entry; + } + + // humanMove applies the human's action, persists, returns their MoveResult, then (if the game is + // still on and it is the robot's turn) schedules the robot's synchronous reply — already applied + // and saved here, but emitted to subscribers on a microtask so the human's move renders first. + private async humanMove(gameId: string, act: (g: LocalGame) => LocalMove): Promise { + const entry = await this.load(gameId); + const seat = entry.game.currentPlayer; + const move = act(entry.game); + const robotMoves = this.runRobots(entry); + await this.persist(entry); + const result = this.moveResult(entry, move, seat); + if (robotMoves.length > 0) { + queueMicrotask(() => this.emitRobot(entry, robotMoves)); + } + return result; + } + + // runRobots plays every consecutive robot seat until it is a human's turn or the game ends, + // returning the robot moves in order. For a two-player vs_ai game this is a single move. + private runRobots(entry: Live): LocalMove[] { + const out: LocalMove[] = []; + while (!entry.game.isOver && this.isRobotSeat(entry, entry.game.currentPlayer)) { + const g = entry.game; + const seat = g.currentPlayer; + const cands = this.legalMoves(g); + const dec = decide(g.seed, g.moveCount, cands, g.scoreOf(seat), this.bestOpponent(g, seat), g.handOf(seat), g.bagLength); + if (dec.kind === 'play') out.push(g.play(dec.move.dir, dec.move.tiles)); + else if (dec.kind === 'exchange' && g.bagLength >= RULESETS[entry.record.variant].rackSize) out.push(g.exchange(dec.exchange)); + else out.push(g.pass()); + entry.record.robotLastMoveAtUnix = nowUnix(); + } + return out; + } + + private emitRobot(entry: Live, moves: LocalMove[]): void { + const set = this.listeners.get(entry.record.id); + if (!set) return; + for (const m of moves) { + const game = this.gameView(entry); + const ev: PushEvent = { kind: 'opponent_moved', gameId: entry.record.id, move: this.moveRecord(entry.record.variant, m), game, bagLen: entry.game.bagLength }; + for (const cb of set) cb(ev); + } + if (entry.game.isOver) { + const game = this.gameView(entry); + const ev: PushEvent = { kind: 'game_over', gameId: entry.record.id, result: entry.game.endReason, scoreLine: '', game }; + for (const cb of set) cb(ev); + } + } + + private async persist(entry: Live): Promise { + entry.record = serializeGame(entry.game, { + id: entry.record.id, + seats: entry.record.seats, + createdAtUnix: entry.record.createdAtUnix, + updatedAtUnix: nowUnix(), + robotLastMoveAtUnix: entry.record.robotLastMoveAtUnix, + }); + await saveLocalGame(entry.record); + } + + private isRobotSeat(entry: Live, seat: number): boolean { + return entry.record.seats[seat]?.kind === 'robot'; + } + + private humanSeat(entry: Live): number { + const i = entry.record.seats.findIndex((s) => s.kind === 'human'); + return i >= 0 ? i : 0; + } + + private bestOpponent(game: LocalGame, seat: number): number { + let best = 0; + for (let i = 0; i < game.playerCount; i++) if (i !== seat) best = Math.max(best, game.scoreOf(i)); + return best; + } + + private legalMoves(game: LocalGame): Move[] { + // The generator can emit a one-letter play only for a dictionary that has one-letter words; + // real dictionaries have none. Filtering to main length >= 2 keeps the robot to legal plays. + return game.generateMoves().filter((m) => m.main.letters.length >= 2); + } + + private topMove(game: LocalGame): Move | null { + const moves = this.legalMoves(game); + return moves.length > 0 ? moves[0] : null; + } + + // --- shape builders -------------------------------------------------------- + + private stateView(entry: Live): StateView { + const seat = this.humanSeat(entry); + const rack = entry.game.handOf(seat).map((t) => indexToLetter(entry.record.variant, t)); + return { game: this.gameView(entry), seat, rack, bagLen: entry.game.bagLength, hintsRemaining: 1, walletBalance: 0 }; + } + + private gameView(entry: Live): GameView { + const { game, record } = entry; + const seats: Seat[] = record.seats.map((s, i) => ({ + seat: i, + accountId: `${LOCAL_ID_PREFIX}${s.kind}:${i}`, + displayName: s.name, + score: game.scoreOf(i), + hintsUsed: 0, + isWinner: game.isOver && i === game.winnerIndex, + })); + return { + id: record.id, + variant: record.variant, + dictVersion: record.dictVersion, + status: game.isOver ? 'finished' : 'active', + players: game.playerCount, + toMove: game.currentPlayer, + turnTimeoutSecs: 0, + multipleWordsPerTurn: record.multipleWords, + moveCount: game.moveCount, + endReason: game.isOver ? game.endReason : '', + lastActivityUnix: record.updatedAtUnix, + vsAi: true, + unreadChat: false, + unreadMessages: false, + seats, + }; + } + + private moveResult(entry: Live, move: LocalMove, seat: number): MoveResult { + return { + move: this.moveRecord(entry.record.variant, move), + game: this.gameView(entry), + rack: entry.game.handOf(seat).map((t) => indexToLetter(entry.record.variant, t)), + bagLen: entry.game.bagLength, + }; + } + + private moveRecord(variant: Variant, m: LocalMove): MoveRecord { + const tiles: Tile[] = (m.tiles ?? []).map((t) => ({ row: t.row, col: t.col, letter: indexToLetter(variant, t.letter), blank: t.blank })); + const words = (m.words ?? []).map((w) => w.map((i) => indexToLetter(variant, i)).join('').toLowerCase()); + return { + player: m.player, + action: m.action, + dir: m.dir === undefined ? '' : m.dir === 0 ? 'H' : 'V', + mainRow: m.mainRow ?? 0, + mainCol: m.mainCol ?? 0, + tiles, + words, + count: m.count ?? 0, + score: m.score, + total: m.total, + }; + } + + private moveFromGen(variant: Variant, player: number, gm: Move): MoveRecord { + const tiles: Tile[] = gm.tiles.map((t) => ({ row: t.row, col: t.col, letter: indexToLetter(variant, t.letter), blank: t.blank })); + const words = [gm.main, ...gm.cross].map((w) => w.letters.map((i) => indexToLetter(variant, i)).join('').toLowerCase()); + return { + player, + action: 'play', + dir: gm.dir === 0 ? 'H' : 'V', + mainRow: gm.main.row, + mainCol: gm.main.col, + tiles, + words, + count: 0, + score: gm.score, + total: 0, + }; + } +} + +function nowUnix(): number { + return Math.floor(Date.now() / 1000); +} diff --git a/ui/src/lib/localgame/testdata/rulesets.json b/ui/src/lib/localgame/testdata/rulesets.json index a693d39..f9b4168 100644 --- a/ui/src/lib/localgame/testdata/rulesets.json +++ b/ui/src/lib/localgame/testdata/rulesets.json @@ -73,6 +73,41 @@ 1, 1, 3 + ], + "letters": [ + "А", + "Б", + "В", + "Г", + "Д", + "Е", + "Ё", + "Ж", + "З", + "И", + "Й", + "К", + "Л", + "М", + "Н", + "О", + "П", + "Р", + "С", + "Т", + "У", + "Ф", + "Х", + "Ц", + "Ч", + "Ш", + "Щ", + "Ъ", + "Ы", + "Ь", + "Э", + "Ю", + "Я" ] }, "scrabble_en": { @@ -135,6 +170,34 @@ 1, 2, 1 + ], + "letters": [ + "A", + "B", + "C", + "D", + "E", + "F", + "G", + "H", + "I", + "J", + "K", + "L", + "M", + "N", + "O", + "P", + "Q", + "R", + "S", + "T", + "U", + "V", + "W", + "X", + "Y", + "Z" ] }, "scrabble_ru": { @@ -211,6 +274,41 @@ 1, 1, 2 + ], + "letters": [ + "А", + "Б", + "В", + "Г", + "Д", + "Е", + "Ё", + "Ж", + "З", + "И", + "Й", + "К", + "Л", + "М", + "Н", + "О", + "П", + "Р", + "С", + "Т", + "У", + "Ф", + "Х", + "Ц", + "Ч", + "Ш", + "Щ", + "Ъ", + "Ы", + "Ь", + "Э", + "Ю", + "Я" ] } }