6b6baf5710
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 31s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
Lobby: group the my-games list into your-turn / opponent-turn / finished (empty sections hidden), ordered by last activity (your-turn oldest-first, the other two newest-first), as a compact line-separated list. gameDTO and FB GameView gain last_activity_unix (turn start while active, finish time once finished); a pure lib/lobbysort.ts holds the grouping/ordering. Friends: the in-game 'add to friends' item is now server-derived via a new GET /user/friends/outgoing (+ friends.outgoing op), returning addressees with a pending OR declined request (both read as 'request sent'), so it is correct across reloads; it shows a disabled '✓ in friends' once accepted. It live-updates when the opponent answers: RespondFriendRequest now publishes friend_added (accept) / friend_declined (new notify sub-kind, decline) to the original requester, whose open game re-derives its friend state. Tests: lobbysort unit test; gateway outgoing + last_activity transcode tests; backend integration ListOutgoingRequests + respond-publishes-to-requester; e2e updated for the new lobby section labels + a non-friend active opponent. Docs: ARCHITECTURE notify catalog, FUNCTIONAL(+ru) lobby/friends, PLAN.
239 lines
5.7 KiB
TypeScript
239 lines
5.7 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'],
|
||
};
|
||
|
||
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 Stage 8 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: 'english',
|
||
turnTimeoutSecs: 86400,
|
||
hintsAllowed: true,
|
||
hintsPerPlayer: 1,
|
||
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: english, 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: 'english',
|
||
dictVersion: 'v1',
|
||
status: 'active',
|
||
players: 2,
|
||
toMove: 0,
|
||
turnTimeoutSecs: 86400,
|
||
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: 'english',
|
||
dictVersion: 'v1',
|
||
status: 'finished',
|
||
players: 2,
|
||
toMove: 0,
|
||
turnTimeoutSecs: 86400,
|
||
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: 'russian_scrabble',
|
||
dictVersion: 'v1',
|
||
status: 'finished',
|
||
players: 2,
|
||
toMove: 0,
|
||
turnTimeoutSecs: 86400,
|
||
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;
|
||
}
|