Files
scrabble-game/ui/src/lib/mock/data.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

250 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Seed data for the mock transport. Enough to exercise the playable slice locally
// (pnpm start) with no backend: a profile, one active mid-game whose board already
// has tiles, and two finished games. Coordinates are 0-indexed (centre 7,7). Words do
// not need to be strictly legal here — this is a visual/interaction fixture; real
// legality and scoring come from the backend.
import type {
AccountRef,
ChatMessage,
GameView,
Invitation,
MoveRecord,
Profile,
Seat,
Session,
Stats,
} from '../model';
export const ME = 'me';
export const SESSION: Session = {
token: 'mock-token',
userId: ME,
isGuest: true,
displayName: 'You',
// Both languages by default, so the mock-driven UI offers every variant.
supportedLanguages: ['en', 'ru'],
serviceLanguage: '',
};
export const PROFILE: Profile = {
userId: ME,
displayName: 'You',
preferredLanguage: 'en',
timeZone: 'UTC',
awayStart: '00:00',
awayEnd: '07:00',
hintBalance: 3,
blockChat: false,
blockFriendRequests: false,
isGuest: false,
notificationsInAppOnly: true,
};
// Seed social/account data for the mock (pnpm start + Playwright). The mock profile
// is a durable account so the social surfaces (friends, stats, history) are reachable.
// Ann is the active game's opponent but deliberately not a friend, so the in-game
// "add to friends" flow is demonstrable; Kaya (a finished-game opponent) is the friend.
export const MOCK_FRIENDS: AccountRef[] = [{ accountId: 'kaya', displayName: 'Kaya' }];
export const MOCK_INCOMING: AccountRef[] = [{ accountId: 'rick', displayName: 'Rick' }];
export const MOCK_STATS: Stats = { wins: 7, losses: 4, draws: 1, maxGamePoints: 421, maxWordPoints: 95 };
export function mockInvitations(): Invitation[] {
return [
{
id: 'inv1',
inviter: { accountId: 'kaya', displayName: 'Kaya' },
invitees: [{ accountId: ME, displayName: 'You', seat: 1, response: 'pending' }],
variant: 'scrabble_ru',
turnTimeoutSecs: 86400,
hintsAllowed: true,
hintsPerPlayer: 1,
multipleWordsPerTurn: false,
dropoutTiles: 'remove',
status: 'pending',
gameId: '',
expiresAtUnix: Math.floor(Date.now() / 1000) + 7 * 86400,
},
];
}
function seat(s: number, accountId: string, displayName: string, score: number, isWinner = false): Seat {
return { seat: s, accountId, displayName, score, hintsUsed: 0, isWinner };
}
function play(
player: number,
dir: 'H' | 'V',
tiles: Array<[number, number, string]>,
words: string[],
score: number,
total: number,
): MoveRecord {
const ts = tiles.map(([row, col, letter]) => ({ row, col, letter, blank: false }));
return {
player,
action: 'play',
dir,
mainRow: ts[0]?.row ?? 7,
mainCol: ts[0]?.col ?? 7,
tiles: ts,
words,
count: words.length,
score,
total,
};
}
export interface MockGame {
view: GameView;
moves: MoveRecord[];
rack: string[];
bagLen: number;
hintsRemaining: number;
chat: ChatMessage[];
}
// --- active game G1: scrabble_en, You (seat 0) vs Ann (seat 1), your turn ---
const G1_MOVES: MoveRecord[] = [
play(0, 'H', [
[7, 5, 'H'],
[7, 6, 'E'],
[7, 7, 'L'],
[7, 8, 'L'],
[7, 9, 'O'],
], ['HELLO'], 16, 16),
play(1, 'V', [
[6, 9, 'W'],
[8, 9, 'R'],
[9, 9, 'L'],
[10, 9, 'D'],
], ['WORLD'], 9, 9),
play(0, 'H', [
[8, 10, 'A'],
[8, 11, 'T'],
], ['RAT'], 3, 19),
play(1, 'V', [
[9, 10, 'N'],
[10, 10, 'D'],
], ['AND'], 4, 13),
];
function activeGame(): MockGame {
return {
view: {
id: 'g1',
variant: 'scrabble_en',
dictVersion: 'v1',
vsAi: false,
unreadChat: false,
status: 'active',
players: 2,
toMove: 0,
turnTimeoutSecs: 86400,
multipleWordsPerTurn: true,
moveCount: G1_MOVES.length,
endReason: '',
lastActivityUnix: Math.floor(Date.now() / 1000) - 7200,
seats: [seat(0, ME, 'You', 19), seat(1, 'ann', 'Ann', 13)],
},
moves: G1_MOVES,
rack: ['R', 'E', 'T', 'I', 'N', 'A', '?'],
bagLen: 58,
hintsRemaining: 1,
chat: [
{
id: 'c1',
gameId: 'g1',
senderId: 'ann',
kind: 'message',
body: 'good luck!',
createdAtUnix: Math.floor(Date.now() / 1000) - 3600,
},
],
};
}
// --- finished games ---
function finishedG2(): MockGame {
return {
view: {
id: 'g2',
variant: 'scrabble_en',
dictVersion: 'v1',
vsAi: false,
unreadChat: false,
status: 'finished',
players: 2,
toMove: 0,
turnTimeoutSecs: 86400,
multipleWordsPerTurn: true,
moveCount: 2,
endReason: 'normal',
lastActivityUnix: Math.floor(Date.now() / 1000) - 86400,
seats: [seat(0, ME, 'You', 320, true), seat(1, 'kaya', 'Kaya', 281)],
},
moves: [
play(0, 'H', [
[7, 6, 'Q'],
[7, 7, 'U'],
[7, 8, 'I'],
[7, 9, 'Z'],
], ['QUIZ'], 48, 48),
play(1, 'V', [
[6, 9, 'J'],
[8, 9, 'A'],
[9, 9, 'M'],
], ['JAZM'], 30, 30),
],
rack: [],
bagLen: 0,
hintsRemaining: 0,
chat: [],
};
}
function finishedG3(): MockGame {
return {
view: {
id: 'g3',
variant: 'scrabble_ru',
dictVersion: 'v1',
vsAi: false,
unreadChat: false,
status: 'finished',
players: 2,
toMove: 0,
turnTimeoutSecs: 86400,
multipleWordsPerTurn: false,
moveCount: 1,
endReason: 'resignation',
lastActivityUnix: Math.floor(Date.now() / 1000) - 172800,
seats: [seat(0, ME, 'You', 150), seat(1, 'rick', 'Rick', 212, true)],
},
moves: [
play(0, 'H', [
[7, 6, 'С'],
[7, 7, 'Л'],
[7, 8, 'О'],
[7, 9, 'В'],
[7, 10, 'О'],
], ['СЛОВО'], 12, 12),
],
rack: [],
bagLen: 0,
hintsRemaining: 0,
chat: [],
};
}
export function seedGames(): Map<string, MockGame> {
const m = new Map<string, MockGame>();
for (const g of [activeGame(), finishedG2(), finishedG3()]) m.set(g.view.id, g);
return m;
}