Stage 7 (wip): wire remaining ops (backend REST, FBS, gateway transcode) + real UI transport
backend: REST handlers for pass/exchange/resign/hint/evaluate/check_word/complaint/history/chat-list/nudge + new game.ListForAccount (my games) + seat display_name resolution pkg/fbs: GameActionRequest/ExchangeRequest/EvalRequest/EvalResult/CheckWordRequest/WordCheckResult/ComplaintRequest/HintResult/History/GameList/ChatList + SeatView.display_name; committed Go regenerated (flatc 23.5.26) gateway: 11 new transcode ops + backendclient methods + FB encoders ui: edge TS codegen (flatc --ts + protoc-gen-es, committed), FlatBuffers<->model codec, real connect-web transport (binary, bearer auth, Subscribe). prod bundle ~69KB gzip JS
This commit is contained in:
@@ -0,0 +1,384 @@
|
||||
// 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user