Stage 8: UI social/account/history surfaces
Tests · Go / test (push) Successful in 7s
Tests · Integration / integration (push) Successful in 13s
Tests · UI / test (push) Successful in 16s

Wire the deferred Stage 7 surfaces end-to-end (UI -> gateway transcode ->
backend REST -> existing domain services): friends (incl. one-time friend
codes), per-user blocks, friend-game invitations, profile editing + email
binding, the statistics screen, and the in-game history + GCG export.

Friends gain two add paths (interview decision, a deliberate plan change):
one-time 6-digit codes (friend_codes table, 12h TTL, single-use, rate-limited
redeem); and play-gated requests (shared game required) where an explicit
decline is permanent, an ignored request lapses after 30 days, and a code
bypasses a decline. Migration 00006 widens friendships_status_chk and adds
friend_codes.

Lobby notification badge is poll + push: a new generic `notify` event drives
it live; the client polls on open/focus. Language stays a single Settings
control that writes through to the durable account's preferred_language. GCG
export is finished-only (game.ErrGameActive) and shares/downloads the .gcg file.

Tests: backend unit + inttest (friend gate/decline/code, ListInvitations,
GetStats, GCG gate), gateway transcode round-trips + notify constructor, UI
vitest (codecs, win-rate, share choice) + Playwright social specs. Docs: PLAN
(Stage 8 done + refinements + TODO-5), ARCHITECTURE, FUNCTIONAL(+ru), UI_DESIGN,
TESTING, module READMEs.
This commit is contained in:
Ilia Denisov
2026-06-03 19:47:40 +02:00
parent 539e24fba1
commit d733ce3119
114 changed files with 8210 additions and 149 deletions
+59
View File
@@ -28,6 +28,8 @@ export const app = $state<{
reduceMotion: boolean;
boardLabels: BoardLabelMode;
localeLocked: boolean;
/** Pending incoming friend requests + invitations, for the lobby badge. */
notifications: number;
}>({
ready: false,
session: null,
@@ -39,6 +41,7 @@ export const app = $state<{
reduceMotion: false,
boardLabels: 'beginner',
localeLocked: false,
notifications: 0,
});
let unsubscribeStream: (() => void) | null = null;
@@ -76,12 +79,35 @@ function openStream(): void {
showToast(t('game.yourTurn'), 'info');
} else if (e.kind === 'match_found') {
navigate(`/game/${e.gameId}`);
} else if (e.kind === 'notify') {
void refreshNotifications();
}
},
() => showToast(t('error.unavailable'), 'error'),
);
}
/**
* refreshNotifications recomputes the lobby badge count (incoming friend requests
* plus open invitations). Authoritative poll, complementing the live 'notify' push.
* Guests have no social surfaces, so it is a no-op for them.
*/
export async function refreshNotifications(): Promise<void> {
if (!app.session || app.profile?.isGuest) {
app.notifications = 0;
return;
}
try {
const [incoming, invitations] = await Promise.all([
gateway.friendsIncoming(),
gateway.invitationsList(),
]);
app.notifications = incoming.length + invitations.length;
} catch {
// Best-effort; leave the previous count on a transient failure.
}
}
function closeStream(): void {
unsubscribeStream?.();
unsubscribeStream = null;
@@ -98,6 +124,7 @@ async function adoptSession(s: Session): Promise<void> {
handleError(err);
}
openStream();
void refreshNotifications();
}
export async function bootstrap(): Promise<void> {
@@ -186,6 +213,30 @@ export function setLocalePref(locale: Locale): void {
app.localeLocked = true;
setLocale(locale);
persistPrefs();
void persistLanguageToServer(locale);
}
/**
* persistLanguageToServer writes the chosen interface language through to the
* durable account's preferred_language, so the single Settings control is the
* source of truth (guests keep only the client preference). Best-effort.
*/
async function persistLanguageToServer(locale: Locale): Promise<void> {
const p = app.profile;
if (!p || p.isGuest || p.preferredLanguage === locale) return;
try {
app.profile = await gateway.profileUpdate({
displayName: p.displayName,
preferredLanguage: locale,
timeZone: p.timeZone,
awayStart: p.awayStart,
awayEnd: p.awayEnd,
blockChat: p.blockChat,
blockFriendRequests: p.blockFriendRequests,
});
} catch {
// The client locale already changed; the server sync is best-effort.
}
}
export function setReduceMotion(on: boolean): void {
@@ -198,3 +249,11 @@ export function setBoardLabels(mode: BoardLabelMode): void {
app.boardLabels = mode;
persistPrefs();
}
// Refresh the lobby badge when the app returns to the foreground — a push 'notify'
// may have been missed while the client was hidden/closed (poll + push, see §10).
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && app.session) void refreshNotifications();
});
}
+36
View File
@@ -5,18 +5,25 @@
// message.
import type {
AccountRef,
ChatMessage,
EvalResult,
FriendCode,
GameList,
GameView,
GcgExport,
History,
HintResult,
Invitation,
InvitationSettings,
MatchResult,
MoveResult,
Profile,
ProfileUpdate,
PushEvent,
Session,
StateView,
Stats,
Tile,
Variant,
WordCheckResult,
@@ -74,6 +81,35 @@ export interface GatewayClient {
chatList(gameId: string): Promise<ChatMessage[]>;
nudge(gameId: string): Promise<ChatMessage>;
// --- friends (Stage 8) ---
friendsList(): Promise<AccountRef[]>;
friendsIncoming(): Promise<AccountRef[]>;
friendRequest(accountId: string): Promise<void>;
friendRespond(requesterId: string, accept: boolean): Promise<void>;
friendCancel(accountId: string): Promise<void>;
unfriend(accountId: string): Promise<void>;
friendCodeIssue(): Promise<FriendCode>;
friendCodeRedeem(code: string): Promise<AccountRef>;
// --- blocks (Stage 8) ---
blocksList(): Promise<AccountRef[]>;
block(accountId: string): Promise<void>;
unblock(accountId: string): Promise<void>;
// --- invitations (Stage 8) ---
invitationsList(): Promise<Invitation[]>;
invitationCreate(inviteeIds: string[], settings: InvitationSettings): Promise<Invitation>;
invitationAccept(invitationId: string): Promise<Invitation>;
invitationDecline(invitationId: string): Promise<Invitation>;
invitationCancel(invitationId: string): Promise<void>;
// --- profile / stats / history (Stage 8) ---
profileUpdate(p: ProfileUpdate): Promise<Profile>;
emailBindRequest(email: string): Promise<void>;
emailBindConfirm(email: string, code: string): Promise<Profile>;
statsGet(): Promise<Stats>;
exportGcg(gameId: string): Promise<GcgExport>;
// --- live stream ---
subscribe(onEvent: (e: PushEvent) => void, onError?: (err: unknown) => void): Unsubscribe;
+93 -1
View File
@@ -1,7 +1,15 @@
import { Builder, ByteBuffer } from 'flatbuffers';
import { describe, expect, it } from 'vitest';
import * as fb from '../gen/fbs/scrabblefb';
import { decodeGameList, decodeSession, encodeSubmitPlay } from './codec';
import {
decodeFriendList,
decodeGameList,
decodeInvitation,
decodeSession,
decodeStats,
encodeSubmitPlay,
encodeTarget,
} from './codec';
describe('codec', () => {
it('encodes a SubmitPlayRequest the gateway can read', () => {
@@ -77,4 +85,88 @@ describe('codec', () => {
expect(gl.games[0].seats[0].displayName).toBe('Ann');
expect(gl.games[0].seats[0].score).toBe(13);
});
it('encodes a TargetRequest', () => {
const r = fb.TargetRequest.getRootAsTargetRequest(new ByteBuffer(encodeTarget('a-1')));
expect(r.accountId()).toBe('a-1');
});
it('decodes a StatsView', () => {
const b = new Builder(64);
fb.StatsView.startStatsView(b);
fb.StatsView.addWins(b, 7);
fb.StatsView.addLosses(b, 4);
fb.StatsView.addDraws(b, 1);
fb.StatsView.addMaxGamePoints(b, 420);
fb.StatsView.addMaxWordPoints(b, 90);
b.finish(fb.StatsView.endStatsView(b));
expect(decodeStats(b.asUint8Array())).toEqual({
wins: 7,
losses: 4,
draws: 1,
maxGamePoints: 420,
maxWordPoints: 90,
});
});
it('decodes a FriendList of account refs', () => {
const b = new Builder(128);
const id = b.createString('a-1');
const dn = b.createString('Ann');
fb.AccountRef.startAccountRef(b);
fb.AccountRef.addAccountId(b, id);
fb.AccountRef.addDisplayName(b, dn);
const ref = fb.AccountRef.endAccountRef(b);
const vec = fb.FriendList.createFriendsVector(b, [ref]);
fb.FriendList.startFriendList(b);
fb.FriendList.addFriends(b, vec);
b.finish(fb.FriendList.endFriendList(b));
expect(decodeFriendList(b.asUint8Array())).toEqual([{ accountId: 'a-1', displayName: 'Ann' }]);
});
it('decodes an Invitation with inviter and invitees', () => {
const b = new Builder(256);
const iid = b.createString('u-1');
const idn = b.createString('Me');
fb.AccountRef.startAccountRef(b);
fb.AccountRef.addAccountId(b, iid);
fb.AccountRef.addDisplayName(b, idn);
const inviter = fb.AccountRef.endAccountRef(b);
const aid = b.createString('inv-1');
const adn = b.createString('Friend');
const resp = b.createString('pending');
fb.InvitationInvitee.startInvitationInvitee(b);
fb.InvitationInvitee.addAccountId(b, aid);
fb.InvitationInvitee.addDisplayName(b, adn);
fb.InvitationInvitee.addSeat(b, 1);
fb.InvitationInvitee.addResponse(b, resp);
const invitee = fb.InvitationInvitee.endInvitationInvitee(b);
const invitees = fb.Invitation.createInviteesVector(b, [invitee]);
const id = b.createString('i-1');
const variant = b.createString('english');
const dropout = b.createString('remove');
const status = b.createString('pending');
const gid = b.createString('');
fb.Invitation.startInvitation(b);
fb.Invitation.addId(b, id);
fb.Invitation.addInviter(b, inviter);
fb.Invitation.addInvitees(b, invitees);
fb.Invitation.addVariant(b, variant);
fb.Invitation.addTurnTimeoutSecs(b, 86400);
fb.Invitation.addHintsAllowed(b, true);
fb.Invitation.addHintsPerPlayer(b, 1);
fb.Invitation.addDropoutTiles(b, dropout);
fb.Invitation.addStatus(b, status);
fb.Invitation.addGameId(b, gid);
b.finish(fb.Invitation.endInvitation(b));
const inv = decodeInvitation(b.asUint8Array());
expect(inv.id).toBe('i-1');
expect(inv.inviter).toEqual({ accountId: 'u-1', displayName: 'Me' });
expect(inv.invitees).toHaveLength(1);
expect(inv.invitees[0]).toEqual({ accountId: 'inv-1', displayName: 'Friend', seat: 1, response: 'pending' });
expect(inv.variant).toBe('english');
});
});
+207
View File
@@ -7,20 +7,28 @@ import { Builder, ByteBuffer, type Offset } from 'flatbuffers';
import * as fb from '../gen/fbs/scrabblefb';
import type { PlacedTile } from './client';
import type {
AccountRef,
ChatMessage,
EvalResult,
FriendCode,
GameList,
GameView,
GcgExport,
History,
HintResult,
Invitation,
InvitationInvitee,
InvitationSettings,
MatchResult,
MoveRecord,
MoveResult,
Profile,
ProfileUpdate,
PushEvent,
Seat,
Session,
StateView,
Stats,
Tile,
Variant,
WordCheckResult,
@@ -250,6 +258,8 @@ export function decodeProfile(buf: Uint8Array): Profile {
displayName: s(p.displayName()),
preferredLanguage: s(p.preferredLanguage()),
timeZone: s(p.timeZone()),
awayStart: s(p.awayStart()),
awayEnd: s(p.awayEnd()),
hintBalance: p.hintBalance(),
blockChat: p.blockChat(),
blockFriendRequests: p.blockFriendRequests(),
@@ -357,6 +367,10 @@ export function decodeEvent(kind: string, payload: Uint8Array): PushEvent | null
const e = fb.MatchFoundEvent.getRootAsMatchFoundEvent(bb);
return { kind: 'match_found', gameId: s(e.gameId()) };
}
case 'notify': {
const e = fb.NotificationEvent.getRootAsNotificationEvent(bb);
return { kind: 'notify', sub: s(e.kind()) };
}
case 'heartbeat':
return { kind: 'heartbeat' };
default:
@@ -364,6 +378,199 @@ export function decodeEvent(kind: string, payload: Uint8Array): PushEvent | null
}
}
// --- Stage 8 encoders ---
export function encodeTarget(accountId: string): Uint8Array {
const b = new Builder(64);
const id = b.createString(accountId);
fb.TargetRequest.startTargetRequest(b);
fb.TargetRequest.addAccountId(b, id);
return finish(b, fb.TargetRequest.endTargetRequest(b));
}
export function encodeFriendRespond(requesterId: string, accept: boolean): Uint8Array {
const b = new Builder(64);
const id = b.createString(requesterId);
fb.FriendRespondRequest.startFriendRespondRequest(b);
fb.FriendRespondRequest.addRequesterId(b, id);
fb.FriendRespondRequest.addAccept(b, accept);
return finish(b, fb.FriendRespondRequest.endFriendRespondRequest(b));
}
export function encodeRedeemCode(code: string): Uint8Array {
const b = new Builder(32);
const c = b.createString(code);
fb.RedeemCodeRequest.startRedeemCodeRequest(b);
fb.RedeemCodeRequest.addCode(b, c);
return finish(b, fb.RedeemCodeRequest.endRedeemCodeRequest(b));
}
export function encodeCreateInvitation(inviteeIds: string[], st: InvitationSettings): Uint8Array {
const b = new Builder(256);
const idOffs = inviteeIds.map((id) => b.createString(id));
const ids = fb.CreateInvitationRequest.createInviteeIdsVector(b, idOffs);
const variant = b.createString(st.variant);
const dropout = b.createString(st.dropoutTiles);
fb.CreateInvitationRequest.startCreateInvitationRequest(b);
fb.CreateInvitationRequest.addInviteeIds(b, ids);
fb.CreateInvitationRequest.addVariant(b, variant);
fb.CreateInvitationRequest.addTurnTimeoutSecs(b, st.turnTimeoutSecs);
fb.CreateInvitationRequest.addHintsAllowed(b, st.hintsAllowed);
fb.CreateInvitationRequest.addHintsPerPlayer(b, st.hintsPerPlayer);
fb.CreateInvitationRequest.addDropoutTiles(b, dropout);
return finish(b, fb.CreateInvitationRequest.endCreateInvitationRequest(b));
}
export function encodeInvitationAction(invitationId: string): Uint8Array {
const b = new Builder(64);
const id = b.createString(invitationId);
fb.InvitationActionRequest.startInvitationActionRequest(b);
fb.InvitationActionRequest.addInvitationId(b, id);
return finish(b, fb.InvitationActionRequest.endInvitationActionRequest(b));
}
export function encodeUpdateProfile(p: ProfileUpdate): Uint8Array {
const b = new Builder(256);
const name = b.createString(p.displayName);
const lang = b.createString(p.preferredLanguage);
const tz = b.createString(p.timeZone);
const as = b.createString(p.awayStart);
const ae = b.createString(p.awayEnd);
fb.UpdateProfileRequest.startUpdateProfileRequest(b);
fb.UpdateProfileRequest.addDisplayName(b, name);
fb.UpdateProfileRequest.addPreferredLanguage(b, lang);
fb.UpdateProfileRequest.addTimeZone(b, tz);
fb.UpdateProfileRequest.addAwayStart(b, as);
fb.UpdateProfileRequest.addAwayEnd(b, ae);
fb.UpdateProfileRequest.addBlockChat(b, p.blockChat);
fb.UpdateProfileRequest.addBlockFriendRequests(b, p.blockFriendRequests);
return finish(b, fb.UpdateProfileRequest.endUpdateProfileRequest(b));
}
export function encodeEmailBind(email: string): Uint8Array {
const b = new Builder(128);
const e = b.createString(email);
fb.EmailBindRequest.startEmailBindRequest(b);
fb.EmailBindRequest.addEmail(b, e);
return finish(b, fb.EmailBindRequest.endEmailBindRequest(b));
}
export function encodeEmailConfirm(email: string, code: string): Uint8Array {
const b = new Builder(128);
const e = b.createString(email);
const c = b.createString(code);
fb.EmailConfirmRequest.startEmailConfirmRequest(b);
fb.EmailConfirmRequest.addEmail(b, e);
fb.EmailConfirmRequest.addCode(b, c);
return finish(b, fb.EmailConfirmRequest.endEmailConfirmRequest(b));
}
// --- Stage 8 decoders ---
function decodeAccountRef(r: fb.AccountRef): AccountRef {
return { accountId: s(r.accountId()), displayName: s(r.displayName()) };
}
export function decodeFriendList(buf: Uint8Array): AccountRef[] {
const l = fb.FriendList.getRootAsFriendList(new ByteBuffer(buf));
const out: AccountRef[] = [];
for (let i = 0; i < l.friendsLength(); i++) {
const r = l.friends(i);
if (r) out.push(decodeAccountRef(r));
}
return out;
}
export function decodeIncomingList(buf: Uint8Array): AccountRef[] {
const l = fb.IncomingRequestList.getRootAsIncomingRequestList(new ByteBuffer(buf));
const out: AccountRef[] = [];
for (let i = 0; i < l.requestsLength(); i++) {
const r = l.requests(i);
if (r) out.push(decodeAccountRef(r));
}
return out;
}
export function decodeBlockList(buf: Uint8Array): AccountRef[] {
const l = fb.BlockList.getRootAsBlockList(new ByteBuffer(buf));
const out: AccountRef[] = [];
for (let i = 0; i < l.blockedLength(); i++) {
const r = l.blocked(i);
if (r) out.push(decodeAccountRef(r));
}
return out;
}
export function decodeFriendCode(buf: Uint8Array): FriendCode {
const c = fb.FriendCode.getRootAsFriendCode(new ByteBuffer(buf));
return { code: s(c.code()), expiresAtUnix: Number(c.expiresAtUnix()) };
}
export function decodeRedeemResult(buf: Uint8Array): AccountRef {
const r = fb.RedeemResult.getRootAsRedeemResult(new ByteBuffer(buf));
const f = r.friend();
return f ? decodeAccountRef(f) : { accountId: '', displayName: '' };
}
export function decodeStats(buf: Uint8Array): Stats {
const v = fb.StatsView.getRootAsStatsView(new ByteBuffer(buf));
return {
wins: v.wins(),
losses: v.losses(),
draws: v.draws(),
maxGamePoints: v.maxGamePoints(),
maxWordPoints: v.maxWordPoints(),
};
}
function decodeInvitationTable(i: fb.Invitation): Invitation {
const inviter = i.inviter();
const invitees: InvitationInvitee[] = [];
for (let k = 0; k < i.inviteesLength(); k++) {
const iv = i.invitees(k);
if (iv) {
invitees.push({
accountId: s(iv.accountId()),
displayName: s(iv.displayName()),
seat: iv.seat(),
response: s(iv.response()),
});
}
}
return {
id: s(i.id()),
inviter: inviter ? decodeAccountRef(inviter) : { accountId: '', displayName: '' },
invitees,
variant: s(i.variant()) as Variant,
turnTimeoutSecs: i.turnTimeoutSecs(),
hintsAllowed: i.hintsAllowed(),
hintsPerPlayer: i.hintsPerPlayer(),
dropoutTiles: s(i.dropoutTiles()),
status: s(i.status()),
gameId: s(i.gameId()),
expiresAtUnix: Number(i.expiresAtUnix()),
};
}
export function decodeInvitation(buf: Uint8Array): Invitation {
return decodeInvitationTable(fb.Invitation.getRootAsInvitation(new ByteBuffer(buf)));
}
export function decodeInvitationList(buf: Uint8Array): Invitation[] {
const l = fb.InvitationList.getRootAsInvitationList(new ByteBuffer(buf));
const out: Invitation[] = [];
for (let i = 0; i < l.invitationsLength(); i++) {
const inv = l.invitations(i);
if (inv) out.push(decodeInvitationTable(inv));
}
return out;
}
export function decodeGcg(buf: Uint8Array): GcgExport {
const g = fb.GcgExport.getRootAsGcgExport(new ByteBuffer(buf));
return { gameId: s(g.gameId()), filename: s(g.filename()), content: s(g.content()) };
}
function emptyGame(): GameView {
return {
id: '',
+92 -1
View File
@@ -102,7 +102,21 @@ export const en = {
'profile.timezone': 'Time zone',
'profile.hintBalance': 'Hint balance',
'profile.guest': 'Guest account',
'profile.readonly': 'Editing your profile arrives in a later update.',
'profile.edit': 'Edit profile',
'profile.displayName': 'Display name',
'profile.awayWindow': 'Away window',
'profile.awayHint': 'You are not auto-resigned during these hours.',
'profile.from': 'From',
'profile.to': 'To',
'profile.blockChat': 'Disable chat',
'profile.blockFriendRequests': 'Disable friend requests',
'profile.email': 'Email',
'profile.bindEmail': 'Bind email',
'profile.emailCode': 'Confirmation code',
'profile.emailSent': 'We sent a code to {email}.',
'profile.emailBound': 'Email confirmed.',
'profile.saved': 'Profile saved.',
'profile.guestLocked': 'Sign in with email to manage your profile.',
'settings.title': 'Settings',
'settings.theme': 'Theme',
@@ -143,6 +157,83 @@ export const en = {
'error.unavailable': 'Connection problem. Retrying…',
'error.internal': 'Something went wrong.',
'error.generic': 'Something went wrong.',
'lobby.invitations': 'Invitations',
'lobby.friends': 'Friends',
'friends.title': 'Friends',
'friends.yours': 'Your friends',
'friends.none': 'No friends yet.',
'friends.incoming': 'Friend requests',
'friends.accept': 'Accept',
'friends.decline': 'Decline',
'friends.unfriend': 'Remove',
'friends.block': 'Block',
'friends.add': 'Add a friend',
'friends.addFromGame': 'Add to friends',
'friends.requestSent': 'Friend request sent.',
'friends.getCode': 'Show my code',
'friends.codeHint': 'Give this code to a friend within 12 hours.',
'friends.codeExpires': 'Expires at {time}',
'friends.enterCode': 'Have a code? Add a friend',
'friends.codePlaceholder': '6-digit code',
'friends.redeem': 'Add',
'friends.added': 'Added {name}.',
'friends.blockedList': 'Blocked players',
'friends.unblock': 'Unblock',
'friends.noneBlocked': 'No blocked players.',
'invitations.none': 'No invitations.',
'invitations.from': 'From {name}',
'invitations.with': 'With {names}',
'invitations.accept': 'Accept',
'invitations.decline': 'Decline',
'invitations.cancel': 'Cancel',
'invitations.waiting': 'Waiting for replies',
'new.auto': 'Quick match',
'new.withFriends': 'Play with friends',
'new.pickFriends': 'Choose who to invite',
'new.invite': 'Send invitation',
'new.moveTime': 'Move time',
'new.hintsPerPlayer': 'Hints per player',
'new.invited': 'Invitation sent.',
'new.noFriends': 'Add friends first to invite them.',
'stats.title': 'Statistics',
'stats.wins': 'Wins',
'stats.losses': 'Losses',
'stats.draws': 'Draws',
'stats.played': 'Games',
'stats.winRate': 'Win rate',
'stats.maxGame': 'Best game',
'stats.maxWord': 'Best move',
'stats.guestHint': 'Sign in to track your statistics.',
'game.exportGcg': 'Export GCG',
'game.gcgActiveOnly': 'Available once the game is finished.',
'time.minutes': '{n} min',
'time.hours': '{n} h',
'error.self_relation': 'You cannot do that to yourself.',
'error.request_exists': 'A request or friendship already exists.',
'error.request_blocked': 'This player is not accepting requests.',
'error.request_not_found': 'No matching friend request.',
'error.no_shared_game': 'You can only add someone you have played with.',
'error.request_declined': 'This player declined your request.',
'error.friend_code_invalid': 'That friend code is invalid or expired.',
'error.invalid_invitation': 'Invalid invitation.',
'error.invitation_blocked': 'You cannot invite this player.',
'error.invitation_not_found': 'Invitation not found.',
'error.invitation_not_pending': 'This invitation is no longer open.',
'error.invitation_expired': 'This invitation has expired.',
'error.not_invited': 'You were not invited.',
'error.already_responded': 'You already responded.',
'error.not_inviter': 'Only the inviter can do that.',
'error.game_active': 'Available only after the game is finished.',
'error.invalid_profile': 'Some profile fields are invalid.',
'error.already_confirmed': 'This email is already confirmed.',
} as const;
export type MessageKey = keyof typeof en;
+92 -1
View File
@@ -103,7 +103,21 @@ export const ru: Record<MessageKey, string> = {
'profile.timezone': 'Часовой пояс',
'profile.hintBalance': 'Баланс подсказок',
'profile.guest': 'Гостевой аккаунт',
'profile.readonly': 'Редактирование профиля появится в следующем обновлении.',
'profile.edit': 'Редактировать профиль',
'profile.displayName': 'Отображаемое имя',
'profile.awayWindow': 'Окно отсутствия',
'profile.awayHint': 'В эти часы вам не засчитывают автопоражение.',
'profile.from': 'С',
'profile.to': 'До',
'profile.blockChat': 'Отключить чат',
'profile.blockFriendRequests': 'Отключить заявки в друзья',
'profile.email': 'Эл. почта',
'profile.bindEmail': 'Привязать почту',
'profile.emailCode': 'Код подтверждения',
'profile.emailSent': 'Мы отправили код на {email}.',
'profile.emailBound': 'Почта подтверждена.',
'profile.saved': 'Профиль сохранён.',
'profile.guestLocked': 'Войдите по почте, чтобы управлять профилем.',
'settings.title': 'Настройки',
'settings.theme': 'Тема',
@@ -144,4 +158,81 @@ export const ru: Record<MessageKey, string> = {
'error.unavailable': 'Проблема соединения. Повторяем…',
'error.internal': 'Что-то пошло не так.',
'error.generic': 'Что-то пошло не так.',
'lobby.invitations': 'Приглашения',
'lobby.friends': 'Друзья',
'friends.title': 'Друзья',
'friends.yours': 'Ваши друзья',
'friends.none': 'Друзей пока нет.',
'friends.incoming': 'Заявки в друзья',
'friends.accept': 'Принять',
'friends.decline': 'Отклонить',
'friends.unfriend': 'Удалить',
'friends.block': 'Заблокировать',
'friends.add': 'Добавить друга',
'friends.addFromGame': 'В друзья',
'friends.requestSent': 'Заявка в друзья отправлена.',
'friends.getCode': 'Показать мой код',
'friends.codeHint': 'Передайте этот код другу в течение 12 часов.',
'friends.codeExpires': 'Истекает в {time}',
'friends.enterCode': 'Есть код? Добавить друга',
'friends.codePlaceholder': 'Код из 6 цифр',
'friends.redeem': 'Добавить',
'friends.added': 'Добавлен(а) {name}.',
'friends.blockedList': 'Заблокированные',
'friends.unblock': 'Разблокировать',
'friends.noneBlocked': 'Заблокированных нет.',
'invitations.none': 'Приглашений нет.',
'invitations.from': 'От {name}',
'invitations.with': 'С {names}',
'invitations.accept': 'Принять',
'invitations.decline': 'Отклонить',
'invitations.cancel': 'Отменить',
'invitations.waiting': 'Ожидаем ответы',
'new.auto': 'Быстрая игра',
'new.withFriends': 'Игра с друзьями',
'new.pickFriends': 'Кого пригласить',
'new.invite': 'Отправить приглашение',
'new.moveTime': 'Время на ход',
'new.hintsPerPlayer': 'Подсказок на игрока',
'new.invited': 'Приглашение отправлено.',
'new.noFriends': 'Сначала добавьте друзей, чтобы пригласить их.',
'stats.title': 'Статистика',
'stats.wins': 'Победы',
'stats.losses': 'Поражения',
'stats.draws': 'Ничьи',
'stats.played': 'Игр',
'stats.winRate': 'Доля побед',
'stats.maxGame': 'Лучшая игра',
'stats.maxWord': 'Лучший ход',
'stats.guestHint': 'Войдите, чтобы вести статистику.',
'game.exportGcg': 'Экспорт GCG',
'game.gcgActiveOnly': 'Доступно после завершения игры.',
'time.minutes': '{n} мин',
'time.hours': '{n} ч',
'error.self_relation': 'Нельзя сделать это с самим собой.',
'error.request_exists': 'Заявка или дружба уже существует.',
'error.request_blocked': 'Игрок не принимает заявки.',
'error.request_not_found': 'Подходящей заявки нет.',
'error.no_shared_game': 'Можно добавить только того, с кем вы играли.',
'error.request_declined': 'Игрок отклонил вашу заявку.',
'error.friend_code_invalid': 'Код недействителен или истёк.',
'error.invalid_invitation': 'Неверное приглашение.',
'error.invitation_blocked': 'Нельзя пригласить этого игрока.',
'error.invitation_not_found': 'Приглашение не найдено.',
'error.invitation_not_pending': 'Приглашение больше не открыто.',
'error.invitation_expired': 'Приглашение истекло.',
'error.not_invited': 'Вас не приглашали.',
'error.already_responded': 'Вы уже ответили.',
'error.not_inviter': 'Только пригласивший может это сделать.',
'error.game_active': 'Доступно только после завершения игры.',
'error.invalid_profile': 'Некоторые поля профиля некорректны.',
'error.already_confirmed': 'Эта почта уже подтверждена.',
};
+131 -1
View File
@@ -12,22 +12,39 @@ import type {
} from '../client';
import { GatewayError } from '../client';
import type {
AccountRef,
ChatMessage,
EvalResult,
FriendCode,
GameList,
GcgExport,
History,
HintResult,
Invitation,
InvitationSettings,
MatchResult,
MoveResult,
Profile,
ProfileUpdate,
PushEvent,
Session,
StateView,
Stats,
Variant,
WordCheckResult,
} from '../model';
import { tileValue } from '../premiums';
import { ME, PROFILE, SESSION, seedGames, type MockGame } from './data';
import {
ME,
MOCK_FRIENDS,
MOCK_INCOMING,
MOCK_STATS,
PROFILE,
SESSION,
mockInvitations,
seedGames,
type MockGame,
} from './data';
const POOL: Record<Variant, string> = {
english: 'AAAAEEEEIIIOONNRRTTLLSSUDGBCMPFHVWYK',
@@ -57,6 +74,11 @@ export class MockGateway implements GatewayClient {
private readonly profile: Profile = { ...PROFILE };
private readonly subs = new Set<(e: PushEvent) => void>();
private pendingMatch: string | null = null;
private friends: AccountRef[] = MOCK_FRIENDS.map((f) => ({ ...f }));
private incoming: AccountRef[] = MOCK_INCOMING.map((f) => ({ ...f }));
private blocks: AccountRef[] = [];
private invitations: Invitation[] = mockInvitations();
private readonly stats: Stats = { ...MOCK_STATS };
setToken(_token: string | null): void {
// The mock needs no auth; the real transport stores the bearer token.
@@ -300,6 +322,114 @@ export class MockGateway implements GatewayClient {
return msg;
}
// --- friends ---
private nameFor(id: string): string {
return this.friends.find((f) => f.accountId === id)?.displayName ?? id;
}
async friendsList(): Promise<AccountRef[]> {
return this.friends.map((f) => ({ ...f }));
}
async friendsIncoming(): Promise<AccountRef[]> {
return this.incoming.map((f) => ({ ...f }));
}
async friendRequest(_accountId: string): Promise<void> {
// The real backend requires a shared game; the mock simply acknowledges.
}
async friendRespond(requesterId: string, accept: boolean): Promise<void> {
const i = this.incoming.findIndex((r) => r.accountId === requesterId);
if (i < 0) throw new GatewayError('request_not_found');
const [r] = this.incoming.splice(i, 1);
if (accept) this.friends.push(r);
this.emit({ kind: 'notify', sub: 'friend_request' });
}
async friendCancel(_accountId: string): Promise<void> {}
async unfriend(accountId: string): Promise<void> {
this.friends = this.friends.filter((f) => f.accountId !== accountId);
}
async friendCodeIssue(): Promise<FriendCode> {
return { code: '246813', expiresAtUnix: Math.floor(Date.now() / 1000) + 12 * 3600 };
}
async friendCodeRedeem(code: string): Promise<AccountRef> {
const friend = { accountId: `code-${code}`, displayName: `Friend ${code}` };
this.friends.push(friend);
return { ...friend };
}
// --- blocks ---
async blocksList(): Promise<AccountRef[]> {
return this.blocks.map((b) => ({ ...b }));
}
async block(accountId: string): Promise<void> {
this.friends = this.friends.filter((f) => f.accountId !== accountId);
if (!this.blocks.some((b) => b.accountId === accountId)) {
this.blocks.push({ accountId, displayName: this.nameFor(accountId) });
}
}
async unblock(accountId: string): Promise<void> {
this.blocks = this.blocks.filter((b) => b.accountId !== accountId);
}
// --- invitations ---
async invitationsList(): Promise<Invitation[]> {
return this.invitations.map((i) => structuredClone(i));
}
async invitationCreate(inviteeIds: string[], settings: InvitationSettings): Promise<Invitation> {
const inv: Invitation = {
id: crypto.randomUUID(),
inviter: { accountId: ME, displayName: 'You' },
invitees: inviteeIds.map((id, k) => ({ accountId: id, displayName: this.nameFor(id), seat: k + 1, response: 'pending' })),
variant: settings.variant,
turnTimeoutSecs: settings.turnTimeoutSecs,
hintsAllowed: settings.hintsAllowed,
hintsPerPlayer: settings.hintsPerPlayer,
dropoutTiles: settings.dropoutTiles,
status: 'pending',
gameId: '',
expiresAtUnix: Math.floor(Date.now() / 1000) + 7 * 86400,
};
this.invitations.push(inv);
return structuredClone(inv);
}
private respondInvitation(invitationId: string, status: string): Invitation {
const inv = this.invitations.find((i) => i.id === invitationId);
if (!inv) throw new GatewayError('invitation_not_found');
inv.status = status;
this.invitations = this.invitations.filter((i) => i.id !== invitationId);
return structuredClone(inv);
}
async invitationAccept(invitationId: string): Promise<Invitation> {
return this.respondInvitation(invitationId, 'started');
}
async invitationDecline(invitationId: string): Promise<Invitation> {
return this.respondInvitation(invitationId, 'declined');
}
async invitationCancel(invitationId: string): Promise<void> {
this.invitations = this.invitations.filter((i) => i.id !== invitationId);
}
// --- profile / stats / history ---
async profileUpdate(p: ProfileUpdate): Promise<Profile> {
Object.assign(this.profile, p);
return { ...this.profile };
}
async emailBindRequest(_email: string): Promise<void> {}
async emailBindConfirm(_email: string, _code: string): Promise<Profile> {
this.profile.isGuest = false;
return { ...this.profile };
}
async statsGet(): Promise<Stats> {
return { ...this.stats };
}
async exportGcg(gameId: string): Promise<GcgExport> {
const g = this.game(gameId);
if (g.view.status !== 'finished') throw new GatewayError('game_active');
return {
gameId,
filename: `game-${gameId}.gcg`,
content: `#character-encoding UTF-8\n#player1 p1 You\n#player2 p2 Opp\n`,
};
}
// --- live stream ---
subscribe(onEvent: (e: PushEvent) => void): Unsubscribe {
this.subs.add(onEvent);
+43 -2
View File
@@ -4,7 +4,17 @@
// not need to be strictly legal here — this is a visual/interaction fixture; real
// legality and scoring come from the backend.
import type { ChatMessage, GameView, MoveRecord, Profile, Seat, Session } from '../model';
import type {
AccountRef,
ChatMessage,
GameView,
Invitation,
MoveRecord,
Profile,
Seat,
Session,
Stats,
} from '../model';
export const ME = 'me';
@@ -20,12 +30,43 @@ export const PROFILE: Profile = {
displayName: 'You',
preferredLanguage: 'en',
timeZone: 'UTC',
awayStart: '00:00',
awayEnd: '07:00',
hintBalance: 3,
blockChat: false,
blockFriendRequests: false,
isGuest: true,
isGuest: false,
};
// 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.
export const MOCK_FRIENDS: AccountRef[] = [
{ accountId: 'ann', displayName: 'Ann' },
{ 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 };
}
+73
View File
@@ -100,12 +100,84 @@ export interface Profile {
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;
}
/** 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;
}
/** 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';
}
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;
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;
@@ -134,4 +206,5 @@ export type PushEvent =
| { kind: 'chat_message'; message: ChatMessage }
| { kind: 'nudge'; gameId: string; fromUserId: string }
| { kind: 'match_found'; gameId: string }
| { kind: 'notify'; sub: string }
| { kind: 'heartbeat' };
+6
View File
@@ -10,6 +10,8 @@ export type RouteName =
| 'profile'
| 'settings'
| 'about'
| 'friends'
| 'stats'
| 'notfound';
export interface Route {
@@ -34,6 +36,10 @@ function parse(hash: string): Route {
return { name: 'settings', params: {} };
case 'about':
return { name: 'about', params: {} };
case 'friends':
return { name: 'friends', params: {} };
case 'stats':
return { name: 'stats', params: {} };
default:
return { name: 'notfound', params: {} };
}
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { pickGcgDelivery } from './share';
const file = {} as File;
describe('pickGcgDelivery', () => {
it('shares when the platform can share files', () => {
expect(pickGcgDelivery({ canShare: () => true, share: async () => {} }, file)).toBe('share');
});
it('downloads when the platform cannot share files', () => {
expect(pickGcgDelivery({ canShare: () => false, share: async () => {} }, file)).toBe('download');
});
it('downloads when there is no navigator', () => {
expect(pickGcgDelivery(undefined, file)).toBe('download');
});
it('downloads when the Web Share API is incomplete', () => {
expect(pickGcgDelivery({ canShare: () => true } as never, file)).toBe('download');
});
});
+48
View File
@@ -0,0 +1,48 @@
// GCG export delivery: share on mobile (Web Share API with a file) where supported,
// otherwise download via a Blob + <a download> on desktop. The Capacitor-native file
// save lands with the native wrapper; the Web Share path already covers mobile
// browsers. pickGcgDelivery is the pure decision, unit-tested with a mock navigator.
import type { GcgExport } from './model';
type ShareNav = Pick<Navigator, 'canShare' | 'share'>;
/** pickGcgDelivery decides between the Web Share API and a Blob download for a file. */
export function pickGcgDelivery(nav: ShareNav | undefined, file: File): 'share' | 'download' {
if (
nav &&
typeof nav.canShare === 'function' &&
typeof nav.share === 'function' &&
nav.canShare({ files: [file] })
) {
return 'share';
}
return 'download';
}
/** shareOrDownloadGcg shares the GCG file where supported, else triggers a download. */
export async function shareOrDownloadGcg(gcg: GcgExport): Promise<void> {
const file = new File([gcg.content], gcg.filename, { type: 'application/x-gcg' });
const nav = typeof navigator !== 'undefined' ? navigator : undefined;
if (pickGcgDelivery(nav, file) === 'share' && nav) {
try {
await nav.share({ files: [file], title: gcg.filename });
return;
} catch {
// The user cancelled or sharing failed — fall back to a download.
}
}
downloadFile(gcg.content, gcg.filename);
}
function downloadFile(content: string, filename: string): void {
if (typeof document === 'undefined') return;
const url = URL.createObjectURL(new Blob([content], { type: 'application/x-gcg' }));
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { gamesPlayed, winRate } from './stats';
import type { Stats } from './model';
const s = (wins: number, losses: number, draws: number): Stats => ({
wins,
losses,
draws,
maxGamePoints: 0,
maxWordPoints: 0,
});
describe('stats', () => {
it('sums games played', () => {
expect(gamesPlayed(s(7, 4, 1))).toBe(12);
});
it('computes a rounded win rate', () => {
expect(winRate(s(7, 4, 1))).toBe(58); // 7/12 = 58.33 -> 58
expect(winRate(s(1, 1, 0))).toBe(50);
});
it('win rate is 0 with no games', () => {
expect(winRate(s(0, 0, 0))).toBe(0);
});
});
+14
View File
@@ -0,0 +1,14 @@
// Pure derivations for the statistics screen, extracted so they are unit-testable.
import type { Stats } from './model';
/** gamesPlayed is the total finished games (wins + losses + draws). */
export function gamesPlayed(s: Stats): number {
return s.wins + s.losses + s.draws;
}
/** winRate is the percentage of finished games won, rounded; 0 when none played. */
export function winRate(s: Stats): number {
const n = gamesPlayed(s);
return n > 0 ? Math.round((s.wins / n) * 100) : 0;
}
+67
View File
@@ -119,6 +119,73 @@ export function createTransport(baseUrl: string): GatewayClient {
return codec.decodeChatMessage(await exec('chat.nudge', codec.encodeGameAction(id)));
},
async friendsList() {
return codec.decodeFriendList(await exec('friends.list', codec.empty()));
},
async friendsIncoming() {
return codec.decodeIncomingList(await exec('friends.incoming', codec.empty()));
},
async friendRequest(accountId) {
await exec('friends.request', codec.encodeTarget(accountId));
},
async friendRespond(requesterId, accept) {
await exec('friends.respond', codec.encodeFriendRespond(requesterId, accept));
},
async friendCancel(accountId) {
await exec('friends.cancel', codec.encodeTarget(accountId));
},
async unfriend(accountId) {
await exec('friends.unfriend', codec.encodeTarget(accountId));
},
async friendCodeIssue() {
return codec.decodeFriendCode(await exec('friends.code.issue', codec.empty()));
},
async friendCodeRedeem(code) {
return codec.decodeRedeemResult(await exec('friends.code.redeem', codec.encodeRedeemCode(code)));
},
async blocksList() {
return codec.decodeBlockList(await exec('blocks.list', codec.empty()));
},
async block(accountId) {
await exec('blocks.add', codec.encodeTarget(accountId));
},
async unblock(accountId) {
await exec('blocks.remove', codec.encodeTarget(accountId));
},
async invitationsList() {
return codec.decodeInvitationList(await exec('invitation.list', codec.empty()));
},
async invitationCreate(inviteeIds, settings) {
return codec.decodeInvitation(await exec('invitation.create', codec.encodeCreateInvitation(inviteeIds, settings)));
},
async invitationAccept(invitationId) {
return codec.decodeInvitation(await exec('invitation.accept', codec.encodeInvitationAction(invitationId)));
},
async invitationDecline(invitationId) {
return codec.decodeInvitation(await exec('invitation.decline', codec.encodeInvitationAction(invitationId)));
},
async invitationCancel(invitationId) {
await exec('invitation.cancel', codec.encodeInvitationAction(invitationId));
},
async profileUpdate(p) {
return codec.decodeProfile(await exec('profile.update', codec.encodeUpdateProfile(p)));
},
async emailBindRequest(email) {
await exec('email.bind.request', codec.encodeEmailBind(email));
},
async emailBindConfirm(email, code) {
return codec.decodeProfile(await exec('email.bind.confirm', codec.encodeEmailConfirm(email, code)));
},
async statsGet() {
return codec.decodeStats(await exec('stats.get', codec.empty()));
},
async exportGcg(gameId) {
return codec.decodeGcg(await exec('game.gcg', codec.encodeGameAction(gameId)));
},
subscribe(onEvent, onError) {
const ctrl = new AbortController();
void (async () => {