// 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; 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. */ export interface StateView { game: GameView; seat: number; rack: string[]; bagLen: number; hintsRemaining: 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; } 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; /** 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; } /** A referenced account with its display name (friend, blocked user, invitee). */ export interface AccountRef { accountId: string; displayName: string; } /** A freshly issued one-time friend code (the plaintext is returned once). */ export interface FriendCode { code: string; expiresAtUnix: number; } /** A durable account's lifetime statistics. */ export interface Stats { wins: number; losses: number; draws: number; maxGamePoints: number; maxWordPoints: number; } /** 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; } export interface Session { token: string; userId: string; isGuest: boolean; displayName: string; // supportedLanguages is the set of game languages the service the user signed in // through offers (subset of {en, ru}, at least one). New Game offers only the // variants these languages support (en -> English; ru -> Russian + Эрудит). Empty // means ungated (all variants). supportedLanguages: string[]; // serviceLanguage is the en/ru tag of the Telegram bot this session was minted // through (empty for a non-Telegram login); used to share a link to the same bot. serviceLanguage: 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; } 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' };