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
+8 -6
View File
@@ -7,8 +7,10 @@ platform webviews and packageable to native via Capacitor.
Stage 7 ships the **playable slice**: sign in (guest / email), the "my games" lobby,
auto-match, the board (place tiles by drag or tap, pass, exchange, resign), hint,
word-check + complaint, per-game chat and nudge, the live in-app stream, i18n (en/ru),
theme, and a read-only profile. Friends/blocks, friend-game invitations, profile
editing, the stats screen and the history/GCG viewer are Stage 8.
theme, and the profile. **Stage 8** adds friends/blocks (with one-time friend codes),
friend-game invitations, profile editing + email binding, the statistics screen, the
lobby notification badge, and the in-game history + GCG export (share or download,
finished games only).
## Scripts
@@ -60,10 +62,10 @@ runtime; the Telegram SDK itself is wired in the Telegram stage.
```
src/
lib/ model, client facade, transport (+ mock), codec, board replay,
placement state machine, premiums, i18n, theme, session, router, app store
components/ Header, Modal, Toast
screens/ Login, Lobby, NewGame, Profile, Settings, About
placement state machine, premiums, stats, share, i18n, theme, session, router, app store
components/ Header, Menu (+ badge), Modal, Toast, TabBar, Screen
screens/ Login, Lobby, NewGame, Profile, Settings, About, Friends, Stats
game/ Game, Board, Rack, Controls, MakeMove, Chat
gen/ committed edge codegen (FlatBuffers + Connect)
e2e/ Playwright smoke (mock)
e2e/ Playwright smoke + social specs (mock)
```
+1 -1
View File
@@ -64,7 +64,7 @@ test('check-word sanitises input and shows a verdict', async ({ page }) => {
test('dropping the game ends it and shows the result', async ({ page }) => {
await openGame(page);
await page.locator('.burger').click();
await page.locator('.dropdown button').nth(3).click(); // Drop game
await page.getByRole('button', { name: 'Drop game' }).click(); // robust against menu growth
await page.locator('button.danger').click(); // confirm in the modal
await expect(page.locator('.status .over')).toBeVisible();
});
+75
View File
@@ -0,0 +1,75 @@
import { expect, test, type Page } from '@playwright/test';
// Stage 8 social / account / history surfaces against the mock transport (no backend).
// The mock profile is a durable account, so friends, invitations, stats and the GCG
// export are reachable from the seeded fixture.
async function loginLobby(page: Page): Promise<void> {
await page.goto('/');
await page.getByRole('button', { name: /guest/i }).click();
await expect(page.getByText('Active games')).toBeVisible();
}
async function openFriends(page: Page): Promise<void> {
await page.locator('.burger').first().click();
await page.getByRole('button', { name: /Friends/ }).click();
}
test('friends: issue a code, accept an incoming request, redeem a code', async ({ page }) => {
await loginLobby(page);
await openFriends(page);
// Issue a one-time code — it is shown to share.
await page.getByRole('button', { name: /Show my code/i }).click();
await expect(page.getByTestId('friend-code')).toContainText('246813');
// The seeded incoming request (Rick) can be accepted; the requests section clears.
await expect(page.getByText('Friend requests')).toBeVisible();
await page.getByRole('button', { name: /^Accept$/ }).click();
await expect(page.getByText('Friend requests')).toBeHidden();
// Redeeming a code adds a new friend to the list.
await page.locator('.codein').fill('111111');
await page.getByRole('button', { name: /^Add$/ }).click();
await expect(page.locator('.who', { hasText: 'Friend 111111' })).toBeVisible();
});
test('invitations: the lobby shows an invitation and accepting clears it', async ({ page }) => {
await loginLobby(page);
await expect(page.getByText('Invitations')).toBeVisible();
await expect(page.getByText(/From Kaya/)).toBeVisible();
await page.getByRole('button', { name: /^Accept$/ }).click();
await expect(page.getByText(/From Kaya/)).toBeHidden();
});
test('stats screen shows the metrics', async ({ page }) => {
await loginLobby(page);
await page.getByRole('button', { name: /Stats/ }).click();
await expect(page.getByText('Win rate')).toBeVisible();
await expect(page.getByText('Best move')).toBeVisible();
});
test('profile edit saves a new display name', async ({ page }) => {
await loginLobby(page);
await page.locator('.burger').first().click();
await page.getByRole('button', { name: /Profile/ }).click();
await page.getByRole('button', { name: /Edit profile/ }).click();
await page.locator('.edit input').first().fill('Kaya Test');
await page.getByRole('button', { name: /^Save$/ }).click();
await expect(page.locator('.name')).toHaveText('Kaya Test');
});
test('GCG export appears only for a finished game', async ({ page }) => {
await loginLobby(page);
// The finished game vs Kaya exposes the export; the menu carries the item.
await page.getByRole('button', { name: /Kaya/ }).click();
await page.locator('.burger').first().click();
await expect(page.getByRole('button', { name: /Export GCG/ })).toBeVisible();
});
test('GCG export is hidden for an active game', async ({ page }) => {
await loginLobby(page);
await page.getByRole('button', { name: /Ann/ }).click();
await page.locator('.burger').first().click();
await expect(page.getByRole('button', { name: /Export GCG/ })).toHaveCount(0);
});
+2 -1
View File
@@ -1,6 +1,7 @@
// Bundle-size budget gate. Sums the gzipped size of the built app JS and fails if it
// exceeds the budget — a guard against an accidental heavy dependency. The real
// transport build is ~69 KB gzip today; the budget leaves headroom.
// transport build is ~82 KB gzip after the Stage 8 social/account/history surfaces;
// the budget leaves headroom.
import { readdirSync, readFileSync } from 'node:fs';
import { gzipSync } from 'node:zlib';
+6
View File
@@ -10,6 +10,8 @@
import Profile from './screens/Profile.svelte';
import Settings from './screens/Settings.svelte';
import About from './screens/About.svelte';
import Friends from './screens/Friends.svelte';
import Stats from './screens/Stats.svelte';
import Game from './game/Game.svelte';
onMount(() => {
@@ -31,6 +33,10 @@
<Settings />
{:else if router.route.name === 'about'}
<About />
{:else if router.route.name === 'friends'}
<Friends />
{:else if router.route.name === 'stats'}
<Stats />
{:else}
<Lobby />
{/if}
+46 -3
View File
@@ -1,6 +1,13 @@
<script lang="ts">
// The header hamburger + dropdown, shared by the lobby and game screens.
let { items }: { items: { label: string; onclick: () => void }[] } = $props();
// The header hamburger + dropdown, shared by the lobby and game screens. An item
// may carry a numeric badge; the hamburger shows the total via the `badge` prop so
// a pending count is visible while the menu is closed.
interface MenuItem {
label: string;
onclick: () => void;
badge?: number;
}
let { items, badge = 0 }: { items: MenuItem[]; badge?: number } = $props();
let open = $state(false);
function pick(fn: () => void) {
@@ -12,6 +19,7 @@
<div class="menu">
<button class="burger" onclick={() => (open = !open)} aria-label="Menu">
<span></span><span></span><span></span>
{#if badge > 0}<span class="dot" data-testid="menu-badge">{badge}</span>{/if}
</button>
{#if open}
<!-- svelte-ignore a11y_click_events_have_key_events -->
@@ -19,7 +27,10 @@
<div class="backdrop" onclick={() => (open = false)}></div>
<div class="dropdown">
{#each items as it (it.label)}
<button onclick={() => pick(it.onclick)}>{it.label}</button>
<button onclick={() => pick(it.onclick)}>
<span>{it.label}</span>
{#if it.badge}<span class="idot">{it.badge}</span>{/if}
</button>
{/each}
</div>
{/if}
@@ -31,6 +42,7 @@
display: inline-flex;
}
.burger {
position: relative;
background: none;
border: none;
width: 44px;
@@ -43,6 +55,33 @@
user-select: none;
-webkit-user-select: none;
}
.dot {
position: absolute;
top: -2px;
right: 0;
min-width: 18px;
height: 18px;
padding: 0 4px;
border-radius: 999px;
background: var(--danger, #c0392b);
color: #fff;
font-size: 0.72rem;
line-height: 18px;
text-align: center;
font-weight: 700;
}
.idot {
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 999px;
background: var(--danger, #c0392b);
color: #fff;
font-size: 0.72rem;
line-height: 18px;
text-align: center;
font-weight: 700;
}
.burger span {
display: block;
height: 3px;
@@ -69,6 +108,10 @@
overflow: hidden;
}
.dropdown button {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 12px 16px;
text-align: left;
background: none;
+32 -1
View File
@@ -16,6 +16,7 @@
import { replay } from '../lib/board';
import { alphabet, centre, premiumGrid } from '../lib/premiums';
import { canCheckWord, sanitizeCheckWord } from '../lib/checkword';
import { shareOrDownloadGcg } from '../lib/share';
import {
BLANK,
newPlacement,
@@ -369,10 +370,35 @@
return view.game.seats.some((s) => s.isWinner) ? t('game.lost') : t('game.tied');
}
async function exportGcg() {
try {
await shareOrDownloadGcg(await gateway.exportGcg(id));
} catch (e) {
handleError(e);
}
}
async function addFriend(accountId: string) {
try {
await gateway.friendRequest(accountId);
showToast(t('friends.requestSent'));
} catch (e) {
handleError(e);
}
}
const opponents = $derived(
view ? view.game.seats.filter((s) => s.accountId !== app.session?.userId) : [],
);
const menuItems = $derived([
{ label: t('game.history'), onclick: () => (historyOpen = true) },
{ label: t('game.chat'), onclick: openChat },
{ label: t('game.checkWord'), onclick: openCheck },
...(view?.game.status === 'finished' ? [{ label: t('game.exportGcg'), onclick: exportGcg }] : []),
...(!app.profile?.isGuest
? opponents.map((s) => ({ label: `${t('friends.addFromGame')}: ${s.displayName}`, onclick: () => addFriend(s.accountId) }))
: []),
{ label: t('game.dropGame'), onclick: () => (resignOpen = true) },
]);
</script>
@@ -400,7 +426,7 @@
<li>
<span class="hp">{view.game.seats[m.player]?.displayName ?? m.player}</span>
<span class="ha">{m.action === 'play' ? m.words.join(', ') : m.action}</span>
<span class="hs">{m.score}</span>
<span class="hs">{m.score} <span class="ht">({m.total})</span></span>
</li>
{/each}
{#if moves.length === 0}<li class="hempty"></li>{/if}
@@ -628,6 +654,11 @@
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.ht {
color: var(--text-muted);
font-weight: 400;
font-size: 0.85em;
}
.hempty {
justify-content: center;
color: var(--text-muted);
+20
View File
@@ -1,36 +1,56 @@
// automatically generated by the FlatBuffers compiler, do not modify
export { AccountRef } from './scrabblefb/account-ref.js';
export { Ack } from './scrabblefb/ack.js';
export { BlockList } from './scrabblefb/block-list.js';
export { ChatList } from './scrabblefb/chat-list.js';
export { ChatMessage } from './scrabblefb/chat-message.js';
export { ChatPostRequest } from './scrabblefb/chat-post-request.js';
export { CheckWordRequest } from './scrabblefb/check-word-request.js';
export { ComplaintRequest } from './scrabblefb/complaint-request.js';
export { CreateInvitationRequest } from './scrabblefb/create-invitation-request.js';
export { EmailBindRequest } from './scrabblefb/email-bind-request.js';
export { EmailConfirmRequest } from './scrabblefb/email-confirm-request.js';
export { EmailLoginRequest } from './scrabblefb/email-login-request.js';
export { EmailRequestRequest } from './scrabblefb/email-request-request.js';
export { EnqueueRequest } from './scrabblefb/enqueue-request.js';
export { EvalRequest } from './scrabblefb/eval-request.js';
export { EvalResult } from './scrabblefb/eval-result.js';
export { ExchangeRequest } from './scrabblefb/exchange-request.js';
export { FriendCode } from './scrabblefb/friend-code.js';
export { FriendList } from './scrabblefb/friend-list.js';
export { FriendRespondRequest } from './scrabblefb/friend-respond-request.js';
export { GameActionRequest } from './scrabblefb/game-action-request.js';
export { GameList } from './scrabblefb/game-list.js';
export { GameView } from './scrabblefb/game-view.js';
export { GcgExport } from './scrabblefb/gcg-export.js';
export { GuestLoginRequest } from './scrabblefb/guest-login-request.js';
export { HintResult } from './scrabblefb/hint-result.js';
export { History } from './scrabblefb/history.js';
export { IncomingRequestList } from './scrabblefb/incoming-request-list.js';
export { Invitation } from './scrabblefb/invitation.js';
export { InvitationActionRequest } from './scrabblefb/invitation-action-request.js';
export { InvitationInvitee } from './scrabblefb/invitation-invitee.js';
export { InvitationList } from './scrabblefb/invitation-list.js';
export { MatchFoundEvent } from './scrabblefb/match-found-event.js';
export { MatchResult } from './scrabblefb/match-result.js';
export { MoveRecord } from './scrabblefb/move-record.js';
export { MoveResult } from './scrabblefb/move-result.js';
export { NotificationEvent } from './scrabblefb/notification-event.js';
export { NudgeEvent } from './scrabblefb/nudge-event.js';
export { OpponentMovedEvent } from './scrabblefb/opponent-moved-event.js';
export { Profile } from './scrabblefb/profile.js';
export { RedeemCodeRequest } from './scrabblefb/redeem-code-request.js';
export { RedeemResult } from './scrabblefb/redeem-result.js';
export { SeatView } from './scrabblefb/seat-view.js';
export { Session } from './scrabblefb/session.js';
export { StateRequest } from './scrabblefb/state-request.js';
export { StateView } from './scrabblefb/state-view.js';
export { StatsView } from './scrabblefb/stats-view.js';
export { SubmitPlayRequest } from './scrabblefb/submit-play-request.js';
export { TargetRequest } from './scrabblefb/target-request.js';
export { TelegramLoginRequest } from './scrabblefb/telegram-login-request.js';
export { TileRecord } from './scrabblefb/tile-record.js';
export { UpdateProfileRequest } from './scrabblefb/update-profile-request.js';
export { WordCheckResult } from './scrabblefb/word-check-result.js';
export { YourTurnEvent } from './scrabblefb/your-turn-event.js';
+60
View File
@@ -0,0 +1,60 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class AccountRef {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):AccountRef {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsAccountRef(bb:flatbuffers.ByteBuffer, obj?:AccountRef):AccountRef {
return (obj || new AccountRef()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsAccountRef(bb:flatbuffers.ByteBuffer, obj?:AccountRef):AccountRef {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new AccountRef()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
accountId():string|null
accountId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
accountId(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
displayName():string|null
displayName(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
displayName(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startAccountRef(builder:flatbuffers.Builder) {
builder.startObject(2);
}
static addAccountId(builder:flatbuffers.Builder, accountIdOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, accountIdOffset, 0);
}
static addDisplayName(builder:flatbuffers.Builder, displayNameOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, displayNameOffset, 0);
}
static endAccountRef(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createAccountRef(builder:flatbuffers.Builder, accountIdOffset:flatbuffers.Offset, displayNameOffset:flatbuffers.Offset):flatbuffers.Offset {
AccountRef.startAccountRef(builder);
AccountRef.addAccountId(builder, accountIdOffset);
AccountRef.addDisplayName(builder, displayNameOffset);
return AccountRef.endAccountRef(builder);
}
}
+66
View File
@@ -0,0 +1,66 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
import { AccountRef } from '../scrabblefb/account-ref.js';
export class BlockList {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):BlockList {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsBlockList(bb:flatbuffers.ByteBuffer, obj?:BlockList):BlockList {
return (obj || new BlockList()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsBlockList(bb:flatbuffers.ByteBuffer, obj?:BlockList):BlockList {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new BlockList()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
blocked(index: number, obj?:AccountRef):AccountRef|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? (obj || new AccountRef()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
}
blockedLength():number {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
}
static startBlockList(builder:flatbuffers.Builder) {
builder.startObject(1);
}
static addBlocked(builder:flatbuffers.Builder, blockedOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, blockedOffset, 0);
}
static createBlockedVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
builder.startVector(4, data.length, 4);
for (let i = data.length - 1; i >= 0; i--) {
builder.addOffset(data[i]!);
}
return builder.endVector();
}
static startBlockedVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(4, numElems, 4);
}
static endBlockList(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createBlockList(builder:flatbuffers.Builder, blockedOffset:flatbuffers.Offset):flatbuffers.Offset {
BlockList.startBlockList(builder);
BlockList.addBlocked(builder, blockedOffset);
return BlockList.endBlockList(builder);
}
}
@@ -0,0 +1,119 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class CreateInvitationRequest {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):CreateInvitationRequest {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsCreateInvitationRequest(bb:flatbuffers.ByteBuffer, obj?:CreateInvitationRequest):CreateInvitationRequest {
return (obj || new CreateInvitationRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsCreateInvitationRequest(bb:flatbuffers.ByteBuffer, obj?:CreateInvitationRequest):CreateInvitationRequest {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new CreateInvitationRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
inviteeIds(index: number):string
inviteeIds(index: number,optionalEncoding:flatbuffers.Encoding):string|Uint8Array
inviteeIds(index: number,optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb!.__vector(this.bb_pos + offset) + index * 4, optionalEncoding) : null;
}
inviteeIdsLength():number {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
}
variant():string|null
variant(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
variant(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
turnTimeoutSecs():number {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
hintsAllowed():boolean {
const offset = this.bb!.__offset(this.bb_pos, 10);
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
}
hintsPerPlayer():number {
const offset = this.bb!.__offset(this.bb_pos, 12);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
dropoutTiles():string|null
dropoutTiles(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
dropoutTiles(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 14);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startCreateInvitationRequest(builder:flatbuffers.Builder) {
builder.startObject(6);
}
static addInviteeIds(builder:flatbuffers.Builder, inviteeIdsOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, inviteeIdsOffset, 0);
}
static createInviteeIdsVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
builder.startVector(4, data.length, 4);
for (let i = data.length - 1; i >= 0; i--) {
builder.addOffset(data[i]!);
}
return builder.endVector();
}
static startInviteeIdsVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(4, numElems, 4);
}
static addVariant(builder:flatbuffers.Builder, variantOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, variantOffset, 0);
}
static addTurnTimeoutSecs(builder:flatbuffers.Builder, turnTimeoutSecs:number) {
builder.addFieldInt32(2, turnTimeoutSecs, 0);
}
static addHintsAllowed(builder:flatbuffers.Builder, hintsAllowed:boolean) {
builder.addFieldInt8(3, +hintsAllowed, +false);
}
static addHintsPerPlayer(builder:flatbuffers.Builder, hintsPerPlayer:number) {
builder.addFieldInt32(4, hintsPerPlayer, 0);
}
static addDropoutTiles(builder:flatbuffers.Builder, dropoutTilesOffset:flatbuffers.Offset) {
builder.addFieldOffset(5, dropoutTilesOffset, 0);
}
static endCreateInvitationRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createCreateInvitationRequest(builder:flatbuffers.Builder, inviteeIdsOffset:flatbuffers.Offset, variantOffset:flatbuffers.Offset, turnTimeoutSecs:number, hintsAllowed:boolean, hintsPerPlayer:number, dropoutTilesOffset:flatbuffers.Offset):flatbuffers.Offset {
CreateInvitationRequest.startCreateInvitationRequest(builder);
CreateInvitationRequest.addInviteeIds(builder, inviteeIdsOffset);
CreateInvitationRequest.addVariant(builder, variantOffset);
CreateInvitationRequest.addTurnTimeoutSecs(builder, turnTimeoutSecs);
CreateInvitationRequest.addHintsAllowed(builder, hintsAllowed);
CreateInvitationRequest.addHintsPerPlayer(builder, hintsPerPlayer);
CreateInvitationRequest.addDropoutTiles(builder, dropoutTilesOffset);
return CreateInvitationRequest.endCreateInvitationRequest(builder);
}
}
@@ -0,0 +1,48 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class EmailBindRequest {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):EmailBindRequest {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsEmailBindRequest(bb:flatbuffers.ByteBuffer, obj?:EmailBindRequest):EmailBindRequest {
return (obj || new EmailBindRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsEmailBindRequest(bb:flatbuffers.ByteBuffer, obj?:EmailBindRequest):EmailBindRequest {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new EmailBindRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
email():string|null
email(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
email(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startEmailBindRequest(builder:flatbuffers.Builder) {
builder.startObject(1);
}
static addEmail(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, emailOffset, 0);
}
static endEmailBindRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createEmailBindRequest(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset):flatbuffers.Offset {
EmailBindRequest.startEmailBindRequest(builder);
EmailBindRequest.addEmail(builder, emailOffset);
return EmailBindRequest.endEmailBindRequest(builder);
}
}
@@ -0,0 +1,60 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class EmailConfirmRequest {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):EmailConfirmRequest {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsEmailConfirmRequest(bb:flatbuffers.ByteBuffer, obj?:EmailConfirmRequest):EmailConfirmRequest {
return (obj || new EmailConfirmRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsEmailConfirmRequest(bb:flatbuffers.ByteBuffer, obj?:EmailConfirmRequest):EmailConfirmRequest {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new EmailConfirmRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
email():string|null
email(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
email(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
code():string|null
code(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
code(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startEmailConfirmRequest(builder:flatbuffers.Builder) {
builder.startObject(2);
}
static addEmail(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, emailOffset, 0);
}
static addCode(builder:flatbuffers.Builder, codeOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, codeOffset, 0);
}
static endEmailConfirmRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createEmailConfirmRequest(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset, codeOffset:flatbuffers.Offset):flatbuffers.Offset {
EmailConfirmRequest.startEmailConfirmRequest(builder);
EmailConfirmRequest.addEmail(builder, emailOffset);
EmailConfirmRequest.addCode(builder, codeOffset);
return EmailConfirmRequest.endEmailConfirmRequest(builder);
}
}
+58
View File
@@ -0,0 +1,58 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class FriendCode {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):FriendCode {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsFriendCode(bb:flatbuffers.ByteBuffer, obj?:FriendCode):FriendCode {
return (obj || new FriendCode()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsFriendCode(bb:flatbuffers.ByteBuffer, obj?:FriendCode):FriendCode {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new FriendCode()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
code():string|null
code(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
code(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
expiresAtUnix():bigint {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.readInt64(this.bb_pos + offset) : BigInt('0');
}
static startFriendCode(builder:flatbuffers.Builder) {
builder.startObject(2);
}
static addCode(builder:flatbuffers.Builder, codeOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, codeOffset, 0);
}
static addExpiresAtUnix(builder:flatbuffers.Builder, expiresAtUnix:bigint) {
builder.addFieldInt64(1, expiresAtUnix, BigInt('0'));
}
static endFriendCode(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createFriendCode(builder:flatbuffers.Builder, codeOffset:flatbuffers.Offset, expiresAtUnix:bigint):flatbuffers.Offset {
FriendCode.startFriendCode(builder);
FriendCode.addCode(builder, codeOffset);
FriendCode.addExpiresAtUnix(builder, expiresAtUnix);
return FriendCode.endFriendCode(builder);
}
}
+66
View File
@@ -0,0 +1,66 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
import { AccountRef } from '../scrabblefb/account-ref.js';
export class FriendList {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):FriendList {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsFriendList(bb:flatbuffers.ByteBuffer, obj?:FriendList):FriendList {
return (obj || new FriendList()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsFriendList(bb:flatbuffers.ByteBuffer, obj?:FriendList):FriendList {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new FriendList()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
friends(index: number, obj?:AccountRef):AccountRef|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? (obj || new AccountRef()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
}
friendsLength():number {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
}
static startFriendList(builder:flatbuffers.Builder) {
builder.startObject(1);
}
static addFriends(builder:flatbuffers.Builder, friendsOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, friendsOffset, 0);
}
static createFriendsVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
builder.startVector(4, data.length, 4);
for (let i = data.length - 1; i >= 0; i--) {
builder.addOffset(data[i]!);
}
return builder.endVector();
}
static startFriendsVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(4, numElems, 4);
}
static endFriendList(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createFriendList(builder:flatbuffers.Builder, friendsOffset:flatbuffers.Offset):flatbuffers.Offset {
FriendList.startFriendList(builder);
FriendList.addFriends(builder, friendsOffset);
return FriendList.endFriendList(builder);
}
}
@@ -0,0 +1,58 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class FriendRespondRequest {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):FriendRespondRequest {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsFriendRespondRequest(bb:flatbuffers.ByteBuffer, obj?:FriendRespondRequest):FriendRespondRequest {
return (obj || new FriendRespondRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsFriendRespondRequest(bb:flatbuffers.ByteBuffer, obj?:FriendRespondRequest):FriendRespondRequest {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new FriendRespondRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
requesterId():string|null
requesterId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
requesterId(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
accept():boolean {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
}
static startFriendRespondRequest(builder:flatbuffers.Builder) {
builder.startObject(2);
}
static addRequesterId(builder:flatbuffers.Builder, requesterIdOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, requesterIdOffset, 0);
}
static addAccept(builder:flatbuffers.Builder, accept:boolean) {
builder.addFieldInt8(1, +accept, +false);
}
static endFriendRespondRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createFriendRespondRequest(builder:flatbuffers.Builder, requesterIdOffset:flatbuffers.Offset, accept:boolean):flatbuffers.Offset {
FriendRespondRequest.startFriendRespondRequest(builder);
FriendRespondRequest.addRequesterId(builder, requesterIdOffset);
FriendRespondRequest.addAccept(builder, accept);
return FriendRespondRequest.endFriendRespondRequest(builder);
}
}
+72
View File
@@ -0,0 +1,72 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class GcgExport {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):GcgExport {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsGcgExport(bb:flatbuffers.ByteBuffer, obj?:GcgExport):GcgExport {
return (obj || new GcgExport()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsGcgExport(bb:flatbuffers.ByteBuffer, obj?:GcgExport):GcgExport {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new GcgExport()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
gameId():string|null
gameId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
gameId(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
filename():string|null
filename(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
filename(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
content():string|null
content(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
content(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startGcgExport(builder:flatbuffers.Builder) {
builder.startObject(3);
}
static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, gameIdOffset, 0);
}
static addFilename(builder:flatbuffers.Builder, filenameOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, filenameOffset, 0);
}
static addContent(builder:flatbuffers.Builder, contentOffset:flatbuffers.Offset) {
builder.addFieldOffset(2, contentOffset, 0);
}
static endGcgExport(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createGcgExport(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, filenameOffset:flatbuffers.Offset, contentOffset:flatbuffers.Offset):flatbuffers.Offset {
GcgExport.startGcgExport(builder);
GcgExport.addGameId(builder, gameIdOffset);
GcgExport.addFilename(builder, filenameOffset);
GcgExport.addContent(builder, contentOffset);
return GcgExport.endGcgExport(builder);
}
}
@@ -0,0 +1,66 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
import { AccountRef } from '../scrabblefb/account-ref.js';
export class IncomingRequestList {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):IncomingRequestList {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsIncomingRequestList(bb:flatbuffers.ByteBuffer, obj?:IncomingRequestList):IncomingRequestList {
return (obj || new IncomingRequestList()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsIncomingRequestList(bb:flatbuffers.ByteBuffer, obj?:IncomingRequestList):IncomingRequestList {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new IncomingRequestList()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
requests(index: number, obj?:AccountRef):AccountRef|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? (obj || new AccountRef()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
}
requestsLength():number {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
}
static startIncomingRequestList(builder:flatbuffers.Builder) {
builder.startObject(1);
}
static addRequests(builder:flatbuffers.Builder, requestsOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, requestsOffset, 0);
}
static createRequestsVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
builder.startVector(4, data.length, 4);
for (let i = data.length - 1; i >= 0; i--) {
builder.addOffset(data[i]!);
}
return builder.endVector();
}
static startRequestsVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(4, numElems, 4);
}
static endIncomingRequestList(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createIncomingRequestList(builder:flatbuffers.Builder, requestsOffset:flatbuffers.Offset):flatbuffers.Offset {
IncomingRequestList.startIncomingRequestList(builder);
IncomingRequestList.addRequests(builder, requestsOffset);
return IncomingRequestList.endIncomingRequestList(builder);
}
}
@@ -0,0 +1,48 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class InvitationActionRequest {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):InvitationActionRequest {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsInvitationActionRequest(bb:flatbuffers.ByteBuffer, obj?:InvitationActionRequest):InvitationActionRequest {
return (obj || new InvitationActionRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsInvitationActionRequest(bb:flatbuffers.ByteBuffer, obj?:InvitationActionRequest):InvitationActionRequest {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new InvitationActionRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
invitationId():string|null
invitationId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
invitationId(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startInvitationActionRequest(builder:flatbuffers.Builder) {
builder.startObject(1);
}
static addInvitationId(builder:flatbuffers.Builder, invitationIdOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, invitationIdOffset, 0);
}
static endInvitationActionRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createInvitationActionRequest(builder:flatbuffers.Builder, invitationIdOffset:flatbuffers.Offset):flatbuffers.Offset {
InvitationActionRequest.startInvitationActionRequest(builder);
InvitationActionRequest.addInvitationId(builder, invitationIdOffset);
return InvitationActionRequest.endInvitationActionRequest(builder);
}
}
@@ -0,0 +1,82 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class InvitationInvitee {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):InvitationInvitee {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsInvitationInvitee(bb:flatbuffers.ByteBuffer, obj?:InvitationInvitee):InvitationInvitee {
return (obj || new InvitationInvitee()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsInvitationInvitee(bb:flatbuffers.ByteBuffer, obj?:InvitationInvitee):InvitationInvitee {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new InvitationInvitee()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
accountId():string|null
accountId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
accountId(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
displayName():string|null
displayName(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
displayName(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
seat():number {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
response():string|null
response(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
response(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 10);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startInvitationInvitee(builder:flatbuffers.Builder) {
builder.startObject(4);
}
static addAccountId(builder:flatbuffers.Builder, accountIdOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, accountIdOffset, 0);
}
static addDisplayName(builder:flatbuffers.Builder, displayNameOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, displayNameOffset, 0);
}
static addSeat(builder:flatbuffers.Builder, seat:number) {
builder.addFieldInt32(2, seat, 0);
}
static addResponse(builder:flatbuffers.Builder, responseOffset:flatbuffers.Offset) {
builder.addFieldOffset(3, responseOffset, 0);
}
static endInvitationInvitee(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createInvitationInvitee(builder:flatbuffers.Builder, accountIdOffset:flatbuffers.Offset, displayNameOffset:flatbuffers.Offset, seat:number, responseOffset:flatbuffers.Offset):flatbuffers.Offset {
InvitationInvitee.startInvitationInvitee(builder);
InvitationInvitee.addAccountId(builder, accountIdOffset);
InvitationInvitee.addDisplayName(builder, displayNameOffset);
InvitationInvitee.addSeat(builder, seat);
InvitationInvitee.addResponse(builder, responseOffset);
return InvitationInvitee.endInvitationInvitee(builder);
}
}
@@ -0,0 +1,66 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
import { Invitation } from '../scrabblefb/invitation.js';
export class InvitationList {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):InvitationList {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsInvitationList(bb:flatbuffers.ByteBuffer, obj?:InvitationList):InvitationList {
return (obj || new InvitationList()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsInvitationList(bb:flatbuffers.ByteBuffer, obj?:InvitationList):InvitationList {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new InvitationList()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
invitations(index: number, obj?:Invitation):Invitation|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? (obj || new Invitation()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
}
invitationsLength():number {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
}
static startInvitationList(builder:flatbuffers.Builder) {
builder.startObject(1);
}
static addInvitations(builder:flatbuffers.Builder, invitationsOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, invitationsOffset, 0);
}
static createInvitationsVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
builder.startVector(4, data.length, 4);
for (let i = data.length - 1; i >= 0; i--) {
builder.addOffset(data[i]!);
}
return builder.endVector();
}
static startInvitationsVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(4, numElems, 4);
}
static endInvitationList(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createInvitationList(builder:flatbuffers.Builder, invitationsOffset:flatbuffers.Offset):flatbuffers.Offset {
InvitationList.startInvitationList(builder);
InvitationList.addInvitations(builder, invitationsOffset);
return InvitationList.endInvitationList(builder);
}
}
+162
View File
@@ -0,0 +1,162 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
import { AccountRef } from '../scrabblefb/account-ref.js';
import { InvitationInvitee } from '../scrabblefb/invitation-invitee.js';
export class Invitation {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):Invitation {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsInvitation(bb:flatbuffers.ByteBuffer, obj?:Invitation):Invitation {
return (obj || new Invitation()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsInvitation(bb:flatbuffers.ByteBuffer, obj?:Invitation):Invitation {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new Invitation()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
id():string|null
id(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
id(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
inviter(obj?:AccountRef):AccountRef|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? (obj || new AccountRef()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null;
}
invitees(index: number, obj?:InvitationInvitee):InvitationInvitee|null {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? (obj || new InvitationInvitee()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
}
inviteesLength():number {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
}
variant():string|null
variant(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
variant(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 10);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
turnTimeoutSecs():number {
const offset = this.bb!.__offset(this.bb_pos, 12);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
hintsAllowed():boolean {
const offset = this.bb!.__offset(this.bb_pos, 14);
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
}
hintsPerPlayer():number {
const offset = this.bb!.__offset(this.bb_pos, 16);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
dropoutTiles():string|null
dropoutTiles(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
dropoutTiles(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 18);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
status():string|null
status(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
status(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 20);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
gameId():string|null
gameId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
gameId(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 22);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
expiresAtUnix():bigint {
const offset = this.bb!.__offset(this.bb_pos, 24);
return offset ? this.bb!.readInt64(this.bb_pos + offset) : BigInt('0');
}
static startInvitation(builder:flatbuffers.Builder) {
builder.startObject(11);
}
static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, idOffset, 0);
}
static addInviter(builder:flatbuffers.Builder, inviterOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, inviterOffset, 0);
}
static addInvitees(builder:flatbuffers.Builder, inviteesOffset:flatbuffers.Offset) {
builder.addFieldOffset(2, inviteesOffset, 0);
}
static createInviteesVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
builder.startVector(4, data.length, 4);
for (let i = data.length - 1; i >= 0; i--) {
builder.addOffset(data[i]!);
}
return builder.endVector();
}
static startInviteesVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(4, numElems, 4);
}
static addVariant(builder:flatbuffers.Builder, variantOffset:flatbuffers.Offset) {
builder.addFieldOffset(3, variantOffset, 0);
}
static addTurnTimeoutSecs(builder:flatbuffers.Builder, turnTimeoutSecs:number) {
builder.addFieldInt32(4, turnTimeoutSecs, 0);
}
static addHintsAllowed(builder:flatbuffers.Builder, hintsAllowed:boolean) {
builder.addFieldInt8(5, +hintsAllowed, +false);
}
static addHintsPerPlayer(builder:flatbuffers.Builder, hintsPerPlayer:number) {
builder.addFieldInt32(6, hintsPerPlayer, 0);
}
static addDropoutTiles(builder:flatbuffers.Builder, dropoutTilesOffset:flatbuffers.Offset) {
builder.addFieldOffset(7, dropoutTilesOffset, 0);
}
static addStatus(builder:flatbuffers.Builder, statusOffset:flatbuffers.Offset) {
builder.addFieldOffset(8, statusOffset, 0);
}
static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) {
builder.addFieldOffset(9, gameIdOffset, 0);
}
static addExpiresAtUnix(builder:flatbuffers.Builder, expiresAtUnix:bigint) {
builder.addFieldInt64(10, expiresAtUnix, BigInt('0'));
}
static endInvitation(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
}
@@ -0,0 +1,48 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class NotificationEvent {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):NotificationEvent {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsNotificationEvent(bb:flatbuffers.ByteBuffer, obj?:NotificationEvent):NotificationEvent {
return (obj || new NotificationEvent()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsNotificationEvent(bb:flatbuffers.ByteBuffer, obj?:NotificationEvent):NotificationEvent {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new NotificationEvent()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
kind():string|null
kind(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
kind(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startNotificationEvent(builder:flatbuffers.Builder) {
builder.startObject(1);
}
static addKind(builder:flatbuffers.Builder, kindOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, kindOffset, 0);
}
static endNotificationEvent(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createNotificationEvent(builder:flatbuffers.Builder, kindOffset:flatbuffers.Offset):flatbuffers.Offset {
NotificationEvent.startNotificationEvent(builder);
NotificationEvent.addKind(builder, kindOffset);
return NotificationEvent.endNotificationEvent(builder);
}
}
+26 -2
View File
@@ -68,8 +68,22 @@ isGuest():boolean {
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
}
awayStart():string|null
awayStart(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
awayStart(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 20);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
awayEnd():string|null
awayEnd(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
awayEnd(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 22);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startProfile(builder:flatbuffers.Builder) {
builder.startObject(8);
builder.startObject(10);
}
static addUserId(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset) {
@@ -104,12 +118,20 @@ static addIsGuest(builder:flatbuffers.Builder, isGuest:boolean) {
builder.addFieldInt8(7, +isGuest, +false);
}
static addAwayStart(builder:flatbuffers.Builder, awayStartOffset:flatbuffers.Offset) {
builder.addFieldOffset(8, awayStartOffset, 0);
}
static addAwayEnd(builder:flatbuffers.Builder, awayEndOffset:flatbuffers.Offset) {
builder.addFieldOffset(9, awayEndOffset, 0);
}
static endProfile(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createProfile(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset, displayNameOffset:flatbuffers.Offset, preferredLanguageOffset:flatbuffers.Offset, timeZoneOffset:flatbuffers.Offset, hintBalance:number, blockChat:boolean, blockFriendRequests:boolean, isGuest:boolean):flatbuffers.Offset {
static createProfile(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset, displayNameOffset:flatbuffers.Offset, preferredLanguageOffset:flatbuffers.Offset, timeZoneOffset:flatbuffers.Offset, hintBalance:number, blockChat:boolean, blockFriendRequests:boolean, isGuest:boolean, awayStartOffset:flatbuffers.Offset, awayEndOffset:flatbuffers.Offset):flatbuffers.Offset {
Profile.startProfile(builder);
Profile.addUserId(builder, userIdOffset);
Profile.addDisplayName(builder, displayNameOffset);
@@ -119,6 +141,8 @@ static createProfile(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offse
Profile.addBlockChat(builder, blockChat);
Profile.addBlockFriendRequests(builder, blockFriendRequests);
Profile.addIsGuest(builder, isGuest);
Profile.addAwayStart(builder, awayStartOffset);
Profile.addAwayEnd(builder, awayEndOffset);
return Profile.endProfile(builder);
}
}
@@ -0,0 +1,48 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class RedeemCodeRequest {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):RedeemCodeRequest {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsRedeemCodeRequest(bb:flatbuffers.ByteBuffer, obj?:RedeemCodeRequest):RedeemCodeRequest {
return (obj || new RedeemCodeRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsRedeemCodeRequest(bb:flatbuffers.ByteBuffer, obj?:RedeemCodeRequest):RedeemCodeRequest {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new RedeemCodeRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
code():string|null
code(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
code(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startRedeemCodeRequest(builder:flatbuffers.Builder) {
builder.startObject(1);
}
static addCode(builder:flatbuffers.Builder, codeOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, codeOffset, 0);
}
static endRedeemCodeRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createRedeemCodeRequest(builder:flatbuffers.Builder, codeOffset:flatbuffers.Offset):flatbuffers.Offset {
RedeemCodeRequest.startRedeemCodeRequest(builder);
RedeemCodeRequest.addCode(builder, codeOffset);
return RedeemCodeRequest.endRedeemCodeRequest(builder);
}
}
@@ -0,0 +1,49 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
import { AccountRef } from '../scrabblefb/account-ref.js';
export class RedeemResult {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):RedeemResult {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsRedeemResult(bb:flatbuffers.ByteBuffer, obj?:RedeemResult):RedeemResult {
return (obj || new RedeemResult()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsRedeemResult(bb:flatbuffers.ByteBuffer, obj?:RedeemResult):RedeemResult {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new RedeemResult()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
friend(obj?:AccountRef):AccountRef|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? (obj || new AccountRef()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null;
}
static startRedeemResult(builder:flatbuffers.Builder) {
builder.startObject(1);
}
static addFriend(builder:flatbuffers.Builder, friendOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, friendOffset, 0);
}
static endRedeemResult(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createRedeemResult(builder:flatbuffers.Builder, friendOffset:flatbuffers.Offset):flatbuffers.Offset {
RedeemResult.startRedeemResult(builder);
RedeemResult.addFriend(builder, friendOffset);
return RedeemResult.endRedeemResult(builder);
}
}
+86
View File
@@ -0,0 +1,86 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class StatsView {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):StatsView {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsStatsView(bb:flatbuffers.ByteBuffer, obj?:StatsView):StatsView {
return (obj || new StatsView()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsStatsView(bb:flatbuffers.ByteBuffer, obj?:StatsView):StatsView {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new StatsView()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
wins():number {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
losses():number {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
draws():number {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
maxGamePoints():number {
const offset = this.bb!.__offset(this.bb_pos, 10);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
maxWordPoints():number {
const offset = this.bb!.__offset(this.bb_pos, 12);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
static startStatsView(builder:flatbuffers.Builder) {
builder.startObject(5);
}
static addWins(builder:flatbuffers.Builder, wins:number) {
builder.addFieldInt32(0, wins, 0);
}
static addLosses(builder:flatbuffers.Builder, losses:number) {
builder.addFieldInt32(1, losses, 0);
}
static addDraws(builder:flatbuffers.Builder, draws:number) {
builder.addFieldInt32(2, draws, 0);
}
static addMaxGamePoints(builder:flatbuffers.Builder, maxGamePoints:number) {
builder.addFieldInt32(3, maxGamePoints, 0);
}
static addMaxWordPoints(builder:flatbuffers.Builder, maxWordPoints:number) {
builder.addFieldInt32(4, maxWordPoints, 0);
}
static endStatsView(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createStatsView(builder:flatbuffers.Builder, wins:number, losses:number, draws:number, maxGamePoints:number, maxWordPoints:number):flatbuffers.Offset {
StatsView.startStatsView(builder);
StatsView.addWins(builder, wins);
StatsView.addLosses(builder, losses);
StatsView.addDraws(builder, draws);
StatsView.addMaxGamePoints(builder, maxGamePoints);
StatsView.addMaxWordPoints(builder, maxWordPoints);
return StatsView.endStatsView(builder);
}
}
@@ -0,0 +1,48 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class TargetRequest {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):TargetRequest {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsTargetRequest(bb:flatbuffers.ByteBuffer, obj?:TargetRequest):TargetRequest {
return (obj || new TargetRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsTargetRequest(bb:flatbuffers.ByteBuffer, obj?:TargetRequest):TargetRequest {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new TargetRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
accountId():string|null
accountId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
accountId(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startTargetRequest(builder:flatbuffers.Builder) {
builder.startObject(1);
}
static addAccountId(builder:flatbuffers.Builder, accountIdOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, accountIdOffset, 0);
}
static endTargetRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createTargetRequest(builder:flatbuffers.Builder, accountIdOffset:flatbuffers.Offset):flatbuffers.Offset {
TargetRequest.startTargetRequest(builder);
TargetRequest.addAccountId(builder, accountIdOffset);
return TargetRequest.endTargetRequest(builder);
}
}
@@ -0,0 +1,116 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class UpdateProfileRequest {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):UpdateProfileRequest {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsUpdateProfileRequest(bb:flatbuffers.ByteBuffer, obj?:UpdateProfileRequest):UpdateProfileRequest {
return (obj || new UpdateProfileRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsUpdateProfileRequest(bb:flatbuffers.ByteBuffer, obj?:UpdateProfileRequest):UpdateProfileRequest {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new UpdateProfileRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
displayName():string|null
displayName(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
displayName(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
preferredLanguage():string|null
preferredLanguage(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
preferredLanguage(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
timeZone():string|null
timeZone(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
timeZone(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
awayStart():string|null
awayStart(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
awayStart(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 10);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
awayEnd():string|null
awayEnd(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
awayEnd(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 12);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
blockChat():boolean {
const offset = this.bb!.__offset(this.bb_pos, 14);
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
}
blockFriendRequests():boolean {
const offset = this.bb!.__offset(this.bb_pos, 16);
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
}
static startUpdateProfileRequest(builder:flatbuffers.Builder) {
builder.startObject(7);
}
static addDisplayName(builder:flatbuffers.Builder, displayNameOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, displayNameOffset, 0);
}
static addPreferredLanguage(builder:flatbuffers.Builder, preferredLanguageOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, preferredLanguageOffset, 0);
}
static addTimeZone(builder:flatbuffers.Builder, timeZoneOffset:flatbuffers.Offset) {
builder.addFieldOffset(2, timeZoneOffset, 0);
}
static addAwayStart(builder:flatbuffers.Builder, awayStartOffset:flatbuffers.Offset) {
builder.addFieldOffset(3, awayStartOffset, 0);
}
static addAwayEnd(builder:flatbuffers.Builder, awayEndOffset:flatbuffers.Offset) {
builder.addFieldOffset(4, awayEndOffset, 0);
}
static addBlockChat(builder:flatbuffers.Builder, blockChat:boolean) {
builder.addFieldInt8(5, +blockChat, +false);
}
static addBlockFriendRequests(builder:flatbuffers.Builder, blockFriendRequests:boolean) {
builder.addFieldInt8(6, +blockFriendRequests, +false);
}
static endUpdateProfileRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createUpdateProfileRequest(builder:flatbuffers.Builder, displayNameOffset:flatbuffers.Offset, preferredLanguageOffset:flatbuffers.Offset, timeZoneOffset:flatbuffers.Offset, awayStartOffset:flatbuffers.Offset, awayEndOffset:flatbuffers.Offset, blockChat:boolean, blockFriendRequests:boolean):flatbuffers.Offset {
UpdateProfileRequest.startUpdateProfileRequest(builder);
UpdateProfileRequest.addDisplayName(builder, displayNameOffset);
UpdateProfileRequest.addPreferredLanguage(builder, preferredLanguageOffset);
UpdateProfileRequest.addTimeZone(builder, timeZoneOffset);
UpdateProfileRequest.addAwayStart(builder, awayStartOffset);
UpdateProfileRequest.addAwayEnd(builder, awayEndOffset);
UpdateProfileRequest.addBlockChat(builder, blockChat);
UpdateProfileRequest.addBlockFriendRequests(builder, blockFriendRequests);
return UpdateProfileRequest.endUpdateProfileRequest(builder);
}
}
+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 () => {
+243
View File
@@ -0,0 +1,243 @@
<script lang="ts">
import { onMount } from 'svelte';
import Screen from '../components/Screen.svelte';
import { app, handleError, refreshNotifications, showToast } from '../lib/app.svelte';
import { gateway } from '../lib/gateway';
import { t } from '../lib/i18n/index.svelte';
import type { AccountRef, FriendCode } from '../lib/model';
let friends = $state<AccountRef[]>([]);
let incoming = $state<AccountRef[]>([]);
let blocked = $state<AccountRef[]>([]);
let code = $state<FriendCode | null>(null);
let redeemInput = $state('');
async function load() {
try {
[friends, incoming, blocked] = await Promise.all([
gateway.friendsList(),
gateway.friendsIncoming(),
gateway.blocksList(),
]);
} catch (e) {
handleError(e);
}
}
onMount(() => {
if (!app.profile?.isGuest) void load();
});
async function act(fn: () => Promise<unknown>) {
try {
await fn();
await load();
void refreshNotifications();
} catch (e) {
handleError(e);
}
}
const respond = (id: string, accept: boolean) => act(() => gateway.friendRespond(id, accept));
const remove = (id: string) => act(() => gateway.unfriend(id));
const blockUser = (id: string) => act(() => gateway.block(id));
const unblock = (id: string) => act(() => gateway.unblock(id));
async function getCode() {
try {
code = await gateway.friendCodeIssue();
} catch (e) {
handleError(e);
}
}
async function redeem() {
const c = redeemInput.trim();
if (!c) return;
try {
const friend = await gateway.friendCodeRedeem(c);
redeemInput = '';
showToast(t('friends.added', { name: friend.displayName }));
await load();
} catch (e) {
handleError(e);
}
}
function codeTime(unix: number): string {
return new Date(unix * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
</script>
<Screen title={t('friends.title')} back="/">
<div class="page">
{#if app.profile?.isGuest}
<p class="muted">{t('profile.guestLocked')}</p>
{:else}
<section>
<h3>{t('friends.add')}</h3>
<div class="addrow">
<input
class="codein"
bind:value={redeemInput}
placeholder={t('friends.codePlaceholder')}
inputmode="numeric"
maxlength="6"
/>
<button class="btn" onclick={redeem}>{t('friends.redeem')}</button>
</div>
{#if code}
<div class="code" data-testid="friend-code">
<span class="codeval">{code.code}</span>
<span class="codehint">
{t('friends.codeHint')} · {t('friends.codeExpires', { time: codeTime(code.expiresAtUnix) })}
</span>
</div>
{:else}
<button class="link" onclick={getCode}>{t('friends.getCode')}</button>
{/if}
</section>
{#if incoming.length}
<section>
<h3>{t('friends.incoming')}</h3>
{#each incoming as r (r.accountId)}
<div class="item">
<span class="who">{r.displayName}</span>
<span class="acts">
<button class="btn" onclick={() => respond(r.accountId, true)}>{t('friends.accept')}</button>
<button class="ghost" onclick={() => respond(r.accountId, false)}>{t('friends.decline')}</button>
</span>
</div>
{/each}
</section>
{/if}
<section>
<h3>{t('friends.yours')}</h3>
{#if friends.length}
{#each friends as f (f.accountId)}
<div class="item">
<span class="who">{f.displayName}</span>
<span class="acts">
<button class="ghost" onclick={() => remove(f.accountId)}>{t('friends.unfriend')}</button>
<button class="ghost danger" onclick={() => blockUser(f.accountId)}>{t('friends.block')}</button>
</span>
</div>
{/each}
{:else}
<p class="muted">{t('friends.none')}</p>
{/if}
</section>
{#if blocked.length}
<section>
<h3>{t('friends.blockedList')}</h3>
{#each blocked as b (b.accountId)}
<div class="item">
<span class="who">{b.displayName}</span>
<button class="ghost" onclick={() => unblock(b.accountId)}>{t('friends.unblock')}</button>
</div>
{/each}
</section>
{/if}
{/if}
</div>
</Screen>
<style>
.page {
padding: var(--pad);
display: flex;
flex-direction: column;
gap: 22px;
}
h3 {
margin: 0 0 10px;
font-size: 0.95rem;
color: var(--text-muted);
}
.muted {
color: var(--text-muted);
margin: 0;
}
.addrow {
display: flex;
gap: 8px;
}
.codein {
flex: 1;
padding: 10px 12px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
letter-spacing: 0.3em;
font-size: 1.1rem;
}
.code {
margin-top: 10px;
padding: 12px 14px;
border: 1px dashed var(--border);
border-radius: var(--radius);
display: flex;
flex-direction: column;
gap: 4px;
}
.codeval {
font-size: 1.8rem;
font-weight: 700;
letter-spacing: 0.3em;
}
.codehint {
font-size: 0.8rem;
color: var(--text-muted);
}
.link {
margin-top: 10px;
background: none;
border: none;
color: var(--accent);
padding: 4px 0;
text-align: left;
}
.item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 12px;
border: 1px solid var(--border);
background: var(--surface);
border-radius: var(--radius-sm);
margin-bottom: 8px;
}
.who {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.acts {
display: flex;
gap: 8px;
flex: 0 0 auto;
}
.btn {
padding: 8px 12px;
border: 1px solid var(--accent);
background: var(--accent);
color: var(--accent-text);
border-radius: var(--radius-sm);
}
.ghost {
padding: 8px 12px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
}
.ghost.danger {
color: var(--danger, #c0392b);
}
</style>
+91 -8
View File
@@ -6,15 +6,23 @@
import { app, handleError, showToast } from '../lib/app.svelte';
import { gateway } from '../lib/gateway';
import { navigate } from '../lib/router.svelte';
import { t } from '../lib/i18n/index.svelte';
import { t, type MessageKey } from '../lib/i18n/index.svelte';
import { resultBadge } from '../lib/result';
import type { GameView } from '../lib/model';
import type { AccountRef, GameView, Invitation } from '../lib/model';
let games = $state<GameView[]>([]);
let invitations = $state<Invitation[]>([]);
let incoming = $state<AccountRef[]>([]);
const guest = $derived(app.profile?.isGuest ?? true);
async function load() {
try {
games = (await gateway.gamesList()).games;
if (!guest) {
[invitations, incoming] = await Promise.all([gateway.invitationsList(), gateway.friendsIncoming()]);
app.notifications = invitations.length + incoming.length;
}
} catch (e) {
handleError(e);
}
@@ -42,18 +50,73 @@
}
const menuItems = $derived([
...(guest ? [] : [{ label: t('lobby.friends'), onclick: () => navigate('/friends'), badge: incoming.length }]),
{ label: t('lobby.profile'), onclick: () => navigate('/profile') },
{ label: t('lobby.settings'), onclick: () => navigate('/settings') },
{ label: t('lobby.about'), onclick: () => navigate('/about') },
]);
async function acceptInvite(inv: Invitation) {
try {
const r = await gateway.invitationAccept(inv.id);
if (r.gameId) navigate(`/game/${r.gameId}`);
else await load();
} catch (e) {
handleError(e);
}
}
const declineInvite = (inv: Invitation) => act(() => gateway.invitationDecline(inv.id));
const cancelInvite = (inv: Invitation) => act(() => gateway.invitationCancel(inv.id));
async function act(fn: () => Promise<unknown>) {
try {
await fn();
await load();
} catch (e) {
handleError(e);
}
}
const variantKey: Record<string, MessageKey> = {
english: 'new.english',
russian: 'new.russian',
erudit: 'new.erudit',
};
</script>
<Screen title={app.profile?.displayName ?? t('app.title')}>
{#snippet menu()}
<Menu items={menuItems} />
<Menu items={menuItems} badge={app.notifications} />
{/snippet}
<div class="lobby">
{#if invitations.length}
<section>
<h2>{t('lobby.invitations')}</h2>
{#each invitations as inv (inv.id)}
<div class="invite">
<span class="emoji">💌</span>
<span class="info">
{#if inv.inviter.accountId === myId}
<span class="who">{t('invitations.with', { names: inv.invitees.map((i) => i.displayName).join(', ') })}</span>
<span class="sub">{t('invitations.waiting')}</span>
{:else}
<span class="who">{t('invitations.from', { name: inv.inviter.displayName })}</span>
<span class="sub">{t(variantKey[inv.variant] ?? 'new.english')}</span>
{/if}
</span>
<span class="acts">
{#if inv.inviter.accountId === myId}
<button class="ghost" onclick={() => cancelInvite(inv)}>{t('invitations.cancel')}</button>
{:else}
<button class="btn" onclick={() => acceptInvite(inv)}>{t('invitations.accept')}</button>
<button class="ghost" onclick={() => declineInvite(inv)}>{t('invitations.decline')}</button>
{/if}
</span>
</div>
{/each}
</section>
{/if}
{#each [{ h: 'lobby.activeGames', list: active }, { h: 'lobby.finishedGames', list: finished }] as group (group.h)}
{#if group.list.length}
<section>
@@ -72,7 +135,7 @@
{/if}
{/each}
{#if !active.length && !finished.length}
{#if !active.length && !finished.length && !invitations.length}
<p class="empty">{t('lobby.noActive')}</p>
{/if}
</div>
@@ -82,11 +145,11 @@
<button class="tab" onclick={() => navigate('/new')}>
<span class="sq">🎲</span><span class="lbl">{t('lobby.new')}</span>
</button>
<button class="tab" onclick={() => showToast(t('lobby.soon'))}>
<span class="sq">⚔️</span><span class="lbl">{t('lobby.tournaments')}</span>
<button class="tab" onclick={() => navigate('/stats')}>
<span class="sq">📊</span><span class="lbl">{t('lobby.stats')}</span>
</button>
<button class="tab" onclick={() => showToast(t('lobby.soon'))}>
<span class="sq">🧮</span><span class="lbl">{t('lobby.stats')}</span>
<span class="sq">🏆</span><span class="lbl">{t('lobby.tournaments')}</span>
</button>
</TabBar>
{/snippet}
@@ -111,7 +174,8 @@
font-size: 0.9rem;
margin: 0;
}
.row {
.row,
.invite {
display: flex;
align-items: center;
justify-content: space-between;
@@ -147,4 +211,23 @@
line-height: 1;
flex: 0 0 auto;
}
.acts {
display: flex;
gap: 8px;
flex: 0 0 auto;
}
.btn {
padding: 8px 12px;
border: 1px solid var(--accent);
background: var(--accent);
color: var(--accent-text);
border-radius: var(--radius-sm);
}
.ghost {
padding: 8px 12px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
}
</style>
+162 -10
View File
@@ -1,18 +1,28 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { onDestroy, onMount } from 'svelte';
import Screen from '../components/Screen.svelte';
import { gateway } from '../lib/gateway';
import { handleError } from '../lib/app.svelte';
import { app, handleError, showToast } from '../lib/app.svelte';
import { navigate } from '../lib/router.svelte';
import { t, type MessageKey } from '../lib/i18n/index.svelte';
import type { Variant } from '../lib/model';
import type { AccountRef, Variant } from '../lib/model';
const variants: { id: Variant; label: MessageKey }[] = [
{ id: 'english', label: 'new.english' },
{ id: 'russian', label: 'new.russian' },
{ id: 'erudit', label: 'new.erudit' },
];
const timeouts = [
{ secs: 300, key: 'time.minutes' as MessageKey, n: 5 },
{ secs: 1800, key: 'time.minutes' as MessageKey, n: 30 },
{ secs: 3600, key: 'time.hours' as MessageKey, n: 1 },
{ secs: 86400, key: 'time.hours' as MessageKey, n: 24 },
];
const guest = $derived(app.profile?.isGuest ?? true);
let mode = $state<'auto' | 'friends'>('auto');
// --- auto-match ---
let searching = $state(false);
let poll: ReturnType<typeof setInterval> | null = null;
@@ -48,6 +58,43 @@
}
}
// --- friend game ---
let friends = $state<AccountRef[]>([]);
let selected = $state<string[]>([]);
let inviteVariant = $state<Variant>('english');
let timeoutSecs = $state(86400);
let hints = $state(1);
onMount(async () => {
if (guest) return;
try {
friends = await gateway.friendsList();
} catch (e) {
handleError(e);
}
});
function toggle(id: string) {
selected = selected.includes(id) ? selected.filter((x) => x !== id) : [...selected, id];
}
async function sendInvite() {
if (selected.length === 0 || selected.length > 3) return;
try {
await gateway.invitationCreate(selected, {
variant: inviteVariant,
turnTimeoutSecs: timeoutSecs,
hintsAllowed: hints > 0,
hintsPerPlayer: hints,
dropoutTiles: 'remove',
});
showToast(t('new.invited'));
navigate('/');
} catch (e) {
handleError(e);
}
}
onDestroy(stop);
</script>
@@ -60,12 +107,60 @@
<button class="cancel" onclick={() => { stop(); navigate('/'); }}>{t('common.cancel')}</button>
</div>
{:else}
<p class="subtitle">{t('new.subtitle')}</p>
<div class="variants">
{#each variants as v (v.id)}
<button class="variant" onclick={() => find(v.id)}>{t(v.label)}</button>
{/each}
</div>
{#if !guest}
<div class="seg modes">
<button class="opt" class:active={mode === 'auto'} onclick={() => (mode = 'auto')}>{t('new.auto')}</button>
<button class="opt" class:active={mode === 'friends'} onclick={() => (mode = 'friends')}>{t('new.withFriends')}</button>
</div>
{/if}
{#if mode === 'auto'}
<p class="subtitle">{t('new.subtitle')}</p>
<div class="variants">
{#each variants as v (v.id)}
<button class="variant" onclick={() => find(v.id)}>{t(v.label)}</button>
{/each}
</div>
{:else}
{#if friends.length === 0}
<p class="subtitle">{t('new.noFriends')}</p>
{:else}
<h3>{t('new.pickFriends')}</h3>
<div class="friends">
{#each friends as f (f.accountId)}
<label class="friend">
<input type="checkbox" checked={selected.includes(f.accountId)} onchange={() => toggle(f.accountId)} />
<span>{f.displayName}</span>
</label>
{/each}
</div>
<h3>{t('new.title')}</h3>
<div class="seg">
{#each variants as v (v.id)}
<button class="opt" class:active={inviteVariant === v.id} onclick={() => (inviteVariant = v.id)}>{t(v.label)}</button>
{/each}
</div>
<h3>{t('new.moveTime')}</h3>
<div class="seg">
{#each timeouts as to (to.secs)}
<button class="opt" class:active={timeoutSecs === to.secs} onclick={() => (timeoutSecs = to.secs)}>
{t(to.key, { n: to.n })}
</button>
{/each}
</div>
<h3>{t('new.hintsPerPlayer')}</h3>
<div class="seg">
{#each [0, 1, 2] as h (h)}
<button class="opt" class:active={hints === h} onclick={() => (hints = h)}>{h}</button>
{/each}
</div>
<button class="invite" disabled={selected.length === 0} onclick={sendInvite}>{t('new.invite')}</button>
{/if}
{/if}
{/if}
</div>
</Screen>
@@ -73,15 +168,23 @@
<style>
.page {
padding: var(--pad);
display: flex;
flex-direction: column;
gap: 14px;
}
.subtitle {
color: var(--text-muted);
margin: 0;
}
h3 {
margin: 6px 0 0;
font-size: 0.9rem;
color: var(--text-muted);
}
.variants {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 12px;
}
.variant {
padding: 16px;
@@ -93,6 +196,55 @@
font-weight: 600;
user-select: none;
}
.seg {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.modes {
margin-bottom: 4px;
}
.opt {
flex: 1;
min-width: 64px;
padding: 10px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
user-select: none;
}
.opt.active {
background: var(--accent);
color: var(--accent-text);
border-color: var(--accent);
}
.friends {
display: flex;
flex-direction: column;
gap: 6px;
}
.friend {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: 1px solid var(--border);
background: var(--surface);
border-radius: var(--radius-sm);
}
.invite {
margin-top: 8px;
padding: 14px;
border: 1px solid var(--accent);
background: var(--accent);
color: var(--accent-text);
border-radius: var(--radius);
font-weight: 600;
}
.invite:disabled {
opacity: 0.5;
}
.searching {
display: grid;
place-items: center;
+218 -16
View File
@@ -1,23 +1,141 @@
<script lang="ts">
import Screen from '../components/Screen.svelte';
import { app, logout } from '../lib/app.svelte';
import { app, handleError, logout, showToast } from '../lib/app.svelte';
import { gateway } from '../lib/gateway';
import { t } from '../lib/i18n/index.svelte';
import type { ProfileUpdate } from '../lib/model';
let editing = $state(false);
let form = $state<ProfileUpdate>(blankForm());
let emailInput = $state('');
let codeInput = $state('');
let emailSent = $state(false);
function blankForm(): ProfileUpdate {
const p = app.profile;
return {
displayName: p?.displayName ?? '',
preferredLanguage: p?.preferredLanguage ?? 'en',
timeZone: p?.timeZone ?? 'UTC',
awayStart: p?.awayStart ?? '00:00',
awayEnd: p?.awayEnd ?? '07:00',
blockChat: p?.blockChat ?? false,
blockFriendRequests: p?.blockFriendRequests ?? false,
};
}
function startEdit() {
form = blankForm();
editing = true;
}
async function save() {
try {
app.profile = await gateway.profileUpdate(form);
editing = false;
showToast(t('profile.saved'));
} catch (e) {
handleError(e);
}
}
async function requestEmail() {
const email = emailInput.trim();
if (!email) return;
try {
await gateway.emailBindRequest(email);
emailSent = true;
showToast(t('profile.emailSent', { email }));
} catch (e) {
handleError(e);
}
}
async function confirmEmail() {
try {
app.profile = await gateway.emailBindConfirm(emailInput.trim(), codeInput.trim());
emailSent = false;
emailInput = '';
codeInput = '';
showToast(t('profile.emailBound'));
} catch (e) {
handleError(e);
}
}
</script>
<Screen title={t('profile.title')} back="/">
<div class="page">
{#if app.profile}
<div class="name">{app.profile.displayName}</div>
{#if app.profile.isGuest}<span class="badge">{t('profile.guest')}</span>{/if}
<dl>
<dt>{t('profile.language')}</dt>
<dd>{app.profile.preferredLanguage}</dd>
<dt>{t('profile.timezone')}</dt>
<dd>{app.profile.timeZone}</dd>
<dt>{t('profile.hintBalance')}</dt>
<dd>{app.profile.hintBalance}</dd>
</dl>
<p class="muted">{t('profile.readonly')}</p>
{@const p = app.profile}
<div class="name">{p.displayName}</div>
{#if p.isGuest}<span class="badge">{t('profile.guest')}</span>{/if}
{#if editing}
<form class="edit" onsubmit={(e) => { e.preventDefault(); void save(); }}>
<label>
<span>{t('profile.displayName')}</span>
<input bind:value={form.displayName} maxlength="64" />
</label>
<label>
<span>{t('profile.timezone')}</span>
<input bind:value={form.timeZone} />
</label>
<fieldset class="away">
<legend>{t('profile.awayWindow')}</legend>
<div class="times">
<label><span>{t('profile.from')}</span><input type="time" bind:value={form.awayStart} /></label>
<label><span>{t('profile.to')}</span><input type="time" bind:value={form.awayEnd} /></label>
</div>
<p class="muted">{t('profile.awayHint')}</p>
</fieldset>
<label class="check">
<input type="checkbox" bind:checked={form.blockChat} />
<span>{t('profile.blockChat')}</span>
</label>
<label class="check">
<input type="checkbox" bind:checked={form.blockFriendRequests} />
<span>{t('profile.blockFriendRequests')}</span>
</label>
<div class="formacts">
<button type="submit" class="btn">{t('common.save')}</button>
<button type="button" class="ghost" onclick={() => (editing = false)}>{t('common.cancel')}</button>
</div>
</form>
{:else}
<dl>
<dt>{t('profile.language')}</dt>
<dd>{p.preferredLanguage}</dd>
<dt>{t('profile.timezone')}</dt>
<dd>{p.timeZone}</dd>
<dt>{t('profile.awayWindow')}</dt>
<dd>{p.awayStart}{p.awayEnd}</dd>
<dt>{t('profile.hintBalance')}</dt>
<dd>{p.hintBalance}</dd>
</dl>
{#if p.isGuest}
<p class="muted">{t('profile.guestLocked')}</p>
{:else}
<button class="btn" onclick={startEdit}>{t('profile.edit')}</button>
<section class="emailbox">
<h3>{t('profile.bindEmail')}</h3>
{#if !emailSent}
<div class="addrow">
<input bind:value={emailInput} placeholder={t('login.emailPlaceholder')} type="email" />
<button class="ghost" onclick={requestEmail}>{t('login.sendCode')}</button>
</div>
{:else}
<div class="addrow">
<input bind:value={codeInput} placeholder={t('profile.emailCode')} inputmode="numeric" maxlength="6" />
<button class="btn" onclick={confirmEmail}>{t('common.ok')}</button>
</div>
{/if}
</section>
{/if}
{/if}
<button class="logout" onclick={() => logout()}>{t('login.title')} / logout</button>
{/if}
</div>
@@ -26,14 +144,16 @@
<style>
.page {
padding: var(--pad);
display: flex;
flex-direction: column;
gap: 16px;
}
.name {
font-size: 1.4rem;
font-weight: 700;
}
.badge {
display: inline-block;
margin-top: 4px;
align-self: flex-start;
padding: 2px 8px;
border-radius: 999px;
background: var(--surface-2);
@@ -44,7 +164,7 @@
display: grid;
grid-template-columns: auto 1fr;
gap: 6px 16px;
margin: 20px 0;
margin: 0;
}
dt {
color: var(--text-muted);
@@ -56,9 +176,91 @@
.muted {
color: var(--text-muted);
font-size: 0.9rem;
margin: 0;
}
.edit {
display: flex;
flex-direction: column;
gap: 14px;
}
.edit label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.9rem;
color: var(--text-muted);
}
.edit input:not([type]),
.edit input[type='time'] {
padding: 9px 11px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
}
.away {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 12px;
margin: 0;
}
.away legend {
color: var(--text-muted);
font-size: 0.9rem;
padding: 0 4px;
}
.times {
display: flex;
gap: 12px;
}
.times label {
flex: 1;
}
.check {
flex-direction: row !important;
align-items: center;
gap: 10px !important;
color: var(--text) !important;
}
.formacts {
display: flex;
gap: 10px;
}
.emailbox h3 {
margin: 0 0 8px;
font-size: 0.95rem;
color: var(--text-muted);
}
.addrow {
display: flex;
gap: 8px;
}
.addrow input {
flex: 1;
padding: 9px 11px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
}
.btn {
align-self: flex-start;
padding: 9px 14px;
border: 1px solid var(--accent);
background: var(--accent);
color: var(--accent-text);
border-radius: var(--radius-sm);
}
.ghost {
padding: 9px 14px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
}
.logout {
margin-top: 16px;
margin-top: 8px;
align-self: flex-start;
padding: 8px 14px;
border: 1px solid var(--border);
background: var(--surface);
+82
View File
@@ -0,0 +1,82 @@
<script lang="ts">
import { onMount } from 'svelte';
import Screen from '../components/Screen.svelte';
import { app, handleError } from '../lib/app.svelte';
import { gateway } from '../lib/gateway';
import { t, type MessageKey } from '../lib/i18n/index.svelte';
import { gamesPlayed, winRate } from '../lib/stats';
import type { Stats } from '../lib/model';
let stats = $state<Stats | null>(null);
onMount(async () => {
if (app.profile?.isGuest) return;
try {
stats = await gateway.statsGet();
} catch (e) {
handleError(e);
}
});
const cards = $derived<{ key: MessageKey; value: string | number }[]>(
stats
? [
{ key: 'stats.wins', value: stats.wins },
{ key: 'stats.losses', value: stats.losses },
{ key: 'stats.draws', value: stats.draws },
{ key: 'stats.played', value: gamesPlayed(stats) },
{ key: 'stats.winRate', value: `${winRate(stats)}%` },
{ key: 'stats.maxGame', value: stats.maxGamePoints },
{ key: 'stats.maxWord', value: stats.maxWordPoints },
]
: [],
);
</script>
<Screen title={t('stats.title')} back="/">
<div class="page">
{#if app.profile?.isGuest}
<p class="muted">{t('stats.guestHint')}</p>
{:else if stats}
<div class="grid">
{#each cards as c (c.key)}
<div class="card">
<span class="num">{c.value}</span>
<span class="lbl">{t(c.key)}</span>
</div>
{/each}
</div>
{/if}
</div>
</Screen>
<style>
.page {
padding: var(--pad);
}
.muted {
color: var(--text-muted);
}
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.card {
display: flex;
flex-direction: column;
gap: 4px;
padding: 18px 16px;
border: 1px solid var(--border);
background: var(--surface);
border-radius: var(--radius);
}
.num {
font-size: 1.7rem;
font-weight: 700;
}
.lbl {
color: var(--text-muted);
font-size: 0.85rem;
}
</style>