// 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; /** Offline hotseat only: the seat resigned or was excluded by the host. Set by the local source so * the finished-game medal ranking can place it last (no medal); undefined for online seats. */ resigned?: 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; /** true = a local offline pass-and-play (hotseat) game: 2-4 humans share one device. Set only by * the local source; the online gateway never sets it. Drives the per-seat PIN-lock UI and the host * menu (which replaces the hint control). A hotseat game is never vsAi. */ hotseat?: 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; /** The game's origin for the active-game limits: 0 unknown (a pre-existing game, never gated), * 1 vs_ai, 2 random, 3 friends. The lobby counts active games per kind against the caller's * per-kind limits (Profile.gameLimits) to lock a capped New-Game start. Local/offline games * leave it 0 (they never count toward the online limits). */ kind: number; 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 is the per-game allowance alone; the * purchasable hint wallet lives on the profile (payments), and the client adds it (see lib/hints). */ export interface StateView { game: GameView; seat: number; rack: string[]; bagLen: number; hintsRemaining: number; /** For a vs_ai game, the seconds until the idle hint unlocks (computed by the source: the SERVER * clock online, the device clock offline) โ€” 0/undefined while open (the human's first move, or a * non-vs_ai game). The client anchors a MONOTONIC countdown (performance.now()) to it on receipt, * so a client clock change cannot skew it; a relaunch re-fetches a fresh value. See lib/hints. */ hintUnlockLeftSeconds?: number; /** Offline hotseat only: the seat to move is PIN-locked, so its rack is withheld (empty) until the * seat's PIN is entered (see LocalSource.unlockSeat). Absent/false everywhere else. */ locked?: boolean; } 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; } /** One chip balance in the wallet: the funding source, the chip count, and whether it is * spendable in the current execution context (false for a frozen VK-iOS balance or untrusted). */ export interface WalletSegment { source: string; chips: number; spendable: boolean; } /** The user-facing wallet: the context-visible chip segments and the context-applicable * benefits (the no-ads term end as unix millis / forever flag, and the available hints). */ export interface Wallet { segments: WalletSegment[]; adsForever: boolean; /** No-ads term end as unix milliseconds; 0 = no active term. */ adsPaidUntilMs: number; hints: number; /** Chips a rewarded-video view earns in the current context; 0 = unavailable here (outside VK or * unconfigured). The client shows the "watch for chips" button only when this is positive. */ rewardChips: number; } /** One atom line of a storefront product: the base value type it grants ("chips"/"hints"/ * "noads_days"/"tournament") and how many of it the product carries. */ // WalletOrder is a created money order to fund a chip pack: its id and the provider launch URL the // client opens (the Robokassa hosted-payment page). Chips arrive later, by the verified server // callback. export interface WalletOrder { orderId: string; redirectUrl: string; } export interface CatalogAtom { atomType: string; quantity: number; } /** One storefront product for the caller's context. A "value" is bought with chips (chips is its * uniform price, money fields zero); a "pack" funds chips with money (moneyAmount is its price in * the context currency's minor units under moneyCurrency, chips zero). atoms lists what it grants. */ export interface CatalogProduct { kind: 'value' | 'pack'; productId: string; title: string; chips: number; moneyAmount: number; moneyCurrency: string; atoms: CatalogAtom[]; } /** The storefront: the products visible and purchasable in the caller's context โ€” the chip-priced * values and the chip packs priced in the context's payment method. */ export interface Catalog { products: CatalogProduct[]; } 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; /** The post-move interstitial-ad config for the client-mirrored gate (cooldowns + suppressed). */ ads?: AdsConfig; /** The account's confirmed email address ("" when none). */ email: string; /** Whether a Telegram / VK identity is attached โ€” drives the Add / Unlink controls. */ telegramLinked: boolean; vkLinked: boolean; /** Current dictionary version per game variant (deployment-wide, not per-account). An * offline-capable PWA reads it to preload the matching dawg for each enabled variant off * this cold-start response. Empty only in a degenerate deployment with no resident dictionary. */ dictVersions: Partial>; /** The caller's tier active-game caps per kind (-1 = unlimited); the New-Game screen counts the * player's active games per kind from the lobby and locks a capped start. Absent from an old * backend (then no lock applies). */ gameLimits?: GameLimits; } /** GameKind labels the game_kind wire values for the active-game limits. */ export const GameKind = { vsAi: 1, random: 2, friends: 3 } as const; /** The caller's tier active-game caps per kind (-1 = unlimited), resolved to the guest or durable * tier server-side. The New-Game screen locks a start whose kind is at its cap. */ export interface GameLimits { vsAi: number; random: number; friends: number; } /** The post-move interstitial-ad config for the client-mirrored gate: the cooldowns (seconds) and * whether ads are suppressed in the current context (a no-ads benefit here, or the no_banner role). */ export interface AdsConfig { cooldownGlobalS: number; cooldownVsAiS: number; cooldownHintS: number; suppressed: boolean; } /** Banner is the advertising-banner block of an eligible viewer's profile. */ export interface Banner { campaigns: BannerCampaign[]; timings: BannerTimings; } /** ColorTriple is a banner colour override's three "#rrggbb" colours. */ export interface ColorTriple { bg: string; fg: string; link: string; } /** * BannerColors is a campaign's optional palette: `all` overrides every theme, `dark` * further overrides the dark theme (either may be null โ€” the neutral tokens then apply). * resolveBannerColors (lib/bannerColors) collapses it to the rendered theme's colours. */ export interface BannerColors { all: ColorTriple | null; dark: ColorTriple | null; } /** 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[]; /** Colour override for every theme; null (or absent) keeps the neutral tokens. */ overrideAll?: ColorTriple | null; /** Colour override for the dark theme only; null (or absent) falls back to overrideAll/tokens. */ overrideDark?: ColorTriple | null; } /** 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/unlink/change 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), 'merged', 'unlinked' (a provider detached) or 'changed' (the email was // switched). 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' | 'unlinked' | 'changed'; 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' };