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

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:
Ilia Denisov
2026-06-11 22:42:33 +02:00
parent feee3d6511
commit 92f48a3b12
49 changed files with 419 additions and 401 deletions
+2 -2
View File
@@ -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. */
-2
View File
@@ -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
View File
@@ -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 {
+1 -2
View File
@@ -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',
+1 -2
View File
@@ -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': 'пас',
+6 -4
View File
@@ -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> {
+2
View File
@@ -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 -22
View File
@@ -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
View File
@@ -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 };
}
+4 -4
View File
@@ -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)));