c305363ccd
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 1s
CI / deploy (pull_request) Successful in 1m4s
Quick auto-match no longer waits on a separate screen: Enqueue opens a real game seating the caller with an empty opponent seat (new game status 'open') and the player enters it at once. A second human searching the same variant+rule joins that open game; otherwise a background reaper seats a robot after a 90s + random 0-90s wait, pushing a new in-app opponent_joined event that fills the opponent card and re-enables resign and chat in place. Matchmaking state is now the open games in the database (the in-memory pool, lobby.poll and lobby.cancel are gone), serialised by a per-bucket advisory lock. While a game is open the starter may move on their turn, but resign, chat and nudge are refused; the lobby and opponent card show "searching for opponent". Schema edited in the baseline (no prod data): 'open' status, nullable game_players.account_id for the empty seat, and a games.open_deadline_at stamp; jet code regenerated.
400 lines
13 KiB
Svelte
400 lines
13 KiB
Svelte
<script lang="ts">
|
||
import { onMount } from 'svelte';
|
||
import Screen from '../components/Screen.svelte';
|
||
import TabBar from '../components/TabBar.svelte';
|
||
import { app, handleError, showToast } from '../lib/app.svelte';
|
||
import { connection } from '../lib/connection.svelte';
|
||
import { gateway } from '../lib/gateway';
|
||
import { navigate } from '../lib/router.svelte';
|
||
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
||
import { resultBadge } from '../lib/result';
|
||
import { getLobby, setLobby } from '../lib/lobbycache';
|
||
import { groupGames } from '../lib/lobbysort';
|
||
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()]);
|
||
// The ⚙️ badge counts only what lives behind it (incoming friend requests);
|
||
// invitations surface in their own lobby section above.
|
||
app.notifications = incoming.length;
|
||
}
|
||
setLobby({ games, invitations, incoming });
|
||
} catch (e) {
|
||
handleError(e);
|
||
}
|
||
}
|
||
|
||
onMount(() => {
|
||
// Render instantly from the cached lists (so the screen does not "draw in" during
|
||
// the back-slide), then refresh in the background.
|
||
const cached = getLobby();
|
||
if (cached) {
|
||
games = cached.games;
|
||
invitations = cached.invitations;
|
||
incoming = cached.incoming;
|
||
}
|
||
void load();
|
||
});
|
||
$effect(() => {
|
||
if (app.lastEvent) void load();
|
||
});
|
||
|
||
const myId = $derived(app.session?.userId ?? '');
|
||
const groups = $derived(groupGames(games, myId));
|
||
|
||
function opponents(g: GameView): string {
|
||
// An auto-match game still waiting for an opponent shows the "searching" placeholder.
|
||
if (g.status === 'open') return t('game.searchingForOpponent');
|
||
return g.seats
|
||
.filter((s) => s.accountId !== myId)
|
||
.map((s) => s.displayName)
|
||
.join(', ');
|
||
}
|
||
function scoreline(g: GameView): string {
|
||
const me = g.seats.find((s) => s.accountId === myId);
|
||
const opp = g.seats.filter((s) => s.accountId !== myId).map((s) => s.score);
|
||
return `${me?.score ?? 0} : ${opp.join(', ')}`;
|
||
}
|
||
|
||
// Hiding a finished game. The delete action sits behind each finished row and is
|
||
// revealed by swiping the row left (touch) or tapping its kebab (any pointer); the action is
|
||
// per-account and irreversible. Only one row is revealed at a time.
|
||
let revealedId = $state<string | null>(null);
|
||
let drag: { id: string; x0: number; y0: number } | null = null;
|
||
// A horizontal swipe must not also count as a tap that opens the game; armed on swipe,
|
||
// consumed by the next tap, and reset on the next pointerdown so a later tap is never eaten.
|
||
let swiped = false;
|
||
|
||
function onRowDown(e: PointerEvent, id: string): void {
|
||
swiped = false;
|
||
if (e.pointerType === 'mouse') return; // desktop reveals via the kebab, not a swipe
|
||
drag = { id, x0: e.clientX, y0: e.clientY };
|
||
}
|
||
function onRowUp(e: PointerEvent, finished: boolean): void {
|
||
if (!drag) return;
|
||
const dx = e.clientX - drag.x0;
|
||
const dy = e.clientY - drag.y0;
|
||
// Only a finished row reveals on a horizontal swipe; that swipe then suppresses the tap so it
|
||
// does not also open the game. Active rows ignore swipes and stay plain tap-to-open.
|
||
if (finished && Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy) * 1.4) {
|
||
swiped = true;
|
||
revealedId = dx < 0 ? drag.id : null;
|
||
}
|
||
drag = null;
|
||
}
|
||
function openGame(g: GameView): void {
|
||
if (swiped) {
|
||
swiped = false;
|
||
return;
|
||
}
|
||
if (revealedId === g.id) {
|
||
revealedId = null;
|
||
return;
|
||
}
|
||
navigate(`/game/${g.id}`);
|
||
}
|
||
function toggleReveal(id: string): void {
|
||
revealedId = revealedId === id ? null : id;
|
||
}
|
||
async function hide(id: string): Promise<void> {
|
||
revealedId = null;
|
||
const prev = games;
|
||
games = games.filter((g) => g.id !== id); // optimistic; the backend already filters it out
|
||
setLobby({ games, invitations, incoming });
|
||
try {
|
||
await gateway.hideGame(id);
|
||
} catch (e) {
|
||
games = prev;
|
||
setLobby({ games, invitations, incoming });
|
||
handleError(e);
|
||
}
|
||
}
|
||
|
||
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> = {
|
||
scrabble_en: 'new.english',
|
||
scrabble_ru: 'new.russian',
|
||
erudit_ru: 'new.erudit',
|
||
};
|
||
</script>
|
||
|
||
<Screen title={app.profile?.displayName ?? t('app.title')}>
|
||
<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}
|
||
{#if !inv.multipleWordsPerTurn}<span class="sub">{t('game.oneWordRule')}</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.yourTurn', list: groups.yourTurn, finished: false }, { h: 'lobby.theirTurn', list: groups.theirTurn, finished: false }, { h: 'lobby.finishedGames', list: groups.finished, finished: true }] as group (group.h)}
|
||
{#if group.list.length}
|
||
<section>
|
||
<h2>{t(group.h as 'lobby.yourTurn')}</h2>
|
||
<div class="list">
|
||
{#each group.list as g (g.id)}
|
||
<div class="rowwrap" class:revealed={group.finished && revealedId === g.id}>
|
||
{#if group.finished}
|
||
<button class="del" onclick={() => hide(g.id)} disabled={!connection.online} aria-label={t('lobby.hideGame')}>❌</button>
|
||
{/if}
|
||
<div class="row">
|
||
<button
|
||
class="open"
|
||
onpointerdown={(e) => onRowDown(e, g.id)}
|
||
onpointerup={(e) => onRowUp(e, group.finished)}
|
||
onclick={() => openGame(g)}
|
||
>
|
||
<span class="info">
|
||
<span class="who">{opponents(g) || '—'}</span>
|
||
<span class="sub">{scoreline(g)}</span>
|
||
</span>
|
||
<span class="emoji">{resultBadge(g, myId).emoji}</span>
|
||
</button>
|
||
{#if group.finished}
|
||
<button class="kebab" onclick={() => toggleReveal(g.id)} aria-label={t('lobby.hideGame')}>⋮</button>
|
||
{:else}
|
||
<!-- A visual duplicate of the row's tap target: opens the game on tap, but kept out
|
||
of the tab order / a11y tree since the .open button already does the same. -->
|
||
<button class="chev" onclick={() => openGame(g)} tabindex={-1} aria-hidden="true">›</button>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
</section>
|
||
{/if}
|
||
{/each}
|
||
|
||
{#if !games.length && !invitations.length}
|
||
<p class="empty">{t('lobby.noActive')}</p>
|
||
{/if}
|
||
</div>
|
||
|
||
{#snippet tabbar()}
|
||
<TabBar>
|
||
<button class="tab" onclick={() => navigate('/new')}>
|
||
<span class="sq">🎲</span><span class="lbl">{t('lobby.new')}</span>
|
||
</button>
|
||
<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.tournaments')}</span>
|
||
</button>
|
||
<button class="tab" onclick={() => navigate('/settings')}>
|
||
<span class="sq">⚙️{#if app.notifications > 0}<span class="badge">{app.notifications}</span>{/if}</span>
|
||
<span class="lbl">{t('lobby.settings')}</span>
|
||
</button>
|
||
</TabBar>
|
||
{/snippet}
|
||
</Screen>
|
||
|
||
<style>
|
||
.lobby {
|
||
padding: var(--pad);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 18px;
|
||
}
|
||
h2 {
|
||
font-size: 0.9rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.04em;
|
||
color: var(--text-muted);
|
||
margin: 0 0 8px;
|
||
}
|
||
.empty {
|
||
color: var(--text-muted);
|
||
font-size: 0.9rem;
|
||
margin: 0;
|
||
}
|
||
.invite {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
width: 100%;
|
||
text-align: left;
|
||
padding: 12px 14px;
|
||
margin-bottom: 8px;
|
||
border: 1px solid var(--border);
|
||
background: var(--surface);
|
||
color: var(--text);
|
||
border-radius: var(--radius);
|
||
user-select: none;
|
||
}
|
||
/* Game rows are a compact, flat list: no per-card frame, a hairline divider between
|
||
consecutive rows. */
|
||
.list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
/* Each finished row can slide left to reveal a delete action sitting behind it; the row's
|
||
own opaque background hides that action until revealed. */
|
||
.rowwrap {
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
.rowwrap + .rowwrap {
|
||
border-top: 1px solid var(--border);
|
||
}
|
||
.del {
|
||
position: absolute;
|
||
inset: 0 0 0 auto;
|
||
width: 64px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border: none;
|
||
background: var(--danger);
|
||
color: #fff;
|
||
font-size: 1.1rem;
|
||
}
|
||
.row {
|
||
position: relative;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 2px;
|
||
background: var(--bg);
|
||
transform: translateX(0);
|
||
transition: transform 0.18s ease;
|
||
}
|
||
.rowwrap.revealed .row {
|
||
transform: translateX(-64px);
|
||
}
|
||
.open {
|
||
flex: 1 1 auto;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
min-width: 0;
|
||
text-align: left;
|
||
padding: 10px 6px;
|
||
border: none;
|
||
background: none;
|
||
color: var(--text);
|
||
user-select: none;
|
||
touch-action: pan-y; /* keep vertical list scroll; we only read horizontal swipes */
|
||
}
|
||
/* A tap/click on a game row leaves no highlight: drop the WebKit tap-flash on
|
||
both tappable areas (the open body and the chevron / kebab) and the held
|
||
:active background, which added nothing and only spoiled the look. */
|
||
.open,
|
||
.chev,
|
||
.kebab {
|
||
-webkit-tap-highlight-color: transparent;
|
||
}
|
||
.kebab {
|
||
flex: 0 0 auto;
|
||
width: 30px;
|
||
padding: 10px 0;
|
||
border: none;
|
||
background: none;
|
||
color: var(--text-muted);
|
||
font-size: 1.4rem;
|
||
line-height: 1;
|
||
}
|
||
.chev {
|
||
flex: 0 0 auto;
|
||
width: 30px;
|
||
padding: 10px 0;
|
||
border: none;
|
||
background: none;
|
||
text-align: center;
|
||
color: var(--text-muted);
|
||
font-size: 1.4rem;
|
||
line-height: 1;
|
||
}
|
||
.info {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
min-width: 0;
|
||
}
|
||
.who {
|
||
font-weight: 600;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
.sub {
|
||
font-size: 0.85rem;
|
||
color: var(--text-muted);
|
||
}
|
||
.emoji {
|
||
font-size: 1.8rem;
|
||
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>
|