Files
scrabble-game/ui/src/lib/codec.ts
T
Ilia Denisov 409462fc09 feat(ui): one-tap confirm deeplink screen + client language
Add the /confirm/<token> SPA route and Confirm screen: a prefetch-safe button
POSTs the token via a new confirmEmailLink RPC (client/transport/mock/codec +
ConfirmLinkResult model). A login adopts the minted session and enters the app; a
link shows confirmed / merge-in-the-app; an invalid or expired token asks for a new
code. Exempt /confirm from the no-session /login redirect. Forward the client
locale on the email request (authEmailRequest gains language → app.locale) so a
fresh web login email is localised. Handle the new 'profile' live-event sub-kind by
re-fetching the profile, so a link confirmed in another browser reflects in-app at
once. i18n en+ru.
2026-07-03 04:30:16 +02:00

960 lines
33 KiB
TypeScript

// FlatBuffers <-> domain-model codec. The real transport encodes each request table
// and decodes each response/event table here, mirroring the gateway's Go encoders in
// reverse. The screens only ever see the plain model (lib/model), never these wire
// types.
import { Builder, ByteBuffer, type Offset } from 'flatbuffers';
import * as fb from '../gen/fbs/scrabblefb';
import { indexForLetter, letterForIndex, setAlphabet, type AlphabetEntryWire } from './alphabet';
import type { PlacedTile } from './client';
import type {
AccountRef,
BlockList,
OutgoingList,
RobotBlockEntry,
RobotFriendRequestEntry,
Banner,
BannerCampaign,
BestMove,
BestMoveTile,
BlockStatus,
ChatMessage,
EvalResult,
FeedbackState,
FriendCode,
ExportUrl,
GameList,
GameView,
GcgExport,
History,
HintResult,
Invitation,
InvitationInvitee,
InvitationSettings,
ConfirmLinkResult,
LinkResult,
MatchResult,
MoveRecord,
MoveResult,
Profile,
ProfileUpdate,
PushEvent,
Seat,
Session,
StateView,
Stats,
Tile,
Variant,
WordCheckResult,
} from './model';
// --- request encoders ---
// buildPlayTile encodes one to-place tile by its alphabet index; a placed blank
// carries its designated letter's index with blank set.
function buildPlayTile(b: Builder, t: PlacedTile, variant: Variant): Offset {
fb.PlayTile.startPlayTile(b);
fb.PlayTile.addRow(b, t.row);
fb.PlayTile.addCol(b, t.col);
fb.PlayTile.addLetter(b, indexForLetter(variant, t.letter));
fb.PlayTile.addBlank(b, t.blank);
return fb.PlayTile.endPlayTile(b);
}
function finish(b: Builder, root: Offset): Uint8Array {
b.finish(root);
return b.asUint8Array();
}
export const empty = (): Uint8Array => new Uint8Array();
export function encodeGameAction(gameId: string): Uint8Array {
const b = new Builder(64);
const gid = b.createString(gameId);
fb.GameActionRequest.startGameActionRequest(b);
fb.GameActionRequest.addGameId(b, gid);
return finish(b, fb.GameActionRequest.endGameActionRequest(b));
}
export function encodeStateRequest(gameId: string, includeAlphabet: boolean): Uint8Array {
const b = new Builder(64);
const gid = b.createString(gameId);
fb.StateRequest.startStateRequest(b);
fb.StateRequest.addGameId(b, gid);
fb.StateRequest.addIncludeAlphabet(b, includeAlphabet);
return finish(b, fb.StateRequest.endStateRequest(b));
}
// encodeDraftSave wraps the player's composition JSON. The string is opaque on the
// wire — the gateway forwards it verbatim and only the client reads {rack_order, board_tiles}.
export function encodeDraftSave(gameId: string, json: string): Uint8Array {
const b = new Builder(256);
const gid = b.createString(gameId);
const j = b.createString(json);
fb.DraftRequest.startDraftRequest(b);
fb.DraftRequest.addGameId(b, gid);
fb.DraftRequest.addJson(b, j);
return finish(b, fb.DraftRequest.endDraftRequest(b));
}
export function encodeSubmitPlay(
gameId: string,
tiles: PlacedTile[],
variant: Variant,
): Uint8Array {
const b = new Builder(256);
const tileOffs = tiles.map((t) => buildPlayTile(b, t, variant));
const vec = fb.SubmitPlayRequest.createTilesVector(b, tileOffs);
const gid = b.createString(gameId);
fb.SubmitPlayRequest.startSubmitPlayRequest(b);
fb.SubmitPlayRequest.addGameId(b, gid);
fb.SubmitPlayRequest.addTiles(b, vec);
return finish(b, fb.SubmitPlayRequest.endSubmitPlayRequest(b));
}
export function encodeEval(gameId: string, tiles: PlacedTile[], variant: Variant): Uint8Array {
const b = new Builder(256);
const tileOffs = tiles.map((t) => buildPlayTile(b, t, variant));
const vec = fb.EvalRequest.createTilesVector(b, tileOffs);
const gid = b.createString(gameId);
fb.EvalRequest.startEvalRequest(b);
fb.EvalRequest.addGameId(b, gid);
fb.EvalRequest.addTiles(b, vec);
return finish(b, fb.EvalRequest.endEvalRequest(b));
}
export function encodeExchange(gameId: string, tiles: string[], variant: Variant): Uint8Array {
const b = new Builder(128);
const vec = fb.ExchangeRequest.createTilesVector(
b,
tiles.map((l) => indexForLetter(variant, l)),
);
const gid = b.createString(gameId);
fb.ExchangeRequest.startExchangeRequest(b);
fb.ExchangeRequest.addGameId(b, gid);
fb.ExchangeRequest.addTiles(b, vec);
return finish(b, fb.ExchangeRequest.endExchangeRequest(b));
}
export function encodeCheckWord(gameId: string, word: string, variant: Variant): Uint8Array {
const b = new Builder(128);
const vec = fb.CheckWordRequest.createWordVector(
b,
Array.from(word).map((ch) => indexForLetter(variant, ch)),
);
const gid = b.createString(gameId);
fb.CheckWordRequest.startCheckWordRequest(b);
fb.CheckWordRequest.addGameId(b, gid);
fb.CheckWordRequest.addWord(b, vec);
return finish(b, fb.CheckWordRequest.endCheckWordRequest(b));
}
export function encodeComplaint(gameId: string, word: string, note: string): Uint8Array {
const b = new Builder(256);
const gid = b.createString(gameId);
const w = b.createString(word);
const n = b.createString(note);
fb.ComplaintRequest.startComplaintRequest(b);
fb.ComplaintRequest.addGameId(b, gid);
fb.ComplaintRequest.addWord(b, w);
fb.ComplaintRequest.addNote(b, n);
return finish(b, fb.ComplaintRequest.endComplaintRequest(b));
}
export function encodeEnqueue(variant: Variant, multipleWords: boolean, vsAi: boolean): Uint8Array {
const b = new Builder(64);
const v = b.createString(variant);
fb.EnqueueRequest.startEnqueueRequest(b);
fb.EnqueueRequest.addVariant(b, v);
fb.EnqueueRequest.addMultipleWordsPerTurn(b, multipleWords);
fb.EnqueueRequest.addVsAi(b, vsAi);
return finish(b, fb.EnqueueRequest.endEnqueueRequest(b));
}
export function encodeChatPost(gameId: string, body: string): Uint8Array {
const b = new Builder(128);
const gid = b.createString(gameId);
const bd = b.createString(body);
fb.ChatPostRequest.startChatPostRequest(b);
fb.ChatPostRequest.addGameId(b, gid);
fb.ChatPostRequest.addBody(b, bd);
return finish(b, fb.ChatPostRequest.endChatPostRequest(b));
}
export function encodeTelegramLogin(initData: string, browserTz: string): Uint8Array {
const b = new Builder(512);
const d = b.createString(initData);
const tz = b.createString(browserTz);
fb.TelegramLoginRequest.startTelegramLoginRequest(b);
fb.TelegramLoginRequest.addInitData(b, d);
fb.TelegramLoginRequest.addBrowserTz(b, tz);
return finish(b, fb.TelegramLoginRequest.endTelegramLoginRequest(b));
}
export function encodeVKLogin(params: string, browserTz: string, displayName: string): Uint8Array {
const b = new Builder(512);
const p = b.createString(params);
const tz = b.createString(browserTz);
const dn = b.createString(displayName);
fb.VKLoginRequest.startVKLoginRequest(b);
fb.VKLoginRequest.addParams(b, p);
fb.VKLoginRequest.addBrowserTz(b, tz);
fb.VKLoginRequest.addDisplayName(b, dn);
return finish(b, fb.VKLoginRequest.endVKLoginRequest(b));
}
export function encodeGuestLogin(locale: string, browserTz: string): Uint8Array {
const b = new Builder(64);
const l = b.createString(locale);
const tz = b.createString(browserTz);
fb.GuestLoginRequest.startGuestLoginRequest(b);
fb.GuestLoginRequest.addLocale(b, l);
fb.GuestLoginRequest.addBrowserTz(b, tz);
return finish(b, fb.GuestLoginRequest.endGuestLoginRequest(b));
}
export function encodeEmailRequest(email: string, browserTz: string, language: string): Uint8Array {
const b = new Builder(128);
const e = b.createString(email);
const tz = b.createString(browserTz);
const l = b.createString(language);
fb.EmailRequestRequest.startEmailRequestRequest(b);
fb.EmailRequestRequest.addEmail(b, e);
fb.EmailRequestRequest.addBrowserTz(b, tz);
fb.EmailRequestRequest.addLanguage(b, l);
return finish(b, fb.EmailRequestRequest.endEmailRequestRequest(b));
}
export function encodeEmailLogin(email: string, code: string): Uint8Array {
const b = new Builder(128);
const e = b.createString(email);
const c = b.createString(code);
fb.EmailLoginRequest.startEmailLoginRequest(b);
fb.EmailLoginRequest.addEmail(b, e);
fb.EmailLoginRequest.addCode(b, c);
return finish(b, fb.EmailLoginRequest.endEmailLoginRequest(b));
}
export function encodeEmailConfirmLink(token: string): Uint8Array {
const b = new Builder(128);
const t = b.createString(token);
fb.EmailConfirmLinkRequest.startEmailConfirmLinkRequest(b);
fb.EmailConfirmLinkRequest.addToken(b, t);
return finish(b, fb.EmailConfirmLinkRequest.endEmailConfirmLinkRequest(b));
}
// --- response decoders ---
function s(v: string | null): string {
return v ?? '';
}
function decodeTile(t: fb.TileRecord): Tile {
// The wire keeps the move journal in the solver's lower case; the UI renders upper case.
return { row: t.row(), col: t.col(), letter: s(t.letter()).toUpperCase(), blank: t.blank() };
}
function decodeSeat(v: fb.SeatView): Seat {
return {
seat: v.seat(),
accountId: s(v.accountId()),
displayName: s(v.displayName()),
score: v.score(),
hintsUsed: v.hintsUsed(),
isWinner: v.isWinner(),
};
}
function decodeGameView(g: fb.GameView): GameView {
const seats: Seat[] = [];
for (let i = 0; i < g.seatsLength(); i++) {
const sv = g.seats(i);
if (sv) seats.push(decodeSeat(sv));
}
return {
id: s(g.id()),
variant: s(g.variant()) as Variant,
dictVersion: s(g.dictVersion()),
status: s(g.status()),
players: g.players(),
toMove: g.toMove(),
turnTimeoutSecs: g.turnTimeoutSecs(),
multipleWordsPerTurn: g.multipleWordsPerTurn(),
moveCount: g.moveCount(),
endReason: s(g.endReason()),
lastActivityUnix: Number(g.lastActivityUnix()),
vsAi: g.vsAi(),
unreadChat: g.unreadChat(),
unreadMessages: g.unreadMessages(),
seats,
};
}
function decodeMove(m: fb.MoveRecord): MoveRecord {
const tiles: Tile[] = [];
for (let i = 0; i < m.tilesLength(); i++) {
const t = m.tiles(i);
if (t) tiles.push(decodeTile(t));
}
const words: string[] = [];
for (let i = 0; i < m.wordsLength(); i++) words.push(s(m.words(i)).toUpperCase());
return {
player: m.player(),
action: s(m.action()),
dir: s(m.dir()),
mainRow: m.mainRow(),
mainCol: m.mainCol(),
tiles,
words,
count: m.count(),
score: m.score(),
total: m.total(),
};
}
function decodeChatMsg(m: fb.ChatMessage): ChatMessage {
return {
id: s(m.id()),
gameId: s(m.gameId()),
senderId: s(m.senderId()),
kind: s(m.kind()),
body: s(m.body()),
createdAtUnix: Number(m.createdAtUnix()),
};
}
// sessionFromTable projects a FlatBuffers Session table (a root or a nested one) to
// the Session model.
function sessionFromTable(t: fb.Session): Session {
return {
token: s(t.token()),
userId: s(t.userId()),
isGuest: t.isGuest(),
displayName: s(t.displayName()),
};
}
export function decodeSession(buf: Uint8Array): Session {
return sessionFromTable(fb.Session.getRootAsSession(new ByteBuffer(buf)));
}
export function decodeProfile(buf: Uint8Array): Profile {
const p = fb.Profile.getRootAsProfile(new ByteBuffer(buf));
return {
userId: s(p.userId()),
displayName: s(p.displayName()),
preferredLanguage: s(p.preferredLanguage()),
timeZone: s(p.timeZone()),
awayStart: s(p.awayStart()),
awayEnd: s(p.awayEnd()),
hintBalance: p.hintBalance(),
blockChat: p.blockChat(),
blockFriendRequests: p.blockFriendRequests(),
isGuest: p.isGuest(),
notificationsInAppOnly: p.notificationsInAppOnly(),
variantPreferences: decodeVariantPreferences(p),
banner: decodeBanner(p),
};
}
// decodeVariantPreferences reads the Profile.variant_preferences vector (the variants
// the player enabled in Settings).
function decodeVariantPreferences(p: fb.Profile): Variant[] {
const out: Variant[] = [];
for (let i = 0; i < p.variantPreferencesLength(); i++) out.push(s(p.variantPreferences(i)) as Variant);
return out;
}
// decodeBanner projects the optional advertising-banner block of a Profile, or
// undefined when the viewer is not eligible (the field is absent).
function decodeBanner(p: fb.Profile): Banner | undefined {
const b = p.banner();
if (!b) return undefined;
const campaigns: BannerCampaign[] = [];
for (let i = 0; i < b.campaignsLength(); i++) {
const c = b.campaigns(i);
if (!c) continue;
const messages: string[] = [];
for (let j = 0; j < c.messagesLength(); j++) messages.push(c.messages(j));
campaigns.push({ weight: c.weight(), messages });
}
return {
campaigns,
timings: {
holdMs: b.holdMs(),
edgePauseMs: b.edgePauseMs(),
scrollPxPerSec: b.scrollPxPerSec(),
fadeOutMs: b.fadeOutMs(),
gapMs: b.gapMs(),
fadeInMs: b.fadeInMs(),
},
};
}
export function decodeBlockStatus(buf: Uint8Array): BlockStatus {
const b = fb.BlockStatus.getRootAsBlockStatus(new ByteBuffer(buf));
return {
blocked: b.blocked(),
permanent: b.permanent(),
until: s(b.until()),
reason: s(b.reason()),
};
}
// decodeStateViewTable projects a StateView table (a root or one nested in an event) to the
// model. It caches the alphabet when present (a per-variant cache miss) and decodes the index
// rack to display letters with it.
function decodeStateViewTable(v: fb.StateView): StateView {
const g = v.game();
const variant = (g ? s(g.variant()) : 'scrabble_en') as Variant;
if (v.alphabetLength() > 0) {
const entries: AlphabetEntryWire[] = [];
for (let i = 0; i < v.alphabetLength(); i++) {
const e = v.alphabet(i);
if (e) entries.push({ index: e.index(), letter: s(e.letter()), value: e.value() });
}
setAlphabet(variant, entries);
}
const rack: string[] = [];
for (let i = 0; i < v.rackLength(); i++) rack.push(letterForIndex(variant, v.rack(i) ?? 0));
return {
game: g ? decodeGameView(g) : emptyGame(),
seat: v.seat(),
rack,
bagLen: v.bagLen(),
hintsRemaining: v.hintsRemaining(),
walletBalance: v.walletBalance(),
};
}
export function decodeStateView(buf: Uint8Array): StateView {
return decodeStateViewTable(fb.StateView.getRootAsStateView(new ByteBuffer(buf)));
}
export function decodeMoveResult(buf: Uint8Array): MoveResult {
const r = fb.MoveResult.getRootAsMoveResult(new ByteBuffer(buf));
const m = r.move();
const g = r.game();
// The actor's refilled rack rides back as alphabet indices; decode it with the game's variant.
const variant = (g ? s(g.variant()) : 'scrabble_en') as Variant;
const rack: string[] = [];
for (let i = 0; i < r.rackLength(); i++) rack.push(letterForIndex(variant, r.rack(i) ?? 0));
return {
move: m ? decodeMove(m) : emptyMove(),
game: g ? decodeGameView(g) : emptyGame(),
rack,
bagLen: r.bagLen(),
};
}
export function decodeHintResult(buf: Uint8Array): HintResult {
const r = fb.HintResult.getRootAsHintResult(new ByteBuffer(buf));
const m = r.move();
return { move: m ? decodeMove(m) : emptyMove(), hintsRemaining: r.hintsRemaining(), walletBalance: r.walletBalance() };
}
export function decodeEvalResult(buf: Uint8Array): EvalResult {
const r = fb.EvalResult.getRootAsEvalResult(new ByteBuffer(buf));
const words: string[] = [];
for (let i = 0; i < r.wordsLength(); i++) words.push(s(r.words(i)));
return { legal: r.legal(), score: r.score(), words, dir: s(r.dir()) };
}
export function decodeWordCheck(buf: Uint8Array): WordCheckResult {
const r = fb.WordCheckResult.getRootAsWordCheckResult(new ByteBuffer(buf));
return { word: s(r.word()), legal: r.legal() };
}
// decodeDraftView returns the player's stored composition JSON (empty when none is stored or
// for the save acknowledgement); the caller parses {rack_order, board_tiles}.
export function decodeDraftView(buf: Uint8Array): string {
const v = fb.DraftView.getRootAsDraftView(new ByteBuffer(buf));
return v.json() ?? '';
}
export function decodeHistory(buf: Uint8Array): History {
const h = fb.History.getRootAsHistory(new ByteBuffer(buf));
const moves: MoveRecord[] = [];
for (let i = 0; i < h.movesLength(); i++) {
const m = h.moves(i);
if (m) moves.push(decodeMove(m));
}
return { gameId: s(h.gameId()), moves };
}
export function decodeGameList(buf: Uint8Array): GameList {
const gl = fb.GameList.getRootAsGameList(new ByteBuffer(buf));
const games: GameView[] = [];
for (let i = 0; i < gl.gamesLength(); i++) {
const g = gl.games(i);
if (g) games.push(decodeGameView(g));
}
return { games, atGameLimit: gl.atGameLimit() };
}
export function decodeMatchResult(buf: Uint8Array): MatchResult {
const m = fb.MatchResult.getRootAsMatchResult(new ByteBuffer(buf));
const g = m.game();
// Enqueue always lands the caller in a game — an open game awaiting an opponent has
// matched=false but still carries it — so decode the game whenever it is present.
return { matched: m.matched(), game: g ? decodeGameView(g) : undefined };
}
export function decodeChatMessage(buf: Uint8Array): ChatMessage {
return decodeChatMsg(fb.ChatMessage.getRootAsChatMessage(new ByteBuffer(buf)));
}
export function decodeChatList(buf: Uint8Array): ChatMessage[] {
const cl = fb.ChatList.getRootAsChatList(new ByteBuffer(buf));
const out: ChatMessage[] = [];
for (let i = 0; i < cl.messagesLength(); i++) {
const m = cl.messages(i);
if (m) out.push(decodeChatMsg(m));
}
return out;
}
export function encodeFeedbackSubmit(
body: string,
attachment: Uint8Array | null,
attachmentName: string,
channel: string,
version: string,
browserTz: string,
): Uint8Array {
const b = new Builder(256);
const bodyOff = b.createString(body);
const attOff = attachment && attachment.length > 0 ? fb.FeedbackSubmitRequest.createAttachmentVector(b, attachment) : 0;
const nameOff = b.createString(attachmentName);
const chOff = b.createString(channel);
const verOff = b.createString(version);
const tzOff = b.createString(browserTz);
fb.FeedbackSubmitRequest.startFeedbackSubmitRequest(b);
fb.FeedbackSubmitRequest.addBody(b, bodyOff);
if (attOff) fb.FeedbackSubmitRequest.addAttachment(b, attOff);
fb.FeedbackSubmitRequest.addAttachmentName(b, nameOff);
fb.FeedbackSubmitRequest.addChannel(b, chOff);
fb.FeedbackSubmitRequest.addVersion(b, verOff);
fb.FeedbackSubmitRequest.addBrowserTz(b, tzOff);
return finish(b, fb.FeedbackSubmitRequest.endFeedbackSubmitRequest(b));
}
export function decodeFeedbackState(buf: Uint8Array): FeedbackState {
const st = fb.FeedbackState.getRootAsFeedbackState(new ByteBuffer(buf));
const r = st.reply();
return {
canSend: st.canSend(),
blockedReason: s(st.blockedReason()),
reply: r ? { body: s(r.body()), repliedAtUnix: Number(r.repliedAtUnix()) } : null,
};
}
export function decodeFeedbackUnread(buf: Uint8Array): boolean {
return fb.FeedbackUnread.getRootAsFeedbackUnread(new ByteBuffer(buf)).replyUnread();
}
export function decodeEvent(kind: string, payload: Uint8Array): PushEvent | null {
const bb = new ByteBuffer(payload);
switch (kind) {
case 'your_turn': {
const e = fb.YourTurnEvent.getRootAsYourTurnEvent(bb);
return { kind: 'your_turn', gameId: s(e.gameId()), deadlineUnix: Number(e.deadlineUnix()), opponentName: s(e.opponentName()), moveCount: e.moveCount() };
}
case 'opponent_moved': {
const e = fb.OpponentMovedEvent.getRootAsOpponentMovedEvent(bb);
const m = e.move();
const g = e.game();
return {
kind: 'opponent_moved',
gameId: s(e.gameId()),
move: m ? decodeMove(m) : undefined,
game: g ? decodeGameView(g) : undefined,
bagLen: e.bagLen(),
};
}
case 'game_over': {
const e = fb.GameOverEvent.getRootAsGameOverEvent(bb);
const g = e.game();
return {
kind: 'game_over',
gameId: s(e.gameId()),
result: s(e.result()),
scoreLine: s(e.scoreLine()),
game: g ? decodeGameView(g) : undefined,
};
}
case 'chat_message':
return { kind: 'chat_message', message: decodeChatMsg(fb.ChatMessage.getRootAsChatMessage(bb)) };
case 'nudge': {
const e = fb.NudgeEvent.getRootAsNudgeEvent(bb);
return { kind: 'nudge', gameId: s(e.gameId()), fromUserId: s(e.fromUserId()), senderName: s(e.senderName()) };
}
case 'match_found': {
const e = fb.MatchFoundEvent.getRootAsMatchFoundEvent(bb);
const st = e.state();
return { kind: 'match_found', gameId: s(e.gameId()), state: st ? decodeStateViewTable(st) : undefined };
}
case 'opponent_joined': {
// opponent_joined reuses the match_found payload layout (game id + the recipient's state).
const e = fb.MatchFoundEvent.getRootAsMatchFoundEvent(bb);
const st = e.state();
return { kind: 'opponent_joined', gameId: s(e.gameId()), state: st ? decodeStateViewTable(st) : undefined };
}
case 'notify': {
const e = fb.NotificationEvent.getRootAsNotificationEvent(bb);
const acc = e.account();
const inv = e.invitation();
const st = e.state();
return {
kind: 'notify',
sub: s(e.kind()),
account: acc ? decodeAccountRef(acc) : undefined,
invitation: inv ? decodeInvitationTable(inv) : undefined,
state: st ? decodeStateViewTable(st) : undefined,
};
}
case 'heartbeat':
return { kind: 'heartbeat' };
default:
return null;
}
}
// --- social encoders ---
export function encodeTarget(accountId: string, gameId?: string): Uint8Array {
const b = new Builder(64);
const id = b.createString(accountId);
// game_id is set only by an in-game block, so a disguised-robot opponent is recorded
// per-game; every other target path omits it.
const gid = gameId ? b.createString(gameId) : 0;
fb.TargetRequest.startTargetRequest(b);
fb.TargetRequest.addAccountId(b, id);
if (gid) fb.TargetRequest.addGameId(b, gid);
return finish(b, fb.TargetRequest.endTargetRequest(b));
}
export function encodeFriendRespond(requesterId: string, accept: boolean): Uint8Array {
const b = new Builder(64);
const id = b.createString(requesterId);
fb.FriendRespondRequest.startFriendRespondRequest(b);
fb.FriendRespondRequest.addRequesterId(b, id);
fb.FriendRespondRequest.addAccept(b, accept);
return finish(b, fb.FriendRespondRequest.endFriendRespondRequest(b));
}
export function encodeRedeemCode(code: string): Uint8Array {
const b = new Builder(32);
const c = b.createString(code);
fb.RedeemCodeRequest.startRedeemCodeRequest(b);
fb.RedeemCodeRequest.addCode(b, c);
return finish(b, fb.RedeemCodeRequest.endRedeemCodeRequest(b));
}
export function encodeCreateInvitation(inviteeIds: string[], st: InvitationSettings): Uint8Array {
const b = new Builder(256);
const idOffs = inviteeIds.map((id) => b.createString(id));
const ids = fb.CreateInvitationRequest.createInviteeIdsVector(b, idOffs);
const variant = b.createString(st.variant);
const dropout = b.createString(st.dropoutTiles);
fb.CreateInvitationRequest.startCreateInvitationRequest(b);
fb.CreateInvitationRequest.addInviteeIds(b, ids);
fb.CreateInvitationRequest.addVariant(b, variant);
fb.CreateInvitationRequest.addTurnTimeoutSecs(b, st.turnTimeoutSecs);
fb.CreateInvitationRequest.addHintsAllowed(b, st.hintsAllowed);
fb.CreateInvitationRequest.addHintsPerPlayer(b, st.hintsPerPlayer);
fb.CreateInvitationRequest.addDropoutTiles(b, dropout);
fb.CreateInvitationRequest.addMultipleWordsPerTurn(b, st.multipleWordsPerTurn);
return finish(b, fb.CreateInvitationRequest.endCreateInvitationRequest(b));
}
export function encodeInvitationAction(invitationId: string): Uint8Array {
const b = new Builder(64);
const id = b.createString(invitationId);
fb.InvitationActionRequest.startInvitationActionRequest(b);
fb.InvitationActionRequest.addInvitationId(b, id);
return finish(b, fb.InvitationActionRequest.endInvitationActionRequest(b));
}
export function encodeUpdateProfile(p: ProfileUpdate): Uint8Array {
const b = new Builder(256);
const name = b.createString(p.displayName);
const lang = b.createString(p.preferredLanguage);
const tz = b.createString(p.timeZone);
const as = b.createString(p.awayStart);
const ae = b.createString(p.awayEnd);
const prefs = fb.UpdateProfileRequest.createVariantPreferencesVector(
b,
p.variantPreferences.map((v) => b.createString(v)),
);
fb.UpdateProfileRequest.startUpdateProfileRequest(b);
fb.UpdateProfileRequest.addDisplayName(b, name);
fb.UpdateProfileRequest.addPreferredLanguage(b, lang);
fb.UpdateProfileRequest.addTimeZone(b, tz);
fb.UpdateProfileRequest.addAwayStart(b, as);
fb.UpdateProfileRequest.addAwayEnd(b, ae);
fb.UpdateProfileRequest.addBlockChat(b, p.blockChat);
fb.UpdateProfileRequest.addBlockFriendRequests(b, p.blockFriendRequests);
fb.UpdateProfileRequest.addNotificationsInAppOnly(b, p.notificationsInAppOnly);
fb.UpdateProfileRequest.addVariantPreferences(b, prefs);
return finish(b, fb.UpdateProfileRequest.endUpdateProfileRequest(b));
}
// --- account linking & merge ---
export function encodeLinkEmailRequest(email: string): Uint8Array {
const b = new Builder(128);
const e = b.createString(email);
fb.LinkEmailRequest.startLinkEmailRequest(b);
fb.LinkEmailRequest.addEmail(b, e);
return finish(b, fb.LinkEmailRequest.endLinkEmailRequest(b));
}
export function encodeLinkEmailConfirm(email: string, code: string): Uint8Array {
const b = new Builder(128);
const e = b.createString(email);
const c = b.createString(code);
fb.LinkEmailConfirm.startLinkEmailConfirm(b);
fb.LinkEmailConfirm.addEmail(b, e);
fb.LinkEmailConfirm.addCode(b, c);
return finish(b, fb.LinkEmailConfirm.endLinkEmailConfirm(b));
}
export function encodeLinkTelegram(data: string): Uint8Array {
const b = new Builder(256);
const d = b.createString(data);
fb.LinkTelegramRequest.startLinkTelegramRequest(b);
fb.LinkTelegramRequest.addData(b, d);
return finish(b, fb.LinkTelegramRequest.endLinkTelegramRequest(b));
}
export function decodeLinkResult(buf: Uint8Array): LinkResult {
const r = fb.LinkResult.getRootAsLinkResult(new ByteBuffer(buf));
const sess = r.session();
return {
status: (s(r.status()) || 'linked') as LinkResult['status'],
secondaryUserId: s(r.secondaryUserId()),
secondaryDisplayName: s(r.secondaryDisplayName()),
secondaryGames: r.secondaryGames(),
secondaryFriends: r.secondaryFriends(),
session: sess ? sessionFromTable(sess) : null,
};
}
export function decodeConfirmLinkResult(buf: Uint8Array): ConfirmLinkResult {
const r = fb.EmailConfirmLinkResult.getRootAsEmailConfirmLinkResult(new ByteBuffer(buf));
const sess = r.session();
return {
purpose: (s(r.purpose()) || 'link') as ConfirmLinkResult['purpose'],
status: (s(r.status()) || 'confirmed') as ConfirmLinkResult['status'],
session: sess ? sessionFromTable(sess) : null,
};
}
// --- social decoders ---
function decodeAccountRef(r: fb.AccountRef): AccountRef {
return { accountId: s(r.accountId()), displayName: s(r.displayName()) };
}
export function decodeFriendList(buf: Uint8Array): AccountRef[] {
const l = fb.FriendList.getRootAsFriendList(new ByteBuffer(buf));
const out: AccountRef[] = [];
for (let i = 0; i < l.friendsLength(); i++) {
const r = l.friends(i);
if (r) out.push(decodeAccountRef(r));
}
return out;
}
export function decodeIncomingList(buf: Uint8Array): AccountRef[] {
const l = fb.IncomingRequestList.getRootAsIncomingRequestList(new ByteBuffer(buf));
const out: AccountRef[] = [];
for (let i = 0; i < l.requestsLength(); i++) {
const r = l.requests(i);
if (r) out.push(decodeAccountRef(r));
}
return out;
}
export function decodeOutgoingList(buf: Uint8Array): OutgoingList {
const l = fb.OutgoingRequestList.getRootAsOutgoingRequestList(new ByteBuffer(buf));
const requests: AccountRef[] = [];
for (let i = 0; i < l.requestsLength(); i++) {
const r = l.requests(i);
if (r) requests.push(decodeAccountRef(r));
}
const robots: RobotFriendRequestEntry[] = [];
for (let i = 0; i < l.robotsLength(); i++) {
const r = l.robots(i);
if (r)
robots.push({
id: r.id() ?? '',
displayName: r.displayName() ?? '',
gameId: r.gameId() ?? '',
seat: r.seat(),
});
}
return { requests, robots };
}
export function decodeBlockList(buf: Uint8Array): BlockList {
const l = fb.BlockList.getRootAsBlockList(new ByteBuffer(buf));
const blocked: AccountRef[] = [];
for (let i = 0; i < l.blockedLength(); i++) {
const r = l.blocked(i);
if (r) blocked.push(decodeAccountRef(r));
}
const robots: RobotBlockEntry[] = [];
for (let i = 0; i < l.robotsLength(); i++) {
const r = l.robots(i);
if (r)
robots.push({
id: r.id() ?? '',
displayName: r.displayName() ?? '',
gameId: r.gameId() ?? '',
seat: r.seat(),
});
}
return { blocked, robots };
}
export function decodeFriendCode(buf: Uint8Array): FriendCode {
const c = fb.FriendCode.getRootAsFriendCode(new ByteBuffer(buf));
return { code: s(c.code()), expiresAtUnix: Number(c.expiresAtUnix()) };
}
export function decodeRedeemResult(buf: Uint8Array): AccountRef {
const r = fb.RedeemResult.getRootAsRedeemResult(new ByteBuffer(buf));
const f = r.friend();
return f ? decodeAccountRef(f) : { accountId: '', displayName: '' };
}
export function decodeStats(buf: Uint8Array): Stats {
const v = fb.StatsView.getRootAsStatsView(new ByteBuffer(buf));
const bestMoves: BestMove[] = [];
for (let i = 0; i < v.bestMovesLength(); i++) {
const m = v.bestMoves(i);
if (!m) continue;
const word: BestMoveTile[] = [];
for (let j = 0; j < m.wordLength(); j++) {
const t = m.word(j);
if (t) word.push({ letter: s(t.letter()), value: t.value(), blank: t.blank() });
}
bestMoves.push({ variant: s(m.variant()) as Variant, score: m.score(), word });
}
return {
wins: v.wins(),
losses: v.losses(),
draws: v.draws(),
maxGamePoints: v.maxGamePoints(),
maxWordPoints: v.maxWordPoints(),
moves: v.moves(),
hintsUsed: v.hintsUsed(),
bestMoves,
};
}
function decodeInvitationTable(i: fb.Invitation): Invitation {
const inviter = i.inviter();
const invitees: InvitationInvitee[] = [];
for (let k = 0; k < i.inviteesLength(); k++) {
const iv = i.invitees(k);
if (iv) {
invitees.push({
accountId: s(iv.accountId()),
displayName: s(iv.displayName()),
seat: iv.seat(),
response: s(iv.response()),
});
}
}
return {
id: s(i.id()),
inviter: inviter ? decodeAccountRef(inviter) : { accountId: '', displayName: '' },
invitees,
variant: s(i.variant()) as Variant,
turnTimeoutSecs: i.turnTimeoutSecs(),
hintsAllowed: i.hintsAllowed(),
hintsPerPlayer: i.hintsPerPlayer(),
multipleWordsPerTurn: i.multipleWordsPerTurn(),
dropoutTiles: s(i.dropoutTiles()),
status: s(i.status()),
gameId: s(i.gameId()),
expiresAtUnix: Number(i.expiresAtUnix()),
};
}
export function decodeInvitation(buf: Uint8Array): Invitation {
return decodeInvitationTable(fb.Invitation.getRootAsInvitation(new ByteBuffer(buf)));
}
export function decodeInvitationList(buf: Uint8Array): Invitation[] {
const l = fb.InvitationList.getRootAsInvitationList(new ByteBuffer(buf));
const out: Invitation[] = [];
for (let i = 0; i < l.invitationsLength(); i++) {
const inv = l.invitations(i);
if (inv) out.push(decodeInvitationTable(inv));
}
return out;
}
export function decodeGcg(buf: Uint8Array): GcgExport {
const g = fb.GcgExport.getRootAsGcgExport(new ByteBuffer(buf));
return { gameId: s(g.gameId()), filename: s(g.filename()), content: s(g.content()) };
}
export function encodeExportUrlRequest(
gameId: string,
kind: 'png' | 'gcg',
dateLocale: string,
actionLabels: string[],
timeZone: string,
): Uint8Array {
const b = new Builder(256);
const gid = b.createString(gameId);
const k = b.createString(kind);
const dl = b.createString(dateLocale);
const tz = b.createString(timeZone);
const labels = fb.ExportUrlRequest.createActionLabelsVector(
b,
actionLabels.map((l) => b.createString(l)),
);
fb.ExportUrlRequest.startExportUrlRequest(b);
fb.ExportUrlRequest.addGameId(b, gid);
fb.ExportUrlRequest.addKind(b, k);
fb.ExportUrlRequest.addDateLocale(b, dl);
fb.ExportUrlRequest.addActionLabels(b, labels);
fb.ExportUrlRequest.addTimeZone(b, tz);
return finish(b, fb.ExportUrlRequest.endExportUrlRequest(b));
}
export function decodeExportUrl(buf: Uint8Array): ExportUrl {
const e = fb.ExportUrl.getRootAsExportUrl(new ByteBuffer(buf));
return { path: s(e.path()), filename: s(e.filename()) };
}
function emptyGame(): GameView {
return {
id: '',
variant: 'scrabble_en',
dictVersion: '',
status: '',
players: 0,
toMove: 0,
turnTimeoutSecs: 0,
multipleWordsPerTurn: true,
moveCount: 0,
endReason: '',
lastActivityUnix: 0,
vsAi: false,
unreadChat: false,
unreadMessages: false,
seats: [],
};
}
function emptyMove(): MoveRecord {
return { player: 0, action: '', dir: '', mainRow: 0, mainCol: 0, tiles: [], words: [], count: 0, score: 0, total: 0 };
}