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
+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: '',