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, decodeExportUrl, decodeFriendList, decodeGameList, decodeInvitation, decodeLinkResult, decodeMatchResult, decodeOutgoingList, decodeProfile, decodeSession, decodeFeedbackState, decodeFeedbackUnread, decodeStateView, decodeStats, encodeCheckWord, encodeEmailRequest, encodeEmailConfirmLink, encodeFeedbackSubmit, encodeDraftSave, encodeEnqueue, encodeExchange, encodeExportUrlRequest, encodeAccountDeleteConfirm, decodeDeleteRequestResult, encodeGuestLogin, encodeLinkUnlink, encodeLinkVK, encodeStateRequest, encodeSubmitPlay, encodeTarget, encodeTelegramLogin, encodeUpdateProfile, encodeVKLogin, } from './codec'; describe('codec', () => { it('round-trips the export-url request and response', () => { const req = fb.ExportUrlRequest.getRootAsExportUrlRequest( new ByteBuffer( encodeExportUrlRequest('g-1', 'png', 'ru-RU', ['пас', 'обмен', 'сдался', 'таймаут'], 'Europe/Moscow'), ), ); expect(req.gameId()).toBe('g-1'); expect(req.kind()).toBe('png'); expect(req.dateLocale()).toBe('ru-RU'); expect(req.timeZone()).toBe('Europe/Moscow'); expect(req.actionLabelsLength()).toBe(4); expect(req.actionLabels(1)).toBe('обмен'); const b = new Builder(128); const path = b.createString('/dl/g-1/png?e=1&s=x'); const fn = b.createString('game-g-1.png'); fb.ExportUrl.startExportUrl(b); fb.ExportUrl.addPath(b, path); fb.ExportUrl.addFilename(b, fn); b.finish(fb.ExportUrl.endExportUrl(b)); const out = decodeExportUrl(b.asUint8Array()); expect(out).toEqual({ path: '/dl/g-1/png?e=1&s=x', filename: 'game-g-1.png' }); }); it('encodes an enqueue request carrying the variant, word rule and vs_ai flag', () => { for (const vsAi of [false, true]) { const req = fb.EnqueueRequest.getRootAsEnqueueRequest( new ByteBuffer(encodeEnqueue('scrabble_ru', false, vsAi)), ); expect(req.variant()).toBe('scrabble_ru'); expect(req.multipleWordsPerTurn()).toBe(false); expect(req.vsAi()).toBe(vsAi); } }); 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('decodes a nudge event carrying the sender name', () => { const b = new Builder(64); const gid = b.createString('g1'); const from = b.createString('u2'); const name = b.createString('Ann'); fb.NudgeEvent.startNudgeEvent(b); fb.NudgeEvent.addGameId(b, gid); fb.NudgeEvent.addFromUserId(b, from); fb.NudgeEvent.addSenderName(b, name); b.finish(fb.NudgeEvent.endNudgeEvent(b)); expect(decodeEvent('nudge', b.asUint8Array())).toEqual({ kind: 'nudge', gameId: 'g1', fromUserId: 'u2', senderName: 'Ann', }); }); it('carries the detected browser zone on every account-creating auth request', () => { const tg = fb.TelegramLoginRequest.getRootAsTelegramLoginRequest( new ByteBuffer(encodeTelegramLogin('init-data-blob', '+03:00')), ); expect(tg.initData()).toBe('init-data-blob'); expect(tg.browserTz()).toBe('+03:00'); const guest = fb.GuestLoginRequest.getRootAsGuestLoginRequest( new ByteBuffer(encodeGuestLogin('ru', '-05:30')), ); expect(guest.locale()).toBe('ru'); expect(guest.browserTz()).toBe('-05:30'); const email = fb.EmailRequestRequest.getRootAsEmailRequestRequest( new ByteBuffer(encodeEmailRequest('a@example.com', '+00:00', 'en', true)), ); expect(email.email()).toBe('a@example.com'); expect(email.browserTz()).toBe('+00:00'); expect(email.pwa()).toBe(true); expect(email.language()).toBe('en'); const confirm = fb.EmailConfirmLinkRequest.getRootAsEmailConfirmLinkRequest( new ByteBuffer(encodeEmailConfirmLink('tok-abc')), ); expect(confirm.token()).toBe('tok-abc'); const vk = fb.VKLoginRequest.getRootAsVKLoginRequest( new ByteBuffer(encodeVKLogin('vk_user_id=494075&vk_ts=1&sign=abc', '+03:00', 'Иван Петров')), ); expect(vk.params()).toBe('vk_user_id=494075&vk_ts=1&sign=abc'); expect(vk.browserTz()).toBe('+03:00'); expect(vk.displayName()).toBe('Иван Петров'); }); 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', 'v1.2.3', '+03:00')), ); expect(req.body()).toBe('please fix'); expect(req.attachmentName()).toBe('shot.png'); expect(req.channel()).toBe('ios'); expect(req.version()).toBe('v1.2.3'); expect(req.browserTz()).toBe('+03:00'); 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', 'dev', '+00:00')), ); expect(req2.body()).toBe('hi'); expect(req2.version()).toBe('dev'); expect(req2.browserTz()).toBe('+00:00'); 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'); fb.Session.startSession(b); fb.Session.addToken(b, token); fb.Session.addUserId(b, uid); fb.Session.addIsGuest(b, true); fb.Session.addDisplayName(b, name); b.finish(fb.Session.endSession(b)); expect(decodeSession(b.asUint8Array())).toEqual({ token: 'tok', userId: 'u1', isGuest: true, displayName: 'Me', }); }); 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)); fb.GameView.addVsAi(b, true); fb.GameView.addUnreadChat(b, true); const game = fb.GameView.endGameView(b); const games = fb.GameList.createGamesVector(b, [game]); fb.GameList.startGameList(b); fb.GameList.addGames(b, games); fb.GameList.addAtGameLimit(b, true); 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); expect(gl.games[0].vsAi).toBe(true); expect(gl.games[0].unreadChat).toBe(true); expect(gl.atGameLimit).toBe(true); }); it('decodes an OutgoingRequestList of account refs plus per-game robot requests', () => { 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 reqVec = fb.OutgoingRequestList.createRequestsVector(b, [ref]); const rid = b.createString('r-1'); const rdn = b.createString('Robbie'); const rgid = b.createString('g-1'); const robot = fb.RobotFriendRef.createRobotFriendRef(b, rid, rdn, rgid, 1); const robVec = fb.OutgoingRequestList.createRobotsVector(b, [robot]); fb.OutgoingRequestList.startOutgoingRequestList(b); fb.OutgoingRequestList.addRequests(b, reqVec); fb.OutgoingRequestList.addRobots(b, robVec); b.finish(fb.OutgoingRequestList.endOutgoingRequestList(b)); expect(decodeOutgoingList(b.asUint8Array())).toEqual({ requests: [{ accountId: 'o-1', displayName: 'Pat' }], robots: [{ id: 'r-1', displayName: 'Robbie', gameId: 'g-1', seat: 1 }], }); }); 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); fb.StatsView.addMoves(b, 248); fb.StatsView.addHintsUsed(b, 12); b.finish(fb.StatsView.endStatsView(b)); expect(decodeStats(b.asUint8Array())).toEqual({ wins: 7, losses: 4, draws: 1, maxGamePoints: 420, maxWordPoints: 90, moves: 248, hintsUsed: 12, bestMoves: [], }); }); it('decodes a StatsView carrying a per-variant best move with a blank tile', () => { const b = new Builder(256); // Word "ca" where the 'a' is a blank: it carries its letter but scores 0. const cLetter = b.createString('c'); fb.BestMoveTile.startBestMoveTile(b); fb.BestMoveTile.addLetter(b, cLetter); fb.BestMoveTile.addValue(b, 3); fb.BestMoveTile.addBlank(b, false); const tileC = fb.BestMoveTile.endBestMoveTile(b); const aLetter = b.createString('a'); fb.BestMoveTile.startBestMoveTile(b); fb.BestMoveTile.addLetter(b, aLetter); fb.BestMoveTile.addValue(b, 0); fb.BestMoveTile.addBlank(b, true); const tileA = fb.BestMoveTile.endBestMoveTile(b); const word = fb.BestMoveView.createWordVector(b, [tileC, tileA]); const variant = b.createString('scrabble_en'); fb.BestMoveView.startBestMoveView(b); fb.BestMoveView.addVariant(b, variant); fb.BestMoveView.addScore(b, 90); fb.BestMoveView.addWord(b, word); const bm = fb.BestMoveView.endBestMoveView(b); const bestMoves = fb.StatsView.createBestMovesVector(b, [bm]); fb.StatsView.startStatsView(b); fb.StatsView.addWins(b, 7); fb.StatsView.addBestMoves(b, bestMoves); b.finish(fb.StatsView.endStatsView(b)); expect(decodeStats(b.asUint8Array())).toEqual({ wins: 7, losses: 0, draws: 0, maxGamePoints: 0, maxWordPoints: 0, moves: 0, hintsUsed: 0, bestMoves: [ { variant: 'scrabble_en', score: 90, word: [ { letter: 'c', value: 3, blank: false }, { letter: 'a', value: 0, blank: true }, ], }, ], }); }); 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('encodes an unlink request carrying the provider kind', () => { const r = fb.LinkUnlinkRequest.getRootAsLinkUnlinkRequest(new ByteBuffer(encodeLinkUnlink('telegram'))); expect(r.kind()).toBe('telegram'); }); it('encodes a VK link request carrying the PKCE code-exchange inputs', () => { const r = fb.LinkVKRequest.getRootAsLinkVKRequest(new ByteBuffer(encodeLinkVK('the-code', 'dev-1', 'verifier-1'))); expect(r.code()).toBe('the-code'); expect(r.deviceId()).toBe('dev-1'); expect(r.codeVerifier()).toBe('verifier-1'); }); it('round-trips the account-delete confirm + request-result wire', () => { const c = fb.AccountDeleteConfirm.getRootAsAccountDeleteConfirm( new ByteBuffer(encodeAccountDeleteConfirm('123456', 'DELETE')), ); expect(c.code()).toBe('123456'); expect(c.phrase()).toBe('DELETE'); const b = new Builder(32); const m = b.createString('email'); fb.AccountDeleteRequestResult.startAccountDeleteRequestResult(b); fb.AccountDeleteRequestResult.addMethod(b, m); b.finish(fb.AccountDeleteRequestResult.endAccountDeleteRequestResult(b)); expect(decodeDeleteRequestResult(b.asUint8Array()).method).toBe('email'); }); it('passes an unlinked / changed LinkResult status straight through', () => { for (const status of ['unlinked', 'changed'] as const) { const b = new Builder(64); const s = b.createString(status); fb.LinkResult.startLinkResult(b); fb.LinkResult.addStatus(b, s); b.finish(fb.LinkResult.endLinkResult(b)); expect(decodeLinkResult(b.asUint8Array()).status).toBe(status); } }); 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' }); }); 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'); }); it('decodes the profile banner block (campaigns + timings)', () => { const b = new Builder(256); const uid = b.createString('u-1'); const m0 = b.createString('promo-en'); const msgs = fb.BannerCampaign.createMessagesVector(b, [m0]); fb.BannerCampaign.startBannerCampaign(b); fb.BannerCampaign.addWeight(b, 3); fb.BannerCampaign.addMessages(b, msgs); const camp = fb.BannerCampaign.endBannerCampaign(b); const camps = fb.BannerInfo.createCampaignsVector(b, [camp]); const banner = fb.BannerInfo.createBannerInfo(b, camps, 60000, 5000, 40, 1000, 250, 1000); fb.Profile.startProfile(b); fb.Profile.addUserId(b, uid); fb.Profile.addBanner(b, banner); b.finish(fb.Profile.endProfile(b)); const p = decodeProfile(b.asUint8Array()); // A plain campaign carries no override (both sets null → the neutral theme tokens). expect(p.banner?.campaigns).toEqual([{ weight: 3, messages: ['promo-en'], overrideAll: null, overrideDark: null }]); expect(p.banner?.timings).toEqual({ holdMs: 60000, edgePauseMs: 5000, scrollPxPerSec: 40, fadeOutMs: 1000, gapMs: 250, fadeInMs: 1000, }); }); it('decodes a campaign colour override (both sets)', () => { const b = new Builder(256); const uid = b.createString('u-1'); const m0 = b.createString('branded'); const msgs = fb.BannerCampaign.createMessagesVector(b, [m0]); const s = (v: string) => b.createString(v); const [obg, ofg, ol, obgd, ofgd, old] = ['#aa0000', '#ffffff', '#ffdd00', '#330000', '#eeeeee', '#ffcc00'].map(s); const camp = fb.BannerCampaign.createBannerCampaign(b, 1, msgs, obg, ofg, ol, obgd, ofgd, old); const camps = fb.BannerInfo.createCampaignsVector(b, [camp]); const banner = fb.BannerInfo.createBannerInfo(b, camps, 60000, 5000, 40, 1000, 250, 1000); fb.Profile.startProfile(b); fb.Profile.addUserId(b, uid); fb.Profile.addBanner(b, banner); b.finish(fb.Profile.endProfile(b)); const p = decodeProfile(b.asUint8Array()); expect(p.banner?.campaigns[0].overrideAll).toEqual({ bg: '#aa0000', fg: '#ffffff', link: '#ffdd00' }); expect(p.banner?.campaigns[0].overrideDark).toEqual({ bg: '#330000', fg: '#eeeeee', link: '#ffcc00' }); }); it('leaves the profile banner undefined when absent', () => { const b = new Builder(64); const uid = b.createString('u-1'); fb.Profile.startProfile(b); fb.Profile.addUserId(b, uid); b.finish(fb.Profile.endProfile(b)); expect(decodeProfile(b.asUint8Array()).banner).toBeUndefined(); }); it('decodes the profile variant preferences', () => { const b = new Builder(64); const uid = b.createString('u-1'); const prefs = fb.Profile.createVariantPreferencesVector(b, [b.createString('erudit_ru'), b.createString('scrabble_en')]); fb.Profile.startProfile(b); fb.Profile.addUserId(b, uid); fb.Profile.addVariantPreferences(b, prefs); b.finish(fb.Profile.endProfile(b)); expect(decodeProfile(b.asUint8Array()).variantPreferences).toEqual(['erudit_ru', 'scrabble_en']); }); it('decodes the profile dictionary versions into a per-variant map', () => { const b = new Builder(128); const uid = b.createString('u-1'); const en = fb.DictVersion.createDictVersion(b, b.createString('scrabble_en'), b.createString('v1.3.0')); const er = fb.DictVersion.createDictVersion(b, b.createString('erudit_ru'), b.createString('v1.2.0')); const vec = fb.Profile.createDictVersionsVector(b, [en, er]); fb.Profile.startProfile(b); fb.Profile.addUserId(b, uid); fb.Profile.addDictVersions(b, vec); b.finish(fb.Profile.endProfile(b)); expect(decodeProfile(b.asUint8Array()).dictVersions).toEqual({ scrabble_en: 'v1.3.0', erudit_ru: 'v1.2.0' }); }); it('decodes an absent dict_versions vector to an empty map', () => { const b = new Builder(64); const uid = b.createString('u-1'); fb.Profile.startProfile(b); fb.Profile.addUserId(b, uid); b.finish(fb.Profile.endProfile(b)); expect(decodeProfile(b.asUint8Array()).dictVersions).toEqual({}); }); it('encodes the update-profile variant preferences', () => { const buf = encodeUpdateProfile({ displayName: 'Kaya', preferredLanguage: 'ru', timeZone: 'UTC', awayStart: '00:00', awayEnd: '07:00', blockChat: false, blockFriendRequests: false, notificationsInAppOnly: true, variantPreferences: ['erudit_ru', 'scrabble_ru'], }); const t = fb.UpdateProfileRequest.getRootAsUpdateProfileRequest(new ByteBuffer(buf)); const out: string[] = []; for (let i = 0; i < t.variantPreferencesLength(); i++) out.push(t.variantPreferences(i)); expect(out).toEqual(['erudit_ru', 'scrabble_ru']); }); }); // 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', '?']); }); });