Files
scrabble-game/ui/src/screens/NewGame.svelte
T
Ilia Denisov a3cb917ec7
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 58s
fix(lobby): land in the opened game on enqueue + keep open games active in the lobby
Review fixes for open-game auto-match: decodeMatchResult dropped the game when matched=false (an open game awaiting an opponent), so the client never navigated into it - decode the game whenever present. The lobby grouped open games (status != 'active') into 'finished'; treat 'open' as in progress in groupGames/isMyTurn and resultBadge. The under-board status bar now reads "Opponent's turn" while the empty opponent seat is to move (instead of the searching placeholder). The New Game rule toggle is shown from the start when a Russian variant is available, so selecting a variant no longer shifts the layout.

Regression tests: codec (game decoded with matched=false), lobbysort + result (open is in progress), and the new-game e2e updated. UI-only; no backend or schema change.
2026-06-13 10:29:56 +02:00

404 lines
12 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte';
import Screen from '../components/Screen.svelte';
import { gateway } from '../lib/gateway';
import { app, handleError, showToast } from '../lib/app.svelte';
import { connection } from '../lib/connection.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,
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 languages the sign-in service supports;
// the auto-match list and the friend-invite picker both use this.
const variants = $derived(availableVariants(app.session?.supportedLanguages));
// "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);
let mode = $state<'auto' | 'friends'>('auto');
// --- 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 {
const r = await gateway.lobbyEnqueue(v, multipleWordsForRequest(v, multipleWords));
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('');
// No default game type yet — the player must pick one (a smarter default from play
// history / language would be a future refinement). '' 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',
multipleWordsPerTurn: multipleWordsForRequest(inviteVariant, multipleWords),
});
showToast(t('new.invited'));
navigate('/');
} catch (e) {
handleError(e);
}
}
</script>
<Screen title={t('new.title')} back="/">
<div class="page">
{#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"
class:selected={selectedAuto === v.id}
onclick={() => (selectedAuto = v.id)}
disabled={!connection.online}
>
<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">{t('new.moveLimit', { n: AUTO_MATCH_HOURS })}</p>
<p class="searchhint">{t('new.searchHint')}</p>
<button
class="invite"
disabled={!selectedAuto || !connection.online || starting}
onclick={() => selectedAuto && find(selectedAuto)}
>{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}>
<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}
</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;
}
/* 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 {
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);
}
.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;
}
.searchhint {
margin: 0;
text-align: center;
color: var(--text-muted);
font-size: 0.85rem;
line-height: 1.4;
}
</style>