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', '?']); }); });