d7337d24ea
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m48s
Russian "Эрудит" treats a word laid on the board as belonging to the game: it cannot be laid again. Neither the solver, the backend nor the offline JS port knew the rule, so a player (and the robot) could replay a word freely. Official Scrabble places no such restriction, so both Scrabble variants keep playing unrestricted. The rule applies in two ways. A play whose main word is already on the board is illegal, and is neither accepted nor generated. A play whose perpendicular cross-word is already there stands — that word is incidental to laying the main word — but scores nothing. The set of played words is the game's own move journal, main words and cross-words alike, compared decoded, so a word spelled with a blank is the same word. It lives in the game layer, not the solver: only a game knows its history, and the solver stays stateless and standard-rules. The backend applies it at submit, at the move preview and over generated moves (filtering and re-ranking them, so neither the robot nor the hint can offer a play the engine would then refuse); the client port does the same for the offline engine and for the on-device preview of an online game. The rule is pinned per game (games.no_repeat_words, set from the variant at creation) rather than keyed on the variant, because a game is replayed from its journal on every open. Applied retroactively it would make an already-played repeat illegal — closing that game as a draw — and would rescore a play whose cross-word repeats an earlier word, shifting a live game's totals. Games created before the rule keep playing without it. The flag rides the wire as a trailing field because the client's preview must score the way the server does, and offline games pin the same answer in their own record.
309 lines
8.2 KiB
TypeScript
309 lines
8.2 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',
|
||
};
|
||
|
||
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,
|
||
// Every variant enabled, so the mock-driven UI offers the full New Game picker.
|
||
variantPreferences: ['erudit_ru', 'scrabble_ru', 'scrabble_en'],
|
||
email: 'you@example.com',
|
||
telegramLinked: false,
|
||
vkLinked: false,
|
||
// Every variant's current dictionary version, as the backend advertises it on the profile —
|
||
// the offline preloader targets `variant@version`.
|
||
dictVersions: { erudit_ru: 'v1.3.0', scrabble_ru: 'v1.3.0', scrabble_en: 'v1.3.0' },
|
||
// Post-move interstitial config (short cooldowns so the mock stub is exercisable); not suppressed.
|
||
ads: { cooldownGlobalS: 300, cooldownVsAiS: 1800, cooldownHintS: 60, suppressed: false },
|
||
// The durable tier's per-kind active-game caps (the seeded defaults); the e2e lowers them through
|
||
// window.__mock.setGameLimits to exercise the New-Game lock.
|
||
gameLimits: { vsAi: 10, random: 10, friends: 10 },
|
||
};
|
||
|
||
// 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,
|
||
unreadMessages: false,
|
||
kind: 2,
|
||
status: 'active',
|
||
players: 2,
|
||
toMove: 0,
|
||
turnTimeoutSecs: 86400,
|
||
multipleWordsPerTurn: true,
|
||
noRepeatWords: false,
|
||
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,
|
||
unreadMessages: false,
|
||
kind: 2,
|
||
status: 'finished',
|
||
players: 2,
|
||
toMove: 0,
|
||
turnTimeoutSecs: 86400,
|
||
multipleWordsPerTurn: true,
|
||
noRepeatWords: false,
|
||
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,
|
||
unreadMessages: false,
|
||
kind: 2,
|
||
status: 'finished',
|
||
players: 2,
|
||
toMove: 0,
|
||
turnTimeoutSecs: 86400,
|
||
multipleWordsPerTurn: false,
|
||
noRepeatWords: 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;
|
||
}
|