Stage 8: UI social/account/history surfaces
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:
+131
-1
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user