release: offline mode + local pass-and-play (hotseat) — proposed v1.12.0 #212

Merged
developer merged 79 commits from development into master 2026-07-07 14:40:43 +00:00
7 changed files with 652 additions and 7 deletions
Showing only changes of commit 9ecbe480db - Show all commits
+17 -7
View File
@@ -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 {
+33
View File
@@ -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]);
});
}
+6
View File
@@ -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: [...'АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ'],
},
};
+96
View File
@@ -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);
});
});
+400
View File
@@ -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
View File
@@ -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": [
"А",
"Б",
"В",
"Г",
"Д",
"Е",
"Ё",
"Ж",
"З",
"И",
"Й",
"К",
"Л",
"М",
"Н",
"О",
"П",
"Р",
"С",
"Т",
"У",
"Ф",
"Х",
"Ц",
"Ч",
"Ш",
"Щ",
"Ъ",
"Ы",
"Ь",
"Э",
"Ю",
"Я"
]
}
}