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:
@@ -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: '',
|
||||
|
||||
Reference in New Issue
Block a user