8793bd34f2
The statistics screen gains real depth, plus a hint-count bug fix found along the way. - Best move per variant: the screen shows the actual best-move word (drawn as game tiles; a wildcard shows its letter but no value), broken down by game variant, empty variants omitted. New account_best_move table, written at game finish. - Moves & hint share: two new lifetime tiles — the player's play count and the share of plays that used a hint — from summed account_stats counters (moves, hints_used). Honest-AI games are excluded, like the rest of the stats. - Hint-count fix: the in-game hint badge no longer goes stale across games. The global wallet now rides the wire apart from the per-game allowance (wallet_balance on StateView/HintResult/StatsView), so the client reads the live wallet rather than a per-game snapshot; game_players.hints_used now counts every hint (allowance + wallet), its true per-game total. - Account merge: sums the new moves/hints_used counters and merges the per-variant best moves (higher score kept), which it previously dropped. - Admin: the user card shows Moves and Hints used. - UI polish: tab/label wording, game-over text, and e2e selectors hardened against label changes. All wire additions are trailing (backward-compatible). Docs (ARCHITECTURE, FUNCTIONAL +ru, UISN_DESIGN) updated in step.
290 lines
7.3 KiB
TypeScript
290 lines
7.3 KiB
TypeScript
// 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: 134,
|
||
moves: 248, // plays across all games
|
||
hintsUsed: 12, // -> hint share 12/248 = 4.8%
|
||
// Letters are lower-cased as the backend emits them; the tile renderer upper-cases for
|
||
// display. The 'd' in "wonderful" is a blank (value 0) to exercise wildcard rendering.
|
||
// Erudit is absent on purpose, so the screen demonstrates skipping a not-yet-played variant.
|
||
bestMoves: [
|
||
{
|
||
variant: 'scrabble_en',
|
||
score: 134,
|
||
word: [
|
||
{ letter: 'w', value: 4, blank: false },
|
||
{ letter: 'o', value: 1, blank: false },
|
||
{ letter: 'n', value: 1, blank: false },
|
||
{ letter: 'd', value: 0, blank: true },
|
||
{ letter: 'e', value: 1, blank: false },
|
||
{ letter: 'r', value: 1, blank: false },
|
||
{ letter: 'f', value: 4, blank: false },
|
||
{ letter: 'u', value: 1, blank: false },
|
||
{ letter: 'l', value: 1, blank: false },
|
||
],
|
||
},
|
||
{
|
||
variant: 'scrabble_ru',
|
||
score: 88,
|
||
word: [
|
||
{ letter: 'с', value: 1, blank: false },
|
||
{ letter: 'ъ', value: 10, blank: false },
|
||
{ letter: 'ё', value: 4, blank: false },
|
||
{ letter: 'м', value: 2, blank: false },
|
||
{ letter: 'к', value: 2, blank: false },
|
||
{ letter: 'а', value: 1, blank: false },
|
||
],
|
||
},
|
||
],
|
||
};
|
||
|
||
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;
|
||
}
|