// 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 type { PlacedTile } from './client'; import type { ChatMessage, EvalResult, GameList, GameView, History, HintResult, MatchResult, MoveRecord, MoveResult, Profile, PushEvent, Seat, Session, StateView, Tile, Variant, WordCheckResult, } from './model'; // --- request encoders --- function buildTile(b: Builder, t: PlacedTile): Offset { const letter = b.createString(t.letter); fb.TileRecord.startTileRecord(b); fb.TileRecord.addRow(b, t.row); fb.TileRecord.addCol(b, t.col); fb.TileRecord.addLetter(b, letter); fb.TileRecord.addBlank(b, t.blank); return fb.TileRecord.endTileRecord(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): Uint8Array { const b = new Builder(64); const gid = b.createString(gameId); fb.StateRequest.startStateRequest(b); fb.StateRequest.addGameId(b, gid); return finish(b, fb.StateRequest.endStateRequest(b)); } export function encodeSubmitPlay(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[]): Uint8Array { const b = new Builder(256); const tileOffs = tiles.map((t) => buildTile(b, t)); const vec = fb.SubmitPlayRequest.createTilesVector(b, tileOffs); const gid = b.createString(gameId); const d = b.createString(dir); fb.SubmitPlayRequest.startSubmitPlayRequest(b); fb.SubmitPlayRequest.addGameId(b, gid); fb.SubmitPlayRequest.addDir(b, d); fb.SubmitPlayRequest.addTiles(b, vec); return finish(b, fb.SubmitPlayRequest.endSubmitPlayRequest(b)); } export function encodeEval(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[]): Uint8Array { const b = new Builder(256); const tileOffs = tiles.map((t) => buildTile(b, t)); const vec = fb.EvalRequest.createTilesVector(b, tileOffs); const gid = b.createString(gameId); const d = b.createString(dir); fb.EvalRequest.startEvalRequest(b); fb.EvalRequest.addGameId(b, gid); fb.EvalRequest.addDir(b, d); fb.EvalRequest.addTiles(b, vec); return finish(b, fb.EvalRequest.endEvalRequest(b)); } export function encodeExchange(gameId: string, tiles: string[]): Uint8Array { const b = new Builder(128); const offs = tiles.map((s) => b.createString(s)); const vec = fb.ExchangeRequest.createTilesVector(b, offs); 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): Uint8Array { const b = new Builder(128); const gid = b.createString(gameId); const w = b.createString(word); fb.CheckWordRequest.startCheckWordRequest(b); fb.CheckWordRequest.addGameId(b, gid); fb.CheckWordRequest.addWord(b, w); 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): Uint8Array { const b = new Builder(64); const v = b.createString(variant); fb.EnqueueRequest.startEnqueueRequest(b); fb.EnqueueRequest.addVariant(b, v); 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 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 { return { row: t.row(), col: t.col(), letter: s(t.letter()), 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(), moveCount: g.moveCount(), endReason: s(g.endReason()), 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))); 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()), }; } export function decodeSession(buf: Uint8Array): Session { const t = fb.Session.getRootAsSession(new ByteBuffer(buf)); return { token: s(t.token()), userId: s(t.userId()), isGuest: t.isGuest(), displayName: s(t.displayName()) }; } 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()), hintBalance: p.hintBalance(), blockChat: p.blockChat(), blockFriendRequests: p.blockFriendRequests(), isGuest: p.isGuest(), }; } export function decodeStateView(buf: Uint8Array): StateView { const v = fb.StateView.getRootAsStateView(new ByteBuffer(buf)); const g = v.game(); const rack: string[] = []; for (let i = 0; i < v.rackLength(); i++) rack.push(s(v.rack(i))); return { game: g ? decodeGameView(g) : emptyGame(), seat: v.seat(), rack, bagLen: v.bagLen(), hintsRemaining: v.hintsRemaining(), }; } export function decodeMoveResult(buf: Uint8Array): MoveResult { const r = fb.MoveResult.getRootAsMoveResult(new ByteBuffer(buf)); const m = r.move(); const g = r.game(); return { move: m ? decodeMove(m) : emptyMove(), game: g ? decodeGameView(g) : emptyGame() }; } 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 }; } export function decodeWordCheck(buf: Uint8Array): WordCheckResult { const r = fb.WordCheckResult.getRootAsWordCheckResult(new ByteBuffer(buf)); return { word: s(r.word()), legal: r.legal() }; } 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 }; } export function decodeMatchResult(buf: Uint8Array): MatchResult { const m = fb.MatchResult.getRootAsMatchResult(new ByteBuffer(buf)); const g = m.game(); return { matched: m.matched(), game: m.matched() && 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 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()) }; } case 'opponent_moved': { const e = fb.OpponentMovedEvent.getRootAsOpponentMovedEvent(bb); return { kind: 'opponent_moved', gameId: s(e.gameId()), seat: e.seat(), action: s(e.action()), score: e.score(), total: e.total() }; } 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()) }; } case 'match_found': { const e = fb.MatchFoundEvent.getRootAsMatchFoundEvent(bb); return { kind: 'match_found', gameId: s(e.gameId()) }; } case 'heartbeat': return { kind: 'heartbeat' }; default: return null; } } function emptyGame(): GameView { return { id: '', variant: 'english', dictVersion: '', status: '', players: 0, toMove: 0, turnTimeoutSecs: 0, moveCount: 0, endReason: '', seats: [], }; } function emptyMove(): MoveRecord { return { player: 0, action: '', dir: '', mainRow: 0, mainCol: 0, tiles: [], words: [], count: 0, score: 0, total: 0 }; }