Files
scrabble-game/ui/src/lib/model.ts
T
Ilia Denisov 13be7c3d9a
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 23s
CI / ui (pull_request) Successful in 1m12s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m53s
feat(ads): VK post-move interstitial + retire deprecated hint_balance/paid_account
Interstitial video after a confirmed play or a hint, VK-only, offline
banner-only. The gate is client-mirrored: the backend puts the config
cooldowns (global 5m / vs_ai 30m / hint 1m) and a suppressed flag (the
no-ads / no_banner gate, same as the banner) on Profile.ads via adsFor;
the client self-gates on a per-kind last-shown time in localStorage, with
the VITE_ADS_STUB contour "ad fired" toast. Never fires after a pass,
exchange or resign. maybeShowInterstitial + vkShowInterstitial; the codec
and gateway transcode carry the ads block.

Also retires the deprecated accounts.hint_balance / paid_account domain
usage (expand-contract, code only — the columns stay for a later DROP so
image rollback stays DB-safe): drop the Account fields and their scan, the
dead account.SpendHint, account.GrantHints and the admin grant-hints
action; the in-game hint display now comes wholly from the payments hint
benefit. Banner eligibility and account merge no longer read the legacy
flags.

Tests: ads.test.ts (the client-mirrored gate), the codec ads round-trip,
the gateway ads transcode test, and a profile ads-config integration test
(cooldowns + suppressed under no-ads / no_banner). Docs: PAYMENTS §10 (+ru)
interstitial, the decision amends, backend README, the plan.
2026-07-10 02:48:10 +02:00

513 lines
18 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;
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;
/** 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 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' };