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:
@@ -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],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user