Files
scrabble-game/ui/src/lib/model.ts
T
Ilia Denisov aaac816dc2
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m16s
feat(chat): unread read-receipts with lobby/game dot and history-open ack
Persist per-message read state as a chat_messages.unread_seats bitmask
(migration 00008): a text message seeds every recipient seat's bit, a nudge
only the awaited seat's. A seat's bit clears when the player opens the move
history or chat (POST /games/:id/chat/read, sent only when something is
unread), and a nudge additionally clears when its recipient answers by moving
(a wired game NudgeClearer, dependency-inverted so game keeps off social).

UI shows a per-viewer unread dot in the lobby (next to the opponent) and the
game score bar — the unread_chat game-view flag seeds it from authoritative
REST views, live chat/nudge events raise it. Opening the move history counts
as reading (even without entering chat): the 💬 fade-blinks twice and the
client acks. Admin Messages gains an unread-only filter, a read/unread column,
and a per-message card with the per-seat read breakdown. Observability:
chat_read_duration histogram + chat_unread_messages gauge + social tracing.
2026-06-17 11:12:38 +02:00

322 lines
9.7 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. */
export interface StateView {
game: GameView;
seat: number;
rack: string[];
bagLen: number;
hintsRemaining: 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;
}
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;
}
/** A durable account's lifetime statistics. */
export interface Stats {
wins: number;
losses: number;
draws: number;
maxGamePoints: number;
maxWordPoints: number;
}
/** 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' };