Files
scrabble-game/ui/src/screens/NewGame.svelte
T
Ilia Denisov e9f836db87
Tests · Go / test (push) Successful in 9s
Tests · Integration / integration (push) Successful in 10s
Tests · UI / test (push) Successful in 20s
Tests · Go / test (pull_request) Successful in 8s
Tests · Integration / integration (pull_request) Successful in 11s
Tests · UI / test (pull_request) Successful in 19s
Stage 15: dual Telegram bots & language-gated variants
Service-agnostic refinement of the owner's idea: the sign-in service returns a
set of supported game languages with the user identity, and the lobby gates the
New Game variant choice by it (en -> English; ru -> Russian + Эрудит).

- Connector hosts two bots in one container (one per service language, each its
  own token + game channel; the same telegram_id spans both). ValidateInitData
  tries each token and returns the validating bot's service_language +
  supported_languages. Per-language config (TELEGRAM_BOT_TOKEN_EN/_RU, channels).
- supported_languages rides the Session (fbs, session-scoped, not persisted); the
  UI offers only the matching variants on New Game — gating only the START of a
  new game (auto-match + friend invite), not accept/open/play; backend does not
  enforce.
- service_language persisted (accounts.service_language, migration 00010, written
  every login, last-login-wins) and routes the user-facing Notify push back
  through the right bot (push-target coalesces with preferred_language).
- Admin SendToUser/SendToGameChannel gain an operator-chosen language selector in
  the console (unrelated to ValidateInitData).
- Non-Telegram logins carry the gateway default set
  (GATEWAY_DEFAULT_SUPPORTED_LANGUAGES, all variants).

Wire (committed regen): ValidateInitDataResponse +service_language
+supported_languages; Session +supported_languages; SendToUser/SendToGameChannel
+language. Docs (ARCHITECTURE/FUNCTIONAL/_ru/READMEs) + PLAN updated; stage marked done.
2026-06-05 09:35:53 +02:00

341 lines
9.0 KiB
Svelte

<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import Screen from '../components/Screen.svelte';
import { gateway } from '../lib/gateway';
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 { AccountRef, Variant } from '../lib/model';
import { availableVariants } from '../lib/variants';
// The offered variants are gated by the languages the sign-in service supports
// (Stage 15); the auto-match list and the friend-invite picker both use this.
const variants = $derived(availableVariants(app.session?.supportedLanguages));
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;
function stop() {
if (poll) {
clearInterval(poll);
poll = null;
}
}
async function find(v: Variant) {
searching = true;
try {
const r = await gateway.lobbyEnqueue(v);
if (r.matched && r.game) {
navigate(`/game/${r.game.id}`);
return;
}
poll = setInterval(async () => {
try {
const p = await gateway.lobbyPoll();
if (p.matched && p.game) {
stop();
navigate(`/game/${p.game.id}`);
}
} catch (e) {
handleError(e);
}
}, 2500);
} catch (e) {
searching = false;
handleError(e);
}
}
// --- friend game ---
let friends = $state<AccountRef[]>([]);
let selected = $state<string[]>([]);
let friendFilter = $state('');
// No default game type yet — the player must pick one (a smarter default from play
// history / language is TODO-6). '' renders the disabled placeholder option.
let inviteVariant = $state<Variant | ''>('');
let timeoutSecs = $state(86400);
let hints = $state(1);
const filteredFriends = $derived(
friendFilter.trim()
? friends.filter((f) => f.displayName.toLowerCase().includes(friendFilter.trim().toLowerCase()))
: friends,
);
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 || !inviteVariant) 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>
<Screen title={t('new.title')} back="/">
<div class="page">
{#if searching}
<div class="searching">
<div class="spinner"></div>
<p>{t('new.searching')}</p>
<button class="cancel" onclick={() => { stop(); navigate('/'); }}>{t('common.cancel')}</button>
</div>
{:else}
{#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}
<div class="fg">
<div class="picked">
<span class="ftitle">{t('new.pickFriends')} ({selected.length})</span>
<input class="search" bind:value={friendFilter} placeholder={t('new.searchFriends')} />
</div>
<div class="friends-scroll">
{#each filteredFriends 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}
{#if filteredFriends.length === 0}<p class="muted"></p>{/if}
</div>
<div class="settings-row">
<label class="field">
<span>{t('new.gameType')}</span>
<select bind:value={inviteVariant} class:placeholder={!inviteVariant}>
<option value="" disabled></option>
{#each variants as v (v.id)}<option value={v.id}>{t(v.label)}</option>{/each}
</select>
</label>
<label class="field">
<span>{t('new.moveTime')}</span>
<select bind:value={timeoutSecs}>
{#each timeouts as to (to.secs)}<option value={to.secs}>{t(to.key, { n: to.n })}</option>{/each}
</select>
</label>
<label class="field">
<span>{t('new.hintsPerPlayer')}</span>
<select bind:value={hints}>
{#each [0, 1, 2] as h (h)}<option value={h}>{h}</option>{/each}
</select>
</label>
</div>
<button class="invite" disabled={selected.length === 0 || !inviteVariant} onclick={sendInvite}>{t('new.invite')}</button>
</div>
{/if}
{/if}
</div>
</Screen>
<style>
.page {
padding: var(--pad);
display: flex;
flex-direction: column;
gap: 14px;
height: 100%;
box-sizing: border-box;
}
.subtitle {
color: var(--text-muted);
margin: 0;
}
.variants {
display: flex;
flex-direction: column;
gap: 10px;
}
.variant {
padding: 16px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius);
font-size: 1.05rem;
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);
}
.fg {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.picked {
display: flex;
flex-direction: column;
gap: 8px;
}
.ftitle {
font-size: 0.9rem;
color: var(--text-muted);
}
.search {
min-width: 0;
padding: 9px 11px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
}
.friends-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
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);
}
.settings-row {
display: flex;
gap: 8px;
}
.field {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.field span {
font-size: 0.78rem;
color: var(--text-muted);
}
.field select {
width: 100%;
box-sizing: border-box;
min-width: 0;
padding: 9px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
}
.field select.placeholder {
color: var(--text-muted);
}
.muted {
color: var(--text-muted);
margin: 0;
}
.invite {
flex: 0 0 auto;
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;
gap: 14px;
padding: 48px 0;
color: var(--text-muted);
}
.spinner {
width: 36px;
height: 36px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.cancel {
padding: 8px 16px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
}
</style>