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:
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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' };
|
||||
|
||||
@@ -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: {} };
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user