d7337d24ea
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m48s
Russian "Эрудит" treats a word laid on the board as belonging to the game: it cannot be laid again. Neither the solver, the backend nor the offline JS port knew the rule, so a player (and the robot) could replay a word freely. Official Scrabble places no such restriction, so both Scrabble variants keep playing unrestricted. The rule applies in two ways. A play whose main word is already on the board is illegal, and is neither accepted nor generated. A play whose perpendicular cross-word is already there stands — that word is incidental to laying the main word — but scores nothing. The set of played words is the game's own move journal, main words and cross-words alike, compared decoded, so a word spelled with a blank is the same word. It lives in the game layer, not the solver: only a game knows its history, and the solver stays stateless and standard-rules. The backend applies it at submit, at the move preview and over generated moves (filtering and re-ranking them, so neither the robot nor the hint can offer a play the engine would then refuse); the client port does the same for the offline engine and for the on-device preview of an online game. The rule is pinned per game (games.no_repeat_words, set from the variant at creation) rather than keyed on the variant, because a game is replayed from its journal on every open. Applied retroactively it would make an already-played repeat illegal — closing that game as a draw — and would rescore a play whose cross-word repeats an earlier word, shifting a live game's totals. Games created before the rule keep playing without it. The flag rides the wire as a trailing field because the client's preview must score the way the server does, and offline games pin the same answer in their own record.
1002 lines
38 KiB
TypeScript
1002 lines
38 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,
|
|
decodeExportUrl,
|
|
decodeFriendList,
|
|
decodeGameList,
|
|
decodeInvitation,
|
|
decodeLinkResult,
|
|
decodeMatchResult,
|
|
decodeOutgoingList,
|
|
decodeProfile,
|
|
decodeWallet,
|
|
encodeWalletReward,
|
|
decodeCatalog,
|
|
encodeWalletBuy,
|
|
encodeWalletOrder,
|
|
decodeWalletOrder,
|
|
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 wallet buy request and the wallet view', () => {
|
|
// buy request: product id survives the wire
|
|
const req = fb.WalletBuyRequest.getRootAsWalletBuyRequest(new ByteBuffer(encodeWalletBuy('prod-42')));
|
|
expect(req.productId()).toBe('prod-42');
|
|
|
|
// wallet response: build a Wallet payload and decode it, checking the segment vector,
|
|
// the spendable flag and the benefit fields (the long ms field decodes to a number).
|
|
const b = new Builder(128);
|
|
const src = b.createString('direct');
|
|
fb.WalletSegment.startWalletSegment(b);
|
|
fb.WalletSegment.addSource(b, src);
|
|
fb.WalletSegment.addChips(b, 120);
|
|
fb.WalletSegment.addSpendable(b, true);
|
|
const seg = fb.WalletSegment.endWalletSegment(b);
|
|
const segs = fb.Wallet.createSegmentsVector(b, [seg]);
|
|
fb.Wallet.startWallet(b);
|
|
fb.Wallet.addSegments(b, segs);
|
|
fb.Wallet.addAdsForever(b, false);
|
|
fb.Wallet.addAdsPaidUntilMs(b, BigInt(1_700_000_000_000));
|
|
fb.Wallet.addHints(b, 5);
|
|
fb.Wallet.addRewardChips(b, 7);
|
|
b.finish(fb.Wallet.endWallet(b));
|
|
|
|
const w = decodeWallet(b.asUint8Array());
|
|
expect(w.segments).toEqual([{ source: 'direct', chips: 120, spendable: true }]);
|
|
expect(w.adsForever).toBe(false);
|
|
expect(w.adsPaidUntilMs).toBe(1_700_000_000_000);
|
|
expect(w.hints).toBe(5);
|
|
expect(w.rewardChips).toBe(7);
|
|
});
|
|
|
|
it('round-trips the wallet reward request (nonce)', () => {
|
|
const req = fb.WalletRewardRequest.getRootAsWalletRewardRequest(
|
|
new ByteBuffer(encodeWalletReward('nonce-9')),
|
|
);
|
|
expect(req.nonce()).toBe('nonce-9');
|
|
});
|
|
|
|
it('round-trips the wallet order request and response', () => {
|
|
// order request: pack id survives the wire
|
|
const req = fb.WalletOrderRequest.getRootAsWalletOrderRequest(new ByteBuffer(encodeWalletOrder('pack-7')));
|
|
expect(req.productId()).toBe('pack-7');
|
|
|
|
// order response: the created id and the provider launch URL decode back
|
|
const b = new Builder(64);
|
|
const oid = b.createString('order-123');
|
|
const url = b.createString('https://pay.example/abc');
|
|
fb.WalletOrderResponse.startWalletOrderResponse(b);
|
|
fb.WalletOrderResponse.addOrderId(b, oid);
|
|
fb.WalletOrderResponse.addRedirectUrl(b, url);
|
|
b.finish(fb.WalletOrderResponse.endWalletOrderResponse(b));
|
|
|
|
const order = decodeWalletOrder(b.asUint8Array());
|
|
expect(order.orderId).toBe('order-123');
|
|
expect(order.redirectUrl).toBe('https://pay.example/abc');
|
|
});
|
|
|
|
it('round-trips the catalog view (value + pack)', () => {
|
|
const b = new Builder(256);
|
|
|
|
// a chip-priced value with one atom
|
|
const vKind = b.createString('value');
|
|
const vId = b.createString('val-1');
|
|
const vTitle = b.createString('50 hints');
|
|
const aType = b.createString('hints');
|
|
fb.CatalogAtom.startCatalogAtom(b);
|
|
fb.CatalogAtom.addAtomType(b, aType);
|
|
fb.CatalogAtom.addQuantity(b, 50);
|
|
const atom = fb.CatalogAtom.endCatalogAtom(b);
|
|
const vAtoms = fb.CatalogProduct.createAtomsVector(b, [atom]);
|
|
fb.CatalogProduct.startCatalogProduct(b);
|
|
fb.CatalogProduct.addKind(b, vKind);
|
|
fb.CatalogProduct.addProductId(b, vId);
|
|
fb.CatalogProduct.addTitle(b, vTitle);
|
|
fb.CatalogProduct.addChips(b, 100);
|
|
fb.CatalogProduct.addMoneyAmount(b, BigInt(0));
|
|
fb.CatalogProduct.addAtoms(b, vAtoms);
|
|
const value = fb.CatalogProduct.endCatalogProduct(b);
|
|
|
|
// a RUB-priced chip pack (no atoms exercised)
|
|
const pKind = b.createString('pack');
|
|
const pId = b.createString('pack-1');
|
|
const pTitle = b.createString('100 chips');
|
|
const pCur = b.createString('RUB');
|
|
fb.CatalogProduct.startCatalogProduct(b);
|
|
fb.CatalogProduct.addKind(b, pKind);
|
|
fb.CatalogProduct.addProductId(b, pId);
|
|
fb.CatalogProduct.addTitle(b, pTitle);
|
|
fb.CatalogProduct.addChips(b, 0);
|
|
fb.CatalogProduct.addMoneyAmount(b, BigInt(14900));
|
|
fb.CatalogProduct.addMoneyCurrency(b, pCur);
|
|
const pack = fb.CatalogProduct.endCatalogProduct(b);
|
|
|
|
const prods = fb.Catalog.createProductsVector(b, [value, pack]);
|
|
fb.Catalog.startCatalog(b);
|
|
fb.Catalog.addProducts(b, prods);
|
|
b.finish(fb.Catalog.endCatalog(b));
|
|
|
|
const c = decodeCatalog(b.asUint8Array());
|
|
expect(c.products).toEqual([
|
|
{ kind: 'value', productId: 'val-1', title: '50 hints', chips: 100, moneyAmount: 0, moneyCurrency: '', atoms: [{ atomType: 'hints', quantity: 50 }] },
|
|
{ kind: 'pack', productId: 'pack-1', title: '100 chips', chips: 0, moneyAmount: 14900, moneyCurrency: 'RUB', atoms: [] },
|
|
]);
|
|
});
|
|
|
|
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', 'ios')),
|
|
);
|
|
expect(tg.initData()).toBe('init-data-blob');
|
|
expect(tg.browserTz()).toBe('+03:00');
|
|
expect(tg.subtype()).toBe('ios');
|
|
|
|
const guest = fb.GuestLoginRequest.getRootAsGuestLoginRequest(
|
|
new ByteBuffer(encodeGuestLogin('ru', '-05:30', 'android')),
|
|
);
|
|
expect(guest.locale()).toBe('ru');
|
|
expect(guest.browserTz()).toBe('-05:30');
|
|
expect(guest.subtype()).toBe('android');
|
|
|
|
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);
|
|
fb.SeatView.addDeleted(b, true);
|
|
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);
|
|
fb.GameView.addKind(b, 2);
|
|
fb.GameView.addNoRepeatWords(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);
|
|
// The deleted mark rides the seat: it hides the social controls aimed at that seat.
|
|
expect(gl.games[0].seats[0].deleted).toBe(true);
|
|
expect(gl.games[0].lastActivityUnix).toBe(1717000000);
|
|
expect(gl.games[0].vsAi).toBe(true);
|
|
expect(gl.games[0].unreadChat).toBe(true);
|
|
expect(gl.games[0].kind).toBe(2);
|
|
// The Erudit no-repeat rule is pinned per game and rides the view: the on-device move
|
|
// preview needs it to score the way the server does.
|
|
expect(gl.games[0].noRepeatWords).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 interstitial-ad config', () => {
|
|
const b = new Builder(64);
|
|
const uid = b.createString('u-1');
|
|
const ads = fb.AdsInfo.createAdsInfo(b, 300, 1800, 60, false);
|
|
fb.Profile.startProfile(b);
|
|
fb.Profile.addUserId(b, uid);
|
|
fb.Profile.addAds(b, ads);
|
|
b.finish(fb.Profile.endProfile(b));
|
|
expect(decodeProfile(b.asUint8Array()).ads).toEqual({
|
|
cooldownGlobalS: 300,
|
|
cooldownVsAiS: 1800,
|
|
cooldownHintS: 60,
|
|
suppressed: false,
|
|
});
|
|
});
|
|
|
|
it('decodes the profile active-game limits (guest tier)', () => {
|
|
const b = new Builder(64);
|
|
const uid = b.createString('u-1');
|
|
const limits = fb.GameLimits.createGameLimits(b, 1, 1, 0);
|
|
fb.Profile.startProfile(b);
|
|
fb.Profile.addUserId(b, uid);
|
|
fb.Profile.addGameLimits(b, limits);
|
|
b.finish(fb.Profile.endProfile(b));
|
|
expect(decodeProfile(b.asUint8Array()).gameLimits).toEqual({ vsAi: 1, random: 1, friends: 0 });
|
|
});
|
|
|
|
it('leaves the profile game limits 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()).gameLimits).toBeUndefined();
|
|
});
|
|
|
|
it('carries the suppressed flag through the ad config', () => {
|
|
const b = new Builder(64);
|
|
const uid = b.createString('u-1');
|
|
const ads = fb.AdsInfo.createAdsInfo(b, 300, 1800, 60, true);
|
|
fb.Profile.startProfile(b);
|
|
fb.Profile.addUserId(b, uid);
|
|
fb.Profile.addAds(b, ads);
|
|
b.finish(fb.Profile.endProfile(b));
|
|
expect(decodeProfile(b.asUint8Array()).ads?.suppressed).toBe(true);
|
|
});
|
|
|
|
it('leaves the profile ads 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()).ads).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', '?']);
|
|
});
|
|
});
|