// Domain model โ€” plain TypeScript shapes the screens use, deliberately decoupled // from the FlatBuffers wire types. Both the real transport (which decodes // FlatBuffers) and the mock transport speak this model, so the UI never touches // generated wire code directly. export type Variant = 'scrabble_en' | 'scrabble_ru' | 'erudit_ru'; /** Backend game status strings. 'open' is an auto-match game the player has entered * but which is still waiting for an opponent (the opponent seat has no account). */ export type GameStatus = 'active' | 'finished' | 'open' | string; /** Decoded move action kinds (history-independent, see ARCHITECTURE ยง9.1). */ export type MoveAction = 'play' | 'pass' | 'exchange' | 'resign' | 'timeout' | string; /** Play orientation: H is across a row, V is down a column. */ export type Direction = 'H' | 'V'; export interface Tile { row: number; col: number; letter: string; blank: boolean; } export interface Seat { seat: number; accountId: string; displayName: string; score: number; hintsUsed: number; isWinner: boolean; } export interface GameView { id: string; variant: Variant; dictVersion: string; status: GameStatus; players: number; toMove: number; turnTimeoutSecs: number; /** true = standard Scrabble; false = the single-word rule (Russian games). */ multipleWordsPerTurn: boolean; moveCount: number; endReason: string; /** Lobby sort key: the current turn's start (active) or the finish time (finished), Unix seconds. */ lastActivityUnix: number; /** true = an honest-AI game: the opponent is shown as ๐Ÿค– and chat/nudge/add-friend are disabled. */ vsAi: boolean; /** Per-viewer flag: the requesting player has at least one unread chat entry (message or * nudge) in this game. Set on the authoritative REST views (lobby list, game state, * move result); the live-event GameView leaves it false (events bump unread instead). */ unreadChat: boolean; /** Per-viewer flag: at least one unread entry is a real message (not just a nudge), so the * badge can be coloured apart from the nudge-only case. Same REST-seeded lifecycle as * unreadChat; the live-event GameView leaves it false. */ unreadMessages: boolean; seats: Seat[]; } export interface MoveRecord { player: number; action: MoveAction; dir: string; mainRow: number; mainCol: number; tiles: Tile[]; words: string[]; count: number; score: number; total: number; } /** A seated player's private view of a game. hintsRemaining folds the per-game allowance * together with the global wallet; walletBalance is the wallet alone, so the client can * derive the per-game allowance (hintsRemaining - walletBalance) and keep the wallet live * across games (see lib/hints). */ export interface StateView { game: GameView; seat: number; rack: string[]; bagLen: number; hintsRemaining: number; walletBalance: number; } export interface MoveResult { move: MoveRecord; game: GameView; /** The actor's refilled rack after the move, so the mover renders the next state without a refetch. */ rack: string[]; bagLen: number; } export interface HintResult { move: MoveRecord; hintsRemaining: number; walletBalance: number; } export interface EvalResult { legal: boolean; score: number; words: string[]; /** Orientation the backend inferred for the play ("H"/"V"), empty when illegal. */ dir: string; } export interface WordCheckResult { word: string; legal: boolean; } export interface ChatMessage { id: string; gameId: string; senderId: string; kind: string; body: string; createdAtUnix: number; } /** FeedbackReply is the operator's answer shown on the feedback screen. */ export interface FeedbackReply { body: string; repliedAtUnix: number; } /** * FeedbackState is the feedback screen state. blockedReason is "" (can send), * "pending" (a previous message is still under review) or "banned"; reply is the * operator's answer to show, or null. */ export interface FeedbackState { canSend: boolean; blockedReason: string; reply: FeedbackReply | null; } export interface Profile { userId: string; displayName: string; preferredLanguage: string; timeZone: string; /** "HH:MM" daily away-window bounds, in timeZone. */ awayStart: string; awayEnd: string; hintBalance: number; blockChat: boolean; blockFriendRequests: boolean; isGuest: boolean; /** Confine notifications to the in-app stream (no out-of-app platform push). */ notificationsInAppOnly: boolean; /** Variants the player allows themselves to be matched into (Erudit-first). The New * Game picker is gated by this set, which is never empty. */ variantPreferences: Variant[]; /** The advertising-banner block, present only for a viewer eligible to see it. */ banner?: Banner; } /** Banner is the advertising-banner block of an eligible viewer's profile. */ export interface Banner { campaigns: BannerCampaign[]; timings: BannerTimings; } /** BannerCampaign is one campaign in the rotation feed. */ export interface BannerCampaign { /** GCD-reduced show weight for the client's smooth weighted round-robin. */ weight: number; /** Messages in display order, already resolved to the viewer's bot language. */ messages: string[]; } /** BannerTimings are the global banner display timings (ms, except scrollPxPerSec). */ export interface BannerTimings { holdMs: number; edgePauseMs: number; scrollPxPerSec: number; fadeOutMs: number; gapMs: number; fadeInMs: number; } /** BlockStatus is the caller's current manual block, fetched after any call reports * the "account_blocked" code. When blocked the app shows the terminal blocked screen * and stops all push/poll. */ export interface BlockStatus { blocked: boolean; permanent: boolean; /** RFC3339 UTC instant for a temporary block; "" for a permanent one or when not blocked. */ until: string; /** Operator reason resolved to the account's language; "" when none was cited. */ reason: string; } /** The full editable profile sent to profileUpdate (overwrites every field). */ export interface ProfileUpdate { displayName: string; preferredLanguage: string; timeZone: string; awayStart: string; awayEnd: string; blockChat: boolean; blockFriendRequests: boolean; notificationsInAppOnly: boolean; variantPreferences: Variant[]; } /** A referenced account with its display name (friend, blocked user, invitee). */ export interface AccountRef { accountId: string; displayName: string; } // RobotBlockEntry is one blocked disguised-robot opponent: a per-game record (not a real // account) carrying the game name the player saw and the game/seat it was blocked in. Its id // is used to unblock it. export interface RobotBlockEntry { id: string; displayName: string; gameId: string; seat: number; } // BlockList is the caller's blocked humans plus the per-game disguised-robot blocks. export interface BlockList { blocked: AccountRef[]; robots: RobotBlockEntry[]; } // RobotFriendRequestEntry is one per-game friend request to a disguised-robot opponent: a // per-game record (not a real account) carrying the game name the player saw and the // game/seat it was sent in, so the in-game card can re-mark that seat as already requested. export interface RobotFriendRequestEntry { id: string; displayName: string; gameId: string; seat: number; } // OutgoingList is the caller's outgoing friend requests: human addressees already requested // (pending or declined) plus the per-game disguised-robot requests. export interface OutgoingList { requests: AccountRef[]; robots: RobotFriendRequestEntry[]; } /** A freshly issued one-time friend code (the plaintext is returned once). */ export interface FriendCode { code: string; expiresAtUnix: number; } /** One letter cell of a best-move word: its display letter, its tile value (0 for a * blank) and whether it is a blank โ€” enough to render it as a game tile without the * variant's alphabet table. */ export interface BestMoveTile { letter: string; value: number; blank: boolean; } /** An account's highest-scoring single play within one variant: the variant, the play's * total score and its main word as ordered tiles. */ export interface BestMove { variant: Variant; score: number; word: BestMoveTile[]; } /** A durable account's lifetime statistics. bestMoves breaks the best move down per * variant (with the word itself); it is empty for an account with no recorded play and * lists only variants the account has played. */ export interface Stats { wins: number; losses: number; draws: number; maxGamePoints: number; maxWordPoints: number; /** Lifetime count of the player's plays (tile placements). */ moves: number; /** Lifetime count of hints the player took (allowance + wallet). */ hintsUsed: number; bestMoves: BestMove[]; } /** Settings the inviter chooses for a friend game. */ export interface InvitationSettings { variant: Variant; turnTimeoutSecs: number; hintsAllowed: boolean; hintsPerPlayer: number; dropoutTiles: 'remove' | 'return'; /** true = standard Scrabble; false = the single-word rule (Russian games). */ multipleWordsPerTurn: boolean; } export interface InvitationInvitee { accountId: string; displayName: string; seat: number; response: 'pending' | 'accepted' | 'declined' | string; } export interface Invitation { id: string; inviter: AccountRef; invitees: InvitationInvitee[]; variant: Variant; turnTimeoutSecs: number; hintsAllowed: boolean; hintsPerPlayer: number; /** true = standard Scrabble; false = the single-word rule (Russian games). */ multipleWordsPerTurn: boolean; dropoutTiles: string; status: string; gameId: string; expiresAtUnix: number; } /** A finished game's GCG transcript for download/share. */ export interface GcgExport { gameId: string; filename: string; content: string; } /** A minted signed download link for a finished game's export artifact. path is * relative โ€” the client resolves it against its own origin (the SPA and the * gateway edge share it), so the server never needs to know the public host. */ export interface ExportUrl { path: string; filename: string; } export interface Session { token: string; userId: string; isGuest: boolean; displayName: string; } // LinkResult is the outcome of an account link/merge step. status is // 'linked' (bound to the current account), 'merge_required' (the identity belongs to // another account โ€” the secondary* fields summarise it for the irreversible // confirmation) or 'merged'. session is set only when the active account switched // (a guest initiator whose durable counterpart won); the client adopts it. export interface LinkResult { status: 'linked' | 'merge_required' | 'merged'; secondaryUserId: string; secondaryDisplayName: string; secondaryGames: number; secondaryFriends: number; session: Session | null; } // ConfirmLinkResult is the outcome of a one-tap deeplink confirmation. purpose is // 'login' (session carries the minted credential to sign in) or 'link' (status is // 'confirmed' or 'merge_required' โ€” the app finishes any merge from its manual flow). export interface ConfirmLinkResult { purpose: 'login' | 'link'; status: 'confirmed' | 'merge_required'; session: Session | null; } export interface MatchResult { matched: boolean; game?: GameView; } export interface History { gameId: string; moves: MoveRecord[]; } export interface GameList { games: GameView[]; /** True when the caller has reached the simultaneous quick-game cap; the lobby then * disables "New Game" and shows a notice. */ atGameLimit: boolean; } /** * A live event delivered over the Subscribe stream. The game events carry the move as a * delta โ€” move plus the post-move summary (and the bag size) โ€” the client applies to its * cached game without a refetch; match_found / game_started / opponent_joined carry the * recipient's StateView; notify carries the changed lobby payload. The enriched fields are optional * so a client falls back to a refetch when a payload is absent (a gap, or an older peer). */ export type PushEvent = | { kind: 'your_turn'; gameId: string; deadlineUnix: number; opponentName: string; moveCount: number } | { kind: 'opponent_moved'; gameId: string; move?: MoveRecord; game?: GameView; bagLen: number } | { kind: 'game_over'; gameId: string; result: string; scoreLine: string; game?: GameView } | { kind: 'chat_message'; message: ChatMessage } | { kind: 'nudge'; gameId: string; fromUserId: string; senderName: string } | { kind: 'match_found'; gameId: string; state?: StateView } | { kind: 'opponent_joined'; gameId: string; state?: StateView } | { kind: 'notify'; sub: string; account?: AccountRef; invitation?: Invitation; state?: StateView } | { kind: 'heartbeat' };