b78ce42922
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
Each feedback submission now carries the client app version (__APP_VERSION__), snapshotted like the interface language: FlatBuffers FeedbackSubmitRequest gains a version field → gateway transcode → backend, persisted in a new nullable feedback_messages.app_version column (migration 00003, additive so an image rollback stays DB-safe). The operator console detail shows the app version and renders the Filed time in UTC plus the sender's time zone (fmtTimeIn). Touches: fbs schema + regenerated Go/TS codegen, codec + transport (the client attaches its build), gateway transcode + backendclient, feedback store/service, admin view + template, docs (ARCHITECTURE §15, FUNCTIONAL + _ru). Verified: feedback integration tests (migration + version round-trip), codec round-trip, check/unit/build green.
669 lines
24 KiB
TypeScript
669 lines
24 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,
|
|
decodeProfile,
|
|
decodeSession,
|
|
decodeFeedbackState,
|
|
decodeFeedbackUnread,
|
|
decodeStateView,
|
|
decodeStats,
|
|
encodeCheckWord,
|
|
encodeFeedbackSubmit,
|
|
encodeDraftSave,
|
|
encodeEnqueue,
|
|
encodeExchange,
|
|
encodeStateRequest,
|
|
encodeSubmitPlay,
|
|
encodeTarget,
|
|
encodeUpdateProfile,
|
|
} from './codec';
|
|
|
|
describe('codec', () => {
|
|
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('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')),
|
|
);
|
|
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(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')),
|
|
);
|
|
expect(req2.body()).toBe('hi');
|
|
expect(req2.version()).toBe('dev');
|
|
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('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]);
|
|
const camp = fb.BannerCampaign.createBannerCampaign(b, 3, msgs);
|
|
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).toEqual([{ weight: 3, messages: ['promo-en'] }]);
|
|
expect(p.banner?.timings).toEqual({
|
|
holdMs: 60000,
|
|
edgePauseMs: 5000,
|
|
scrollPxPerSec: 40,
|
|
fadeOutMs: 1000,
|
|
gapMs: 250,
|
|
fadeInMs: 1000,
|
|
});
|
|
});
|
|
|
|
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('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', '?']);
|
|
});
|
|
});
|