6e77de4c1e
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 53s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m26s
Three owner-requested polish changes: - robot: replace the lengthening 60-90 min -> 6 h proactive-nudge ramp with a flat uniform 9-12 h wait before every nudge; the existing sleep-window gate still skips and defers a nudge that would land in the robot's night. - ui: colour the lobby/in-game unread dot by type -- the regular danger colour when a chat message is unread, a softer amber (--warn) when only nudges are. Adds a per-viewer unread_messages flag (chat_messages.kind='message') across the backend DTO, FlatBuffers wire, gateway transcode and the UI store. - ui: float games with any unread notification to the top of the lobby's your-turn and opponent-turn sections (finished keeps its order), reusing the existing unread_chat flag. Docs (ARCHITECTURE 7, FUNCTIONAL + _ru) updated. No DB migration; the new wire field is backward-compatible.
371 lines
12 KiB
TypeScript
371 lines
12 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;
|
|
/** 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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// 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[];
|
|
}
|
|
|
|
/** 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' };
|