Stage 8: UI social/account/history surfaces
Wire the deferred Stage 7 surfaces end-to-end (UI -> gateway transcode -> backend REST -> existing domain services): friends (incl. one-time friend codes), per-user blocks, friend-game invitations, profile editing + email binding, the statistics screen, and the in-game history + GCG export. Friends gain two add paths (interview decision, a deliberate plan change): one-time 6-digit codes (friend_codes table, 12h TTL, single-use, rate-limited redeem); and play-gated requests (shared game required) where an explicit decline is permanent, an ignored request lapses after 30 days, and a code bypasses a decline. Migration 00006 widens friendships_status_chk and adds friend_codes. Lobby notification badge is poll + push: a new generic `notify` event drives it live; the client polls on open/focus. Language stays a single Settings control that writes through to the durable account's preferred_language. GCG export is finished-only (game.ErrGameActive) and shares/downloads the .gcg file. Tests: backend unit + inttest (friend gate/decline/code, ListInvitations, GetStats, GCG gate), gateway transcode round-trips + notify constructor, UI vitest (codecs, win-rate, share choice) + Playwright social specs. Docs: PLAN (Stage 8 done + refinements + TODO-5), ARCHITECTURE, FUNCTIONAL(+ru), UI_DESIGN, TESTING, module READMEs.
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,8 @@ export const app = $state<{
|
||||
reduceMotion: boolean;
|
||||
boardLabels: BoardLabelMode;
|
||||
localeLocked: boolean;
|
||||
/** Pending incoming friend requests + invitations, for the lobby badge. */
|
||||
notifications: number;
|
||||
}>({
|
||||
ready: false,
|
||||
session: null,
|
||||
@@ -39,6 +41,7 @@ export const app = $state<{
|
||||
reduceMotion: false,
|
||||
boardLabels: 'beginner',
|
||||
localeLocked: false,
|
||||
notifications: 0,
|
||||
});
|
||||
|
||||
let unsubscribeStream: (() => void) | null = null;
|
||||
@@ -76,12 +79,35 @@ function openStream(): void {
|
||||
showToast(t('game.yourTurn'), 'info');
|
||||
} else if (e.kind === 'match_found') {
|
||||
navigate(`/game/${e.gameId}`);
|
||||
} else if (e.kind === 'notify') {
|
||||
void refreshNotifications();
|
||||
}
|
||||
},
|
||||
() => showToast(t('error.unavailable'), 'error'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* refreshNotifications recomputes the lobby badge count (incoming friend requests
|
||||
* plus open invitations). Authoritative poll, complementing the live 'notify' push.
|
||||
* Guests have no social surfaces, so it is a no-op for them.
|
||||
*/
|
||||
export async function refreshNotifications(): Promise<void> {
|
||||
if (!app.session || app.profile?.isGuest) {
|
||||
app.notifications = 0;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const [incoming, invitations] = await Promise.all([
|
||||
gateway.friendsIncoming(),
|
||||
gateway.invitationsList(),
|
||||
]);
|
||||
app.notifications = incoming.length + invitations.length;
|
||||
} catch {
|
||||
// Best-effort; leave the previous count on a transient failure.
|
||||
}
|
||||
}
|
||||
|
||||
function closeStream(): void {
|
||||
unsubscribeStream?.();
|
||||
unsubscribeStream = null;
|
||||
@@ -98,6 +124,7 @@ async function adoptSession(s: Session): Promise<void> {
|
||||
handleError(err);
|
||||
}
|
||||
openStream();
|
||||
void refreshNotifications();
|
||||
}
|
||||
|
||||
export async function bootstrap(): Promise<void> {
|
||||
@@ -186,6 +213,30 @@ export function setLocalePref(locale: Locale): void {
|
||||
app.localeLocked = true;
|
||||
setLocale(locale);
|
||||
persistPrefs();
|
||||
void persistLanguageToServer(locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* persistLanguageToServer writes the chosen interface language through to the
|
||||
* durable account's preferred_language, so the single Settings control is the
|
||||
* source of truth (guests keep only the client preference). Best-effort.
|
||||
*/
|
||||
async function persistLanguageToServer(locale: Locale): Promise<void> {
|
||||
const p = app.profile;
|
||||
if (!p || p.isGuest || p.preferredLanguage === locale) return;
|
||||
try {
|
||||
app.profile = await gateway.profileUpdate({
|
||||
displayName: p.displayName,
|
||||
preferredLanguage: locale,
|
||||
timeZone: p.timeZone,
|
||||
awayStart: p.awayStart,
|
||||
awayEnd: p.awayEnd,
|
||||
blockChat: p.blockChat,
|
||||
blockFriendRequests: p.blockFriendRequests,
|
||||
});
|
||||
} catch {
|
||||
// The client locale already changed; the server sync is best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
export function setReduceMotion(on: boolean): void {
|
||||
@@ -198,3 +249,11 @@ export function setBoardLabels(mode: BoardLabelMode): void {
|
||||
app.boardLabels = mode;
|
||||
persistPrefs();
|
||||
}
|
||||
|
||||
// Refresh the lobby badge when the app returns to the foreground — a push 'notify'
|
||||
// may have been missed while the client was hidden/closed (poll + push, see §10).
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible' && app.session) void refreshNotifications();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,18 +5,25 @@
|
||||
// message.
|
||||
|
||||
import type {
|
||||
AccountRef,
|
||||
ChatMessage,
|
||||
EvalResult,
|
||||
FriendCode,
|
||||
GameList,
|
||||
GameView,
|
||||
GcgExport,
|
||||
History,
|
||||
HintResult,
|
||||
Invitation,
|
||||
InvitationSettings,
|
||||
MatchResult,
|
||||
MoveResult,
|
||||
Profile,
|
||||
ProfileUpdate,
|
||||
PushEvent,
|
||||
Session,
|
||||
StateView,
|
||||
Stats,
|
||||
Tile,
|
||||
Variant,
|
||||
WordCheckResult,
|
||||
@@ -74,6 +81,35 @@ export interface GatewayClient {
|
||||
chatList(gameId: string): Promise<ChatMessage[]>;
|
||||
nudge(gameId: string): Promise<ChatMessage>;
|
||||
|
||||
// --- friends (Stage 8) ---
|
||||
friendsList(): Promise<AccountRef[]>;
|
||||
friendsIncoming(): Promise<AccountRef[]>;
|
||||
friendRequest(accountId: string): Promise<void>;
|
||||
friendRespond(requesterId: string, accept: boolean): Promise<void>;
|
||||
friendCancel(accountId: string): Promise<void>;
|
||||
unfriend(accountId: string): Promise<void>;
|
||||
friendCodeIssue(): Promise<FriendCode>;
|
||||
friendCodeRedeem(code: string): Promise<AccountRef>;
|
||||
|
||||
// --- blocks (Stage 8) ---
|
||||
blocksList(): Promise<AccountRef[]>;
|
||||
block(accountId: string): Promise<void>;
|
||||
unblock(accountId: string): Promise<void>;
|
||||
|
||||
// --- invitations (Stage 8) ---
|
||||
invitationsList(): Promise<Invitation[]>;
|
||||
invitationCreate(inviteeIds: string[], settings: InvitationSettings): Promise<Invitation>;
|
||||
invitationAccept(invitationId: string): Promise<Invitation>;
|
||||
invitationDecline(invitationId: string): Promise<Invitation>;
|
||||
invitationCancel(invitationId: string): Promise<void>;
|
||||
|
||||
// --- profile / stats / history (Stage 8) ---
|
||||
profileUpdate(p: ProfileUpdate): Promise<Profile>;
|
||||
emailBindRequest(email: string): Promise<void>;
|
||||
emailBindConfirm(email: string, code: string): Promise<Profile>;
|
||||
statsGet(): Promise<Stats>;
|
||||
exportGcg(gameId: string): Promise<GcgExport>;
|
||||
|
||||
// --- live stream ---
|
||||
subscribe(onEvent: (e: PushEvent) => void, onError?: (err: unknown) => void): Unsubscribe;
|
||||
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { Builder, ByteBuffer } from 'flatbuffers';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import * as fb from '../gen/fbs/scrabblefb';
|
||||
import { decodeGameList, decodeSession, encodeSubmitPlay } from './codec';
|
||||
import {
|
||||
decodeFriendList,
|
||||
decodeGameList,
|
||||
decodeInvitation,
|
||||
decodeSession,
|
||||
decodeStats,
|
||||
encodeSubmitPlay,
|
||||
encodeTarget,
|
||||
} from './codec';
|
||||
|
||||
describe('codec', () => {
|
||||
it('encodes a SubmitPlayRequest the gateway can read', () => {
|
||||
@@ -77,4 +85,88 @@ describe('codec', () => {
|
||||
expect(gl.games[0].seats[0].displayName).toBe('Ann');
|
||||
expect(gl.games[0].seats[0].score).toBe(13);
|
||||
});
|
||||
|
||||
it('encodes a TargetRequest', () => {
|
||||
const r = fb.TargetRequest.getRootAsTargetRequest(new ByteBuffer(encodeTarget('a-1')));
|
||||
expect(r.accountId()).toBe('a-1');
|
||||
});
|
||||
|
||||
it('decodes a StatsView', () => {
|
||||
const b = new Builder(64);
|
||||
fb.StatsView.startStatsView(b);
|
||||
fb.StatsView.addWins(b, 7);
|
||||
fb.StatsView.addLosses(b, 4);
|
||||
fb.StatsView.addDraws(b, 1);
|
||||
fb.StatsView.addMaxGamePoints(b, 420);
|
||||
fb.StatsView.addMaxWordPoints(b, 90);
|
||||
b.finish(fb.StatsView.endStatsView(b));
|
||||
expect(decodeStats(b.asUint8Array())).toEqual({
|
||||
wins: 7,
|
||||
losses: 4,
|
||||
draws: 1,
|
||||
maxGamePoints: 420,
|
||||
maxWordPoints: 90,
|
||||
});
|
||||
});
|
||||
|
||||
it('decodes a FriendList of account refs', () => {
|
||||
const b = new Builder(128);
|
||||
const id = b.createString('a-1');
|
||||
const dn = b.createString('Ann');
|
||||
fb.AccountRef.startAccountRef(b);
|
||||
fb.AccountRef.addAccountId(b, id);
|
||||
fb.AccountRef.addDisplayName(b, dn);
|
||||
const ref = fb.AccountRef.endAccountRef(b);
|
||||
const vec = fb.FriendList.createFriendsVector(b, [ref]);
|
||||
fb.FriendList.startFriendList(b);
|
||||
fb.FriendList.addFriends(b, vec);
|
||||
b.finish(fb.FriendList.endFriendList(b));
|
||||
expect(decodeFriendList(b.asUint8Array())).toEqual([{ accountId: 'a-1', displayName: 'Ann' }]);
|
||||
});
|
||||
|
||||
it('decodes an Invitation with inviter and invitees', () => {
|
||||
const b = new Builder(256);
|
||||
const iid = b.createString('u-1');
|
||||
const idn = b.createString('Me');
|
||||
fb.AccountRef.startAccountRef(b);
|
||||
fb.AccountRef.addAccountId(b, iid);
|
||||
fb.AccountRef.addDisplayName(b, idn);
|
||||
const inviter = fb.AccountRef.endAccountRef(b);
|
||||
|
||||
const aid = b.createString('inv-1');
|
||||
const adn = b.createString('Friend');
|
||||
const resp = b.createString('pending');
|
||||
fb.InvitationInvitee.startInvitationInvitee(b);
|
||||
fb.InvitationInvitee.addAccountId(b, aid);
|
||||
fb.InvitationInvitee.addDisplayName(b, adn);
|
||||
fb.InvitationInvitee.addSeat(b, 1);
|
||||
fb.InvitationInvitee.addResponse(b, resp);
|
||||
const invitee = fb.InvitationInvitee.endInvitationInvitee(b);
|
||||
const invitees = fb.Invitation.createInviteesVector(b, [invitee]);
|
||||
|
||||
const id = b.createString('i-1');
|
||||
const variant = b.createString('english');
|
||||
const dropout = b.createString('remove');
|
||||
const status = b.createString('pending');
|
||||
const gid = b.createString('');
|
||||
fb.Invitation.startInvitation(b);
|
||||
fb.Invitation.addId(b, id);
|
||||
fb.Invitation.addInviter(b, inviter);
|
||||
fb.Invitation.addInvitees(b, invitees);
|
||||
fb.Invitation.addVariant(b, variant);
|
||||
fb.Invitation.addTurnTimeoutSecs(b, 86400);
|
||||
fb.Invitation.addHintsAllowed(b, true);
|
||||
fb.Invitation.addHintsPerPlayer(b, 1);
|
||||
fb.Invitation.addDropoutTiles(b, dropout);
|
||||
fb.Invitation.addStatus(b, status);
|
||||
fb.Invitation.addGameId(b, gid);
|
||||
b.finish(fb.Invitation.endInvitation(b));
|
||||
|
||||
const inv = decodeInvitation(b.asUint8Array());
|
||||
expect(inv.id).toBe('i-1');
|
||||
expect(inv.inviter).toEqual({ accountId: 'u-1', displayName: 'Me' });
|
||||
expect(inv.invitees).toHaveLength(1);
|
||||
expect(inv.invitees[0]).toEqual({ accountId: 'inv-1', displayName: 'Friend', seat: 1, response: 'pending' });
|
||||
expect(inv.variant).toBe('english');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,20 +7,28 @@ import { Builder, ByteBuffer, type Offset } from 'flatbuffers';
|
||||
import * as fb from '../gen/fbs/scrabblefb';
|
||||
import type { PlacedTile } from './client';
|
||||
import type {
|
||||
AccountRef,
|
||||
ChatMessage,
|
||||
EvalResult,
|
||||
FriendCode,
|
||||
GameList,
|
||||
GameView,
|
||||
GcgExport,
|
||||
History,
|
||||
HintResult,
|
||||
Invitation,
|
||||
InvitationInvitee,
|
||||
InvitationSettings,
|
||||
MatchResult,
|
||||
MoveRecord,
|
||||
MoveResult,
|
||||
Profile,
|
||||
ProfileUpdate,
|
||||
PushEvent,
|
||||
Seat,
|
||||
Session,
|
||||
StateView,
|
||||
Stats,
|
||||
Tile,
|
||||
Variant,
|
||||
WordCheckResult,
|
||||
@@ -250,6 +258,8 @@ export function decodeProfile(buf: Uint8Array): Profile {
|
||||
displayName: s(p.displayName()),
|
||||
preferredLanguage: s(p.preferredLanguage()),
|
||||
timeZone: s(p.timeZone()),
|
||||
awayStart: s(p.awayStart()),
|
||||
awayEnd: s(p.awayEnd()),
|
||||
hintBalance: p.hintBalance(),
|
||||
blockChat: p.blockChat(),
|
||||
blockFriendRequests: p.blockFriendRequests(),
|
||||
@@ -357,6 +367,10 @@ export function decodeEvent(kind: string, payload: Uint8Array): PushEvent | null
|
||||
const e = fb.MatchFoundEvent.getRootAsMatchFoundEvent(bb);
|
||||
return { kind: 'match_found', gameId: s(e.gameId()) };
|
||||
}
|
||||
case 'notify': {
|
||||
const e = fb.NotificationEvent.getRootAsNotificationEvent(bb);
|
||||
return { kind: 'notify', sub: s(e.kind()) };
|
||||
}
|
||||
case 'heartbeat':
|
||||
return { kind: 'heartbeat' };
|
||||
default:
|
||||
@@ -364,6 +378,199 @@ export function decodeEvent(kind: string, payload: Uint8Array): PushEvent | null
|
||||
}
|
||||
}
|
||||
|
||||
// --- Stage 8 encoders ---
|
||||
|
||||
export function encodeTarget(accountId: string): Uint8Array {
|
||||
const b = new Builder(64);
|
||||
const id = b.createString(accountId);
|
||||
fb.TargetRequest.startTargetRequest(b);
|
||||
fb.TargetRequest.addAccountId(b, id);
|
||||
return finish(b, fb.TargetRequest.endTargetRequest(b));
|
||||
}
|
||||
|
||||
export function encodeFriendRespond(requesterId: string, accept: boolean): Uint8Array {
|
||||
const b = new Builder(64);
|
||||
const id = b.createString(requesterId);
|
||||
fb.FriendRespondRequest.startFriendRespondRequest(b);
|
||||
fb.FriendRespondRequest.addRequesterId(b, id);
|
||||
fb.FriendRespondRequest.addAccept(b, accept);
|
||||
return finish(b, fb.FriendRespondRequest.endFriendRespondRequest(b));
|
||||
}
|
||||
|
||||
export function encodeRedeemCode(code: string): Uint8Array {
|
||||
const b = new Builder(32);
|
||||
const c = b.createString(code);
|
||||
fb.RedeemCodeRequest.startRedeemCodeRequest(b);
|
||||
fb.RedeemCodeRequest.addCode(b, c);
|
||||
return finish(b, fb.RedeemCodeRequest.endRedeemCodeRequest(b));
|
||||
}
|
||||
|
||||
export function encodeCreateInvitation(inviteeIds: string[], st: InvitationSettings): Uint8Array {
|
||||
const b = new Builder(256);
|
||||
const idOffs = inviteeIds.map((id) => b.createString(id));
|
||||
const ids = fb.CreateInvitationRequest.createInviteeIdsVector(b, idOffs);
|
||||
const variant = b.createString(st.variant);
|
||||
const dropout = b.createString(st.dropoutTiles);
|
||||
fb.CreateInvitationRequest.startCreateInvitationRequest(b);
|
||||
fb.CreateInvitationRequest.addInviteeIds(b, ids);
|
||||
fb.CreateInvitationRequest.addVariant(b, variant);
|
||||
fb.CreateInvitationRequest.addTurnTimeoutSecs(b, st.turnTimeoutSecs);
|
||||
fb.CreateInvitationRequest.addHintsAllowed(b, st.hintsAllowed);
|
||||
fb.CreateInvitationRequest.addHintsPerPlayer(b, st.hintsPerPlayer);
|
||||
fb.CreateInvitationRequest.addDropoutTiles(b, dropout);
|
||||
return finish(b, fb.CreateInvitationRequest.endCreateInvitationRequest(b));
|
||||
}
|
||||
|
||||
export function encodeInvitationAction(invitationId: string): Uint8Array {
|
||||
const b = new Builder(64);
|
||||
const id = b.createString(invitationId);
|
||||
fb.InvitationActionRequest.startInvitationActionRequest(b);
|
||||
fb.InvitationActionRequest.addInvitationId(b, id);
|
||||
return finish(b, fb.InvitationActionRequest.endInvitationActionRequest(b));
|
||||
}
|
||||
|
||||
export function encodeUpdateProfile(p: ProfileUpdate): Uint8Array {
|
||||
const b = new Builder(256);
|
||||
const name = b.createString(p.displayName);
|
||||
const lang = b.createString(p.preferredLanguage);
|
||||
const tz = b.createString(p.timeZone);
|
||||
const as = b.createString(p.awayStart);
|
||||
const ae = b.createString(p.awayEnd);
|
||||
fb.UpdateProfileRequest.startUpdateProfileRequest(b);
|
||||
fb.UpdateProfileRequest.addDisplayName(b, name);
|
||||
fb.UpdateProfileRequest.addPreferredLanguage(b, lang);
|
||||
fb.UpdateProfileRequest.addTimeZone(b, tz);
|
||||
fb.UpdateProfileRequest.addAwayStart(b, as);
|
||||
fb.UpdateProfileRequest.addAwayEnd(b, ae);
|
||||
fb.UpdateProfileRequest.addBlockChat(b, p.blockChat);
|
||||
fb.UpdateProfileRequest.addBlockFriendRequests(b, p.blockFriendRequests);
|
||||
return finish(b, fb.UpdateProfileRequest.endUpdateProfileRequest(b));
|
||||
}
|
||||
|
||||
export function encodeEmailBind(email: string): Uint8Array {
|
||||
const b = new Builder(128);
|
||||
const e = b.createString(email);
|
||||
fb.EmailBindRequest.startEmailBindRequest(b);
|
||||
fb.EmailBindRequest.addEmail(b, e);
|
||||
return finish(b, fb.EmailBindRequest.endEmailBindRequest(b));
|
||||
}
|
||||
|
||||
export function encodeEmailConfirm(email: string, code: string): Uint8Array {
|
||||
const b = new Builder(128);
|
||||
const e = b.createString(email);
|
||||
const c = b.createString(code);
|
||||
fb.EmailConfirmRequest.startEmailConfirmRequest(b);
|
||||
fb.EmailConfirmRequest.addEmail(b, e);
|
||||
fb.EmailConfirmRequest.addCode(b, c);
|
||||
return finish(b, fb.EmailConfirmRequest.endEmailConfirmRequest(b));
|
||||
}
|
||||
|
||||
// --- Stage 8 decoders ---
|
||||
|
||||
function decodeAccountRef(r: fb.AccountRef): AccountRef {
|
||||
return { accountId: s(r.accountId()), displayName: s(r.displayName()) };
|
||||
}
|
||||
|
||||
export function decodeFriendList(buf: Uint8Array): AccountRef[] {
|
||||
const l = fb.FriendList.getRootAsFriendList(new ByteBuffer(buf));
|
||||
const out: AccountRef[] = [];
|
||||
for (let i = 0; i < l.friendsLength(); i++) {
|
||||
const r = l.friends(i);
|
||||
if (r) out.push(decodeAccountRef(r));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function decodeIncomingList(buf: Uint8Array): AccountRef[] {
|
||||
const l = fb.IncomingRequestList.getRootAsIncomingRequestList(new ByteBuffer(buf));
|
||||
const out: AccountRef[] = [];
|
||||
for (let i = 0; i < l.requestsLength(); i++) {
|
||||
const r = l.requests(i);
|
||||
if (r) out.push(decodeAccountRef(r));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function decodeBlockList(buf: Uint8Array): AccountRef[] {
|
||||
const l = fb.BlockList.getRootAsBlockList(new ByteBuffer(buf));
|
||||
const out: AccountRef[] = [];
|
||||
for (let i = 0; i < l.blockedLength(); i++) {
|
||||
const r = l.blocked(i);
|
||||
if (r) out.push(decodeAccountRef(r));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function decodeFriendCode(buf: Uint8Array): FriendCode {
|
||||
const c = fb.FriendCode.getRootAsFriendCode(new ByteBuffer(buf));
|
||||
return { code: s(c.code()), expiresAtUnix: Number(c.expiresAtUnix()) };
|
||||
}
|
||||
|
||||
export function decodeRedeemResult(buf: Uint8Array): AccountRef {
|
||||
const r = fb.RedeemResult.getRootAsRedeemResult(new ByteBuffer(buf));
|
||||
const f = r.friend();
|
||||
return f ? decodeAccountRef(f) : { accountId: '', displayName: '' };
|
||||
}
|
||||
|
||||
export function decodeStats(buf: Uint8Array): Stats {
|
||||
const v = fb.StatsView.getRootAsStatsView(new ByteBuffer(buf));
|
||||
return {
|
||||
wins: v.wins(),
|
||||
losses: v.losses(),
|
||||
draws: v.draws(),
|
||||
maxGamePoints: v.maxGamePoints(),
|
||||
maxWordPoints: v.maxWordPoints(),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeInvitationTable(i: fb.Invitation): Invitation {
|
||||
const inviter = i.inviter();
|
||||
const invitees: InvitationInvitee[] = [];
|
||||
for (let k = 0; k < i.inviteesLength(); k++) {
|
||||
const iv = i.invitees(k);
|
||||
if (iv) {
|
||||
invitees.push({
|
||||
accountId: s(iv.accountId()),
|
||||
displayName: s(iv.displayName()),
|
||||
seat: iv.seat(),
|
||||
response: s(iv.response()),
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: s(i.id()),
|
||||
inviter: inviter ? decodeAccountRef(inviter) : { accountId: '', displayName: '' },
|
||||
invitees,
|
||||
variant: s(i.variant()) as Variant,
|
||||
turnTimeoutSecs: i.turnTimeoutSecs(),
|
||||
hintsAllowed: i.hintsAllowed(),
|
||||
hintsPerPlayer: i.hintsPerPlayer(),
|
||||
dropoutTiles: s(i.dropoutTiles()),
|
||||
status: s(i.status()),
|
||||
gameId: s(i.gameId()),
|
||||
expiresAtUnix: Number(i.expiresAtUnix()),
|
||||
};
|
||||
}
|
||||
|
||||
export function decodeInvitation(buf: Uint8Array): Invitation {
|
||||
return decodeInvitationTable(fb.Invitation.getRootAsInvitation(new ByteBuffer(buf)));
|
||||
}
|
||||
|
||||
export function decodeInvitationList(buf: Uint8Array): Invitation[] {
|
||||
const l = fb.InvitationList.getRootAsInvitationList(new ByteBuffer(buf));
|
||||
const out: Invitation[] = [];
|
||||
for (let i = 0; i < l.invitationsLength(); i++) {
|
||||
const inv = l.invitations(i);
|
||||
if (inv) out.push(decodeInvitationTable(inv));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function decodeGcg(buf: Uint8Array): GcgExport {
|
||||
const g = fb.GcgExport.getRootAsGcgExport(new ByteBuffer(buf));
|
||||
return { gameId: s(g.gameId()), filename: s(g.filename()), content: s(g.content()) };
|
||||
}
|
||||
|
||||
function emptyGame(): GameView {
|
||||
return {
|
||||
id: '',
|
||||
|
||||
+92
-1
@@ -102,7 +102,21 @@ export const en = {
|
||||
'profile.timezone': 'Time zone',
|
||||
'profile.hintBalance': 'Hint balance',
|
||||
'profile.guest': 'Guest account',
|
||||
'profile.readonly': 'Editing your profile arrives in a later update.',
|
||||
'profile.edit': 'Edit profile',
|
||||
'profile.displayName': 'Display name',
|
||||
'profile.awayWindow': 'Away window',
|
||||
'profile.awayHint': 'You are not auto-resigned during these hours.',
|
||||
'profile.from': 'From',
|
||||
'profile.to': 'To',
|
||||
'profile.blockChat': 'Disable chat',
|
||||
'profile.blockFriendRequests': 'Disable friend requests',
|
||||
'profile.email': 'Email',
|
||||
'profile.bindEmail': 'Bind email',
|
||||
'profile.emailCode': 'Confirmation code',
|
||||
'profile.emailSent': 'We sent a code to {email}.',
|
||||
'profile.emailBound': 'Email confirmed.',
|
||||
'profile.saved': 'Profile saved.',
|
||||
'profile.guestLocked': 'Sign in with email to manage your profile.',
|
||||
|
||||
'settings.title': 'Settings',
|
||||
'settings.theme': 'Theme',
|
||||
@@ -143,6 +157,83 @@ export const en = {
|
||||
'error.unavailable': 'Connection problem. Retrying…',
|
||||
'error.internal': 'Something went wrong.',
|
||||
'error.generic': 'Something went wrong.',
|
||||
|
||||
'lobby.invitations': 'Invitations',
|
||||
'lobby.friends': 'Friends',
|
||||
|
||||
'friends.title': 'Friends',
|
||||
'friends.yours': 'Your friends',
|
||||
'friends.none': 'No friends yet.',
|
||||
'friends.incoming': 'Friend requests',
|
||||
'friends.accept': 'Accept',
|
||||
'friends.decline': 'Decline',
|
||||
'friends.unfriend': 'Remove',
|
||||
'friends.block': 'Block',
|
||||
'friends.add': 'Add a friend',
|
||||
'friends.addFromGame': 'Add to friends',
|
||||
'friends.requestSent': 'Friend request sent.',
|
||||
'friends.getCode': 'Show my code',
|
||||
'friends.codeHint': 'Give this code to a friend within 12 hours.',
|
||||
'friends.codeExpires': 'Expires at {time}',
|
||||
'friends.enterCode': 'Have a code? Add a friend',
|
||||
'friends.codePlaceholder': '6-digit code',
|
||||
'friends.redeem': 'Add',
|
||||
'friends.added': 'Added {name}.',
|
||||
'friends.blockedList': 'Blocked players',
|
||||
'friends.unblock': 'Unblock',
|
||||
'friends.noneBlocked': 'No blocked players.',
|
||||
|
||||
'invitations.none': 'No invitations.',
|
||||
'invitations.from': 'From {name}',
|
||||
'invitations.with': 'With {names}',
|
||||
'invitations.accept': 'Accept',
|
||||
'invitations.decline': 'Decline',
|
||||
'invitations.cancel': 'Cancel',
|
||||
'invitations.waiting': 'Waiting for replies',
|
||||
|
||||
'new.auto': 'Quick match',
|
||||
'new.withFriends': 'Play with friends',
|
||||
'new.pickFriends': 'Choose who to invite',
|
||||
'new.invite': 'Send invitation',
|
||||
'new.moveTime': 'Move time',
|
||||
'new.hintsPerPlayer': 'Hints per player',
|
||||
'new.invited': 'Invitation sent.',
|
||||
'new.noFriends': 'Add friends first to invite them.',
|
||||
|
||||
'stats.title': 'Statistics',
|
||||
'stats.wins': 'Wins',
|
||||
'stats.losses': 'Losses',
|
||||
'stats.draws': 'Draws',
|
||||
'stats.played': 'Games',
|
||||
'stats.winRate': 'Win rate',
|
||||
'stats.maxGame': 'Best game',
|
||||
'stats.maxWord': 'Best move',
|
||||
'stats.guestHint': 'Sign in to track your statistics.',
|
||||
|
||||
'game.exportGcg': 'Export GCG',
|
||||
'game.gcgActiveOnly': 'Available once the game is finished.',
|
||||
|
||||
'time.minutes': '{n} min',
|
||||
'time.hours': '{n} h',
|
||||
|
||||
'error.self_relation': 'You cannot do that to yourself.',
|
||||
'error.request_exists': 'A request or friendship already exists.',
|
||||
'error.request_blocked': 'This player is not accepting requests.',
|
||||
'error.request_not_found': 'No matching friend request.',
|
||||
'error.no_shared_game': 'You can only add someone you have played with.',
|
||||
'error.request_declined': 'This player declined your request.',
|
||||
'error.friend_code_invalid': 'That friend code is invalid or expired.',
|
||||
'error.invalid_invitation': 'Invalid invitation.',
|
||||
'error.invitation_blocked': 'You cannot invite this player.',
|
||||
'error.invitation_not_found': 'Invitation not found.',
|
||||
'error.invitation_not_pending': 'This invitation is no longer open.',
|
||||
'error.invitation_expired': 'This invitation has expired.',
|
||||
'error.not_invited': 'You were not invited.',
|
||||
'error.already_responded': 'You already responded.',
|
||||
'error.not_inviter': 'Only the inviter can do that.',
|
||||
'error.game_active': 'Available only after the game is finished.',
|
||||
'error.invalid_profile': 'Some profile fields are invalid.',
|
||||
'error.already_confirmed': 'This email is already confirmed.',
|
||||
} as const;
|
||||
|
||||
export type MessageKey = keyof typeof en;
|
||||
|
||||
+92
-1
@@ -103,7 +103,21 @@ export const ru: Record<MessageKey, string> = {
|
||||
'profile.timezone': 'Часовой пояс',
|
||||
'profile.hintBalance': 'Баланс подсказок',
|
||||
'profile.guest': 'Гостевой аккаунт',
|
||||
'profile.readonly': 'Редактирование профиля появится в следующем обновлении.',
|
||||
'profile.edit': 'Редактировать профиль',
|
||||
'profile.displayName': 'Отображаемое имя',
|
||||
'profile.awayWindow': 'Окно отсутствия',
|
||||
'profile.awayHint': 'В эти часы вам не засчитывают автопоражение.',
|
||||
'profile.from': 'С',
|
||||
'profile.to': 'До',
|
||||
'profile.blockChat': 'Отключить чат',
|
||||
'profile.blockFriendRequests': 'Отключить заявки в друзья',
|
||||
'profile.email': 'Эл. почта',
|
||||
'profile.bindEmail': 'Привязать почту',
|
||||
'profile.emailCode': 'Код подтверждения',
|
||||
'profile.emailSent': 'Мы отправили код на {email}.',
|
||||
'profile.emailBound': 'Почта подтверждена.',
|
||||
'profile.saved': 'Профиль сохранён.',
|
||||
'profile.guestLocked': 'Войдите по почте, чтобы управлять профилем.',
|
||||
|
||||
'settings.title': 'Настройки',
|
||||
'settings.theme': 'Тема',
|
||||
@@ -144,4 +158,81 @@ export const ru: Record<MessageKey, string> = {
|
||||
'error.unavailable': 'Проблема соединения. Повторяем…',
|
||||
'error.internal': 'Что-то пошло не так.',
|
||||
'error.generic': 'Что-то пошло не так.',
|
||||
|
||||
'lobby.invitations': 'Приглашения',
|
||||
'lobby.friends': 'Друзья',
|
||||
|
||||
'friends.title': 'Друзья',
|
||||
'friends.yours': 'Ваши друзья',
|
||||
'friends.none': 'Друзей пока нет.',
|
||||
'friends.incoming': 'Заявки в друзья',
|
||||
'friends.accept': 'Принять',
|
||||
'friends.decline': 'Отклонить',
|
||||
'friends.unfriend': 'Удалить',
|
||||
'friends.block': 'Заблокировать',
|
||||
'friends.add': 'Добавить друга',
|
||||
'friends.addFromGame': 'В друзья',
|
||||
'friends.requestSent': 'Заявка в друзья отправлена.',
|
||||
'friends.getCode': 'Показать мой код',
|
||||
'friends.codeHint': 'Передайте этот код другу в течение 12 часов.',
|
||||
'friends.codeExpires': 'Истекает в {time}',
|
||||
'friends.enterCode': 'Есть код? Добавить друга',
|
||||
'friends.codePlaceholder': 'Код из 6 цифр',
|
||||
'friends.redeem': 'Добавить',
|
||||
'friends.added': 'Добавлен(а) {name}.',
|
||||
'friends.blockedList': 'Заблокированные',
|
||||
'friends.unblock': 'Разблокировать',
|
||||
'friends.noneBlocked': 'Заблокированных нет.',
|
||||
|
||||
'invitations.none': 'Приглашений нет.',
|
||||
'invitations.from': 'От {name}',
|
||||
'invitations.with': 'С {names}',
|
||||
'invitations.accept': 'Принять',
|
||||
'invitations.decline': 'Отклонить',
|
||||
'invitations.cancel': 'Отменить',
|
||||
'invitations.waiting': 'Ожидаем ответы',
|
||||
|
||||
'new.auto': 'Быстрая игра',
|
||||
'new.withFriends': 'Игра с друзьями',
|
||||
'new.pickFriends': 'Кого пригласить',
|
||||
'new.invite': 'Отправить приглашение',
|
||||
'new.moveTime': 'Время на ход',
|
||||
'new.hintsPerPlayer': 'Подсказок на игрока',
|
||||
'new.invited': 'Приглашение отправлено.',
|
||||
'new.noFriends': 'Сначала добавьте друзей, чтобы пригласить их.',
|
||||
|
||||
'stats.title': 'Статистика',
|
||||
'stats.wins': 'Победы',
|
||||
'stats.losses': 'Поражения',
|
||||
'stats.draws': 'Ничьи',
|
||||
'stats.played': 'Игр',
|
||||
'stats.winRate': 'Доля побед',
|
||||
'stats.maxGame': 'Лучшая игра',
|
||||
'stats.maxWord': 'Лучший ход',
|
||||
'stats.guestHint': 'Войдите, чтобы вести статистику.',
|
||||
|
||||
'game.exportGcg': 'Экспорт GCG',
|
||||
'game.gcgActiveOnly': 'Доступно после завершения игры.',
|
||||
|
||||
'time.minutes': '{n} мин',
|
||||
'time.hours': '{n} ч',
|
||||
|
||||
'error.self_relation': 'Нельзя сделать это с самим собой.',
|
||||
'error.request_exists': 'Заявка или дружба уже существует.',
|
||||
'error.request_blocked': 'Игрок не принимает заявки.',
|
||||
'error.request_not_found': 'Подходящей заявки нет.',
|
||||
'error.no_shared_game': 'Можно добавить только того, с кем вы играли.',
|
||||
'error.request_declined': 'Игрок отклонил вашу заявку.',
|
||||
'error.friend_code_invalid': 'Код недействителен или истёк.',
|
||||
'error.invalid_invitation': 'Неверное приглашение.',
|
||||
'error.invitation_blocked': 'Нельзя пригласить этого игрока.',
|
||||
'error.invitation_not_found': 'Приглашение не найдено.',
|
||||
'error.invitation_not_pending': 'Приглашение больше не открыто.',
|
||||
'error.invitation_expired': 'Приглашение истекло.',
|
||||
'error.not_invited': 'Вас не приглашали.',
|
||||
'error.already_responded': 'Вы уже ответили.',
|
||||
'error.not_inviter': 'Только пригласивший может это сделать.',
|
||||
'error.game_active': 'Доступно только после завершения игры.',
|
||||
'error.invalid_profile': 'Некоторые поля профиля некорректны.',
|
||||
'error.already_confirmed': 'Эта почта уже подтверждена.',
|
||||
};
|
||||
|
||||
+131
-1
@@ -12,22 +12,39 @@ import type {
|
||||
} from '../client';
|
||||
import { GatewayError } from '../client';
|
||||
import type {
|
||||
AccountRef,
|
||||
ChatMessage,
|
||||
EvalResult,
|
||||
FriendCode,
|
||||
GameList,
|
||||
GcgExport,
|
||||
History,
|
||||
HintResult,
|
||||
Invitation,
|
||||
InvitationSettings,
|
||||
MatchResult,
|
||||
MoveResult,
|
||||
Profile,
|
||||
ProfileUpdate,
|
||||
PushEvent,
|
||||
Session,
|
||||
StateView,
|
||||
Stats,
|
||||
Variant,
|
||||
WordCheckResult,
|
||||
} from '../model';
|
||||
import { tileValue } from '../premiums';
|
||||
import { ME, PROFILE, SESSION, seedGames, type MockGame } from './data';
|
||||
import {
|
||||
ME,
|
||||
MOCK_FRIENDS,
|
||||
MOCK_INCOMING,
|
||||
MOCK_STATS,
|
||||
PROFILE,
|
||||
SESSION,
|
||||
mockInvitations,
|
||||
seedGames,
|
||||
type MockGame,
|
||||
} from './data';
|
||||
|
||||
const POOL: Record<Variant, string> = {
|
||||
english: 'AAAAEEEEIIIOONNRRTTLLSSUDGBCMPFHVWYK',
|
||||
@@ -57,6 +74,11 @@ export class MockGateway implements GatewayClient {
|
||||
private readonly profile: Profile = { ...PROFILE };
|
||||
private readonly subs = new Set<(e: PushEvent) => void>();
|
||||
private pendingMatch: string | null = null;
|
||||
private friends: AccountRef[] = MOCK_FRIENDS.map((f) => ({ ...f }));
|
||||
private incoming: AccountRef[] = MOCK_INCOMING.map((f) => ({ ...f }));
|
||||
private blocks: AccountRef[] = [];
|
||||
private invitations: Invitation[] = mockInvitations();
|
||||
private readonly stats: Stats = { ...MOCK_STATS };
|
||||
|
||||
setToken(_token: string | null): void {
|
||||
// The mock needs no auth; the real transport stores the bearer token.
|
||||
@@ -300,6 +322,114 @@ export class MockGateway implements GatewayClient {
|
||||
return msg;
|
||||
}
|
||||
|
||||
// --- friends ---
|
||||
private nameFor(id: string): string {
|
||||
return this.friends.find((f) => f.accountId === id)?.displayName ?? id;
|
||||
}
|
||||
async friendsList(): Promise<AccountRef[]> {
|
||||
return this.friends.map((f) => ({ ...f }));
|
||||
}
|
||||
async friendsIncoming(): Promise<AccountRef[]> {
|
||||
return this.incoming.map((f) => ({ ...f }));
|
||||
}
|
||||
async friendRequest(_accountId: string): Promise<void> {
|
||||
// The real backend requires a shared game; the mock simply acknowledges.
|
||||
}
|
||||
async friendRespond(requesterId: string, accept: boolean): Promise<void> {
|
||||
const i = this.incoming.findIndex((r) => r.accountId === requesterId);
|
||||
if (i < 0) throw new GatewayError('request_not_found');
|
||||
const [r] = this.incoming.splice(i, 1);
|
||||
if (accept) this.friends.push(r);
|
||||
this.emit({ kind: 'notify', sub: 'friend_request' });
|
||||
}
|
||||
async friendCancel(_accountId: string): Promise<void> {}
|
||||
async unfriend(accountId: string): Promise<void> {
|
||||
this.friends = this.friends.filter((f) => f.accountId !== accountId);
|
||||
}
|
||||
async friendCodeIssue(): Promise<FriendCode> {
|
||||
return { code: '246813', expiresAtUnix: Math.floor(Date.now() / 1000) + 12 * 3600 };
|
||||
}
|
||||
async friendCodeRedeem(code: string): Promise<AccountRef> {
|
||||
const friend = { accountId: `code-${code}`, displayName: `Friend ${code}` };
|
||||
this.friends.push(friend);
|
||||
return { ...friend };
|
||||
}
|
||||
|
||||
// --- blocks ---
|
||||
async blocksList(): Promise<AccountRef[]> {
|
||||
return this.blocks.map((b) => ({ ...b }));
|
||||
}
|
||||
async block(accountId: string): Promise<void> {
|
||||
this.friends = this.friends.filter((f) => f.accountId !== accountId);
|
||||
if (!this.blocks.some((b) => b.accountId === accountId)) {
|
||||
this.blocks.push({ accountId, displayName: this.nameFor(accountId) });
|
||||
}
|
||||
}
|
||||
async unblock(accountId: string): Promise<void> {
|
||||
this.blocks = this.blocks.filter((b) => b.accountId !== accountId);
|
||||
}
|
||||
|
||||
// --- invitations ---
|
||||
async invitationsList(): Promise<Invitation[]> {
|
||||
return this.invitations.map((i) => structuredClone(i));
|
||||
}
|
||||
async invitationCreate(inviteeIds: string[], settings: InvitationSettings): Promise<Invitation> {
|
||||
const inv: Invitation = {
|
||||
id: crypto.randomUUID(),
|
||||
inviter: { accountId: ME, displayName: 'You' },
|
||||
invitees: inviteeIds.map((id, k) => ({ accountId: id, displayName: this.nameFor(id), seat: k + 1, response: 'pending' })),
|
||||
variant: settings.variant,
|
||||
turnTimeoutSecs: settings.turnTimeoutSecs,
|
||||
hintsAllowed: settings.hintsAllowed,
|
||||
hintsPerPlayer: settings.hintsPerPlayer,
|
||||
dropoutTiles: settings.dropoutTiles,
|
||||
status: 'pending',
|
||||
gameId: '',
|
||||
expiresAtUnix: Math.floor(Date.now() / 1000) + 7 * 86400,
|
||||
};
|
||||
this.invitations.push(inv);
|
||||
return structuredClone(inv);
|
||||
}
|
||||
private respondInvitation(invitationId: string, status: string): Invitation {
|
||||
const inv = this.invitations.find((i) => i.id === invitationId);
|
||||
if (!inv) throw new GatewayError('invitation_not_found');
|
||||
inv.status = status;
|
||||
this.invitations = this.invitations.filter((i) => i.id !== invitationId);
|
||||
return structuredClone(inv);
|
||||
}
|
||||
async invitationAccept(invitationId: string): Promise<Invitation> {
|
||||
return this.respondInvitation(invitationId, 'started');
|
||||
}
|
||||
async invitationDecline(invitationId: string): Promise<Invitation> {
|
||||
return this.respondInvitation(invitationId, 'declined');
|
||||
}
|
||||
async invitationCancel(invitationId: string): Promise<void> {
|
||||
this.invitations = this.invitations.filter((i) => i.id !== invitationId);
|
||||
}
|
||||
|
||||
// --- profile / stats / history ---
|
||||
async profileUpdate(p: ProfileUpdate): Promise<Profile> {
|
||||
Object.assign(this.profile, p);
|
||||
return { ...this.profile };
|
||||
}
|
||||
async emailBindRequest(_email: string): Promise<void> {}
|
||||
async emailBindConfirm(_email: string, _code: string): Promise<Profile> {
|
||||
this.profile.isGuest = false;
|
||||
return { ...this.profile };
|
||||
}
|
||||
async statsGet(): Promise<Stats> {
|
||||
return { ...this.stats };
|
||||
}
|
||||
async exportGcg(gameId: string): Promise<GcgExport> {
|
||||
const g = this.game(gameId);
|
||||
if (g.view.status !== 'finished') throw new GatewayError('game_active');
|
||||
return {
|
||||
gameId,
|
||||
filename: `game-${gameId}.gcg`,
|
||||
content: `#character-encoding UTF-8\n#player1 p1 You\n#player2 p2 Opp\n`,
|
||||
};
|
||||
}
|
||||
|
||||
// --- live stream ---
|
||||
subscribe(onEvent: (e: PushEvent) => void): Unsubscribe {
|
||||
this.subs.add(onEvent);
|
||||
|
||||
+43
-2
@@ -4,7 +4,17 @@
|
||||
// not need to be strictly legal here — this is a visual/interaction fixture; real
|
||||
// legality and scoring come from the backend.
|
||||
|
||||
import type { ChatMessage, GameView, MoveRecord, Profile, Seat, Session } from '../model';
|
||||
import type {
|
||||
AccountRef,
|
||||
ChatMessage,
|
||||
GameView,
|
||||
Invitation,
|
||||
MoveRecord,
|
||||
Profile,
|
||||
Seat,
|
||||
Session,
|
||||
Stats,
|
||||
} from '../model';
|
||||
|
||||
export const ME = 'me';
|
||||
|
||||
@@ -20,12 +30,43 @@ export const PROFILE: Profile = {
|
||||
displayName: 'You',
|
||||
preferredLanguage: 'en',
|
||||
timeZone: 'UTC',
|
||||
awayStart: '00:00',
|
||||
awayEnd: '07:00',
|
||||
hintBalance: 3,
|
||||
blockChat: false,
|
||||
blockFriendRequests: false,
|
||||
isGuest: true,
|
||||
isGuest: false,
|
||||
};
|
||||
|
||||
// Seed social/account data for the mock (pnpm start + Playwright). The mock profile
|
||||
// is a durable account so the Stage 8 surfaces (friends, stats, history) are reachable.
|
||||
export const MOCK_FRIENDS: AccountRef[] = [
|
||||
{ accountId: 'ann', displayName: 'Ann' },
|
||||
{ accountId: 'kaya', displayName: 'Kaya' },
|
||||
];
|
||||
|
||||
export const MOCK_INCOMING: AccountRef[] = [{ accountId: 'rick', displayName: 'Rick' }];
|
||||
|
||||
export const MOCK_STATS: Stats = { wins: 7, losses: 4, draws: 1, maxGamePoints: 421, maxWordPoints: 95 };
|
||||
|
||||
export function mockInvitations(): Invitation[] {
|
||||
return [
|
||||
{
|
||||
id: 'inv1',
|
||||
inviter: { accountId: 'kaya', displayName: 'Kaya' },
|
||||
invitees: [{ accountId: ME, displayName: 'You', seat: 1, response: 'pending' }],
|
||||
variant: 'english',
|
||||
turnTimeoutSecs: 86400,
|
||||
hintsAllowed: true,
|
||||
hintsPerPlayer: 1,
|
||||
dropoutTiles: 'remove',
|
||||
status: 'pending',
|
||||
gameId: '',
|
||||
expiresAtUnix: Math.floor(Date.now() / 1000) + 7 * 86400,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function seat(s: number, accountId: string, displayName: string, score: number, isWinner = false): Seat {
|
||||
return { seat: s, accountId, displayName, score, hintsUsed: 0, isWinner };
|
||||
}
|
||||
|
||||
@@ -100,12 +100,84 @@ export interface Profile {
|
||||
displayName: string;
|
||||
preferredLanguage: string;
|
||||
timeZone: string;
|
||||
/** "HH:MM" daily away-window bounds, in timeZone. */
|
||||
awayStart: string;
|
||||
awayEnd: string;
|
||||
hintBalance: number;
|
||||
blockChat: boolean;
|
||||
blockFriendRequests: boolean;
|
||||
isGuest: boolean;
|
||||
}
|
||||
|
||||
/** The full editable profile sent to profileUpdate (overwrites every field). */
|
||||
export interface ProfileUpdate {
|
||||
displayName: string;
|
||||
preferredLanguage: string;
|
||||
timeZone: string;
|
||||
awayStart: string;
|
||||
awayEnd: string;
|
||||
blockChat: boolean;
|
||||
blockFriendRequests: boolean;
|
||||
}
|
||||
|
||||
/** A referenced account with its display name (friend, blocked user, invitee). */
|
||||
export interface AccountRef {
|
||||
accountId: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
/** A freshly issued one-time friend code (the plaintext is returned once). */
|
||||
export interface FriendCode {
|
||||
code: string;
|
||||
expiresAtUnix: number;
|
||||
}
|
||||
|
||||
/** A durable account's lifetime statistics. */
|
||||
export interface Stats {
|
||||
wins: number;
|
||||
losses: number;
|
||||
draws: number;
|
||||
maxGamePoints: number;
|
||||
maxWordPoints: number;
|
||||
}
|
||||
|
||||
/** Settings the inviter chooses for a friend game. */
|
||||
export interface InvitationSettings {
|
||||
variant: Variant;
|
||||
turnTimeoutSecs: number;
|
||||
hintsAllowed: boolean;
|
||||
hintsPerPlayer: number;
|
||||
dropoutTiles: 'remove' | 'return';
|
||||
}
|
||||
|
||||
export interface InvitationInvitee {
|
||||
accountId: string;
|
||||
displayName: string;
|
||||
seat: number;
|
||||
response: 'pending' | 'accepted' | 'declined' | string;
|
||||
}
|
||||
|
||||
export interface Invitation {
|
||||
id: string;
|
||||
inviter: AccountRef;
|
||||
invitees: InvitationInvitee[];
|
||||
variant: Variant;
|
||||
turnTimeoutSecs: number;
|
||||
hintsAllowed: boolean;
|
||||
hintsPerPlayer: number;
|
||||
dropoutTiles: string;
|
||||
status: string;
|
||||
gameId: string;
|
||||
expiresAtUnix: number;
|
||||
}
|
||||
|
||||
/** A finished game's GCG transcript for download/share. */
|
||||
export interface GcgExport {
|
||||
gameId: string;
|
||||
filename: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
token: string;
|
||||
userId: string;
|
||||
@@ -134,4 +206,5 @@ export type PushEvent =
|
||||
| { kind: 'chat_message'; message: ChatMessage }
|
||||
| { kind: 'nudge'; gameId: string; fromUserId: string }
|
||||
| { kind: 'match_found'; gameId: string }
|
||||
| { kind: 'notify'; sub: string }
|
||||
| { kind: 'heartbeat' };
|
||||
|
||||
@@ -10,6 +10,8 @@ export type RouteName =
|
||||
| 'profile'
|
||||
| 'settings'
|
||||
| 'about'
|
||||
| 'friends'
|
||||
| 'stats'
|
||||
| 'notfound';
|
||||
|
||||
export interface Route {
|
||||
@@ -34,6 +36,10 @@ function parse(hash: string): Route {
|
||||
return { name: 'settings', params: {} };
|
||||
case 'about':
|
||||
return { name: 'about', params: {} };
|
||||
case 'friends':
|
||||
return { name: 'friends', params: {} };
|
||||
case 'stats':
|
||||
return { name: 'stats', params: {} };
|
||||
default:
|
||||
return { name: 'notfound', params: {} };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { pickGcgDelivery } from './share';
|
||||
|
||||
const file = {} as File;
|
||||
|
||||
describe('pickGcgDelivery', () => {
|
||||
it('shares when the platform can share files', () => {
|
||||
expect(pickGcgDelivery({ canShare: () => true, share: async () => {} }, file)).toBe('share');
|
||||
});
|
||||
|
||||
it('downloads when the platform cannot share files', () => {
|
||||
expect(pickGcgDelivery({ canShare: () => false, share: async () => {} }, file)).toBe('download');
|
||||
});
|
||||
|
||||
it('downloads when there is no navigator', () => {
|
||||
expect(pickGcgDelivery(undefined, file)).toBe('download');
|
||||
});
|
||||
|
||||
it('downloads when the Web Share API is incomplete', () => {
|
||||
expect(pickGcgDelivery({ canShare: () => true } as never, file)).toBe('download');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
// GCG export delivery: share on mobile (Web Share API with a file) where supported,
|
||||
// otherwise download via a Blob + <a download> on desktop. The Capacitor-native file
|
||||
// save lands with the native wrapper; the Web Share path already covers mobile
|
||||
// browsers. pickGcgDelivery is the pure decision, unit-tested with a mock navigator.
|
||||
|
||||
import type { GcgExport } from './model';
|
||||
|
||||
type ShareNav = Pick<Navigator, 'canShare' | 'share'>;
|
||||
|
||||
/** pickGcgDelivery decides between the Web Share API and a Blob download for a file. */
|
||||
export function pickGcgDelivery(nav: ShareNav | undefined, file: File): 'share' | 'download' {
|
||||
if (
|
||||
nav &&
|
||||
typeof nav.canShare === 'function' &&
|
||||
typeof nav.share === 'function' &&
|
||||
nav.canShare({ files: [file] })
|
||||
) {
|
||||
return 'share';
|
||||
}
|
||||
return 'download';
|
||||
}
|
||||
|
||||
/** shareOrDownloadGcg shares the GCG file where supported, else triggers a download. */
|
||||
export async function shareOrDownloadGcg(gcg: GcgExport): Promise<void> {
|
||||
const file = new File([gcg.content], gcg.filename, { type: 'application/x-gcg' });
|
||||
const nav = typeof navigator !== 'undefined' ? navigator : undefined;
|
||||
if (pickGcgDelivery(nav, file) === 'share' && nav) {
|
||||
try {
|
||||
await nav.share({ files: [file], title: gcg.filename });
|
||||
return;
|
||||
} catch {
|
||||
// The user cancelled or sharing failed — fall back to a download.
|
||||
}
|
||||
}
|
||||
downloadFile(gcg.content, gcg.filename);
|
||||
}
|
||||
|
||||
function downloadFile(content: string, filename: string): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
const url = URL.createObjectURL(new Blob([content], { type: 'application/x-gcg' }));
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { gamesPlayed, winRate } from './stats';
|
||||
import type { Stats } from './model';
|
||||
|
||||
const s = (wins: number, losses: number, draws: number): Stats => ({
|
||||
wins,
|
||||
losses,
|
||||
draws,
|
||||
maxGamePoints: 0,
|
||||
maxWordPoints: 0,
|
||||
});
|
||||
|
||||
describe('stats', () => {
|
||||
it('sums games played', () => {
|
||||
expect(gamesPlayed(s(7, 4, 1))).toBe(12);
|
||||
});
|
||||
|
||||
it('computes a rounded win rate', () => {
|
||||
expect(winRate(s(7, 4, 1))).toBe(58); // 7/12 = 58.33 -> 58
|
||||
expect(winRate(s(1, 1, 0))).toBe(50);
|
||||
});
|
||||
|
||||
it('win rate is 0 with no games', () => {
|
||||
expect(winRate(s(0, 0, 0))).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
// Pure derivations for the statistics screen, extracted so they are unit-testable.
|
||||
|
||||
import type { Stats } from './model';
|
||||
|
||||
/** gamesPlayed is the total finished games (wins + losses + draws). */
|
||||
export function gamesPlayed(s: Stats): number {
|
||||
return s.wins + s.losses + s.draws;
|
||||
}
|
||||
|
||||
/** winRate is the percentage of finished games won, rounded; 0 when none played. */
|
||||
export function winRate(s: Stats): number {
|
||||
const n = gamesPlayed(s);
|
||||
return n > 0 ? Math.round((s.wins / n) * 100) : 0;
|
||||
}
|
||||
@@ -119,6 +119,73 @@ export function createTransport(baseUrl: string): GatewayClient {
|
||||
return codec.decodeChatMessage(await exec('chat.nudge', codec.encodeGameAction(id)));
|
||||
},
|
||||
|
||||
async friendsList() {
|
||||
return codec.decodeFriendList(await exec('friends.list', codec.empty()));
|
||||
},
|
||||
async friendsIncoming() {
|
||||
return codec.decodeIncomingList(await exec('friends.incoming', codec.empty()));
|
||||
},
|
||||
async friendRequest(accountId) {
|
||||
await exec('friends.request', codec.encodeTarget(accountId));
|
||||
},
|
||||
async friendRespond(requesterId, accept) {
|
||||
await exec('friends.respond', codec.encodeFriendRespond(requesterId, accept));
|
||||
},
|
||||
async friendCancel(accountId) {
|
||||
await exec('friends.cancel', codec.encodeTarget(accountId));
|
||||
},
|
||||
async unfriend(accountId) {
|
||||
await exec('friends.unfriend', codec.encodeTarget(accountId));
|
||||
},
|
||||
async friendCodeIssue() {
|
||||
return codec.decodeFriendCode(await exec('friends.code.issue', codec.empty()));
|
||||
},
|
||||
async friendCodeRedeem(code) {
|
||||
return codec.decodeRedeemResult(await exec('friends.code.redeem', codec.encodeRedeemCode(code)));
|
||||
},
|
||||
|
||||
async blocksList() {
|
||||
return codec.decodeBlockList(await exec('blocks.list', codec.empty()));
|
||||
},
|
||||
async block(accountId) {
|
||||
await exec('blocks.add', codec.encodeTarget(accountId));
|
||||
},
|
||||
async unblock(accountId) {
|
||||
await exec('blocks.remove', codec.encodeTarget(accountId));
|
||||
},
|
||||
|
||||
async invitationsList() {
|
||||
return codec.decodeInvitationList(await exec('invitation.list', codec.empty()));
|
||||
},
|
||||
async invitationCreate(inviteeIds, settings) {
|
||||
return codec.decodeInvitation(await exec('invitation.create', codec.encodeCreateInvitation(inviteeIds, settings)));
|
||||
},
|
||||
async invitationAccept(invitationId) {
|
||||
return codec.decodeInvitation(await exec('invitation.accept', codec.encodeInvitationAction(invitationId)));
|
||||
},
|
||||
async invitationDecline(invitationId) {
|
||||
return codec.decodeInvitation(await exec('invitation.decline', codec.encodeInvitationAction(invitationId)));
|
||||
},
|
||||
async invitationCancel(invitationId) {
|
||||
await exec('invitation.cancel', codec.encodeInvitationAction(invitationId));
|
||||
},
|
||||
|
||||
async profileUpdate(p) {
|
||||
return codec.decodeProfile(await exec('profile.update', codec.encodeUpdateProfile(p)));
|
||||
},
|
||||
async emailBindRequest(email) {
|
||||
await exec('email.bind.request', codec.encodeEmailBind(email));
|
||||
},
|
||||
async emailBindConfirm(email, code) {
|
||||
return codec.decodeProfile(await exec('email.bind.confirm', codec.encodeEmailConfirm(email, code)));
|
||||
},
|
||||
async statsGet() {
|
||||
return codec.decodeStats(await exec('stats.get', codec.empty()));
|
||||
},
|
||||
async exportGcg(gameId) {
|
||||
return codec.decodeGcg(await exec('game.gcg', codec.encodeGameAction(gameId)));
|
||||
},
|
||||
|
||||
subscribe(onEvent, onError) {
|
||||
const ctrl = new AbortController();
|
||||
void (async () => {
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user