Stage 13: alphabet on the wire (UI alphabet-agnostic, TODO-4)
Tests · Go / test (push) Successful in 10s
Tests · Integration / integration (push) Successful in 12s
Tests · UI / test (push) Successful in 19s
Tests · Go / test (pull_request) Successful in 9s
Tests · Integration / integration (pull_request) Successful in 12s
Tests · UI / test (pull_request) Successful in 19s
Tests · Go / test (push) Successful in 10s
Tests · Integration / integration (push) Successful in 12s
Tests · UI / test (push) Successful in 19s
Tests · Go / test (pull_request) Successful in 9s
Tests · Integration / integration (pull_request) Successful in 12s
Tests · UI / test (pull_request) Successful in 19s
Live play now exchanges per-variant alphabet indices instead of concrete letters (rack out; submit-play, evaluate, exchange, word-check in). The client caches each variant's (index, letter, value) table behind StateRequest.include_alphabet and renders the rack and blank chooser from it, dropping the hardcoded value/alphabet tables. History, the durable journal and GCG stay decoded concrete characters (ARCHITECTURE §9.1, unchanged). - pkg/fbs: new AlphabetEntry + PlayTile; StateView.rack -> [ubyte] + alphabet; StateRequest.include_alphabet; SubmitPlay/Eval tiles -> [PlayTile]; Exchange tiles + CheckWord word -> [ubyte] (committed Go + TS regenerated). - engine: AlphabetTable + a cached per-variant codec (LetterForIndex/EncodeRack/ DecodeTiles/DecodeWord) + BlankIndex sentinel; Go parity test. - backend server edge maps index<->letter (new thin game.Service.GameVariant); game.Service domain methods, engine.Game and the robot keep one letter-based play path. The gateway forwards indices verbatim (no alphabet table). - ui: lib/alphabet.ts in-memory cache; codec encodes/decodes indices; premiums.ts is geometry-only; the mock seeds a fixture table; the UI normalises display to upper case (codec + cache), leaving placement/board/checkword unchanged. Parity moved to the Go engine.AlphabetTable test; premiums.ts loses its value tables. Discharges TODO-4.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { BoardCell } from '../lib/board';
|
||||
import type { Premium } from '../lib/premiums';
|
||||
import { tileValue } from '../lib/premiums';
|
||||
import { valueForLetter } from '../lib/alphabet';
|
||||
import type { Variant } from '../lib/model';
|
||||
import { bonusLabel, type BoardLabelMode } from '../lib/boardlabels';
|
||||
import type { Locale } from '../lib/i18n/catalog';
|
||||
@@ -98,7 +98,7 @@
|
||||
>
|
||||
{#if letter}
|
||||
<span class="letter">{letter}</span>
|
||||
{#if !blank}<span class="val">{tileValue(variant, letter)}</span>{/if}
|
||||
{#if !blank}<span class="val">{valueForLetter(variant, letter)}</span>{/if}
|
||||
{:else if r === centre.row && c === centre.col}
|
||||
<span class="star">★</span>
|
||||
{:else if bl?.kind === 'single'}
|
||||
|
||||
+19
-10
@@ -14,7 +14,8 @@
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
import type { ChatMessage, Direction, EvalResult, MoveRecord, StateView } from '../lib/model';
|
||||
import { replay } from '../lib/board';
|
||||
import { alphabet, centre, premiumGrid } from '../lib/premiums';
|
||||
import { centre, premiumGrid } from '../lib/premiums';
|
||||
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
|
||||
import { canCheckWord, sanitizeCheckWord } from '../lib/checkword';
|
||||
import { shareOrDownloadGcg } from '../lib/share';
|
||||
import {
|
||||
@@ -84,7 +85,13 @@
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [st, hist] = await Promise.all([gateway.gameState(id), gateway.gameHistory(id)]);
|
||||
// Ask for the alphabet table only on a per-variant cache miss (the first open of a
|
||||
// game whose variant the client has not cached yet); steady-state polls omit it.
|
||||
const includeAlphabet = !view || !hasAlphabet(view.game.variant);
|
||||
const [st, hist] = await Promise.all([
|
||||
gateway.gameState(id, includeAlphabet),
|
||||
gateway.gameHistory(id),
|
||||
]);
|
||||
view = st;
|
||||
moves = hist.moves;
|
||||
placement = newPlacement(st.rack);
|
||||
@@ -206,7 +213,7 @@
|
||||
if (!sub) return;
|
||||
previewTimer = setTimeout(async () => {
|
||||
try {
|
||||
preview = await gateway.evaluate(id, sub.dir, sub.tiles);
|
||||
preview = await gateway.evaluate(id, sub.dir, sub.tiles, variant);
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
@@ -218,7 +225,7 @@
|
||||
if (!sub) return;
|
||||
busy = true;
|
||||
try {
|
||||
await gateway.submitPlay(id, sub.dir, sub.tiles);
|
||||
await gateway.submitPlay(id, sub.dir, sub.tiles, variant);
|
||||
zoomed = false;
|
||||
await load();
|
||||
} catch (e) {
|
||||
@@ -298,7 +305,7 @@
|
||||
exchangeOpen = false;
|
||||
busy = true;
|
||||
try {
|
||||
await gateway.exchange(id, tiles);
|
||||
await gateway.exchange(id, tiles, variant);
|
||||
await load();
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
@@ -313,7 +320,7 @@
|
||||
checkOpen = true;
|
||||
}
|
||||
function onCheckInput(e: Event) {
|
||||
checkWord = sanitizeCheckWord((e.target as HTMLInputElement).value, alphabet(variant));
|
||||
checkWord = sanitizeCheckWord((e.target as HTMLInputElement).value, alphabetLetters(variant));
|
||||
}
|
||||
// Check is disabled while cooling down, for an already-checked word, or an out-of-range
|
||||
// length. The input filter already restricts to the variant's alphabet.
|
||||
@@ -326,9 +333,11 @@
|
||||
cooling = true;
|
||||
setTimeout(() => (cooling = false), 5000);
|
||||
try {
|
||||
const r = await gateway.checkWord(id, w);
|
||||
checkedWords.set(r.word.toUpperCase(), r.legal);
|
||||
checkResult = r;
|
||||
const r = await gateway.checkWord(id, w, variant);
|
||||
// Key the cache and the displayed result on the upper-case word the player typed; the
|
||||
// server echoes the decoded concrete word in the solver's lower case.
|
||||
checkedWords.set(w, r.legal);
|
||||
checkResult = { word: w, legal: r.legal };
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
@@ -535,7 +544,7 @@
|
||||
{#if blankPrompt}
|
||||
<Modal title={t('game.chooseBlank')} onclose={() => (blankPrompt = null)}>
|
||||
<div class="alpha">
|
||||
{#each alphabet(variant) as ch (ch)}
|
||||
{#each alphabetLetters(variant) as ch (ch)}
|
||||
<button onclick={() => chooseBlank(ch)}>{ch}</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { RackSlot } from '../lib/placement';
|
||||
import { BLANK } from '../lib/placement';
|
||||
import { tileValue } from '../lib/premiums';
|
||||
import { valueForLetter } from '../lib/alphabet';
|
||||
import type { Variant } from '../lib/model';
|
||||
|
||||
let {
|
||||
@@ -30,7 +30,7 @@
|
||||
onpointerdown={(e) => ondown(e, slot.index)}
|
||||
>
|
||||
<span class="letter">{slot.letter === BLANK ? '' : slot.letter}</span>
|
||||
{#if slot.letter !== BLANK}<span class="val">{tileValue(variant, slot.letter)}</span>{/if}
|
||||
{#if slot.letter !== BLANK}<span class="val">{valueForLetter(variant, slot.letter)}</span>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
export { AccountRef } from './scrabblefb/account-ref.js';
|
||||
export { Ack } from './scrabblefb/ack.js';
|
||||
export { AlphabetEntry } from './scrabblefb/alphabet-entry.js';
|
||||
export { BlockList } from './scrabblefb/block-list.js';
|
||||
export { ChatList } from './scrabblefb/chat-list.js';
|
||||
export { ChatMessage } from './scrabblefb/chat-message.js';
|
||||
@@ -41,6 +42,7 @@ export { MoveResult } from './scrabblefb/move-result.js';
|
||||
export { NotificationEvent } from './scrabblefb/notification-event.js';
|
||||
export { NudgeEvent } from './scrabblefb/nudge-event.js';
|
||||
export { OpponentMovedEvent } from './scrabblefb/opponent-moved-event.js';
|
||||
export { PlayTile } from './scrabblefb/play-tile.js';
|
||||
export { Profile } from './scrabblefb/profile.js';
|
||||
export { RedeemCodeRequest } from './scrabblefb/redeem-code-request.js';
|
||||
export { RedeemResult } from './scrabblefb/redeem-result.js';
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// automatically generated by the FlatBuffers compiler, do not modify
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
export class AlphabetEntry {
|
||||
bb: flatbuffers.ByteBuffer|null = null;
|
||||
bb_pos = 0;
|
||||
__init(i:number, bb:flatbuffers.ByteBuffer):AlphabetEntry {
|
||||
this.bb_pos = i;
|
||||
this.bb = bb;
|
||||
return this;
|
||||
}
|
||||
|
||||
static getRootAsAlphabetEntry(bb:flatbuffers.ByteBuffer, obj?:AlphabetEntry):AlphabetEntry {
|
||||
return (obj || new AlphabetEntry()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||
}
|
||||
|
||||
static getSizePrefixedRootAsAlphabetEntry(bb:flatbuffers.ByteBuffer, obj?:AlphabetEntry):AlphabetEntry {
|
||||
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
|
||||
return (obj || new AlphabetEntry()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||
}
|
||||
|
||||
index():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 4);
|
||||
return offset ? this.bb!.readUint8(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
letter():string|null
|
||||
letter(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||
letter(optionalEncoding?:any):string|Uint8Array|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
value():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 8);
|
||||
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
static startAlphabetEntry(builder:flatbuffers.Builder) {
|
||||
builder.startObject(3);
|
||||
}
|
||||
|
||||
static addIndex(builder:flatbuffers.Builder, index:number) {
|
||||
builder.addFieldInt8(0, index, 0);
|
||||
}
|
||||
|
||||
static addLetter(builder:flatbuffers.Builder, letterOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(1, letterOffset, 0);
|
||||
}
|
||||
|
||||
static addValue(builder:flatbuffers.Builder, value:number) {
|
||||
builder.addFieldInt32(2, value, 0);
|
||||
}
|
||||
|
||||
static endAlphabetEntry(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
}
|
||||
|
||||
static createAlphabetEntry(builder:flatbuffers.Builder, index:number, letterOffset:flatbuffers.Offset, value:number):flatbuffers.Offset {
|
||||
AlphabetEntry.startAlphabetEntry(builder);
|
||||
AlphabetEntry.addIndex(builder, index);
|
||||
AlphabetEntry.addLetter(builder, letterOffset);
|
||||
AlphabetEntry.addValue(builder, value);
|
||||
return AlphabetEntry.endAlphabetEntry(builder);
|
||||
}
|
||||
}
|
||||
@@ -27,11 +27,19 @@ gameId(optionalEncoding?:any):string|Uint8Array|null {
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
word():string|null
|
||||
word(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||
word(optionalEncoding?:any):string|Uint8Array|null {
|
||||
word(index: number):number|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
return offset ? this.bb!.readUint8(this.bb!.__vector(this.bb_pos + offset) + index) : 0;
|
||||
}
|
||||
|
||||
wordLength():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
wordArray():Uint8Array|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||
return offset ? new Uint8Array(this.bb!.bytes().buffer, this.bb!.bytes().byteOffset + this.bb!.__vector(this.bb_pos + offset), this.bb!.__vector_len(this.bb_pos + offset)) : null;
|
||||
}
|
||||
|
||||
static startCheckWordRequest(builder:flatbuffers.Builder) {
|
||||
@@ -46,6 +54,18 @@ static addWord(builder:flatbuffers.Builder, wordOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(1, wordOffset, 0);
|
||||
}
|
||||
|
||||
static createWordVector(builder:flatbuffers.Builder, data:number[]|Uint8Array):flatbuffers.Offset {
|
||||
builder.startVector(1, data.length, 1);
|
||||
for (let i = data.length - 1; i >= 0; i--) {
|
||||
builder.addInt8(data[i]!);
|
||||
}
|
||||
return builder.endVector();
|
||||
}
|
||||
|
||||
static startWordVector(builder:flatbuffers.Builder, numElems:number) {
|
||||
builder.startVector(1, numElems, 1);
|
||||
}
|
||||
|
||||
static endCheckWordRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
import { TileRecord } from '../scrabblefb/tile-record.js';
|
||||
import { PlayTile } from '../scrabblefb/play-tile.js';
|
||||
|
||||
|
||||
export class EvalRequest {
|
||||
@@ -37,9 +37,9 @@ dir(optionalEncoding?:any):string|Uint8Array|null {
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
tiles(index: number, obj?:TileRecord):TileRecord|null {
|
||||
tiles(index: number, obj?:PlayTile):PlayTile|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 8);
|
||||
return offset ? (obj || new TileRecord()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
|
||||
return offset ? (obj || new PlayTile()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
|
||||
}
|
||||
|
||||
tilesLength():number {
|
||||
|
||||
@@ -27,11 +27,9 @@ gameId(optionalEncoding?:any):string|Uint8Array|null {
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
tiles(index: number):string
|
||||
tiles(index: number,optionalEncoding:flatbuffers.Encoding):string|Uint8Array
|
||||
tiles(index: number,optionalEncoding?:any):string|Uint8Array|null {
|
||||
tiles(index: number):number|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||
return offset ? this.bb!.__string(this.bb!.__vector(this.bb_pos + offset) + index * 4, optionalEncoding) : null;
|
||||
return offset ? this.bb!.readUint8(this.bb!.__vector(this.bb_pos + offset) + index) : 0;
|
||||
}
|
||||
|
||||
tilesLength():number {
|
||||
@@ -39,6 +37,11 @@ tilesLength():number {
|
||||
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
tilesArray():Uint8Array|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||
return offset ? new Uint8Array(this.bb!.bytes().buffer, this.bb!.bytes().byteOffset + this.bb!.__vector(this.bb_pos + offset), this.bb!.__vector_len(this.bb_pos + offset)) : null;
|
||||
}
|
||||
|
||||
static startExchangeRequest(builder:flatbuffers.Builder) {
|
||||
builder.startObject(2);
|
||||
}
|
||||
@@ -51,16 +54,16 @@ static addTiles(builder:flatbuffers.Builder, tilesOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(1, tilesOffset, 0);
|
||||
}
|
||||
|
||||
static createTilesVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
|
||||
builder.startVector(4, data.length, 4);
|
||||
static createTilesVector(builder:flatbuffers.Builder, data:number[]|Uint8Array):flatbuffers.Offset {
|
||||
builder.startVector(1, data.length, 1);
|
||||
for (let i = data.length - 1; i >= 0; i--) {
|
||||
builder.addOffset(data[i]!);
|
||||
builder.addInt8(data[i]!);
|
||||
}
|
||||
return builder.endVector();
|
||||
}
|
||||
|
||||
static startTilesVector(builder:flatbuffers.Builder, numElems:number) {
|
||||
builder.startVector(4, numElems, 4);
|
||||
builder.startVector(1, numElems, 1);
|
||||
}
|
||||
|
||||
static endExchangeRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// automatically generated by the FlatBuffers compiler, do not modify
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
export class PlayTile {
|
||||
bb: flatbuffers.ByteBuffer|null = null;
|
||||
bb_pos = 0;
|
||||
__init(i:number, bb:flatbuffers.ByteBuffer):PlayTile {
|
||||
this.bb_pos = i;
|
||||
this.bb = bb;
|
||||
return this;
|
||||
}
|
||||
|
||||
static getRootAsPlayTile(bb:flatbuffers.ByteBuffer, obj?:PlayTile):PlayTile {
|
||||
return (obj || new PlayTile()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||
}
|
||||
|
||||
static getSizePrefixedRootAsPlayTile(bb:flatbuffers.ByteBuffer, obj?:PlayTile):PlayTile {
|
||||
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
|
||||
return (obj || new PlayTile()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||
}
|
||||
|
||||
row():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 4);
|
||||
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
col():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
letter():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 8);
|
||||
return offset ? this.bb!.readUint8(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
blank():boolean {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 10);
|
||||
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
|
||||
}
|
||||
|
||||
static startPlayTile(builder:flatbuffers.Builder) {
|
||||
builder.startObject(4);
|
||||
}
|
||||
|
||||
static addRow(builder:flatbuffers.Builder, row:number) {
|
||||
builder.addFieldInt32(0, row, 0);
|
||||
}
|
||||
|
||||
static addCol(builder:flatbuffers.Builder, col:number) {
|
||||
builder.addFieldInt32(1, col, 0);
|
||||
}
|
||||
|
||||
static addLetter(builder:flatbuffers.Builder, letter:number) {
|
||||
builder.addFieldInt8(2, letter, 0);
|
||||
}
|
||||
|
||||
static addBlank(builder:flatbuffers.Builder, blank:boolean) {
|
||||
builder.addFieldInt8(3, +blank, +false);
|
||||
}
|
||||
|
||||
static endPlayTile(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
}
|
||||
|
||||
static createPlayTile(builder:flatbuffers.Builder, row:number, col:number, letter:number, blank:boolean):flatbuffers.Offset {
|
||||
PlayTile.startPlayTile(builder);
|
||||
PlayTile.addRow(builder, row);
|
||||
PlayTile.addCol(builder, col);
|
||||
PlayTile.addLetter(builder, letter);
|
||||
PlayTile.addBlank(builder, blank);
|
||||
return PlayTile.endPlayTile(builder);
|
||||
}
|
||||
}
|
||||
@@ -27,22 +27,32 @@ gameId(optionalEncoding?:any):string|Uint8Array|null {
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
includeAlphabet():boolean {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
|
||||
}
|
||||
|
||||
static startStateRequest(builder:flatbuffers.Builder) {
|
||||
builder.startObject(1);
|
||||
builder.startObject(2);
|
||||
}
|
||||
|
||||
static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(0, gameIdOffset, 0);
|
||||
}
|
||||
|
||||
static addIncludeAlphabet(builder:flatbuffers.Builder, includeAlphabet:boolean) {
|
||||
builder.addFieldInt8(1, +includeAlphabet, +false);
|
||||
}
|
||||
|
||||
static endStateRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
}
|
||||
|
||||
static createStateRequest(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||
static createStateRequest(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, includeAlphabet:boolean):flatbuffers.Offset {
|
||||
StateRequest.startStateRequest(builder);
|
||||
StateRequest.addGameId(builder, gameIdOffset);
|
||||
StateRequest.addIncludeAlphabet(builder, includeAlphabet);
|
||||
return StateRequest.endStateRequest(builder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
import { AlphabetEntry } from '../scrabblefb/alphabet-entry.js';
|
||||
import { GameView } from '../scrabblefb/game-view.js';
|
||||
|
||||
|
||||
@@ -33,11 +34,9 @@ seat():number {
|
||||
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
rack(index: number):string
|
||||
rack(index: number,optionalEncoding:flatbuffers.Encoding):string|Uint8Array
|
||||
rack(index: number,optionalEncoding?:any):string|Uint8Array|null {
|
||||
rack(index: number):number|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 8);
|
||||
return offset ? this.bb!.__string(this.bb!.__vector(this.bb_pos + offset) + index * 4, optionalEncoding) : null;
|
||||
return offset ? this.bb!.readUint8(this.bb!.__vector(this.bb_pos + offset) + index) : 0;
|
||||
}
|
||||
|
||||
rackLength():number {
|
||||
@@ -45,6 +44,11 @@ rackLength():number {
|
||||
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
rackArray():Uint8Array|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 8);
|
||||
return offset ? new Uint8Array(this.bb!.bytes().buffer, this.bb!.bytes().byteOffset + this.bb!.__vector(this.bb_pos + offset), this.bb!.__vector_len(this.bb_pos + offset)) : null;
|
||||
}
|
||||
|
||||
bagLen():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 10);
|
||||
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
||||
@@ -55,8 +59,18 @@ hintsRemaining():number {
|
||||
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
alphabet(index: number, obj?:AlphabetEntry):AlphabetEntry|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 14);
|
||||
return offset ? (obj || new AlphabetEntry()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
|
||||
}
|
||||
|
||||
alphabetLength():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 14);
|
||||
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
static startStateView(builder:flatbuffers.Builder) {
|
||||
builder.startObject(5);
|
||||
builder.startObject(6);
|
||||
}
|
||||
|
||||
static addGame(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset) {
|
||||
@@ -71,16 +85,16 @@ static addRack(builder:flatbuffers.Builder, rackOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(2, rackOffset, 0);
|
||||
}
|
||||
|
||||
static createRackVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
|
||||
builder.startVector(4, data.length, 4);
|
||||
static createRackVector(builder:flatbuffers.Builder, data:number[]|Uint8Array):flatbuffers.Offset {
|
||||
builder.startVector(1, data.length, 1);
|
||||
for (let i = data.length - 1; i >= 0; i--) {
|
||||
builder.addOffset(data[i]!);
|
||||
builder.addInt8(data[i]!);
|
||||
}
|
||||
return builder.endVector();
|
||||
}
|
||||
|
||||
static startRackVector(builder:flatbuffers.Builder, numElems:number) {
|
||||
builder.startVector(4, numElems, 4);
|
||||
builder.startVector(1, numElems, 1);
|
||||
}
|
||||
|
||||
static addBagLen(builder:flatbuffers.Builder, bagLen:number) {
|
||||
@@ -91,18 +105,35 @@ static addHintsRemaining(builder:flatbuffers.Builder, hintsRemaining:number) {
|
||||
builder.addFieldInt32(4, hintsRemaining, 0);
|
||||
}
|
||||
|
||||
static addAlphabet(builder:flatbuffers.Builder, alphabetOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(5, alphabetOffset, 0);
|
||||
}
|
||||
|
||||
static createAlphabetVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
|
||||
builder.startVector(4, data.length, 4);
|
||||
for (let i = data.length - 1; i >= 0; i--) {
|
||||
builder.addOffset(data[i]!);
|
||||
}
|
||||
return builder.endVector();
|
||||
}
|
||||
|
||||
static startAlphabetVector(builder:flatbuffers.Builder, numElems:number) {
|
||||
builder.startVector(4, numElems, 4);
|
||||
}
|
||||
|
||||
static endStateView(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
}
|
||||
|
||||
static createStateView(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset, seat:number, rackOffset:flatbuffers.Offset, bagLen:number, hintsRemaining:number):flatbuffers.Offset {
|
||||
static createStateView(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset, seat:number, rackOffset:flatbuffers.Offset, bagLen:number, hintsRemaining:number, alphabetOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||
StateView.startStateView(builder);
|
||||
StateView.addGame(builder, gameOffset);
|
||||
StateView.addSeat(builder, seat);
|
||||
StateView.addRack(builder, rackOffset);
|
||||
StateView.addBagLen(builder, bagLen);
|
||||
StateView.addHintsRemaining(builder, hintsRemaining);
|
||||
StateView.addAlphabet(builder, alphabetOffset);
|
||||
return StateView.endStateView(builder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
import { TileRecord } from '../scrabblefb/tile-record.js';
|
||||
import { PlayTile } from '../scrabblefb/play-tile.js';
|
||||
|
||||
|
||||
export class SubmitPlayRequest {
|
||||
@@ -37,9 +37,9 @@ dir(optionalEncoding?:any):string|Uint8Array|null {
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
tiles(index: number, obj?:TileRecord):TileRecord|null {
|
||||
tiles(index: number, obj?:PlayTile):PlayTile|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 8);
|
||||
return offset ? (obj || new TileRecord()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
|
||||
return offset ? (obj || new PlayTile()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
|
||||
}
|
||||
|
||||
tilesLength():number {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
alphabetLetters,
|
||||
BLANK_INDEX,
|
||||
hasAlphabet,
|
||||
indexForLetter,
|
||||
letterForIndex,
|
||||
setAlphabet,
|
||||
valueForLetter,
|
||||
} from './alphabet';
|
||||
|
||||
// The cache module is per-file-isolated by vitest, so only what these tests seed exists.
|
||||
describe('alphabet cache (Stage 13)', () => {
|
||||
it('upper-cases letters for display and maps indices and values case-insensitively', () => {
|
||||
setAlphabet('english', [
|
||||
{ index: 0, letter: 'a', value: 1 },
|
||||
{ index: 16, letter: 'q', value: 10 },
|
||||
]);
|
||||
expect(hasAlphabet('english')).toBe(true);
|
||||
expect(letterForIndex('english', 0)).toBe('A');
|
||||
expect(letterForIndex('english', 16)).toBe('Q');
|
||||
expect(indexForLetter('english', 'a')).toBe(0);
|
||||
expect(indexForLetter('english', 'Q')).toBe(16);
|
||||
expect(valueForLetter('english', 'a')).toBe(1);
|
||||
expect(valueForLetter('english', 'Q')).toBe(10);
|
||||
});
|
||||
|
||||
it('handles the blank sentinel and unknown letters/indices', () => {
|
||||
setAlphabet('english', [{ index: 0, letter: 'a', value: 1 }]);
|
||||
expect(letterForIndex('english', BLANK_INDEX)).toBe('?');
|
||||
expect(indexForLetter('english', '?')).toBe(BLANK_INDEX);
|
||||
expect(valueForLetter('english', '?')).toBe(0);
|
||||
expect(letterForIndex('english', 99)).toBe(''); // out of range
|
||||
expect(valueForLetter('english', 'Z')).toBe(0); // not in this alphabet
|
||||
expect(() => indexForLetter('english', 'Z')).toThrow();
|
||||
});
|
||||
|
||||
it('lists the alphabet for the blank chooser and is empty for an uncached variant', () => {
|
||||
setAlphabet('english', [
|
||||
{ index: 0, letter: 'a', value: 1 },
|
||||
{ index: 1, letter: 'b', value: 3 },
|
||||
]);
|
||||
expect(alphabetLetters('english')).toEqual(['A', 'B']);
|
||||
expect(hasAlphabet('erudit')).toBe(false);
|
||||
expect(alphabetLetters('erudit')).toEqual([]);
|
||||
expect(valueForLetter('erudit', 'A')).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
// Per-variant alphabet table cache (Stage 13). The client is alphabet-agnostic: it caches
|
||||
// each variant's (index, letter, value) table — sent by the server on a per-variant cache
|
||||
// miss, behind game.state's include_alphabet flag — and renders the rack and the blank
|
||||
// chooser with it while live play exchanges bare alphabet indices on the wire. Letters are
|
||||
// stored upper-cased for display (the rest of the UI works in upper case) and index lookups
|
||||
// are case-insensitive. A blank rides as the sentinel index 255 in a rack/exchange list; a
|
||||
// placed blank instead travels as its designated letter's index with a separate blank flag.
|
||||
|
||||
import type { Variant } from './model';
|
||||
|
||||
/** BLANK_INDEX is the wire sentinel for a blank tile in a rack/exchange index list. */
|
||||
export const BLANK_INDEX = 255;
|
||||
|
||||
/** BLANK_LETTER is the glyph a blank rack tile decodes to (matches placement.BLANK). */
|
||||
const BLANK_LETTER = '?';
|
||||
|
||||
/** AlphabetEntryWire is one raw alphabet row as received from the wire or a mock fixture. */
|
||||
export interface AlphabetEntryWire {
|
||||
index: number;
|
||||
letter: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface Table {
|
||||
letters: string[]; // by index, upper-cased
|
||||
values: number[]; // by index
|
||||
indexByLetter: Map<string, number>; // upper-cased letter -> index
|
||||
}
|
||||
|
||||
const cache = new Map<Variant, Table>();
|
||||
|
||||
/** setAlphabet caches a variant's table, upper-casing letters for display. */
|
||||
export function setAlphabet(variant: Variant, entries: AlphabetEntryWire[]): void {
|
||||
let size = 0;
|
||||
for (const e of entries) size = Math.max(size, e.index + 1);
|
||||
const letters = new Array<string>(size).fill('');
|
||||
const values = new Array<number>(size).fill(0);
|
||||
const indexByLetter = new Map<string, number>();
|
||||
for (const e of entries) {
|
||||
const up = e.letter.toUpperCase();
|
||||
letters[e.index] = up;
|
||||
values[e.index] = e.value;
|
||||
indexByLetter.set(up, e.index);
|
||||
}
|
||||
cache.set(variant, { letters, values, indexByLetter });
|
||||
}
|
||||
|
||||
/** hasAlphabet reports whether a variant's table is cached (so the client can skip asking
|
||||
* the server to resend it). */
|
||||
export function hasAlphabet(variant: Variant): boolean {
|
||||
return cache.has(variant);
|
||||
}
|
||||
|
||||
/** alphabetLetters lists a variant's letters (upper-cased) for the blank chooser and the
|
||||
* word-check input filter; empty when the table is not yet cached. */
|
||||
export function alphabetLetters(variant: Variant): string[] {
|
||||
return cache.get(variant)?.letters.slice() ?? [];
|
||||
}
|
||||
|
||||
/** letterForIndex maps a wire rack index to its display letter: the blank sentinel maps to
|
||||
* "?", an unknown index to "". */
|
||||
export function letterForIndex(variant: Variant, index: number): string {
|
||||
if (index === BLANK_INDEX) return BLANK_LETTER;
|
||||
return cache.get(variant)?.letters[index] ?? '';
|
||||
}
|
||||
|
||||
/** valueForLetter returns a tile's point value; a blank ("?") and an unknown letter score 0. */
|
||||
export function valueForLetter(variant: Variant, letter: string): number {
|
||||
if (!letter || letter === BLANK_LETTER) return 0;
|
||||
const t = cache.get(variant);
|
||||
if (!t) return 0;
|
||||
const i = t.indexByLetter.get(letter.toUpperCase());
|
||||
return i === undefined ? 0 : t.values[i];
|
||||
}
|
||||
|
||||
/** indexForLetter maps a display letter to its wire index; a blank ("?") maps to the blank
|
||||
* sentinel. It throws when the letter is outside the cached alphabet — a placement bug, not
|
||||
* user input (the UI constrains every entry point to the variant's alphabet). */
|
||||
export function indexForLetter(variant: Variant, letter: string): number {
|
||||
if (letter === BLANK_LETTER) return BLANK_INDEX;
|
||||
const i = cache.get(variant)?.indexByLetter.get(letter.toUpperCase());
|
||||
if (i === undefined) throw new Error(`alphabet: no index for "${letter}" in ${variant}`);
|
||||
return i;
|
||||
}
|
||||
@@ -67,15 +67,18 @@ export interface GatewayClient {
|
||||
lobbyPoll(): Promise<MatchResult>;
|
||||
|
||||
// --- game ---
|
||||
gameState(gameId: string): Promise<StateView>;
|
||||
// Stage 13: the play loop exchanges alphabet indices, so submit/evaluate/exchange/
|
||||
// check-word take the game's variant (to map letters<->indices via the cached alphabet
|
||||
// table), and gameState's includeAlphabet asks the server to embed that table.
|
||||
gameState(gameId: string, includeAlphabet: boolean): Promise<StateView>;
|
||||
gameHistory(gameId: string): Promise<History>;
|
||||
submitPlay(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[]): Promise<MoveResult>;
|
||||
submitPlay(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[], variant: Variant): Promise<MoveResult>;
|
||||
pass(gameId: string): Promise<MoveResult>;
|
||||
exchange(gameId: string, tiles: 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, dir: 'H' | 'V', tiles: PlacedTile[]): Promise<EvalResult>;
|
||||
checkWord(gameId: string, word: string): Promise<WordCheckResult>;
|
||||
evaluate(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[], variant: Variant): Promise<EvalResult>;
|
||||
checkWord(gameId: string, word: string, variant: Variant): Promise<WordCheckResult>;
|
||||
complaint(gameId: string, word: string, note: string): Promise<void>;
|
||||
|
||||
// --- chat ---
|
||||
|
||||
@@ -1,28 +1,44 @@
|
||||
import { Builder, ByteBuffer } from 'flatbuffers';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import * as fb from '../gen/fbs/scrabblefb';
|
||||
import { BLANK_INDEX, setAlphabet } from './alphabet';
|
||||
import {
|
||||
decodeFriendList,
|
||||
decodeGameList,
|
||||
decodeInvitation,
|
||||
decodeLinkResult,
|
||||
decodeSession,
|
||||
decodeStateView,
|
||||
decodeStats,
|
||||
encodeCheckWord,
|
||||
encodeExchange,
|
||||
encodeStateRequest,
|
||||
encodeSubmitPlay,
|
||||
encodeTarget,
|
||||
} from './codec';
|
||||
|
||||
describe('codec', () => {
|
||||
it('encodes a SubmitPlayRequest the gateway can read', () => {
|
||||
const buf = encodeSubmitPlay('g1', 'H', [
|
||||
{ row: 7, col: 7, letter: 'A', blank: false },
|
||||
{ row: 7, col: 8, letter: 'B', blank: true },
|
||||
it('encodes a SubmitPlayRequest with alphabet indices (Stage 13)', () => {
|
||||
setAlphabet('english', [
|
||||
{ index: 0, letter: 'a', value: 1 },
|
||||
{ index: 1, letter: 'b', value: 3 },
|
||||
]);
|
||||
// A placed blank carries its designated letter's index with the blank flag set.
|
||||
const buf = encodeSubmitPlay(
|
||||
'g1',
|
||||
'H',
|
||||
[
|
||||
{ row: 7, col: 7, letter: 'A', blank: false },
|
||||
{ row: 7, col: 8, letter: 'B', blank: true },
|
||||
],
|
||||
'english',
|
||||
);
|
||||
const r = fb.SubmitPlayRequest.getRootAsSubmitPlayRequest(new ByteBuffer(buf));
|
||||
expect(r.gameId()).toBe('g1');
|
||||
expect(r.dir()).toBe('H');
|
||||
expect(r.tilesLength()).toBe(2);
|
||||
expect(r.tiles(0)?.letter()).toBe('A');
|
||||
expect(r.tiles(0)?.letter()).toBe(0);
|
||||
expect(r.tiles(1)?.letter()).toBe(1);
|
||||
expect(r.tiles(1)?.blank()).toBe(true);
|
||||
});
|
||||
|
||||
@@ -214,3 +230,72 @@ describe('codec', () => {
|
||||
expect(inv.variant).toBe('english');
|
||||
});
|
||||
});
|
||||
|
||||
// Stage 13: the live play loop exchanges alphabet indices, mapped through the per-variant
|
||||
// table cached in lib/alphabet. Each test seeds the cache it needs (setAlphabet replaces
|
||||
// the whole table), so they are independent of order.
|
||||
describe('codec — alphabet on the wire (Stage 13)', () => {
|
||||
it('encodes an ExchangeRequest as alphabet indices, blank as the sentinel', () => {
|
||||
setAlphabet('english', [
|
||||
{ index: 0, letter: 'a', value: 1 },
|
||||
{ index: 1, letter: 'b', value: 3 },
|
||||
]);
|
||||
const r = fb.ExchangeRequest.getRootAsExchangeRequest(
|
||||
new ByteBuffer(encodeExchange('g1', ['A', '?'], 'english')),
|
||||
);
|
||||
expect(r.tilesLength()).toBe(2);
|
||||
expect(r.tiles(0)).toBe(0);
|
||||
expect(r.tiles(1)).toBe(BLANK_INDEX);
|
||||
});
|
||||
|
||||
it('encodes a CheckWordRequest as alphabet indices', () => {
|
||||
setAlphabet('english', [
|
||||
{ index: 0, letter: 'a', value: 1 },
|
||||
{ index: 2, letter: 'c', value: 3 },
|
||||
{ index: 19, letter: 't', value: 1 },
|
||||
]);
|
||||
const r = fb.CheckWordRequest.getRootAsCheckWordRequest(
|
||||
new ByteBuffer(encodeCheckWord('g1', 'CAT', 'english')),
|
||||
);
|
||||
expect(r.wordLength()).toBe(3);
|
||||
expect([r.word(0), r.word(1), r.word(2)]).toEqual([2, 0, 19]);
|
||||
});
|
||||
|
||||
it('carries the include_alphabet flag on a StateRequest', () => {
|
||||
const on = fb.StateRequest.getRootAsStateRequest(new ByteBuffer(encodeStateRequest('g1', true)));
|
||||
expect(on.gameId()).toBe('g1');
|
||||
expect(on.includeAlphabet()).toBe(true);
|
||||
const off = fb.StateRequest.getRootAsStateRequest(new ByteBuffer(encodeStateRequest('g1', false)));
|
||||
expect(off.includeAlphabet()).toBe(false);
|
||||
});
|
||||
|
||||
it('caches the alphabet table from a StateView and decodes the index rack to letters', () => {
|
||||
const b = new Builder(128);
|
||||
const la = b.createString('a');
|
||||
fb.AlphabetEntry.startAlphabetEntry(b);
|
||||
fb.AlphabetEntry.addIndex(b, 0);
|
||||
fb.AlphabetEntry.addLetter(b, la);
|
||||
fb.AlphabetEntry.addValue(b, 1);
|
||||
const ea = fb.AlphabetEntry.endAlphabetEntry(b);
|
||||
const lb = b.createString('b');
|
||||
fb.AlphabetEntry.startAlphabetEntry(b);
|
||||
fb.AlphabetEntry.addIndex(b, 1);
|
||||
fb.AlphabetEntry.addLetter(b, lb);
|
||||
fb.AlphabetEntry.addValue(b, 3);
|
||||
const eb = fb.AlphabetEntry.endAlphabetEntry(b);
|
||||
const alpha = fb.StateView.createAlphabetVector(b, [ea, eb]);
|
||||
const rack = fb.StateView.createRackVector(b, [0, BLANK_INDEX]);
|
||||
fb.StateView.startStateView(b);
|
||||
fb.StateView.addSeat(b, 0);
|
||||
fb.StateView.addRack(b, rack);
|
||||
fb.StateView.addBagLen(b, 10);
|
||||
fb.StateView.addHintsRemaining(b, 0);
|
||||
fb.StateView.addAlphabet(b, alpha);
|
||||
b.finish(fb.StateView.endStateView(b));
|
||||
|
||||
// No GameView on the buffer, so decode falls back to the default variant 'english';
|
||||
// the embedded table is cached under it and the rack [0, blank] decodes to letters.
|
||||
const sv = decodeStateView(b.asUint8Array());
|
||||
expect(sv.rack).toEqual(['A', '?']);
|
||||
});
|
||||
});
|
||||
|
||||
+52
-22
@@ -5,6 +5,7 @@
|
||||
|
||||
import { Builder, ByteBuffer, type Offset } from 'flatbuffers';
|
||||
import * as fb from '../gen/fbs/scrabblefb';
|
||||
import { indexForLetter, letterForIndex, setAlphabet, type AlphabetEntryWire } from './alphabet';
|
||||
import type { PlacedTile } from './client';
|
||||
import type {
|
||||
AccountRef,
|
||||
@@ -37,14 +38,15 @@ import type {
|
||||
|
||||
// --- request encoders ---
|
||||
|
||||
function buildTile(b: Builder, t: PlacedTile): Offset {
|
||||
const letter = b.createString(t.letter);
|
||||
fb.TileRecord.startTileRecord(b);
|
||||
fb.TileRecord.addRow(b, t.row);
|
||||
fb.TileRecord.addCol(b, t.col);
|
||||
fb.TileRecord.addLetter(b, letter);
|
||||
fb.TileRecord.addBlank(b, t.blank);
|
||||
return fb.TileRecord.endTileRecord(b);
|
||||
// buildPlayTile encodes one to-place tile by its alphabet index (Stage 13); a placed blank
|
||||
// carries its designated letter's index with blank set.
|
||||
function buildPlayTile(b: Builder, t: PlacedTile, variant: Variant): Offset {
|
||||
fb.PlayTile.startPlayTile(b);
|
||||
fb.PlayTile.addRow(b, t.row);
|
||||
fb.PlayTile.addCol(b, t.col);
|
||||
fb.PlayTile.addLetter(b, indexForLetter(variant, t.letter));
|
||||
fb.PlayTile.addBlank(b, t.blank);
|
||||
return fb.PlayTile.endPlayTile(b);
|
||||
}
|
||||
|
||||
function finish(b: Builder, root: Offset): Uint8Array {
|
||||
@@ -62,17 +64,23 @@ export function encodeGameAction(gameId: string): Uint8Array {
|
||||
return finish(b, fb.GameActionRequest.endGameActionRequest(b));
|
||||
}
|
||||
|
||||
export function encodeStateRequest(gameId: string): Uint8Array {
|
||||
export function encodeStateRequest(gameId: string, includeAlphabet: boolean): Uint8Array {
|
||||
const b = new Builder(64);
|
||||
const gid = b.createString(gameId);
|
||||
fb.StateRequest.startStateRequest(b);
|
||||
fb.StateRequest.addGameId(b, gid);
|
||||
fb.StateRequest.addIncludeAlphabet(b, includeAlphabet);
|
||||
return finish(b, fb.StateRequest.endStateRequest(b));
|
||||
}
|
||||
|
||||
export function encodeSubmitPlay(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[]): Uint8Array {
|
||||
export function encodeSubmitPlay(
|
||||
gameId: string,
|
||||
dir: 'H' | 'V',
|
||||
tiles: PlacedTile[],
|
||||
variant: Variant,
|
||||
): Uint8Array {
|
||||
const b = new Builder(256);
|
||||
const tileOffs = tiles.map((t) => buildTile(b, t));
|
||||
const tileOffs = tiles.map((t) => buildPlayTile(b, t, variant));
|
||||
const vec = fb.SubmitPlayRequest.createTilesVector(b, tileOffs);
|
||||
const gid = b.createString(gameId);
|
||||
const d = b.createString(dir);
|
||||
@@ -83,9 +91,14 @@ export function encodeSubmitPlay(gameId: string, dir: 'H' | 'V', tiles: PlacedTi
|
||||
return finish(b, fb.SubmitPlayRequest.endSubmitPlayRequest(b));
|
||||
}
|
||||
|
||||
export function encodeEval(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[]): Uint8Array {
|
||||
export function encodeEval(
|
||||
gameId: string,
|
||||
dir: 'H' | 'V',
|
||||
tiles: PlacedTile[],
|
||||
variant: Variant,
|
||||
): Uint8Array {
|
||||
const b = new Builder(256);
|
||||
const tileOffs = tiles.map((t) => buildTile(b, t));
|
||||
const tileOffs = tiles.map((t) => buildPlayTile(b, t, variant));
|
||||
const vec = fb.EvalRequest.createTilesVector(b, tileOffs);
|
||||
const gid = b.createString(gameId);
|
||||
const d = b.createString(dir);
|
||||
@@ -96,10 +109,12 @@ export function encodeEval(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[]):
|
||||
return finish(b, fb.EvalRequest.endEvalRequest(b));
|
||||
}
|
||||
|
||||
export function encodeExchange(gameId: string, tiles: string[]): Uint8Array {
|
||||
export function encodeExchange(gameId: string, tiles: string[], variant: Variant): Uint8Array {
|
||||
const b = new Builder(128);
|
||||
const offs = tiles.map((s) => b.createString(s));
|
||||
const vec = fb.ExchangeRequest.createTilesVector(b, offs);
|
||||
const vec = fb.ExchangeRequest.createTilesVector(
|
||||
b,
|
||||
tiles.map((l) => indexForLetter(variant, l)),
|
||||
);
|
||||
const gid = b.createString(gameId);
|
||||
fb.ExchangeRequest.startExchangeRequest(b);
|
||||
fb.ExchangeRequest.addGameId(b, gid);
|
||||
@@ -107,13 +122,16 @@ export function encodeExchange(gameId: string, tiles: string[]): Uint8Array {
|
||||
return finish(b, fb.ExchangeRequest.endExchangeRequest(b));
|
||||
}
|
||||
|
||||
export function encodeCheckWord(gameId: string, word: string): Uint8Array {
|
||||
export function encodeCheckWord(gameId: string, word: string, variant: Variant): Uint8Array {
|
||||
const b = new Builder(128);
|
||||
const vec = fb.CheckWordRequest.createWordVector(
|
||||
b,
|
||||
Array.from(word).map((ch) => indexForLetter(variant, ch)),
|
||||
);
|
||||
const gid = b.createString(gameId);
|
||||
const w = b.createString(word);
|
||||
fb.CheckWordRequest.startCheckWordRequest(b);
|
||||
fb.CheckWordRequest.addGameId(b, gid);
|
||||
fb.CheckWordRequest.addWord(b, w);
|
||||
fb.CheckWordRequest.addWord(b, vec);
|
||||
return finish(b, fb.CheckWordRequest.endCheckWordRequest(b));
|
||||
}
|
||||
|
||||
@@ -188,7 +206,8 @@ function s(v: string | null): string {
|
||||
}
|
||||
|
||||
function decodeTile(t: fb.TileRecord): Tile {
|
||||
return { row: t.row(), col: t.col(), letter: s(t.letter()), blank: t.blank() };
|
||||
// The wire keeps the move journal in the solver's lower case; the UI renders upper case.
|
||||
return { row: t.row(), col: t.col(), letter: s(t.letter()).toUpperCase(), blank: t.blank() };
|
||||
}
|
||||
|
||||
function decodeSeat(v: fb.SeatView): Seat {
|
||||
@@ -229,7 +248,7 @@ function decodeMove(m: fb.MoveRecord): MoveRecord {
|
||||
if (t) tiles.push(decodeTile(t));
|
||||
}
|
||||
const words: string[] = [];
|
||||
for (let i = 0; i < m.wordsLength(); i++) words.push(s(m.words(i)));
|
||||
for (let i = 0; i < m.wordsLength(); i++) words.push(s(m.words(i)).toUpperCase());
|
||||
return {
|
||||
player: m.player(),
|
||||
action: s(m.action()),
|
||||
@@ -280,8 +299,19 @@ export function decodeProfile(buf: Uint8Array): Profile {
|
||||
export function decodeStateView(buf: Uint8Array): StateView {
|
||||
const v = fb.StateView.getRootAsStateView(new ByteBuffer(buf));
|
||||
const g = v.game();
|
||||
const variant = (g ? s(g.variant()) : 'english') as Variant;
|
||||
// Cache the alphabet table when the server included it (a per-variant cache miss), then
|
||||
// decode the index rack to display letters with it (Stage 13).
|
||||
if (v.alphabetLength() > 0) {
|
||||
const entries: AlphabetEntryWire[] = [];
|
||||
for (let i = 0; i < v.alphabetLength(); i++) {
|
||||
const e = v.alphabet(i);
|
||||
if (e) entries.push({ index: e.index(), letter: s(e.letter()), value: e.value() });
|
||||
}
|
||||
setAlphabet(variant, entries);
|
||||
}
|
||||
const rack: string[] = [];
|
||||
for (let i = 0; i < v.rackLength(); i++) rack.push(s(v.rack(i)));
|
||||
for (let i = 0; i < v.rackLength(); i++) rack.push(letterForIndex(variant, v.rack(i) ?? 0));
|
||||
return {
|
||||
game: g ? decodeGameView(g) : emptyGame(),
|
||||
seat: v.seat(),
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Mock alphabet fixtures (Stage 13). In production the per-variant (index, letter, value)
|
||||
// table comes from the server; the mock seeds the same client cache from a local copy so
|
||||
// the rack, the blank chooser and the mock's scoring work with no backend. The data is the
|
||||
// solver's value tables (scrabble-solver/rules/rules.go), in alphabet-index order, so a
|
||||
// token's position is its index — the same shape the wire delivers.
|
||||
|
||||
import { setAlphabet, type AlphabetEntryWire } from '../alphabet';
|
||||
import type { Variant } from '../model';
|
||||
|
||||
// "letter+value" tokens in alphabet-index order. English Latin a..z; Russian а..я incl. ё;
|
||||
// Эрудит а..я incl. ё=0.
|
||||
const SPECS: Record<Variant, string> = {
|
||||
english:
|
||||
'a1 b3 c3 d2 e1 f4 g2 h4 i1 j8 k5 l1 m3 n1 o1 p3 q10 r1 s1 t1 u1 v4 w4 x8 y4 z10',
|
||||
russian:
|
||||
'а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',
|
||||
erudit:
|
||||
'а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',
|
||||
};
|
||||
|
||||
function parse(spec: string): AlphabetEntryWire[] {
|
||||
return spec
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map((tok, index) => {
|
||||
const m = tok.match(/^(.+?)(\d+)$/);
|
||||
return { index, letter: m ? m[1] : tok, value: m ? Number(m[2]) : 0 };
|
||||
});
|
||||
}
|
||||
|
||||
let seeded = false;
|
||||
|
||||
/** seedMockAlphabets populates the alphabet cache for every variant once, mirroring the
|
||||
* server-sent tables so the mock-driven UI is alphabet-agnostic too. */
|
||||
export function seedMockAlphabets(): void {
|
||||
if (seeded) return;
|
||||
for (const variant of Object.keys(SPECS) as Variant[]) {
|
||||
setAlphabet(variant, parse(SPECS[variant]));
|
||||
}
|
||||
seeded = true;
|
||||
}
|
||||
@@ -34,7 +34,8 @@ import type {
|
||||
Variant,
|
||||
WordCheckResult,
|
||||
} from '../model';
|
||||
import { tileValue } from '../premiums';
|
||||
import { valueForLetter } from '../alphabet';
|
||||
import { seedMockAlphabets } from './alphabet';
|
||||
import {
|
||||
ME,
|
||||
MOCK_FRIENDS,
|
||||
@@ -93,6 +94,12 @@ export class MockGateway implements GatewayClient {
|
||||
private invitations: Invitation[] = mockInvitations();
|
||||
private readonly stats: Stats = { ...MOCK_STATS };
|
||||
|
||||
constructor() {
|
||||
// Seed the per-variant alphabet cache the rack, blank chooser and scoring read, so the
|
||||
// mock-driven UI is alphabet-agnostic without a backend (Stage 13).
|
||||
seedMockAlphabets();
|
||||
}
|
||||
|
||||
setToken(_token: string | null): void {
|
||||
// The mock needs no auth; the real transport stores the bearer token.
|
||||
}
|
||||
@@ -174,7 +181,7 @@ export class MockGateway implements GatewayClient {
|
||||
}
|
||||
|
||||
// --- game ---
|
||||
async gameState(gameId: string): Promise<StateView> {
|
||||
async gameState(gameId: string, _includeAlphabet: boolean): Promise<StateView> {
|
||||
const g = this.game(gameId);
|
||||
return {
|
||||
game: structuredClone(g.view),
|
||||
@@ -190,12 +197,12 @@ export class MockGateway implements GatewayClient {
|
||||
return { gameId, moves: structuredClone(g.moves) };
|
||||
}
|
||||
|
||||
async submitPlay(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[]): Promise<MoveResult> {
|
||||
async submitPlay(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[], _variant: Variant): Promise<MoveResult> {
|
||||
const g = this.game(gameId);
|
||||
const seat = this.mySeat(g);
|
||||
if (g.view.toMove !== seat) throw new GatewayError('not_your_turn');
|
||||
const variant = g.view.variant;
|
||||
let score = tiles.reduce((s, t) => s + tileValue(variant, t.blank ? '?' : t.letter), 0);
|
||||
let score = tiles.reduce((s, t) => s + valueForLetter(variant, t.blank ? '?' : t.letter), 0);
|
||||
if (tiles.length === 7) score += 50;
|
||||
const total = g.view.seats[seat].score + score;
|
||||
const move = {
|
||||
@@ -265,7 +272,7 @@ export class MockGateway implements GatewayClient {
|
||||
pass(gameId: string): Promise<MoveResult> {
|
||||
return this.simpleAction(gameId, 'pass');
|
||||
}
|
||||
exchange(gameId: string, tiles: string[]): Promise<MoveResult> {
|
||||
exchange(gameId: string, tiles: string[], _variant: Variant): Promise<MoveResult> {
|
||||
return this.simpleAction(gameId, 'exchange', tiles);
|
||||
}
|
||||
resign(gameId: string): Promise<MoveResult> {
|
||||
@@ -287,22 +294,22 @@ export class MockGateway implements GatewayClient {
|
||||
tiles: [{ row: 7, col: 7, letter, blank: false }],
|
||||
words: [letter],
|
||||
count: 1,
|
||||
score: tileValue(g.view.variant, letter),
|
||||
score: valueForLetter(g.view.variant, letter),
|
||||
total: 0,
|
||||
},
|
||||
hintsRemaining: g.hintsRemaining,
|
||||
};
|
||||
}
|
||||
|
||||
async evaluate(gameId: string, _dir: 'H' | 'V', tiles: PlacedTile[]): Promise<EvalResult> {
|
||||
async evaluate(gameId: string, _dir: 'H' | 'V', tiles: PlacedTile[], _variant: Variant): Promise<EvalResult> {
|
||||
const g = this.game(gameId);
|
||||
if (tiles.length === 0) return { legal: false, score: 0, words: [] };
|
||||
let score = tiles.reduce((s, t) => s + tileValue(g.view.variant, t.blank ? '?' : t.letter), 0);
|
||||
let score = tiles.reduce((s, t) => s + valueForLetter(g.view.variant, t.blank ? '?' : t.letter), 0);
|
||||
if (tiles.length === 7) score += 50;
|
||||
return { legal: true, score, words: [tiles.map((t) => t.letter).join('')] };
|
||||
}
|
||||
|
||||
async checkWord(_gameId: string, word: string): Promise<WordCheckResult> {
|
||||
async checkWord(_gameId: string, word: string, _variant: Variant): Promise<WordCheckResult> {
|
||||
return { word, legal: word.trim().length >= 2 };
|
||||
}
|
||||
async complaint(): Promise<void> {}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { alphabet, BOARD_SIZE, centre, premiumGrid, tileValue } from './premiums';
|
||||
import { BOARD_SIZE, centre, premiumGrid } from './premiums';
|
||||
|
||||
// Parity with scrabble-solver/rules/rules.go: english/russian share standardBoard
|
||||
// (centre is a double word); erudit shares the geometry but a non-doubling centre.
|
||||
// Premium-square geometry parity with scrabble-solver/rules/rules.go: english/russian
|
||||
// share standardBoard (centre is a double word); erudit shares the geometry but a
|
||||
// non-doubling centre. Tile-value and alphabet parity moved to the Go engine test
|
||||
// (backend/internal/engine AlphabetTable) in Stage 13 — the server now owns that table.
|
||||
describe('premium layout', () => {
|
||||
it('is a 15x15 grid with TW corners', () => {
|
||||
const g = premiumGrid('english');
|
||||
@@ -35,19 +37,3 @@ describe('premium layout', () => {
|
||||
expect(count('DW')).toBe(17); // 16 double-word squares + the centre
|
||||
});
|
||||
});
|
||||
|
||||
describe('tile values', () => {
|
||||
it('scores letters per variant and zero for a blank', () => {
|
||||
expect(tileValue('english', 'A')).toBe(1);
|
||||
expect(tileValue('english', 'Q')).toBe(10);
|
||||
expect(tileValue('english', '?')).toBe(0);
|
||||
expect(tileValue('russian', 'Ф')).toBe(10);
|
||||
expect(tileValue('erudit', 'Ё')).toBe(0);
|
||||
});
|
||||
|
||||
it('exposes the full alphabet for the blank chooser', () => {
|
||||
expect(alphabet('english')).toHaveLength(26);
|
||||
expect(alphabet('russian')).toHaveLength(33);
|
||||
expect(alphabet('erudit')).toHaveLength(33);
|
||||
});
|
||||
});
|
||||
|
||||
+8
-45
@@ -1,8 +1,9 @@
|
||||
// Board premium layout and tile values — ported verbatim from the engine source of
|
||||
// truth, scrabble-solver/rules/rules.go (standardBoard / eruditBoard, and the
|
||||
// per-variant value tables). These are NOT transmitted on the wire (StateView has
|
||||
// no board), so the client renders them locally. A Vitest parity test pins the
|
||||
// layout against the known geometry. Keep this in lockstep with the solver.
|
||||
// Board premium layout — the 15x15 premium-square geometry, ported from the engine source
|
||||
// of truth, scrabble-solver/rules/rules.go (standardBoard / eruditBoard). The board is not
|
||||
// transmitted on the wire (StateView has no board), so the client renders the premiums
|
||||
// locally; only the centre differs by variant. A Vitest parity test pins the geometry.
|
||||
// Tile values and the alphabet moved to the server-sent per-variant table in Stage 13 (see
|
||||
// lib/alphabet.ts), so this file is geometry only.
|
||||
|
||||
import type { Variant } from './model';
|
||||
|
||||
@@ -84,43 +85,5 @@ export function centre(variant: Variant): { row: number; col: number } {
|
||||
return { row: 7, col: 7 };
|
||||
}
|
||||
|
||||
// --- tile values (points shown on the tile face); blank scores 0 ---
|
||||
|
||||
// English Latin a..z (rules.go English()).
|
||||
const enValues =
|
||||
'a1 b3 c3 d2 e1 f4 g2 h4 i1 j8 k5 l1 m3 n1 o1 p3 q10 r1 s1 t1 u1 v4 w4 x8 y4 z10';
|
||||
// Russian а..я incl. ё (rules.go RussianScrabble()).
|
||||
const ruValues =
|
||||
'а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';
|
||||
// Эрудит а..я incl. ё=0 (rules.go Erudit()).
|
||||
const eruditValues =
|
||||
'а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';
|
||||
|
||||
// Split each "letter+value" token into its letter (all but trailing digits) and its
|
||||
// integer value (the trailing digits).
|
||||
function valueTable(spec: string): Map<string, number> {
|
||||
const m = new Map<string, number>();
|
||||
for (const pair of spec.trim().split(/\s+/)) {
|
||||
const match = pair.match(/^(.+?)(\d+)$/);
|
||||
if (!match) continue;
|
||||
m.set(match[1].toUpperCase(), Number(match[2]));
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
const VALUES: Record<Variant, Map<string, number>> = {
|
||||
english: valueTable(enValues),
|
||||
russian: valueTable(ruValues),
|
||||
erudit: valueTable(eruditValues),
|
||||
};
|
||||
|
||||
/** tileValue returns the point value of a concrete letter; a blank ("?") is 0. */
|
||||
export function tileValue(variant: Variant, letter: string): number {
|
||||
if (!letter || letter === '?') return 0;
|
||||
return VALUES[variant]?.get(letter.toUpperCase()) ?? 0;
|
||||
}
|
||||
|
||||
/** alphabet lists a variant's letters in dictionary order (used by the blank chooser). */
|
||||
export function alphabet(variant: Variant): string[] {
|
||||
return [...VALUES[variant].keys()];
|
||||
}
|
||||
// Tile values and the per-variant alphabet now arrive from the server (lib/alphabet.ts,
|
||||
// Stage 13); the board geometry above is all this module owns.
|
||||
|
||||
+10
-10
@@ -81,20 +81,20 @@ export function createTransport(baseUrl: string): GatewayClient {
|
||||
return codec.decodeMatchResult(await exec('lobby.poll', codec.empty()));
|
||||
},
|
||||
|
||||
async gameState(id) {
|
||||
return codec.decodeStateView(await exec('game.state', codec.encodeStateRequest(id)));
|
||||
async gameState(id, includeAlphabet) {
|
||||
return codec.decodeStateView(await exec('game.state', codec.encodeStateRequest(id, includeAlphabet)));
|
||||
},
|
||||
async gameHistory(id) {
|
||||
return codec.decodeHistory(await exec('game.history', codec.encodeGameAction(id)));
|
||||
},
|
||||
async submitPlay(id, dir, tiles) {
|
||||
return codec.decodeMoveResult(await exec('game.submit_play', codec.encodeSubmitPlay(id, dir, tiles)));
|
||||
async submitPlay(id, dir, tiles, variant) {
|
||||
return codec.decodeMoveResult(await exec('game.submit_play', codec.encodeSubmitPlay(id, dir, tiles, variant)));
|
||||
},
|
||||
async pass(id) {
|
||||
return codec.decodeMoveResult(await exec('game.pass', codec.encodeGameAction(id)));
|
||||
},
|
||||
async exchange(id, tiles) {
|
||||
return codec.decodeMoveResult(await exec('game.exchange', codec.encodeExchange(id, tiles)));
|
||||
async exchange(id, tiles, variant) {
|
||||
return codec.decodeMoveResult(await exec('game.exchange', codec.encodeExchange(id, tiles, variant)));
|
||||
},
|
||||
async resign(id) {
|
||||
return codec.decodeMoveResult(await exec('game.resign', codec.encodeGameAction(id)));
|
||||
@@ -102,11 +102,11 @@ export function createTransport(baseUrl: string): GatewayClient {
|
||||
async hint(id) {
|
||||
return codec.decodeHintResult(await exec('game.hint', codec.encodeGameAction(id)));
|
||||
},
|
||||
async evaluate(id, dir, tiles) {
|
||||
return codec.decodeEvalResult(await exec('game.evaluate', codec.encodeEval(id, dir, tiles)));
|
||||
async evaluate(id, dir, tiles, variant) {
|
||||
return codec.decodeEvalResult(await exec('game.evaluate', codec.encodeEval(id, dir, tiles, variant)));
|
||||
},
|
||||
async checkWord(id, word) {
|
||||
return codec.decodeWordCheck(await exec('game.check_word', codec.encodeCheckWord(id, word)));
|
||||
async checkWord(id, word, variant) {
|
||||
return codec.decodeWordCheck(await exec('game.check_word', codec.encodeCheckWord(id, word, variant)));
|
||||
},
|
||||
async complaint(id, word, note) {
|
||||
await exec('game.complaint', codec.encodeComplaint(id, word, note));
|
||||
|
||||
Reference in New Issue
Block a user