Files
scrabble-game/ui/src/lib/model.ts
T
Ilia Denisov 6d1d8030e3
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 51s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m24s
feat(stats): per-game hints used + lifetime moves and hint share
Store the hints a player used in each game, and add two lifetime tiles —
Moves and Hint share — to the statistics screen.

- per-game: game_players.hints_used now counts EVERY hint (allowance + wallet),
  not just the free allowance, so it is the seat's true total hints used this
  game. The allowance decision (used < HintsPerPlayer) and the hint badge values
  (hints_remaining / wallet_balance) are unchanged — the lobby hint-count fix does
  NOT regress; only the admin "hints used" column, which silently under-counted
  wallet hints, becomes accurate.
- account_stats gains two summed counters: moves (the player's plays — passes and
  exchanges excluded) and hints_used (every hint). Computed at game finish in
  buildStats over the same non-guest, non-honest-AI games as the rest of the stats.
- wire: StatsView gains moves + hints_used (trailing); gateway + UI codec + model;
  regen.
- ui: two tiles (Moves, Hint share = hints_used/moves, one-decimal % in the active
  locale); card order games·wins·draws·losses·moves·hint-share·best game·win-rate.
- docs: ARCHITECTURE §9 + baseline comment, FUNCTIONAL (+ru), UI_DESIGN.

Tests: TestHintPolicy (hints_used counts the wallet hint), TestGameLifecycleAndStats
(moves>0, hints=0), gateway stats round-trip, UI hintShare unit + codec + e2e.
2026-06-17 23:44:53 +02:00

351 lines
11 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;
}
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. 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;
/** 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;
}
/** 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;
}
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' };