2d1fadb50c
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m12s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m54s
Land the offline-model redesign (ANDROID_PLAN.md O1-O7): replace the explicit offline toggle with a single detected net-state machine, unify the lobby, and add a two-tier client-version gate. Contour-safe: both version vars empty => dormant, the wire change is additive, so web/pwa/vk/tg behaviour is unchanged unless the gate is deliberately configured. O1 net-state reducer (test-first). O2 store + wiring (connection/offline shims; +@capacitor/network). O3 remove the offline toggle + migrate the pref. O4 two-tier gate: hard update_required degrades to an offline Update/Play-offline notice (not terminal); soft GATEWAY_RECOMMENDED_CLIENT_VERSION -> X-Update-Recommended nudge. O5 unified lobby (device-local + greyed-from-cache server games; self-set identity; closes G-step-0). O6 create flows (with-friends online/offline segment + offline dict guard). O7 docs. Telegram/VK stay online-only (contour review): the offline model is channel-gated via offlineCapable() (native + plain web; false in the mini-apps). offlineMode.active is hard-gated on it, so the whole model (blue chrome, unified/greyed lobby, transport kill switch, device-local vs_ai/hotseat create) stays inert in Telegram/VK regardless of the detected net state (closing a version-lock path that could have flipped them offline); and New Game's with-friends hides the online/offline segment there, leaving the remote invite alone. ARCHITECTURE already declared "Telegram/VK are exempt" - this makes the code match. Deploy/CI: wire the version gate through the deploy (GATEWAY_MIN_CLIENT_VERSION + GATEWAY_RECOMMENDED_CLIENT_VERSION as plain unprefixed vars via compose + ci.yaml + prod-deploy.yaml + write-prod-env.sh + .env.example) so the gate is live when set (test-contour commit-hash version fails open; empty => dormant). Disable the manual android-build CI workflow for now (rename .disabled). Bump the app bundle budget 127->130 KB for the added always-loaded wiring. Fix the UI Docker stage (gateway/Dockerfile): install --ignore-scripts so the Alpine/musl SPA build no longer tries to native-build sharp (a local android:assets tool, unused in the image); esbuild's binary is an optional dep so Vite still builds. Tests: gateway go green (two-tier gate over a real HTTP handler); docker build of the landing + gateway targets green locally; compose --no-interpolate confirms the gateway env; two new telegram.spec.ts e2e (offline signal keeps the chrome online + quick-match opponent choice visible => server enqueue; with-friends shows no pass-and-play), RED-verified; svelte-check 0/0, vitest 617, e2e 248 (chromium + webkit), build + bundle-size 127.8/130.
881 lines
32 KiB
Svelte
881 lines
32 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import Screen from '../components/Screen.svelte';
|
|
import Modal from '../components/Modal.svelte';
|
|
import GameLimitModal from '../components/GameLimitModal.svelte';
|
|
import PinPad, { type PinResult } from '../components/PinPad.svelte';
|
|
import { getLobby } from '../lib/lobbycache';
|
|
import { isKindLocked } from '../lib/gamelimits';
|
|
import { GameKind } from '../lib/model';
|
|
import { gateway } from '../lib/gateway';
|
|
import { app, handleError, showToast } from '../lib/app.svelte';
|
|
import { connection } from '../lib/connection.svelte';
|
|
import { offlineMode, offlineCapable } from '../lib/offline.svelte';
|
|
import { localSource, isLocalGameId } from '../lib/gamesource';
|
|
import { newLocalGameId, randomSeed } from '../lib/localgame/id';
|
|
import { localGuestId } from '../lib/localguest';
|
|
import { navigate } from '../lib/router.svelte';
|
|
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
|
import { validDisplayName } from '../lib/profileValidation';
|
|
import { verifyPin, type PinLock } from '../lib/pin';
|
|
import { commitName, rosterReady, buildSeats, shuffleSeeded, type RosterRow } from '../lib/roster';
|
|
import type { AccountRef, GameView, Variant } from '../lib/model';
|
|
import {
|
|
availableVariants,
|
|
VARIANT_FLAG,
|
|
VARIANT_RULES,
|
|
supportsMultipleWordsToggle,
|
|
multipleWordsForRequest,
|
|
} from '../lib/variants';
|
|
|
|
// The auto-match move clock (mirrors backend game.DefaultTurnTimeout = 24h).
|
|
const AUTO_MATCH_HOURS = 24;
|
|
|
|
// The offered variants are gated by the player's profile preferences (the variants
|
|
// they enabled in Settings); the auto-match list and the friend-invite picker both use this.
|
|
const variants = $derived(availableVariants(app.profile?.variantPreferences));
|
|
// "Multiple words per turn" off is the single-word rule; it is offered for Russian games
|
|
// only (English is always standard and shows no toggle). Shared by both flows.
|
|
let multipleWords = $state(false);
|
|
// Auto-match: the variant is a select (highlight, no immediate enqueue) confirmed by the
|
|
// Start button. A lone offered variant is pre-selected; with several the player must pick.
|
|
let selectedAuto = $state<Variant | ''>('');
|
|
$effect(() => {
|
|
if (variants.length === 1 && !selectedAuto) selectedAuto = variants[0].id;
|
|
});
|
|
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);
|
|
// Whether this channel supports device-local play. Native and plain web do; the Telegram/VK mini-apps
|
|
// are online-only, so they offer no offline/hotseat segment and never create a device-local game. The
|
|
// channel is fixed for the session, so a plain const (not reactive) is enough.
|
|
const canPlayOffline = offlineCapable();
|
|
let mode = $state<'auto' | 'friends'>('auto');
|
|
// Within "with friends" (offline-capable channels only): online = a remote invite, offline = a
|
|
// pass-and-play (hotseat) game on this device. With no network the online segment is disabled and
|
|
// offline is forced — so the create flow always works.
|
|
let friendsMode = $state<'online' | 'offline'>('online');
|
|
$effect(() => {
|
|
if (offlineMode.active) friendsMode = 'offline';
|
|
});
|
|
// The quick-game opponent: an honest AI (a robot that joins and moves at once, shown as 🤖)
|
|
// or a random human via auto-match. AI is the default.
|
|
let opponent = $state<'ai' | 'random'>('ai');
|
|
|
|
// Active-game limit lock: the auto-start kind (vs_ai or random by the opponent choice) tested
|
|
// against the caller's per-kind cap over the online lobby's active games. A capped start opens the
|
|
// funnel modal instead of enqueuing (the server also refuses it); offline never locks (local
|
|
// games are uncapped). The lock lifts when the profile is re-fetched after a guest→durable upgrade.
|
|
const autoKind = $derived(opponent === 'ai' ? GameKind.vsAi : GameKind.random);
|
|
// The player's current games for the per-kind lock: seeded from the lobby snapshot for an instant
|
|
// render, then refreshed on mount (below) so a game created since the last lobby visit is counted.
|
|
// Only server games count toward the per-kind server limit (device-local games are unlimited), so
|
|
// the merged snapshot is filtered to the server ones here (the on-mount refresh below already reads
|
|
// the server list directly).
|
|
let lobbyGames = $state<GameView[]>(getLobby()?.games.filter((g) => !isLocalGameId(g.id)) ?? []);
|
|
const autoLocked = $derived(!offlineMode.active && isKindLocked(lobbyGames, app.profile?.gameLimits, autoKind));
|
|
let limitOpen = $state(false);
|
|
|
|
// --- auto-match ---
|
|
// Enqueue drops the player straight into a real game — a freshly opened one awaiting an
|
|
// opponent, or another player's open game they just joined — so we navigate into it at once
|
|
// and the player waits inside. The opponent (a human or, after the wait, a robot) takes the
|
|
// empty seat later via the opponent_joined push; there is no separate "searching" screen.
|
|
let starting = $state(false);
|
|
|
|
async function find(v: Variant) {
|
|
if (starting) return;
|
|
starting = true;
|
|
try {
|
|
// Offline mode: create a device-local vs_ai game instead of enqueuing on the backend. The
|
|
// dictionary version comes from the profile (advertised for exactly this) or, for a device-local
|
|
// guest with no profile yet, the bundled __DICT_VERSION__; the bag is seeded locally and the same
|
|
// game screen then drives it through the local source.
|
|
if (offlineMode.active) {
|
|
const version = app.profile?.dictVersions?.[v] ?? __DICT_VERSION__;
|
|
if (!version) {
|
|
handleError(new Error('dict_unavailable'));
|
|
return;
|
|
}
|
|
const id = newLocalGameId();
|
|
await localSource.create({
|
|
id,
|
|
variant: v,
|
|
dictVersion: version,
|
|
seed: randomSeed(),
|
|
multipleWords: multipleWordsForRequest(v, multipleWords),
|
|
seats: [
|
|
// The human seat carries the account id — the server session's, or a device-local guest id
|
|
// when there is no session yet — so the game screen and the lobby identify "you" and the
|
|
// correct turn exactly as for an online game (the robot uses a synthetic id).
|
|
{ kind: 'human', name: app.profile?.displayName ?? t('common.guest'), accountId: app.session?.userId ?? localGuestId() },
|
|
{ kind: 'robot', name: 'Robot' },
|
|
],
|
|
});
|
|
navigate(`/game/${id}`);
|
|
return;
|
|
}
|
|
const r = await gateway.lobbyEnqueue(v, multipleWordsForRequest(v, multipleWords), opponent === 'ai');
|
|
if (r.game) navigate(`/game/${r.game.id}`);
|
|
} catch (e) {
|
|
handleError(e);
|
|
} finally {
|
|
starting = false;
|
|
}
|
|
}
|
|
|
|
// --- friend game ---
|
|
let friends = $state<AccountRef[]>([]);
|
|
let selected = $state<string[]>([]);
|
|
let friendFilter = $state('');
|
|
// A lone offered variant is pre-selected and its picker disabled (nothing else to choose);
|
|
// with several, the player picks. '' renders the disabled placeholder option.
|
|
let inviteVariant = $state<Variant | ''>('');
|
|
$effect(() => {
|
|
if (variants.length === 1 && !inviteVariant) inviteVariant = variants[0].id;
|
|
});
|
|
|
|
// The offline dictionary guard: creating a device-local game (vs_ai or hotseat) needs the variant's
|
|
// dawg to be loadable without the network — bundled (native, always) or IndexedDB-cached (web). A
|
|
// native build bundles every variant, so this only ever bites a web install whose dawg was not
|
|
// cached. dawgReady is null while the async check runs, true/false once resolved; online it stays
|
|
// true (the guard is inert).
|
|
let dawgReady = $state<boolean | null>(true);
|
|
const guardVariant = $derived(mode === 'auto' ? selectedAuto : friendsMode === 'offline' ? inviteVariant : '');
|
|
$effect(() => {
|
|
const v = guardVariant;
|
|
if (!offlineMode.active || !v) {
|
|
dawgReady = true;
|
|
return;
|
|
}
|
|
dawgReady = null; // checking
|
|
const version = app.profile?.dictVersions?.[v] ?? __DICT_VERSION__;
|
|
let cancelled = false;
|
|
// getDawg resolves from IndexedDB / the native bundle offline (the network tier is refused by the
|
|
// kill switch), so a null result means the dictionary is genuinely unavailable on this device.
|
|
void import('../lib/dict')
|
|
.then((m) => m.getDawg(v, version))
|
|
.then((d) => {
|
|
if (!cancelled) dawgReady = !!d;
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
});
|
|
// dictBlocked gates the offline create on a genuinely-missing dictionary (never while still checking).
|
|
const dictBlocked = $derived(offlineMode.active && dawgReady === false);
|
|
|
|
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 () => {
|
|
// Refresh the lobby games so the per-kind lock counts the current set — the cached snapshot can
|
|
// lag a game created since the last lobby visit. Online only; the lock never applies offline, and
|
|
// the server enforces every cap regardless.
|
|
if (!offlineMode.active && connection.online) {
|
|
try {
|
|
lobbyGames = (await gateway.gamesList()).games;
|
|
} catch {
|
|
// Keep the cached seed on a transient failure.
|
|
}
|
|
}
|
|
// Offline mode offers only the local vs_ai flow (the friends section is hidden), and the network
|
|
// is off, so skip the friend fetch — it would be refused by the transport and toast an error.
|
|
if (guest || offlineMode.active) 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',
|
|
multipleWordsPerTurn: multipleWordsForRequest(inviteVariant, multipleWords),
|
|
});
|
|
showToast(t('new.invited'));
|
|
navigate('/');
|
|
} catch (e) {
|
|
handleError(e);
|
|
}
|
|
}
|
|
|
|
// --- offline hotseat (pass-and-play) ---
|
|
// A device-local 2-4 human game. The host (referee) sets a mandatory master PIN first — it gates
|
|
// the roster and, in-game, the host overrides; each player row optionally locks its seat with a PIN.
|
|
let hostPin = $state<PinLock | undefined>(undefined);
|
|
let rows = $state<RosterRow[]>([
|
|
{ name: '', committed: '' },
|
|
{ name: '', committed: '' },
|
|
]);
|
|
let askHostPlays = $state(false); // the "do you take a seat?" prompt shown once the host PIN is set
|
|
|
|
// The PIN-pad overlay target: what a completed PIN applies to.
|
|
type PadTarget =
|
|
| { kind: 'host-set' }
|
|
| { kind: 'host-change' }
|
|
| { kind: 'seat-set'; index: number }
|
|
| { kind: 'seat-change'; index: number }
|
|
| { kind: 'row-delete'; index: number };
|
|
let pad = $state<PadTarget | null>(null);
|
|
// The row whose delete cross is armed: tapping a row's kebab asks the host PIN, and a correct PIN
|
|
// arms its ❌ (mirroring the lobby delete) rather than removing it silently. Tapping the kebab again
|
|
// (or arming another row) clears the authorisation.
|
|
let deleteRow = $state<number | null>(null);
|
|
|
|
const padMode = (p: PadTarget): 'set' | 'verify' | 'change' =>
|
|
p.kind === 'host-set' || p.kind === 'seat-set' ? 'set' : p.kind === 'row-delete' ? 'verify' : 'change';
|
|
function padVerify(p: PadTarget): ((pin: string) => Promise<boolean>) | undefined {
|
|
if (p.kind === 'host-change' || p.kind === 'row-delete') return (pin) => verifyPin(pin, hostPin!);
|
|
if (p.kind === 'seat-change') return (pin) => verifyPin(pin, rows[p.index].pin!);
|
|
return undefined; // set flows need no verifier
|
|
}
|
|
const padTitle = (p: PadTarget): string =>
|
|
p.kind === 'host-set' || p.kind === 'host-change' || p.kind === 'row-delete'
|
|
? t('hotseat.hostPin')
|
|
: t('hotseat.setPin');
|
|
|
|
function onPad(r: PinResult): void {
|
|
const target = pad;
|
|
pad = null;
|
|
if (!target) return;
|
|
switch (target.kind) {
|
|
case 'host-set':
|
|
if (r.kind === 'set') {
|
|
hostPin = r.lock;
|
|
askHostPlays = true;
|
|
}
|
|
break;
|
|
case 'host-change':
|
|
if (r.kind === 'set') hostPin = r.lock; // the host PIN is mandatory — no remove option
|
|
break;
|
|
case 'seat-set':
|
|
if (r.kind === 'set') rows[target.index].pin = r.lock;
|
|
break;
|
|
case 'seat-change':
|
|
if (r.kind === 'set') rows[target.index].pin = r.lock;
|
|
else if (r.kind === 'removed') rows[target.index].pin = undefined;
|
|
break;
|
|
case 'row-delete':
|
|
if (r.kind === 'verified') deleteRow = target.index; // arm the ❌; the actual delete is a tap on it
|
|
break;
|
|
}
|
|
}
|
|
|
|
// onKebab toggles a row's delete authorisation: a second tap (already armed) closes it and resets
|
|
// the host authorisation; otherwise it asks the host PIN (which, on success, arms the ❌).
|
|
function onKebab(i: number): void {
|
|
if (deleteRow === i) {
|
|
deleteRow = null;
|
|
return;
|
|
}
|
|
deleteRow = null; // only one row armed at a time
|
|
pad = { kind: 'row-delete', index: i };
|
|
}
|
|
|
|
function removeRow(i: number): void {
|
|
rows = rows.filter((_, j) => j !== i);
|
|
deleteRow = null;
|
|
}
|
|
|
|
function addRow(): void {
|
|
rows = [...rows, { name: '', committed: '' }];
|
|
deleteRow = null;
|
|
}
|
|
|
|
function setHostPlays(yes: boolean): void {
|
|
askHostPlays = false;
|
|
if (!yes) return;
|
|
// Seat the host at row 0, its name reused from the profile (a normal seat — its optional seat PIN
|
|
// is separate from the master PIN).
|
|
const committed = commitName(app.profile?.displayName ?? '', '');
|
|
rows[0] = { name: committed, committed };
|
|
}
|
|
|
|
function openSeatPin(i: number): void {
|
|
pad = rows[i].pin ? { kind: 'seat-change', index: i } : { kind: 'seat-set', index: i };
|
|
}
|
|
|
|
async function startHotseat(): Promise<void> {
|
|
if (starting || !hostPin || !inviteVariant || !rosterReady(rows)) return;
|
|
starting = true;
|
|
try {
|
|
const version = app.profile?.dictVersions?.[inviteVariant] ?? __DICT_VERSION__;
|
|
if (!version) {
|
|
handleError(new Error('dict_unavailable'));
|
|
return;
|
|
}
|
|
const id = newLocalGameId();
|
|
const seed = randomSeed();
|
|
// Randomise the seating so the FIRST mover is random (the engine starts at seat 0, so shuffling
|
|
// the seats picks a random starter and turn order). Seed-driven, so replay is consistent.
|
|
// Snapshot the roster + PINs to plain objects: the rows/pins are $state proxies, and a proxy
|
|
// fails structured-clone when the record is persisted to IndexedDB (silently, best-effort store)
|
|
// — so the game would vanish on reload. See lib/localgame/store.
|
|
await localSource.create({
|
|
id,
|
|
variant: inviteVariant,
|
|
dictVersion: version,
|
|
seed,
|
|
multipleWords: multipleWordsForRequest(inviteVariant, multipleWords),
|
|
seats: $state.snapshot(shuffleSeeded(buildSeats(rows), seed)),
|
|
hotseat: true,
|
|
hostPin: hostPin ? $state.snapshot(hostPin) : undefined,
|
|
});
|
|
navigate(`/game/${id}`);
|
|
} catch (e) {
|
|
handleError(e);
|
|
} finally {
|
|
starting = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<Screen title={t('new.title')} back="/">
|
|
<div class="page">
|
|
<!-- The auto/friends selector. Online it is hidden for guests (no friends to invite). Offline
|
|
it is always shown: "quick" is the local vs_ai flow, "with friends" is the local
|
|
pass-and-play (hotseat) flow. -->
|
|
{#if !guest || offlineMode.active}
|
|
<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'}
|
|
{#if !offlineMode.active}
|
|
<div class="seg modes">
|
|
<button class="opt" class:active={opponent === 'ai'} onclick={() => (opponent = 'ai')}>🤖 {t('new.opponentAI')}</button>
|
|
<button class="opt" class:active={opponent === 'random'} onclick={() => (opponent = 'random')}>👤 {t('new.opponentRandom')}</button>
|
|
</div>
|
|
{/if}
|
|
<div class="variants">
|
|
{#each variants as v (v.id)}
|
|
<button
|
|
class="variant"
|
|
class:selected={selectedAuto === v.id}
|
|
onclick={() => (selectedAuto = v.id)}
|
|
disabled={!connection.online && !offlineMode.active}
|
|
>
|
|
<span class="vmain">
|
|
<span class="vname">{t(v.label)}</span>
|
|
{#if VARIANT_FLAG[v.id]}
|
|
<span class="vflag">{VARIANT_FLAG[v.id]}</span>
|
|
{:else}
|
|
<img class="vflag-img" src="flag-ussr.svg" alt="" />
|
|
{/if}
|
|
</span>
|
|
<span class="vrules">{t(VARIANT_RULES[v.id])}</span>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{#if variants.some((v) => supportsMultipleWordsToggle(v.id))}
|
|
<label class="toggle">
|
|
<span>{t('new.multipleWordsPerTurn')}</span>
|
|
<input type="checkbox" bind:checked={multipleWords} />
|
|
</label>
|
|
{/if}
|
|
<p class="movelimit">{opponent === 'ai' ? t('new.aiInactiveLimit') : t('new.moveLimit', { n: AUTO_MATCH_HOURS })}</p>
|
|
{#if opponent === 'random'}<p class="searchhint">{t('new.searchHint')}</p>{/if}
|
|
{#if dictBlocked}<p class="dictnote" role="alert">{t('new.dictUnavailable')}</p>{/if}
|
|
<button
|
|
class="invite"
|
|
class:locked={autoLocked}
|
|
disabled={(autoLocked ? false : !selectedAuto) || (!connection.online && !offlineMode.active) || dictBlocked || starting}
|
|
onclick={() => (autoLocked ? (limitOpen = true) : selectedAuto && find(selectedAuto))}
|
|
>{#if autoLocked}🔒 {/if}{t('new.start')}</button>
|
|
<GameLimitModal
|
|
open={limitOpen}
|
|
{guest}
|
|
onclose={() => (limitOpen = false)}
|
|
onlogin={() => { limitOpen = false; navigate('/profile'); }}
|
|
/>
|
|
{:else}
|
|
<!-- With friends: an online remote invite, or an offline pass-and-play (hotseat) on this device.
|
|
The online/offline segment is offered only on offline-capable channels (native / plain web);
|
|
the online-only Telegram/VK mini-apps skip it and show the remote invite directly. With no
|
|
network the online segment is disabled and offline is forced (friendsMode). -->
|
|
{#if canPlayOffline}
|
|
<div class="seg modes">
|
|
<button class="opt" class:active={friendsMode === 'online'} disabled={offlineMode.active} onclick={() => (friendsMode = 'online')}>{t('new.playRemote')}</button>
|
|
<button class="opt" class:active={friendsMode === 'offline'} onclick={() => (friendsMode = 'offline')}>{t('new.playLocal')}</button>
|
|
</div>
|
|
{#if offlineMode.active}<p class="dictnote">{t('new.needsNetwork')}</p>{/if}
|
|
{/if}
|
|
{#if friendsMode === 'offline'}
|
|
<!-- Offline pass-and-play (hotseat): a device-local 2-4 human game. The host sets a master
|
|
PIN first (it gates the roster); each seat may add its own PIN. -->
|
|
<div class="hostpin">
|
|
<span class="ftitle">{t('hotseat.hostPin')}</span>
|
|
<button class="plink" onclick={() => (pad = hostPin ? { kind: 'host-change' } : { kind: 'host-set' })}>
|
|
{hostPin ? t('hotseat.changePin') : t('hotseat.setPin')}
|
|
</button>
|
|
</div>
|
|
<!-- Variant plaques + the multiple-words toggle, identical to Quick Match. -->
|
|
<div class="variants">
|
|
{#each variants as v (v.id)}
|
|
<button class="variant" class:selected={inviteVariant === v.id} onclick={() => (inviteVariant = v.id)}>
|
|
<span class="vmain">
|
|
<span class="vname">{t(v.label)}</span>
|
|
{#if VARIANT_FLAG[v.id]}
|
|
<span class="vflag">{VARIANT_FLAG[v.id]}</span>
|
|
{:else}
|
|
<img class="vflag-img" src="flag-ussr.svg" alt="" />
|
|
{/if}
|
|
</span>
|
|
<span class="vrules">{t(VARIANT_RULES[v.id])}</span>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{#if variants.some((v) => supportsMultipleWordsToggle(v.id))}
|
|
<label class="toggle">
|
|
<span>{t('new.multipleWordsPerTurn')}</span>
|
|
<input type="checkbox" bind:checked={multipleWords} />
|
|
</label>
|
|
{/if}
|
|
<div class="roster">
|
|
{#each rows as row, i (i)}
|
|
<div class="prow">
|
|
<input
|
|
class="pname"
|
|
class:invalid={row.name.trim() !== '' && !validDisplayName(row.name)}
|
|
bind:value={row.name}
|
|
disabled={!hostPin}
|
|
placeholder={t('hotseat.playerName')}
|
|
maxlength="40"
|
|
oninput={() => (row.committed = commitName(row.name, row.committed))}
|
|
onblur={() => (row.name = row.committed)}
|
|
/>
|
|
<button class="plink" disabled={!hostPin || row.committed === ''} onclick={() => openSeatPin(i)}>
|
|
{row.pin ? t('hotseat.changePin') : t('hotseat.setPin')}
|
|
</button>
|
|
{#if rows.length > 2}
|
|
{#if deleteRow === i}
|
|
<button class="prow-del" aria-label={t('hotseat.removePlayer')} onclick={() => removeRow(i)}>❌</button>
|
|
{/if}
|
|
<button
|
|
class="pkebab"
|
|
class:armed={deleteRow === i}
|
|
disabled={!hostPin}
|
|
aria-label={t('hotseat.removePlayer')}
|
|
onclick={() => onKebab(i)}
|
|
>⋮</button>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{#if rows.length < 4}
|
|
<button class="addrow" disabled={!hostPin} onclick={addRow}>
|
|
+ {t('hotseat.addPlayer')}
|
|
</button>
|
|
{/if}
|
|
{#if dictBlocked}<p class="dictnote" role="alert">{t('new.dictUnavailable')}</p>{/if}
|
|
<button
|
|
class="invite"
|
|
disabled={!hostPin || !inviteVariant || !rosterReady(rows) || dictBlocked || starting}
|
|
onclick={startHotseat}
|
|
>{t('new.start')}</button>
|
|
{: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} disabled={variants.length === 1}>
|
|
<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>
|
|
{#if inviteVariant && supportsMultipleWordsToggle(inviteVariant)}
|
|
<label class="toggle">
|
|
<span>{t('new.multipleWordsPerTurn')}</span>
|
|
<input type="checkbox" bind:checked={multipleWords} />
|
|
</label>
|
|
{/if}
|
|
<button class="invite" disabled={selected.length === 0 || !inviteVariant || !connection.online} onclick={sendInvite}>{t('new.invite')}</button>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
|
|
{#if pad}
|
|
<PinPad
|
|
mode={padMode(pad)}
|
|
title={padTitle(pad)}
|
|
verify={padVerify(pad)}
|
|
allowRemove={pad.kind === 'seat-change'}
|
|
onclose={() => (pad = null)}
|
|
onresult={onPad}
|
|
/>
|
|
{/if}
|
|
{#if askHostPlays}
|
|
<Modal title={t('hotseat.hostPlaysTitle')} onclose={() => (askHostPlays = false)}>
|
|
<div class="hostplays">
|
|
<button class="ghost" onclick={() => setHostPlays(false)}>{t('hotseat.hostPlaysNo')}</button>
|
|
<button class="primary" onclick={() => setHostPlays(true)}>{t('hotseat.hostPlaysYes')}</button>
|
|
</div>
|
|
</Modal>
|
|
{/if}
|
|
</div>
|
|
</Screen>
|
|
|
|
<style>
|
|
/* Natural-flow content (matching the Profile sub-view): no forced height and no pinned CTA, so the
|
|
screen's own scroll (Screen .content + --vvh) handles overflow and a focused input scrolls into
|
|
view above the soft keyboard with a single, clean scroll container. */
|
|
.page {
|
|
padding: var(--pad);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 14px;
|
|
box-sizing: border-box;
|
|
}
|
|
.subtitle {
|
|
color: var(--text-muted);
|
|
margin: 0;
|
|
}
|
|
.variants {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
}
|
|
/* A plaque per variant (like the lobby game cards): the name with its flag on the right,
|
|
and a one-line rules summary below. */
|
|
.variant {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
padding: 12px 14px;
|
|
border: 1px solid var(--border);
|
|
background: var(--surface);
|
|
color: var(--text);
|
|
border-radius: var(--radius);
|
|
text-align: left;
|
|
user-select: none;
|
|
}
|
|
.vmain {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 8px;
|
|
}
|
|
.vname {
|
|
font-size: 1.05rem;
|
|
font-weight: 600;
|
|
}
|
|
.vflag {
|
|
font-size: 1.3rem;
|
|
line-height: 1;
|
|
}
|
|
.vflag-img {
|
|
width: 1.6rem;
|
|
height: auto;
|
|
border-radius: 2px;
|
|
}
|
|
.vrules {
|
|
font-size: 0.8rem;
|
|
color: var(--text-muted);
|
|
}
|
|
/* Selected auto-match variant: an accent inset border (the button no longer enqueues on
|
|
tap; the Start button confirms the choice). */
|
|
.variant.selected {
|
|
border-color: var(--accent);
|
|
box-shadow: inset 0 0 0 2px var(--accent);
|
|
}
|
|
.movelimit {
|
|
margin: 0;
|
|
text-align: center;
|
|
color: var(--text-muted);
|
|
font-size: 0.85rem;
|
|
}
|
|
.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 {
|
|
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 {
|
|
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);
|
|
}
|
|
.toggle {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 8px;
|
|
padding: 11px;
|
|
border: 1px solid var(--border);
|
|
background: var(--surface);
|
|
border-radius: var(--radius-sm);
|
|
user-select: none;
|
|
}
|
|
.toggle span {
|
|
font-size: 0.85rem;
|
|
color: var(--text);
|
|
}
|
|
.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;
|
|
}
|
|
/* A capped start (the active-game limit): an outline instead of the filled CTA, with a 🔒, so it
|
|
reads as gated rather than ready. Tapping it opens the funnel modal, never a game. */
|
|
.invite.locked {
|
|
background: transparent;
|
|
color: var(--accent);
|
|
}
|
|
.searchhint {
|
|
margin: 0;
|
|
text-align: center;
|
|
color: var(--text-muted);
|
|
font-size: 0.85rem;
|
|
line-height: 1.4;
|
|
}
|
|
/* The offline-blocker reason (a missing dictionary, or the online segment needing a connection). */
|
|
.dictnote {
|
|
margin: 0;
|
|
text-align: center;
|
|
color: var(--warn);
|
|
font-size: 0.85rem;
|
|
line-height: 1.4;
|
|
}
|
|
/* --- offline hotseat roster --- */
|
|
.hostpin {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 8px;
|
|
padding: 11px;
|
|
border: 1px solid var(--border);
|
|
background: var(--surface);
|
|
border-radius: var(--radius-sm);
|
|
}
|
|
.roster {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
.prow {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
.pname {
|
|
flex: 1;
|
|
min-width: 0;
|
|
padding: 10px 11px;
|
|
border: 1px solid var(--border);
|
|
background: var(--surface);
|
|
color: var(--text);
|
|
border-radius: var(--radius-sm);
|
|
}
|
|
.pname:disabled {
|
|
opacity: 0.5;
|
|
}
|
|
.pname.invalid {
|
|
border-color: var(--danger);
|
|
}
|
|
.plink {
|
|
flex: 0 0 auto;
|
|
padding: 8px 10px;
|
|
border: none;
|
|
background: none;
|
|
color: var(--accent);
|
|
font-weight: 600;
|
|
white-space: nowrap;
|
|
}
|
|
.plink:disabled {
|
|
color: var(--text-muted);
|
|
opacity: 0.6;
|
|
}
|
|
.pkebab {
|
|
flex: 0 0 auto;
|
|
padding: 6px 8px;
|
|
border: none;
|
|
background: none;
|
|
color: var(--text-muted);
|
|
font-size: 1.1rem;
|
|
line-height: 1;
|
|
}
|
|
.pkebab.armed {
|
|
color: var(--accent);
|
|
}
|
|
/* The delete cross a correct host PIN arms next to a player row (mirrors the lobby game delete). */
|
|
.prow-del {
|
|
flex: 0 0 auto;
|
|
padding: 6px 8px;
|
|
border: none;
|
|
background: none;
|
|
font-size: 1rem;
|
|
line-height: 1;
|
|
}
|
|
.addrow {
|
|
align-self: flex-start;
|
|
padding: 8px 12px;
|
|
border: 1px dashed var(--border);
|
|
background: none;
|
|
color: var(--accent);
|
|
border-radius: var(--radius-sm);
|
|
font-weight: 600;
|
|
}
|
|
.addrow:disabled {
|
|
opacity: 0.5;
|
|
color: var(--text-muted);
|
|
}
|
|
.hostplays {
|
|
display: flex;
|
|
gap: 10px;
|
|
justify-content: flex-end;
|
|
}
|
|
.hostplays button {
|
|
padding: 10px 16px;
|
|
border-radius: var(--radius-sm);
|
|
border: 1px solid var(--border);
|
|
background: var(--surface);
|
|
color: var(--text);
|
|
font-weight: 600;
|
|
}
|
|
.hostplays .primary {
|
|
background: var(--accent);
|
|
color: var(--accent-text);
|
|
border-color: var(--accent);
|
|
}
|
|
</style>
|