feat(offline): local game source (Phase B3.1)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 1m9s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 1m9s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
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).
This commit is contained in:
@@ -25,6 +25,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
|
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
|
||||||
"gitea.iliadenisov.ru/developer/scrabble-solver/rack"
|
"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).
|
// parity test can pin that hand-copied table to the Go rulesets (scrabble-solver/rules).
|
||||||
func emitRulesets() {
|
func emitRulesets() {
|
||||||
type rsFix struct {
|
type rsFix struct {
|
||||||
Size int `json:"size"`
|
Size int `json:"size"`
|
||||||
RackSize int `json:"rackSize"`
|
RackSize int `json:"rackSize"`
|
||||||
Bingo int `json:"bingo"`
|
Bingo int `json:"bingo"`
|
||||||
Blanks int `json:"blanks"`
|
Blanks int `json:"blanks"`
|
||||||
Values []int `json:"values"`
|
Values []int `json:"values"`
|
||||||
Counts []int `json:"counts"`
|
Counts []int `json:"counts"`
|
||||||
|
Letters []string `json:"letters"`
|
||||||
}
|
}
|
||||||
out := map[string]rsFix{}
|
out := map[string]rsFix{}
|
||||||
for _, v := range []struct {
|
for _, v := range []struct {
|
||||||
@@ -386,7 +388,15 @@ func emitRulesets() {
|
|||||||
{"scrabble_ru", rules.RussianScrabble()},
|
{"scrabble_ru", rules.RussianScrabble()},
|
||||||
{"erudit_ru", rules.Erudit()},
|
{"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")
|
dir := filepath.Join("ui", "src", "lib", "localgame", "testdata")
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Bag } from './bag';
|
|||||||
import { RULESETS } from './ruleset';
|
import { RULESETS } from './ruleset';
|
||||||
import { generateMoves, GenRack, Both } from '../dict/generate';
|
import { generateMoves, GenRack, Both } from '../dict/generate';
|
||||||
import { validatePlay, type Direction, type Placement, type Ruleset, type Move } from '../dict/validate';
|
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 { premiumGrid, centre, BOARD_SIZE, RACK_SIZE, BINGO, type Premium } from '../premiums';
|
||||||
import { BLANK_INDEX } from '../alphabet';
|
import { BLANK_INDEX } from '../alphabet';
|
||||||
import type { Dawg } from '../dict/dawg';
|
import type { Dawg } from '../dict/dawg';
|
||||||
@@ -35,6 +36,12 @@ export interface LocalMove {
|
|||||||
action: 'play' | 'pass' | 'exchange' | 'resign';
|
action: 'play' | 'pass' | 'exchange' | 'resign';
|
||||||
dir?: Direction;
|
dir?: Direction;
|
||||||
tiles?: Placement[];
|
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. */
|
/** ActionExchange only: number of tiles swapped. */
|
||||||
count?: number;
|
count?: number;
|
||||||
/** ActionExchange only: the swapped tiles (alphabet-index bytes, BLANK_INDEX for a blank),
|
/** 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);
|
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 -----------------------------------------------------------------
|
// --- 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
|
/** 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. */
|
* advances the turn (or ends the game). Throws GameError on an illegal play. */
|
||||||
play(dir: Direction, tiles: Placement[]): LocalMove {
|
play(dir: Direction, tiles: Placement[]): LocalMove {
|
||||||
@@ -277,6 +307,9 @@ export class LocalGame {
|
|||||||
action: 'play',
|
action: 'play',
|
||||||
dir,
|
dir,
|
||||||
tiles: tiles.map((t) => ({ ...t })),
|
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,
|
score: move.score,
|
||||||
total: this.scores[player],
|
total: this.scores[player],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ interface RulesetFix {
|
|||||||
blanks: number;
|
blanks: number;
|
||||||
values: number[];
|
values: number[];
|
||||||
counts: number[];
|
counts: number[];
|
||||||
|
letters: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const fx = JSON.parse(
|
const fx = JSON.parse(
|
||||||
@@ -31,6 +32,7 @@ describe('offline ruleset table parity vs Go rules', () => {
|
|||||||
blanks: rs.blanks,
|
blanks: rs.blanks,
|
||||||
values: [...rs.values],
|
values: [...rs.values],
|
||||||
counts: [...rs.counts],
|
counts: [...rs.counts],
|
||||||
|
letters: [...rs.letters],
|
||||||
}).toEqual(fx[variant]);
|
}).toEqual(fx[variant]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ export interface VariantRuleset {
|
|||||||
values: readonly number[];
|
values: readonly number[];
|
||||||
/** Bag tile count per letter index (how many of each letter the bag holds). */
|
/** Bag tile count per letter index (how many of each letter the bag holds). */
|
||||||
counts: readonly number[];
|
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. */
|
/** RULESETS is the static ruleset table, one entry per variant, mirrored from rules.go. */
|
||||||
@@ -34,6 +37,7 @@ export const RULESETS: Record<Variant, VariantRuleset> = {
|
|||||||
// 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
|
// 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],
|
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],
|
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: {
|
scrabble_ru: {
|
||||||
size: 33,
|
size: 33,
|
||||||
@@ -43,6 +47,7 @@ export const RULESETS: Record<Variant, VariantRuleset> = {
|
|||||||
// а б в г д е ё ж з и й к л м н о п р с т у ф х ц ч ш щ ъ ы ь э ю я
|
// а б в г д е ё ж з и й к л м н о п р с т у ф х ц ч ш щ ъ ы ь э ю я
|
||||||
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],
|
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],
|
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: {
|
erudit_ru: {
|
||||||
size: 33,
|
size: 33,
|
||||||
@@ -52,5 +57,6 @@ export const RULESETS: Record<Variant, VariantRuleset> = {
|
|||||||
// а б в г д е ё ж з и й к л м н о п р с т у ф х ц ч ш щ ъ ы ь э ю я
|
// а б в г д е ё ж з и й к л м н о п р с т у ф х ц ч ш щ ъ ы ь э ю я
|
||||||
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],
|
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],
|
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: [...'АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ'],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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<LocalSource> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<StateView>;
|
||||||
|
gameHistory(gameId: string): Promise<History>;
|
||||||
|
submitPlay(gameId: string, tiles: PlacedTile[], variant: Variant): Promise<MoveResult>;
|
||||||
|
pass(gameId: string): Promise<MoveResult>;
|
||||||
|
exchange(gameId: string, tiles: string[], variant: Variant): Promise<MoveResult>;
|
||||||
|
resign(gameId: string): Promise<MoveResult>;
|
||||||
|
hint(gameId: string): Promise<HintResult>;
|
||||||
|
evaluate(gameId: string, tiles: PlacedTile[], variant: Variant, signal?: AbortSignal): Promise<EvalResult>;
|
||||||
|
checkWord(gameId: string, word: string, variant: Variant): Promise<WordCheckResult>;
|
||||||
|
draftGet(gameId: string): Promise<string>;
|
||||||
|
draftSave(gameId: string, json: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Variant, Map<string, number>>();
|
||||||
|
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<string, Live>();
|
||||||
|
private readonly listeners = new Map<string, Set<(e: PushEvent) => 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<StateView> {
|
||||||
|
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<StateView> {
|
||||||
|
return this.stateView(await this.load(gameId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async gameHistory(gameId: string): Promise<History> {
|
||||||
|
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<MoveResult> {
|
||||||
|
return this.humanMove(gameId, (g) => g.submitPlay(encodePlacements(variant, tiles)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async pass(gameId: string): Promise<MoveResult> {
|
||||||
|
return this.humanMove(gameId, (g) => g.pass());
|
||||||
|
}
|
||||||
|
|
||||||
|
async exchange(gameId: string, tiles: string[], variant: Variant): Promise<MoveResult> {
|
||||||
|
const idx = tiles.map((t) => letterToIndex(variant, t));
|
||||||
|
return this.humanMove(gameId, (g) => g.exchange(idx));
|
||||||
|
}
|
||||||
|
|
||||||
|
async resign(gameId: string): Promise<MoveResult> {
|
||||||
|
// 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<HintResult> {
|
||||||
|
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<EvalResult> {
|
||||||
|
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<WordCheckResult> {
|
||||||
|
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<string> {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
async draftSave(): Promise<void> {
|
||||||
|
/* 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<Live> {
|
||||||
|
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<MoveResult> {
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
+98
@@ -73,6 +73,41 @@
|
|||||||
1,
|
1,
|
||||||
1,
|
1,
|
||||||
3
|
3
|
||||||
|
],
|
||||||
|
"letters": [
|
||||||
|
"А",
|
||||||
|
"Б",
|
||||||
|
"В",
|
||||||
|
"Г",
|
||||||
|
"Д",
|
||||||
|
"Е",
|
||||||
|
"Ё",
|
||||||
|
"Ж",
|
||||||
|
"З",
|
||||||
|
"И",
|
||||||
|
"Й",
|
||||||
|
"К",
|
||||||
|
"Л",
|
||||||
|
"М",
|
||||||
|
"Н",
|
||||||
|
"О",
|
||||||
|
"П",
|
||||||
|
"Р",
|
||||||
|
"С",
|
||||||
|
"Т",
|
||||||
|
"У",
|
||||||
|
"Ф",
|
||||||
|
"Х",
|
||||||
|
"Ц",
|
||||||
|
"Ч",
|
||||||
|
"Ш",
|
||||||
|
"Щ",
|
||||||
|
"Ъ",
|
||||||
|
"Ы",
|
||||||
|
"Ь",
|
||||||
|
"Э",
|
||||||
|
"Ю",
|
||||||
|
"Я"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"scrabble_en": {
|
"scrabble_en": {
|
||||||
@@ -135,6 +170,34 @@
|
|||||||
1,
|
1,
|
||||||
2,
|
2,
|
||||||
1
|
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": {
|
"scrabble_ru": {
|
||||||
@@ -211,6 +274,41 @@
|
|||||||
1,
|
1,
|
||||||
1,
|
1,
|
||||||
2
|
2
|
||||||
|
],
|
||||||
|
"letters": [
|
||||||
|
"А",
|
||||||
|
"Б",
|
||||||
|
"В",
|
||||||
|
"Г",
|
||||||
|
"Д",
|
||||||
|
"Е",
|
||||||
|
"Ё",
|
||||||
|
"Ж",
|
||||||
|
"З",
|
||||||
|
"И",
|
||||||
|
"Й",
|
||||||
|
"К",
|
||||||
|
"Л",
|
||||||
|
"М",
|
||||||
|
"Н",
|
||||||
|
"О",
|
||||||
|
"П",
|
||||||
|
"Р",
|
||||||
|
"С",
|
||||||
|
"Т",
|
||||||
|
"У",
|
||||||
|
"Ф",
|
||||||
|
"Х",
|
||||||
|
"Ц",
|
||||||
|
"Ч",
|
||||||
|
"Ш",
|
||||||
|
"Щ",
|
||||||
|
"Ъ",
|
||||||
|
"Ы",
|
||||||
|
"Ь",
|
||||||
|
"Э",
|
||||||
|
"Ю",
|
||||||
|
"Я"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user