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"
|
||||
"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 {
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<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
|
||||
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<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],
|
||||
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<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],
|
||||
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,
|
||||
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": [
|
||||
"А",
|
||||
"Б",
|
||||
"В",
|
||||
"Г",
|
||||
"Д",
|
||||
"Е",
|
||||
"Ё",
|
||||
"Ж",
|
||||
"З",
|
||||
"И",
|
||||
"Й",
|
||||
"К",
|
||||
"Л",
|
||||
"М",
|
||||
"Н",
|
||||
"О",
|
||||
"П",
|
||||
"Р",
|
||||
"С",
|
||||
"Т",
|
||||
"У",
|
||||
"Ф",
|
||||
"Х",
|
||||
"Ц",
|
||||
"Ч",
|
||||
"Ш",
|
||||
"Щ",
|
||||
"Ъ",
|
||||
"Ы",
|
||||
"Ь",
|
||||
"Э",
|
||||
"Ю",
|
||||
"Я"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user