Backend infers play direction; UI previews words and gates submit on legality
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 44s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m9s
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 44s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m9s
A single tile that only extended a word perpendicular to the client-declared direction was rejected: the UI always sent dir=H for one-tile plays (the dirOverride/Controls toggle was orphaned in the Stage 7 game rework), so placing "А" above "БАК" to form "АБАК" failed the solver's main-word-length check even though the word is in the dictionary. Make the backend infer a play's orientation from the placed tiles and the board (internal/engine.resolveDirection): two or more tiles by the line they share, a lone tile by the axis it abuts (longer word wins, horizontal on a tie). Direction becomes an output, not an input: drop dir from the SubmitPlay/Eval wire requests and add it to EvalResult. Journal replay keeps trusting the stored "H"/"V" (SubmitPlayDir) so a rebuilt game matches the one committed. UI: stop computing/sending direction; the preview now shows the words a move forms with its total score (game.previewWords); the make-move control is disabled until the play is confirmed legal; the "your turn" label hides while tiles are pending. Delete the orphaned Controls.svelte. Regenerate the FlatBuffers bindings (Go + TS) and update the gateway transcode and the loadtest edge client to the new contract. Bake the decision into ARCHITECTURE.md (§5/§9.1), FUNCTIONAL.md (+ _ru) and the backend README.
This commit is contained in:
@@ -1,101 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { EvalResult } from '../lib/model';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
|
||||
let {
|
||||
preview,
|
||||
hints,
|
||||
busy,
|
||||
ambiguous,
|
||||
dir,
|
||||
ondraw,
|
||||
onskip,
|
||||
onshuffle,
|
||||
onhint,
|
||||
ondir,
|
||||
}: {
|
||||
preview: EvalResult | null;
|
||||
hints: number;
|
||||
busy: boolean;
|
||||
ambiguous: boolean;
|
||||
dir: 'H' | 'V';
|
||||
ondraw: () => void;
|
||||
onskip: () => void;
|
||||
onshuffle: () => void;
|
||||
onhint: () => void;
|
||||
ondir: () => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="controls">
|
||||
<div class="preview">
|
||||
{#if preview}
|
||||
{#if preview.legal}
|
||||
<span class="ok">{t('game.preview', { n: preview.score })}</span>
|
||||
{:else}
|
||||
<span class="bad">{t('game.previewIllegal')}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if ambiguous}
|
||||
<button class="dir" onclick={ondir} title="direction">{dir === 'H' ? '↔' : '↕'}</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="row">
|
||||
<button onclick={ondraw} disabled={busy}>{t('game.draw')}</button>
|
||||
<button onclick={onskip} disabled={busy}>{t('game.skip')}</button>
|
||||
<button onclick={onshuffle} disabled={busy}>{t('game.shuffle')}</button>
|
||||
<button class="hint" onclick={onhint} disabled={busy || hints <= 0}>
|
||||
{t('game.hint')}{hints > 0 ? ` (${hints})` : ''}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.preview {
|
||||
min-height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ok {
|
||||
color: var(--ok);
|
||||
}
|
||||
.bad {
|
||||
color: var(--danger);
|
||||
}
|
||||
.dir {
|
||||
margin-left: auto;
|
||||
width: 34px;
|
||||
height: 28px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 1rem;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.row button {
|
||||
flex: 1;
|
||||
padding: 11px 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
.row button:disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
.hint {
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
+8
-12
@@ -12,7 +12,7 @@
|
||||
import { connection } from '../lib/connection.svelte';
|
||||
import { GatewayError } from '../lib/client';
|
||||
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
||||
import type { Direction, EvalResult, MoveRecord, MoveResult, StateView, Tile } from '../lib/model';
|
||||
import type { EvalResult, MoveRecord, MoveResult, StateView, Tile } from '../lib/model';
|
||||
import { lastMoveCells, replay } from '../lib/board';
|
||||
import { historyGrid } from '../lib/history';
|
||||
import { centre, premiumGrid } from '../lib/premiums';
|
||||
@@ -42,7 +42,6 @@
|
||||
let moves = $state<MoveRecord[]>([]);
|
||||
let placement = $state<Placement>(newPlacement([]));
|
||||
let preview = $state<EvalResult | null>(null);
|
||||
let dirOverride = $state<Direction | undefined>(undefined);
|
||||
let busy = $state(false);
|
||||
let zoomed = $state(false);
|
||||
let selected = $state<number | null>(null);
|
||||
@@ -130,7 +129,6 @@
|
||||
moves = hist.moves;
|
||||
setCachedGame(id, st, hist.moves);
|
||||
selected = null;
|
||||
dirOverride = undefined;
|
||||
await applyDraft(st);
|
||||
recompute();
|
||||
refreshRecent();
|
||||
@@ -491,11 +489,11 @@
|
||||
if (previewTimer) clearTimeout(previewTimer);
|
||||
// Off-turn the composition is position-only: no score preview or evaluate.
|
||||
if (!isMyTurn) return;
|
||||
const sub = toSubmit(placement, dirOverride);
|
||||
const sub = toSubmit(placement);
|
||||
if (!sub) return;
|
||||
previewTimer = setTimeout(async () => {
|
||||
try {
|
||||
preview = await gateway.evaluate(id, sub.dir, sub.tiles, variant);
|
||||
preview = await gateway.evaluate(id, sub.tiles, variant);
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
@@ -511,17 +509,16 @@
|
||||
rackIds = r.rack.map((_, i) => i);
|
||||
placement = newPlacement(r.rack);
|
||||
selected = null;
|
||||
dirOverride = undefined;
|
||||
recompute();
|
||||
refreshRecent();
|
||||
}
|
||||
|
||||
async function commit() {
|
||||
const sub = toSubmit(placement, dirOverride);
|
||||
const sub = toSubmit(placement);
|
||||
if (!sub) return;
|
||||
busy = true;
|
||||
try {
|
||||
applyMoveResult(await gateway.submitPlay(id, sub.dir, sub.tiles, variant));
|
||||
applyMoveResult(await gateway.submitPlay(id, sub.tiles, variant));
|
||||
telegramHaptic('success');
|
||||
zoomed = false;
|
||||
} catch (e) {
|
||||
@@ -534,7 +531,6 @@
|
||||
placement = reset(placement);
|
||||
preview = null;
|
||||
selected = null;
|
||||
dirOverride = undefined;
|
||||
scheduleDraftSave();
|
||||
}
|
||||
|
||||
@@ -857,11 +853,11 @@
|
||||
<span>{view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })}</span>
|
||||
{#if gameOver}
|
||||
<strong class="over">{t('game.over')} — {resultText()}</strong>
|
||||
{:else}
|
||||
{:else if placement.pending.length === 0}
|
||||
<span class="turn-ind">{isMyTurn ? t('game.yourTurn') : view.game.seats[view.game.toMove]?.displayName ?? ''}</span>
|
||||
{/if}
|
||||
<span class="scores">
|
||||
{#if preview}{preview.legal ? t('game.scores', { n: preview.score }) : t('game.previewIllegal')}{/if}
|
||||
{#if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -880,7 +876,7 @@
|
||||
/>
|
||||
</div>
|
||||
{#if !gameOver && placement.pending.length > 0}
|
||||
<button class="make" onclick={commit} disabled={busy || !isMyTurn || !connection.online || (preview !== null && !preview.legal)} aria-label={t('game.makeMove')}>✅</button>
|
||||
<button class="make" onclick={commit} disabled={busy || !isMyTurn || !connection.online || !preview?.legal} aria-label={t('game.makeMove')}>✅</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
@@ -30,37 +30,26 @@ gameId(optionalEncoding?:any):string|Uint8Array|null {
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
dir():string|null
|
||||
dir(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||
dir(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;
|
||||
}
|
||||
|
||||
tiles(index: number, obj?:PlayTile):PlayTile|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 8);
|
||||
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||
return offset ? (obj || new PlayTile()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
|
||||
}
|
||||
|
||||
tilesLength():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 8);
|
||||
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
static startEvalRequest(builder:flatbuffers.Builder) {
|
||||
builder.startObject(3);
|
||||
builder.startObject(2);
|
||||
}
|
||||
|
||||
static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(0, gameIdOffset, 0);
|
||||
}
|
||||
|
||||
static addDir(builder:flatbuffers.Builder, dirOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(1, dirOffset, 0);
|
||||
}
|
||||
|
||||
static addTiles(builder:flatbuffers.Builder, tilesOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(2, tilesOffset, 0);
|
||||
builder.addFieldOffset(1, tilesOffset, 0);
|
||||
}
|
||||
|
||||
static createTilesVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
|
||||
@@ -80,10 +69,9 @@ static endEvalRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
return offset;
|
||||
}
|
||||
|
||||
static createEvalRequest(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, dirOffset:flatbuffers.Offset, tilesOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||
static createEvalRequest(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, tilesOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||
EvalRequest.startEvalRequest(builder);
|
||||
EvalRequest.addGameId(builder, gameIdOffset);
|
||||
EvalRequest.addDir(builder, dirOffset);
|
||||
EvalRequest.addTiles(builder, tilesOffset);
|
||||
return EvalRequest.endEvalRequest(builder);
|
||||
}
|
||||
|
||||
@@ -42,8 +42,15 @@ wordsLength():number {
|
||||
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
dir():string|null
|
||||
dir(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||
dir(optionalEncoding?:any):string|Uint8Array|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 10);
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
static startEvalResult(builder:flatbuffers.Builder) {
|
||||
builder.startObject(3);
|
||||
builder.startObject(4);
|
||||
}
|
||||
|
||||
static addLegal(builder:flatbuffers.Builder, legal:boolean) {
|
||||
@@ -70,16 +77,21 @@ static startWordsVector(builder:flatbuffers.Builder, numElems:number) {
|
||||
builder.startVector(4, numElems, 4);
|
||||
}
|
||||
|
||||
static addDir(builder:flatbuffers.Builder, dirOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(3, dirOffset, 0);
|
||||
}
|
||||
|
||||
static endEvalResult(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
}
|
||||
|
||||
static createEvalResult(builder:flatbuffers.Builder, legal:boolean, score:number, wordsOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||
static createEvalResult(builder:flatbuffers.Builder, legal:boolean, score:number, wordsOffset:flatbuffers.Offset, dirOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||
EvalResult.startEvalResult(builder);
|
||||
EvalResult.addLegal(builder, legal);
|
||||
EvalResult.addScore(builder, score);
|
||||
EvalResult.addWords(builder, wordsOffset);
|
||||
EvalResult.addDir(builder, dirOffset);
|
||||
return EvalResult.endEvalResult(builder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,37 +30,26 @@ gameId(optionalEncoding?:any):string|Uint8Array|null {
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
dir():string|null
|
||||
dir(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||
dir(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;
|
||||
}
|
||||
|
||||
tiles(index: number, obj?:PlayTile):PlayTile|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 8);
|
||||
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||
return offset ? (obj || new PlayTile()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
|
||||
}
|
||||
|
||||
tilesLength():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 8);
|
||||
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
static startSubmitPlayRequest(builder:flatbuffers.Builder) {
|
||||
builder.startObject(3);
|
||||
builder.startObject(2);
|
||||
}
|
||||
|
||||
static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(0, gameIdOffset, 0);
|
||||
}
|
||||
|
||||
static addDir(builder:flatbuffers.Builder, dirOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(1, dirOffset, 0);
|
||||
}
|
||||
|
||||
static addTiles(builder:flatbuffers.Builder, tilesOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(2, tilesOffset, 0);
|
||||
builder.addFieldOffset(1, tilesOffset, 0);
|
||||
}
|
||||
|
||||
static createTilesVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
|
||||
@@ -80,10 +69,9 @@ static endSubmitPlayRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
return offset;
|
||||
}
|
||||
|
||||
static createSubmitPlayRequest(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, dirOffset:flatbuffers.Offset, tilesOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||
static createSubmitPlayRequest(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, tilesOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||
SubmitPlayRequest.startSubmitPlayRequest(builder);
|
||||
SubmitPlayRequest.addGameId(builder, gameIdOffset);
|
||||
SubmitPlayRequest.addDir(builder, dirOffset);
|
||||
SubmitPlayRequest.addTiles(builder, tilesOffset);
|
||||
return SubmitPlayRequest.endSubmitPlayRequest(builder);
|
||||
}
|
||||
|
||||
@@ -74,12 +74,12 @@ export interface GatewayClient {
|
||||
// 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[], variant: Variant): Promise<MoveResult>;
|
||||
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, dir: 'H' | 'V', tiles: PlacedTile[], variant: Variant): Promise<EvalResult>;
|
||||
evaluate(gameId: string, tiles: PlacedTile[], variant: Variant): Promise<EvalResult>;
|
||||
checkWord(gameId: string, word: string, variant: Variant): Promise<WordCheckResult>;
|
||||
complaint(gameId: string, word: string, note: string): Promise<void>;
|
||||
/** Hide a finished game from the caller's own lobby list; per-account, irreversible. */
|
||||
|
||||
@@ -43,7 +43,6 @@ describe('codec', () => {
|
||||
// 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 },
|
||||
@@ -52,7 +51,6 @@ describe('codec', () => {
|
||||
);
|
||||
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(0);
|
||||
expect(r.tiles(1)?.letter()).toBe(1);
|
||||
|
||||
+2
-12
@@ -87,7 +87,6 @@ export function encodeDraftSave(gameId: string, json: string): Uint8Array {
|
||||
|
||||
export function encodeSubmitPlay(
|
||||
gameId: string,
|
||||
dir: 'H' | 'V',
|
||||
tiles: PlacedTile[],
|
||||
variant: Variant,
|
||||
): Uint8Array {
|
||||
@@ -95,28 +94,19 @@ export function encodeSubmitPlay(
|
||||
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);
|
||||
fb.SubmitPlayRequest.startSubmitPlayRequest(b);
|
||||
fb.SubmitPlayRequest.addGameId(b, gid);
|
||||
fb.SubmitPlayRequest.addDir(b, d);
|
||||
fb.SubmitPlayRequest.addTiles(b, vec);
|
||||
return finish(b, fb.SubmitPlayRequest.endSubmitPlayRequest(b));
|
||||
}
|
||||
|
||||
export function encodeEval(
|
||||
gameId: string,
|
||||
dir: 'H' | 'V',
|
||||
tiles: PlacedTile[],
|
||||
variant: Variant,
|
||||
): Uint8Array {
|
||||
export function encodeEval(gameId: string, tiles: PlacedTile[], variant: Variant): Uint8Array {
|
||||
const b = new Builder(256);
|
||||
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);
|
||||
fb.EvalRequest.startEvalRequest(b);
|
||||
fb.EvalRequest.addGameId(b, gid);
|
||||
fb.EvalRequest.addDir(b, d);
|
||||
fb.EvalRequest.addTiles(b, vec);
|
||||
return finish(b, fb.EvalRequest.endEvalRequest(b));
|
||||
}
|
||||
@@ -377,7 +367,7 @@ export function decodeEvalResult(buf: Uint8Array): EvalResult {
|
||||
const r = fb.EvalResult.getRootAsEvalResult(new ByteBuffer(buf));
|
||||
const words: string[] = [];
|
||||
for (let i = 0; i < r.wordsLength(); i++) words.push(s(r.words(i)));
|
||||
return { legal: r.legal(), score: r.score(), words };
|
||||
return { legal: r.legal(), score: r.score(), words, dir: s(r.dir()) };
|
||||
}
|
||||
|
||||
export function decodeWordCheck(buf: Uint8Array): WordCheckResult {
|
||||
|
||||
@@ -67,7 +67,7 @@ export const en = {
|
||||
'game.checkWord': 'Check word',
|
||||
'game.dictionary': 'Dictionary',
|
||||
'game.dropGame': 'Drop game',
|
||||
'game.preview': 'Scores {n}',
|
||||
'game.previewWords': '{words}: {n}',
|
||||
'game.previewIllegal': 'Not a legal move',
|
||||
'game.chooseBlank': 'Choose a letter for the blank',
|
||||
'game.exchangeTitle': 'Select tiles to exchange',
|
||||
@@ -86,7 +86,6 @@ export const en = {
|
||||
'game.check': 'Check',
|
||||
'game.checkWait': 'Please wait a moment.',
|
||||
'game.noHintOptions': 'No options with your letters.',
|
||||
'game.scores': 'Scores: {n}',
|
||||
'game.thinking': 'thinking…',
|
||||
|
||||
'move.pass': 'pass',
|
||||
|
||||
@@ -68,7 +68,7 @@ export const ru: Record<MessageKey, string> = {
|
||||
'game.checkWord': 'Проверить слово',
|
||||
'game.dictionary': 'Словарь',
|
||||
'game.dropGame': 'Покинуть игру',
|
||||
'game.preview': 'Очков: {n}',
|
||||
'game.previewWords': '{words}: {n}',
|
||||
'game.previewIllegal': 'Недопустимый ход',
|
||||
'game.chooseBlank': 'Выберите букву для бланка',
|
||||
'game.exchangeTitle': 'Выберите фишки для обмена',
|
||||
@@ -87,7 +87,6 @@ export const ru: Record<MessageKey, string> = {
|
||||
'game.check': 'Проверить',
|
||||
'game.checkWait': 'Секунду, пожалуйста.',
|
||||
'game.noHintOptions': 'Нет вариантов с вашим набором.',
|
||||
'game.scores': 'Очков: {n}',
|
||||
'game.thinking': 'думает…',
|
||||
|
||||
'move.pass': 'пас',
|
||||
|
||||
@@ -205,7 +205,7 @@ export class MockGateway implements GatewayClient {
|
||||
return { gameId, moves: structuredClone(g.moves) };
|
||||
}
|
||||
|
||||
async submitPlay(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[], _variant: Variant): Promise<MoveResult> {
|
||||
async submitPlay(gameId: string, 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');
|
||||
@@ -213,6 +213,7 @@ export class MockGateway implements GatewayClient {
|
||||
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 dir = new Set(tiles.map((t) => t.row)).size === 1 ? 'H' : 'V';
|
||||
const move = {
|
||||
player: seat,
|
||||
action: 'play' as const,
|
||||
@@ -311,12 +312,13 @@ export class MockGateway implements GatewayClient {
|
||||
};
|
||||
}
|
||||
|
||||
async evaluate(gameId: string, _dir: 'H' | 'V', tiles: PlacedTile[], _variant: Variant): Promise<EvalResult> {
|
||||
async evaluate(gameId: string, tiles: PlacedTile[], _variant: Variant): Promise<EvalResult> {
|
||||
const g = this.game(gameId);
|
||||
if (tiles.length === 0) return { legal: false, score: 0, words: [] };
|
||||
if (tiles.length === 0) return { legal: false, score: 0, words: [], dir: '' };
|
||||
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('')] };
|
||||
const dir = new Set(tiles.map((t) => t.row)).size === 1 ? 'H' : 'V';
|
||||
return { legal: true, score, words: [tiles.map((t) => t.letter).join('')], dir };
|
||||
}
|
||||
|
||||
async checkWord(_gameId: string, word: string, _variant: Variant): Promise<WordCheckResult> {
|
||||
|
||||
@@ -84,6 +84,8 @@ export interface EvalResult {
|
||||
legal: boolean;
|
||||
score: number;
|
||||
words: string[];
|
||||
/** Orientation the backend inferred for the play ("H"/"V"), empty when illegal. */
|
||||
dir: string;
|
||||
}
|
||||
|
||||
export interface WordCheckResult {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
BLANK,
|
||||
cellOccupied,
|
||||
direction,
|
||||
isBlankSlot,
|
||||
newPlacement,
|
||||
place,
|
||||
@@ -47,23 +46,11 @@ describe('placement state machine', () => {
|
||||
expect(reset(place(p, 0, 7, 7)).pending).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('infers direction H for a row, V for a column, null for a single tile', () => {
|
||||
let h = place(newPlacement(rack), 0, 7, 7);
|
||||
h = place(h, 1, 7, 8);
|
||||
expect(direction(h)).toBe('H');
|
||||
let v = place(newPlacement(rack), 0, 7, 7);
|
||||
v = place(v, 1, 8, 7);
|
||||
expect(direction(v)).toBe('V');
|
||||
expect(direction(place(newPlacement(rack), 0, 7, 7))).toBeNull();
|
||||
});
|
||||
|
||||
it('builds a sorted submit payload and honours a direction override', () => {
|
||||
it('builds a sorted submit payload and returns null when empty', () => {
|
||||
let p = place(newPlacement(rack), 1, 7, 9);
|
||||
p = place(p, 0, 7, 7);
|
||||
const sub = toSubmit(p);
|
||||
expect(sub?.dir).toBe('H');
|
||||
expect(sub?.tiles.map((t) => t.col)).toEqual([7, 9]);
|
||||
expect(toSubmit(place(newPlacement(rack), 0, 7, 7), 'V')?.dir).toBe('V');
|
||||
expect(toSubmit(newPlacement(rack))).toBeNull();
|
||||
});
|
||||
|
||||
@@ -78,15 +65,8 @@ describe('placement state machine', () => {
|
||||
expect(isBlankSlot(newPlacement(rack), 0)).toBe(false);
|
||||
});
|
||||
|
||||
it('treats a non-linear placement as no inferred direction', () => {
|
||||
let p = place(newPlacement(rack), 0, 7, 7);
|
||||
p = place(p, 1, 8, 8); // diagonal
|
||||
expect(direction(p)).toBeNull();
|
||||
});
|
||||
|
||||
it('defaults a single-tile submit to H without an override', () => {
|
||||
it('submits a single tile as a one-tile payload', () => {
|
||||
const sub = toSubmit(place(newPlacement(rack), 0, 7, 7));
|
||||
expect(sub?.dir).toBe('H');
|
||||
expect(sub?.tiles).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
+5
-22
@@ -4,7 +4,7 @@
|
||||
// payload. It is board-agnostic (the gateway/engine does full legality validation at
|
||||
// submit), which keeps it trivially unit-testable.
|
||||
|
||||
import type { Direction, Tile } from './model';
|
||||
import type { Tile } from './model';
|
||||
import type { PlacedTile } from './client';
|
||||
|
||||
export interface PendingTile {
|
||||
@@ -119,30 +119,13 @@ export function reorderIndices(n: number, from: number, toSlot: number): number[
|
||||
return order;
|
||||
}
|
||||
|
||||
/**
|
||||
* direction infers the play orientation from the pending tiles: H if they share a row,
|
||||
* V if they share a column, null if a single tile (ambiguous) or non-linear (invalid).
|
||||
*/
|
||||
export function direction(p: Placement): Direction | null {
|
||||
if (p.pending.length < 2) return null;
|
||||
const rows = new Set(p.pending.map((t) => t.row));
|
||||
const cols = new Set(p.pending.map((t) => t.col));
|
||||
if (rows.size === 1 && cols.size === p.pending.length) return 'H';
|
||||
if (cols.size === 1 && rows.size === p.pending.length) return 'V';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** toSubmit builds the submit payload. dirOverride resolves a single-tile play, where
|
||||
* the orientation cannot be inferred; otherwise the inferred direction is used. */
|
||||
export function toSubmit(
|
||||
p: Placement,
|
||||
dirOverride?: Direction,
|
||||
): { dir: Direction; tiles: PlacedTile[] } | null {
|
||||
/** toSubmit builds the submit payload: the placed tiles in board order. The backend
|
||||
* infers the play's orientation from the tiles and the board, so none is sent. */
|
||||
export function toSubmit(p: Placement): { tiles: PlacedTile[] } | null {
|
||||
if (p.pending.length === 0) return null;
|
||||
const dir = dirOverride ?? direction(p) ?? 'H';
|
||||
const tiles: PlacedTile[] = p.pending
|
||||
.slice()
|
||||
.sort((a, b) => a.row - b.row || a.col - b.col)
|
||||
.map((t) => ({ row: t.row, col: t.col, letter: t.letter, blank: t.blank }));
|
||||
return { dir, tiles };
|
||||
return { tiles };
|
||||
}
|
||||
|
||||
@@ -97,8 +97,8 @@ export function createTransport(baseUrl: string): GatewayClient {
|
||||
async gameHistory(id) {
|
||||
return codec.decodeHistory(await exec('game.history', codec.encodeGameAction(id)));
|
||||
},
|
||||
async submitPlay(id, dir, tiles, variant) {
|
||||
return codec.decodeMoveResult(await exec('game.submit_play', codec.encodeSubmitPlay(id, dir, tiles, variant)));
|
||||
async submitPlay(id, tiles, variant) {
|
||||
return codec.decodeMoveResult(await exec('game.submit_play', codec.encodeSubmitPlay(id, tiles, variant)));
|
||||
},
|
||||
async pass(id) {
|
||||
return codec.decodeMoveResult(await exec('game.pass', codec.encodeGameAction(id)));
|
||||
@@ -112,8 +112,8 @@ export function createTransport(baseUrl: string): GatewayClient {
|
||||
async hint(id) {
|
||||
return codec.decodeHintResult(await exec('game.hint', codec.encodeGameAction(id)));
|
||||
},
|
||||
async evaluate(id, dir, tiles, variant) {
|
||||
return codec.decodeEvalResult(await exec('game.evaluate', codec.encodeEval(id, dir, tiles, variant)));
|
||||
async evaluate(id, tiles, variant) {
|
||||
return codec.decodeEvalResult(await exec('game.evaluate', codec.encodeEval(id, tiles, variant)));
|
||||
},
|
||||
async checkWord(id, word, variant) {
|
||||
return codec.decodeWordCheck(await exec('game.check_word', codec.encodeCheckWord(id, word, variant)));
|
||||
|
||||
Reference in New Issue
Block a user