aaac816dc2
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m16s
Persist per-message read state as a chat_messages.unread_seats bitmask
(migration 00008): a text message seeds every recipient seat's bit, a nudge
only the awaited seat's. A seat's bit clears when the player opens the move
history or chat (POST /games/:id/chat/read, sent only when something is
unread), and a nudge additionally clears when its recipient answers by moving
(a wired game NudgeClearer, dependency-inverted so game keeps off social).
UI shows a per-viewer unread dot in the lobby (next to the opponent) and the
game score bar — the unread_chat game-view flag seeds it from authoritative
REST views, live chat/nudge events raise it. Opening the move history counts
as reading (even without entering chat): the 💬 fade-blinks twice and the
client acks. Admin Messages gains an unread-only filter, a read/unread column,
and a per-message card with the per-seat read breakdown. Observability:
chat_read_duration histogram + chat_unread_messages gauge + social tracing.
825 lines
28 KiB
TypeScript
825 lines
28 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,
|
|
Banner,
|
|
BannerCampaign,
|
|
BlockStatus,
|
|
ChatMessage,
|
|
EvalResult,
|
|
FeedbackState,
|
|
FriendCode,
|
|
GameList,
|
|
GameView,
|
|
GcgExport,
|
|
History,
|
|
HintResult,
|
|
Invitation,
|
|
InvitationInvitee,
|
|
InvitationSettings,
|
|
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): Uint8Array {
|
|
const b = new Builder(512);
|
|
const d = b.createString(initData);
|
|
fb.TelegramLoginRequest.startTelegramLoginRequest(b);
|
|
fb.TelegramLoginRequest.addInitData(b, d);
|
|
return finish(b, fb.TelegramLoginRequest.endTelegramLoginRequest(b));
|
|
}
|
|
|
|
export function encodeGuestLogin(locale: string): Uint8Array {
|
|
const b = new Builder(64);
|
|
const l = b.createString(locale);
|
|
fb.GuestLoginRequest.startGuestLoginRequest(b);
|
|
fb.GuestLoginRequest.addLocale(b, l);
|
|
return finish(b, fb.GuestLoginRequest.endGuestLoginRequest(b));
|
|
}
|
|
|
|
export function encodeEmailRequest(email: string): Uint8Array {
|
|
const b = new Builder(128);
|
|
const e = b.createString(email);
|
|
fb.EmailRequestRequest.startEmailRequestRequest(b);
|
|
fb.EmailRequestRequest.addEmail(b, e);
|
|
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));
|
|
}
|
|
|
|
// --- 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(),
|
|
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, including the supported-languages set the UI gates variants by.
|
|
function sessionFromTable(t: fb.Session): Session {
|
|
const supportedLanguages: string[] = [];
|
|
for (let i = 0; i < t.supportedLanguagesLength(); i++) supportedLanguages.push(s(t.supportedLanguages(i)));
|
|
return {
|
|
token: s(t.token()),
|
|
userId: s(t.userId()),
|
|
isGuest: t.isGuest(),
|
|
displayName: s(t.displayName()),
|
|
supportedLanguages,
|
|
serviceLanguage: s(t.serviceLanguage()),
|
|
};
|
|
}
|
|
|
|
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(),
|
|
banner: decodeBanner(p),
|
|
};
|
|
}
|
|
|
|
// 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(),
|
|
};
|
|
}
|
|
|
|
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() };
|
|
}
|
|
|
|
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,
|
|
): 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);
|
|
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);
|
|
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): Uint8Array {
|
|
const b = new Builder(64);
|
|
const id = b.createString(accountId);
|
|
fb.TargetRequest.startTargetRequest(b);
|
|
fb.TargetRequest.addAccountId(b, id);
|
|
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);
|
|
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);
|
|
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,
|
|
};
|
|
}
|
|
|
|
// --- 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): AccountRef[] {
|
|
const l = fb.OutgoingRequestList.getRootAsOutgoingRequestList(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 decodeBlockList(buf: Uint8Array): AccountRef[] {
|
|
const l = fb.BlockList.getRootAsBlockList(new ByteBuffer(buf));
|
|
const out: AccountRef[] = [];
|
|
for (let i = 0; i < l.blockedLength(); i++) {
|
|
const r = l.blocked(i);
|
|
if (r) out.push(decodeAccountRef(r));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
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));
|
|
return {
|
|
wins: v.wins(),
|
|
losses: v.losses(),
|
|
draws: v.draws(),
|
|
maxGamePoints: v.maxGamePoints(),
|
|
maxWordPoints: v.maxWordPoints(),
|
|
};
|
|
}
|
|
|
|
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()) };
|
|
}
|
|
|
|
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,
|
|
seats: [],
|
|
};
|
|
}
|
|
|
|
function emptyMove(): MoveRecord {
|
|
return { player: 0, action: '', dir: '', mainRow: 0, mainCol: 0, tiles: [], words: [], count: 0, score: 0, total: 0 };
|
|
}
|