Files
scrabble-game/ui/src/lib/codec.test.ts
T
Ilia Denisov 26aa154547
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 37s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
R1: schema & naming reset — squash migrations, rename variants
Squash the 12 goose migrations into one 00001_baseline.sql (there is no prod
data; verified schema-identical to the chain via a pg_dump diff + the green
integration suite) and rename the game-variant labels
english/russian_scrabble/erudit -> scrabble_en/scrabble_ru/erudit_ru across the
backend, the FlatBuffers wire values and the UI.

dawg filenames and the Go enum identifiers are unchanged; the i18n display keys
are kept. Adds PRERELEASE.md (the R1-R7 pre-release tracker), linked from
CLAUDE.md. Contour DB wipe and the scrabble-dictionary tidy are follow-ups.
2026-06-09 12:09:50 +02:00

339 lines
13 KiB
TypeScript

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 {
decodeDraftView,
decodeFriendList,
decodeGameList,
decodeInvitation,
decodeLinkResult,
decodeOutgoingList,
decodeSession,
decodeStateView,
decodeStats,
encodeCheckWord,
encodeDraftSave,
encodeExchange,
encodeStateRequest,
encodeSubmitPlay,
encodeTarget,
} from './codec';
describe('codec', () => {
it('round-trips a draft save request and view (Stage 17)', () => {
const json = '{"rack_order":"1,0","board_tiles":[]}';
const req = fb.DraftRequest.getRootAsDraftRequest(new ByteBuffer(encodeDraftSave('g1', json)));
expect(req.gameId()).toBe('g1');
expect(req.json()).toBe(json);
const b = new Builder(64);
const j = b.createString('{"x":1}');
fb.DraftView.startDraftView(b);
fb.DraftView.addJson(b, j);
b.finish(fb.DraftView.endDraftView(b));
expect(decodeDraftView(b.asUint8Array())).toBe('{"x":1}');
});
it('encodes a SubmitPlayRequest with alphabet indices (Stage 13)', () => {
setAlphabet('scrabble_en', [
{ 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 },
],
'scrabble_en',
);
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);
expect(r.tiles(1)?.blank()).toBe(true);
});
it('decodes a Session', () => {
const b = new Builder(64);
const token = b.createString('tok');
const uid = b.createString('u1');
const name = b.createString('Me');
const langs = fb.Session.createSupportedLanguagesVector(b, [b.createString('en'), b.createString('ru')]);
fb.Session.startSession(b);
fb.Session.addToken(b, token);
fb.Session.addUserId(b, uid);
fb.Session.addIsGuest(b, true);
fb.Session.addDisplayName(b, name);
fb.Session.addSupportedLanguages(b, langs);
b.finish(fb.Session.endSession(b));
expect(decodeSession(b.asUint8Array())).toEqual({
token: 'tok',
userId: 'u1',
isGuest: true,
displayName: 'Me',
supportedLanguages: ['en', 'ru'],
});
});
it('decodes a GameList including nested seat display names', () => {
const b = new Builder(256);
const aid = b.createString('a1');
const dn = b.createString('Ann');
fb.SeatView.startSeatView(b);
fb.SeatView.addSeat(b, 1);
fb.SeatView.addAccountId(b, aid);
fb.SeatView.addScore(b, 13);
fb.SeatView.addHintsUsed(b, 0);
fb.SeatView.addIsWinner(b, false);
fb.SeatView.addDisplayName(b, dn);
const seat = fb.SeatView.endSeatView(b);
const seats = fb.GameView.createSeatsVector(b, [seat]);
const id = b.createString('g1');
const variant = b.createString('scrabble_en');
const dv = b.createString('v1');
const status = b.createString('active');
const er = b.createString('');
fb.GameView.startGameView(b);
fb.GameView.addId(b, id);
fb.GameView.addVariant(b, variant);
fb.GameView.addDictVersion(b, dv);
fb.GameView.addStatus(b, status);
fb.GameView.addPlayers(b, 2);
fb.GameView.addToMove(b, 0);
fb.GameView.addTurnTimeoutSecs(b, 86400);
fb.GameView.addMoveCount(b, 4);
fb.GameView.addEndReason(b, er);
fb.GameView.addSeats(b, seats);
fb.GameView.addLastActivityUnix(b, BigInt(1717000000));
const game = fb.GameView.endGameView(b);
const games = fb.GameList.createGamesVector(b, [game]);
fb.GameList.startGameList(b);
fb.GameList.addGames(b, games);
b.finish(fb.GameList.endGameList(b));
const gl = decodeGameList(b.asUint8Array());
expect(gl.games).toHaveLength(1);
expect(gl.games[0].id).toBe('g1');
expect(gl.games[0].seats[0].displayName).toBe('Ann');
expect(gl.games[0].seats[0].score).toBe(13);
expect(gl.games[0].lastActivityUnix).toBe(1717000000);
});
it('decodes an OutgoingRequestList of account refs', () => {
const b = new Builder(128);
const id = b.createString('o-1');
const dn = b.createString('Pat');
fb.AccountRef.startAccountRef(b);
fb.AccountRef.addAccountId(b, id);
fb.AccountRef.addDisplayName(b, dn);
const ref = fb.AccountRef.endAccountRef(b);
const vec = fb.OutgoingRequestList.createRequestsVector(b, [ref]);
fb.OutgoingRequestList.startOutgoingRequestList(b);
fb.OutgoingRequestList.addRequests(b, vec);
b.finish(fb.OutgoingRequestList.endOutgoingRequestList(b));
expect(decodeOutgoingList(b.asUint8Array())).toEqual([{ accountId: 'o-1', displayName: 'Pat' }]);
});
it('encodes a TargetRequest', () => {
const r = fb.TargetRequest.getRootAsTargetRequest(new ByteBuffer(encodeTarget('a-1')));
expect(r.accountId()).toBe('a-1');
});
it('decodes a StatsView', () => {
const b = new Builder(64);
fb.StatsView.startStatsView(b);
fb.StatsView.addWins(b, 7);
fb.StatsView.addLosses(b, 4);
fb.StatsView.addDraws(b, 1);
fb.StatsView.addMaxGamePoints(b, 420);
fb.StatsView.addMaxWordPoints(b, 90);
b.finish(fb.StatsView.endStatsView(b));
expect(decodeStats(b.asUint8Array())).toEqual({
wins: 7,
losses: 4,
draws: 1,
maxGamePoints: 420,
maxWordPoints: 90,
});
});
it('decodes a FriendList of account refs', () => {
const b = new Builder(128);
const id = b.createString('a-1');
const dn = b.createString('Ann');
fb.AccountRef.startAccountRef(b);
fb.AccountRef.addAccountId(b, id);
fb.AccountRef.addDisplayName(b, dn);
const ref = fb.AccountRef.endAccountRef(b);
const vec = fb.FriendList.createFriendsVector(b, [ref]);
fb.FriendList.startFriendList(b);
fb.FriendList.addFriends(b, vec);
b.finish(fb.FriendList.endFriendList(b));
expect(decodeFriendList(b.asUint8Array())).toEqual([{ accountId: 'a-1', displayName: 'Ann' }]);
});
it('decodes a merge_required LinkResult without a session', () => {
const b = new Builder(128);
const status = b.createString('merge_required');
const sid = b.createString('b-1');
const sname = b.createString('Ann');
fb.LinkResult.startLinkResult(b);
fb.LinkResult.addStatus(b, status);
fb.LinkResult.addSecondaryUserId(b, sid);
fb.LinkResult.addSecondaryDisplayName(b, sname);
fb.LinkResult.addSecondaryGames(b, 7);
fb.LinkResult.addSecondaryFriends(b, 3);
b.finish(fb.LinkResult.endLinkResult(b));
expect(decodeLinkResult(b.asUint8Array())).toEqual({
status: 'merge_required',
secondaryUserId: 'b-1',
secondaryDisplayName: 'Ann',
secondaryGames: 7,
secondaryFriends: 3,
session: null,
});
});
it('decodes a merged LinkResult carrying a switched session', () => {
const b = new Builder(128);
const token = b.createString('tok-9');
const uid = b.createString('a-1');
const dn = b.createString('Kaya');
fb.Session.startSession(b);
fb.Session.addToken(b, token);
fb.Session.addUserId(b, uid);
fb.Session.addIsGuest(b, false);
fb.Session.addDisplayName(b, dn);
const sess = fb.Session.endSession(b);
const status = b.createString('merged');
fb.LinkResult.startLinkResult(b);
fb.LinkResult.addStatus(b, status);
fb.LinkResult.addSession(b, sess);
b.finish(fb.LinkResult.endLinkResult(b));
const r = decodeLinkResult(b.asUint8Array());
expect(r.status).toBe('merged');
expect(r.session).toEqual({ token: 'tok-9', userId: 'a-1', isGuest: false, displayName: 'Kaya', supportedLanguages: [] });
});
it('decodes an Invitation with inviter and invitees', () => {
const b = new Builder(256);
const iid = b.createString('u-1');
const idn = b.createString('Me');
fb.AccountRef.startAccountRef(b);
fb.AccountRef.addAccountId(b, iid);
fb.AccountRef.addDisplayName(b, idn);
const inviter = fb.AccountRef.endAccountRef(b);
const aid = b.createString('inv-1');
const adn = b.createString('Friend');
const resp = b.createString('pending');
fb.InvitationInvitee.startInvitationInvitee(b);
fb.InvitationInvitee.addAccountId(b, aid);
fb.InvitationInvitee.addDisplayName(b, adn);
fb.InvitationInvitee.addSeat(b, 1);
fb.InvitationInvitee.addResponse(b, resp);
const invitee = fb.InvitationInvitee.endInvitationInvitee(b);
const invitees = fb.Invitation.createInviteesVector(b, [invitee]);
const id = b.createString('i-1');
const variant = b.createString('scrabble_en');
const dropout = b.createString('remove');
const status = b.createString('pending');
const gid = b.createString('');
fb.Invitation.startInvitation(b);
fb.Invitation.addId(b, id);
fb.Invitation.addInviter(b, inviter);
fb.Invitation.addInvitees(b, invitees);
fb.Invitation.addVariant(b, variant);
fb.Invitation.addTurnTimeoutSecs(b, 86400);
fb.Invitation.addHintsAllowed(b, true);
fb.Invitation.addHintsPerPlayer(b, 1);
fb.Invitation.addDropoutTiles(b, dropout);
fb.Invitation.addStatus(b, status);
fb.Invitation.addGameId(b, gid);
b.finish(fb.Invitation.endInvitation(b));
const inv = decodeInvitation(b.asUint8Array());
expect(inv.id).toBe('i-1');
expect(inv.inviter).toEqual({ accountId: 'u-1', displayName: 'Me' });
expect(inv.invitees).toHaveLength(1);
expect(inv.invitees[0]).toEqual({ accountId: 'inv-1', displayName: 'Friend', seat: 1, response: 'pending' });
expect(inv.variant).toBe('scrabble_en');
});
});
// 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('scrabble_en', [
{ index: 0, letter: 'a', value: 1 },
{ index: 1, letter: 'b', value: 3 },
]);
const r = fb.ExchangeRequest.getRootAsExchangeRequest(
new ByteBuffer(encodeExchange('g1', ['A', '?'], 'scrabble_en')),
);
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('scrabble_en', [
{ 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', 'scrabble_en')),
);
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 'scrabble_en';
// 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', '?']);
});
});