3306a016a0
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 28s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m46s
Carry the caller's per-tier active-game caps and each game's kind on the wire (Profile.game_limits + GameView.kind, additive FBS + gateway transcode + client codec, committed regen). The New Game screen counts the player's active games per kind from the lobby and locks a capped start: an outline button with a lock that opens a funnel modal instead of a game -- a sign-in prompt for a guest, a "finish a current game first" notice for a signed-in account (native Telegram popup, in-app modal elsewhere). The lock lifts via the existing profile refetch after a guest->durable upgrade. Remove the lobby's old at_game_limit New-Game tab disable + notice: the flag (now the random-kind cap) conflicted with the per-kind lock -- it hid the screen where the lock lives and wrongly blocked an unfulfilled kind. The New Game tab is always enabled; the per-kind start lock is the only gate. The at_game_limit wire field stays (unused by the client) for a later cleanup. Tests: client lock logic + codec kind/game_limits roundtrip + gateway transcode encode + native popup builders (unit); a mock e2e for the lock badge and the modal.
530 lines
19 KiB
TypeScript
530 lines
19 KiB
TypeScript
// 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<Record<Variant, string>>;
|
|
/** 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' };
|