Files
scrabble-game/ui/src/lib/codec.test.ts
T
Ilia Denisov 6679260d0a feat(session): carry the bot service_language on the Session wire
Thread the Telegram bot's service language (en/ru) from the session mint response
through the gateway into the FlatBuffers Session, so the UI knows which bot the
player signed in through. handleTelegramAuth refreshes the account's service
language onto the response before minting (it was set after the fetched copy).
Empty for a non-Telegram login.
2026-06-15 15:49:26 +02:00

508 lines
19 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 {
decodeBlockStatus,
decodeDraftView,
decodeEvent,
decodeFriendList,
decodeGameList,
decodeInvitation,
decodeLinkResult,
decodeMatchResult,
decodeOutgoingList,
decodeSession,
decodeFeedbackState,
decodeFeedbackUnread,
decodeStateView,
decodeStats,
encodeCheckWord,
encodeFeedbackSubmit,
encodeDraftSave,
encodeExchange,
encodeStateRequest,
encodeSubmitPlay,
encodeTarget,
} from './codec';
describe('codec', () => {
it('round-trips a draft save request and view', () => {
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('round-trips a feedback submit and decodes state + unread', () => {
const att = new Uint8Array([1, 2, 3, 4]);
const req = fb.FeedbackSubmitRequest.getRootAsFeedbackSubmitRequest(
new ByteBuffer(encodeFeedbackSubmit('please fix', att, 'shot.png', 'ios')),
);
expect(req.body()).toBe('please fix');
expect(req.attachmentName()).toBe('shot.png');
expect(req.channel()).toBe('ios');
expect(Array.from(req.attachmentArray() ?? [])).toEqual([1, 2, 3, 4]);
// No attachment: the vector is empty.
const req2 = fb.FeedbackSubmitRequest.getRootAsFeedbackSubmitRequest(
new ByteBuffer(encodeFeedbackSubmit('hi', null, '', 'web')),
);
expect(req2.body()).toBe('hi');
expect(req2.attachmentLength()).toBe(0);
// State carrying a reply.
const b = new Builder(128);
const replyBody = b.createString('we are on it');
fb.FeedbackReply.startFeedbackReply(b);
fb.FeedbackReply.addBody(b, replyBody);
fb.FeedbackReply.addRepliedAtUnix(b, BigInt(1700000000));
const reply = fb.FeedbackReply.endFeedbackReply(b);
const reason = b.createString('');
fb.FeedbackState.startFeedbackState(b);
fb.FeedbackState.addCanSend(b, true);
fb.FeedbackState.addBlockedReason(b, reason);
fb.FeedbackState.addReply(b, reply);
b.finish(fb.FeedbackState.endFeedbackState(b));
expect(decodeFeedbackState(b.asUint8Array())).toEqual({
canSend: true,
blockedReason: '',
reply: { body: 'we are on it', repliedAtUnix: 1700000000 },
});
// State with no reply, blocked as pending.
const b2 = new Builder(64);
const reason2 = b2.createString('pending');
fb.FeedbackState.startFeedbackState(b2);
fb.FeedbackState.addCanSend(b2, false);
fb.FeedbackState.addBlockedReason(b2, reason2);
b2.finish(fb.FeedbackState.endFeedbackState(b2));
expect(decodeFeedbackState(b2.asUint8Array())).toEqual({
canSend: false,
blockedReason: 'pending',
reply: null,
});
// Unread badge flag.
const b3 = new Builder(16);
fb.FeedbackUnread.startFeedbackUnread(b3);
fb.FeedbackUnread.addReplyUnread(b3, true);
b3.finish(fb.FeedbackUnread.endFeedbackUnread(b3));
expect(decodeFeedbackUnread(b3.asUint8Array())).toBe(true);
});
it('decodes a temporary BlockStatus with reason', () => {
const b = new Builder(64);
const until = b.createString('2026-07-01T12:00:00Z');
const reason = b.createString('Спам');
fb.BlockStatus.startBlockStatus(b);
fb.BlockStatus.addBlocked(b, true);
fb.BlockStatus.addPermanent(b, false);
fb.BlockStatus.addUntil(b, until);
fb.BlockStatus.addReason(b, reason);
b.finish(fb.BlockStatus.endBlockStatus(b));
expect(decodeBlockStatus(b.asUint8Array())).toEqual({
blocked: true,
permanent: false,
until: '2026-07-01T12:00:00Z',
reason: 'Спам',
});
});
it('decodes a permanent BlockStatus with empty until/reason', () => {
const b = new Builder(32);
fb.BlockStatus.startBlockStatus(b);
fb.BlockStatus.addBlocked(b, true);
fb.BlockStatus.addPermanent(b, true);
b.finish(fb.BlockStatus.endBlockStatus(b));
expect(decodeBlockStatus(b.asUint8Array())).toEqual({
blocked: true,
permanent: true,
until: '',
reason: '',
});
});
it('encodes a SubmitPlayRequest with alphabet indices', () => {
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',
[
{ 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.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'],
serviceLanguage: '',
});
});
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: [], serviceLanguage: '' });
});
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');
});
it('decodes a your_turn event, carrying the previous player name for the toast', () => {
const b = new Builder(64);
const gid = b.createString('g-1');
const name = b.createString('Ann');
fb.YourTurnEvent.startYourTurnEvent(b);
fb.YourTurnEvent.addGameId(b, gid);
fb.YourTurnEvent.addOpponentName(b, name);
fb.YourTurnEvent.addMoveCount(b, 7);
b.finish(fb.YourTurnEvent.endYourTurnEvent(b));
expect(decodeEvent('your_turn', b.asUint8Array())).toEqual({
kind: 'your_turn',
gameId: 'g-1',
deadlineUnix: 0,
opponentName: 'Ann',
moveCount: 7,
});
});
it('decodes a your_turn event with no opponent name as an empty string', () => {
const b = new Builder(64);
const gid = b.createString('g-2');
fb.YourTurnEvent.startYourTurnEvent(b);
fb.YourTurnEvent.addGameId(b, gid);
b.finish(fb.YourTurnEvent.endYourTurnEvent(b));
expect(decodeEvent('your_turn', b.asUint8Array())).toEqual({
kind: 'your_turn',
gameId: 'g-2',
deadlineUnix: 0,
opponentName: '',
moveCount: 0,
});
});
it('decodes an opponent_joined event (reusing the match_found payload layout)', () => {
const b = new Builder(64);
const gid = b.createString('g-open');
fb.MatchFoundEvent.startMatchFoundEvent(b);
fb.MatchFoundEvent.addGameId(b, gid);
b.finish(fb.MatchFoundEvent.endMatchFoundEvent(b));
expect(decodeEvent('opponent_joined', b.asUint8Array())).toEqual({
kind: 'opponent_joined',
gameId: 'g-open',
state: undefined,
});
});
it('decodes the match game even when matched is false (an open game awaiting an opponent)', () => {
const b = new Builder(256);
const id = b.createString('g-open');
const variant = b.createString('scrabble_en');
const dv = b.createString('v1');
const status = b.createString('open');
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, 0);
fb.GameView.addEndReason(b, er);
fb.GameView.addLastActivityUnix(b, BigInt(0));
const game = fb.GameView.endGameView(b);
fb.MatchResult.startMatchResult(b);
fb.MatchResult.addMatched(b, false);
fb.MatchResult.addGame(b, game);
b.finish(fb.MatchResult.endMatchResult(b));
const r = decodeMatchResult(b.asUint8Array());
expect(r.matched).toBe(false);
expect(r.game?.id).toBe('g-open');
expect(r.game?.status).toBe('open');
});
});
// 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', () => {
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', '?']);
});
});