Stage 17: test-contour verification & defect fixes #19

Merged
developer merged 28 commits from feature/stage-17-contour-verification-fixes into development 2026-06-07 19:20:40 +00:00
10 changed files with 110 additions and 48 deletions
Showing only changes of commit c94cd3c3bf - Show all commits
+5
View File
@@ -287,6 +287,11 @@ jobs:
export SCRABBLE_CONFIG_DIR="$conf" export SCRABBLE_CONFIG_DIR="$conf"
docker compose --ansi never build --progress plain docker compose --ansi never build --progress plain
docker compose --ansi never up -d --remove-orphans docker compose --ansi never up -d --remove-orphans
# The config-only services bind-mount the reseeded config dir. A plain `up -d`
# leaves them on the previous bind mount (the dir was rm'd + recreated), so a
# changed Caddyfile or Grafana dashboard is ignored — force-recreate them to
# pick up the fresh config.
docker compose --ansi never up -d --force-recreate --no-deps caddy otelcol prometheus tempo grafana
- name: Probe the gateway through caddy - name: Probe the gateway through caddy
run: | run: |
@@ -18,6 +18,7 @@
<a href="/_gm/complaints"{{if eq .ActiveNav "complaints"}} class="active"{{end}}>Complaints</a> <a href="/_gm/complaints"{{if eq .ActiveNav "complaints"}} class="active"{{end}}>Complaints</a>
<a href="/_gm/dictionary"{{if eq .ActiveNav "dictionary"}} class="active"{{end}}>Dictionary</a> <a href="/_gm/dictionary"{{if eq .ActiveNav "dictionary"}} class="active"{{end}}>Dictionary</a>
<a href="/_gm/broadcast"{{if eq .ActiveNav "broadcast"}} class="active"{{end}}>Broadcast</a> <a href="/_gm/broadcast"{{if eq .ActiveNav "broadcast"}} class="active"{{end}}>Broadcast</a>
<a href="/_gm/grafana/">Grafana ↗</a>
</nav> </nav>
</header> </header>
<main class="content"> <main class="content">
+4
View File
@@ -209,6 +209,10 @@ services:
GF_AUTH_BASIC_ENABLED: "false" GF_AUTH_BASIC_ENABLED: "false"
GF_USERS_ALLOW_SIGN_UP: "false" GF_USERS_ALLOW_SIGN_UP: "false"
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-admin} GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-admin}
# Disable Grafana Live: its WebSocket (/_gm/grafana/api/live/ws) otherwise hits
# caddy's Basic-Auth and re-prompts for the password on every dashboard; the
# dashboards poll and do not need Live.
GF_LIVE_MAX_CONNECTIONS: "0"
volumes: volumes:
- ${SCRABBLE_CONFIG_DIR:-.}/grafana/provisioning:/etc/grafana/provisioning:ro - ${SCRABBLE_CONFIG_DIR:-.}/grafana/provisioning:/etc/grafana/provisioning:ro
# Dashboards live under /etc/grafana (NOT /var/lib/grafana, which the # Dashboards live under /etc/grafana (NOT /var/lib/grafana, which the
+5 -1
View File
@@ -88,7 +88,11 @@ var (
func DefaultRateLimit() RateLimitConfig { func DefaultRateLimit() RateLimitConfig {
return RateLimitConfig{ return RateLimitConfig{
PublicPerMinute: 30, PublicBurst: 10, PublicPerMinute: 30, PublicBurst: 10,
UserPerMinute: 120, UserBurst: 40, // Per-user (not per-IP): one user may run several devices, each holding a
// Subscribe stream and reloading state on every live event, so the authenticated
// budget is generous (a per-user cap cannot DoS the service). Raised in Stage 17
// after multi-device play tripped the old 120/40.
UserPerMinute: 300, UserBurst: 80,
AdminPerMinute: 60, AdminBurst: 20, AdminPerMinute: 60, AdminBurst: 20,
EmailPer10Min: 5, EmailBurst: 2, EmailPer10Min: 5, EmailBurst: 2,
} }
+3 -4
View File
@@ -16,16 +16,15 @@ async function openGame(page: Page): Promise<void> {
await expect(page.locator('.pane')).toHaveCount(1); await expect(page.locator('.pane')).toHaveCount(1);
} }
test('placing a tile and confirming via 🏁 commits the move', async ({ page }) => { test('placing a tile and confirming via commits the move', async ({ page }) => {
await openGame(page); await openGame(page);
await page.locator('.rack .tile').first().click(); await page.locator('.rack .tile').first().click();
await page.locator('[data-cell]:not(.filled)').nth(30).click(); await page.locator('[data-cell]:not(.filled)').nth(30).click();
await expect(page.locator('[data-cell].pending')).toHaveCount(1); await expect(page.locator('[data-cell].pending')).toHaveCount(1);
await page.locator('.make').click(); // open the MakeMove popover (short tap) await page.locator('.make').click(); // ✅ commits the move directly (no popover)
await page.locator('.pop.go').click(); // "Make move ✅"
// After the commit the placement is cleared: no pending tile, no 🏁 control. // After the commit the placement is cleared: no pending tile, no control.
await expect(page.locator('[data-cell].pending')).toHaveCount(0); await expect(page.locator('[data-cell].pending')).toHaveCount(0);
await expect(page.locator('.make')).toBeHidden(); await expect(page.locator('.make')).toBeHidden();
}); });
+1 -1
View File
@@ -25,6 +25,6 @@ test('guest reaches a board and previews a placement', async ({ page }) => {
// The score preview appears where the hints count used to be. // The score preview appears where the hints count used to be.
await expect(page.locator('.scores')).toContainText(/\d/); await expect(page.locator('.scores')).toContainText(/\d/);
// The contextual MakeMove control (🏁) appears once a tile is pending. // The contextual MakeMove control () appears once a tile is pending.
await expect(page.locator('.make')).toBeVisible(); await expect(page.locator('.make')).toBeVisible();
}); });
+3
View File
@@ -119,4 +119,7 @@
font-size: 1.25rem; font-size: 1.25rem;
line-height: 1; line-height: 1;
} }
.iconbtn:disabled {
opacity: 0.45;
}
</style> </style>
+29 -20
View File
@@ -126,7 +126,11 @@
$effect(() => { $effect(() => {
const e = app.lastEvent; const e = app.lastEvent;
if (!e) return; if (!e) return;
if ((e.kind === 'opponent_moved' || e.kind === 'your_turn') && e.gameId === id) void load(); if (e.kind === 'opponent_moved' && e.gameId === id) {
// Skip the echo of my own move (the backend now notifies the actor too, for the
// player's other devices): this device already reloaded after the submit.
if (e.seat !== view?.seat) void load();
} else if (e.kind === 'your_turn' && e.gameId === id) void load();
else if (e.kind === 'chat_message' && e.message.gameId === id && panel === 'chat') void loadChat(); else if (e.kind === 'chat_message' && e.message.gameId === id && panel === 'chat') void loadChat();
else if (e.kind === 'nudge' && e.gameId === id && panel === 'chat') void loadChat(); else if (e.kind === 'nudge' && e.gameId === id && panel === 'chat') void loadChat();
}); });
@@ -522,13 +526,7 @@
<Rack {slots} {variant} {selected} ondown={onRackDown} /> <Rack {slots} {variant} {selected} ondown={onRackDown} />
</div> </div>
{#if !gameOver && placement.pending.length > 0} {#if !gameOver && placement.pending.length > 0}
<HoldConfirm triggerClass="make" onhold={commit}> <button class="make" onclick={commit} disabled={busy} aria-label={t('game.makeMove')}>✅</button>
{#snippet trigger()}<span class="flag">🏁</span>{/snippet}
{#snippet popover(close)}
<button class="pop go" onclick={() => { close(); commit(); }}>{t('game.makeMove')} ✅</button>
<button class="pop rs" onclick={() => { close(); resetPlacement(); }}>{t('game.reset')} ❌</button>
{/snippet}
</HoldConfirm>
{/if} {/if}
</div> </div>
{:else} {:else}
@@ -545,16 +543,22 @@
{#snippet trigger()}<span class="sq">🥺</span><span class="lbl">{t('game.skip')}</span>{/snippet} {#snippet trigger()}<span class="sq">🥺</span><span class="lbl">{t('game.skip')}</span>{/snippet}
{#snippet popover(close)}<button class="pop go" onclick={() => { close(); doPass(); }}>{t('game.confirm')} ✅</button>{/snippet} {#snippet popover(close)}<button class="pop go" onclick={() => { close(); doPass(); }}>{t('game.confirm')} ✅</button>{/snippet}
</HoldConfirm> </HoldConfirm>
<HoldConfirm triggerClass="tab" disabled={busy || !isMyTurn} onhold={doHint}> <HoldConfirm triggerClass="tab" disabled={busy || !isMyTurn || (view?.hintsRemaining ?? 0) <= 0} onhold={doHint}>
{#snippet trigger()} {#snippet trigger()}
<span class="sq">🛟{#if (view?.hintsRemaining ?? 0) > 0}<span class="badge">{view?.hintsRemaining}</span>{/if}</span> <span class="sq">🛟{#if (view?.hintsRemaining ?? 0) > 0}<span class="badge">{view?.hintsRemaining}</span>{/if}</span>
<span class="lbl">{t('game.hint')}</span> <span class="lbl">{t('game.hint')}</span>
{/snippet} {/snippet}
{#snippet popover(close)}<button class="pop go" onclick={() => { close(); doHint(); }}>{t('game.confirm')} ✅</button>{/snippet} {#snippet popover(close)}<button class="pop go" onclick={() => { close(); doHint(); }}>{t('game.confirm')} ✅</button>{/snippet}
</HoldConfirm> </HoldConfirm>
<button class="tab" disabled={busy || gameOver || placement.pending.length > 0} onclick={shuffle}> {#if placement.pending.length > 0}
<button class="tab" disabled={busy} onclick={resetPlacement}>
<span class="sq">↩️</span><span class="lbl">{t('game.reset')}</span>
</button>
{:else}
<button class="tab" disabled={busy || gameOver} onclick={shuffle}>
<span class="sq">🔀</span> <span class="sq">🔀</span>
</button> </button>
{/if}
</TabBar> </TabBar>
{/if} {/if}
{/snippet} {/snippet}
@@ -641,15 +645,18 @@
text-align: center; text-align: center;
padding: 5px 4px; padding: 5px 4px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: var(--surface-2); /* inactive seats recede: they blend into the bar, slightly sunk */
/* inactive seats read as "sunk in" */ background: transparent;
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.22); box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.18);
}
.seat .nm {
color: var(--text-muted);
} }
.seat.turn { .seat.turn {
/* the active seat is "raised": lifted clear of the others with side shadows */ /* the active seat pops: a raised, accented chip lifted clear of the bar */
background: var(--bg-elev); background: var(--surface-2);
box-shadow: box-shadow:
0 1px 2px rgba(0, 0, 0, 0.16), 0 2px 6px rgba(0, 0, 0, 0.3),
-3px 0 6px -2px rgba(0, 0, 0, 0.26), -3px 0 6px -2px rgba(0, 0, 0, 0.26),
3px 0 6px -2px rgba(0, 0, 0, 0.26); 3px 0 6px -2px rgba(0, 0, 0, 0.26);
position: relative; position: relative;
@@ -767,16 +774,18 @@
flex: 1; flex: 1;
min-width: 0; min-width: 0;
} }
.flag { .make {
font-size: 1.6rem;
}
:global(.make) {
min-width: 56px; min-width: 56px;
background: var(--accent); background: var(--accent);
color: var(--accent-text); color: var(--accent-text);
border: none;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
display: grid; display: grid;
place-items: center; place-items: center;
font-size: 1.6rem;
}
.make:disabled {
opacity: 0.55;
} }
.pop { .pop {
padding: 9px 14px; padding: 9px 14px;
+45 -18
View File
@@ -9,7 +9,7 @@ import { GatewayError } from './client';
import { navigate, router } from './router.svelte'; import { navigate, router } from './router.svelte';
import { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.svelte'; import { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.svelte';
import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref } from './theme'; import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref } from './theme';
import { insideTelegram, onTelegramPath, telegramColorScheme, telegramLaunch } from './telegram'; import { insideTelegram, onTelegramPath, telegramColorScheme, telegramLaunch, telegramOnEvent } from './telegram';
import { parseStartParam } from './deeplink'; import { parseStartParam } from './deeplink';
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session'; import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
import { clearGameCache } from './gamecache'; import { clearGameCache } from './gamecache';
@@ -52,11 +52,38 @@ let streamAlive = false;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null; let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let toastTimer: ReturnType<typeof setTimeout> | null = null; let toastTimer: ReturnType<typeof setTimeout> | null = null;
/** documentHidden reports whether the app is currently backgrounded. */ // Background/foreground tracking, to silence the reconnect banner during a normal app
// suspend (iOS lock / home, Telegram tab switch) and reconnect quietly on return.
let backgrounded = false;
let foregroundedAt = 0;
const reconnectGraceMs = 4000;
/** documentHidden reports whether the page is currently hidden. */
function documentHidden(): boolean { function documentHidden(): boolean {
return typeof document !== 'undefined' && document.visibilityState === 'hidden'; return typeof document !== 'undefined' && document.visibilityState === 'hidden';
} }
/**
* bannerSuppressed reports whether the connection banner should stay hidden: while
* backgrounded, and for a short grace after returning to the foreground — a connection
* dropped while suspended surfaces its error on resume, before the silent reconnect lands.
*/
function bannerSuppressed(): boolean {
return backgrounded || documentHidden() || Date.now() - foregroundedAt < reconnectGraceMs;
}
function goBackground(): void {
backgrounded = true;
}
function goForeground(): void {
backgrounded = false;
foregroundedAt = Date.now();
if (!app.session) return;
if (!streamAlive) openStream(); // silently re-establish a stream dropped while away
void refreshNotifications();
}
export function showToast(text: string, kind: Toast['kind'] = 'info'): void { export function showToast(text: string, kind: Toast['kind'] = 'info'): void {
app.toast = { kind, text }; app.toast = { kind, text };
if (toastTimer) clearTimeout(toastTimer); if (toastTimer) clearTimeout(toastTimer);
@@ -96,14 +123,10 @@ function openStream(): void {
}, },
() => { () => {
streamAlive = false; streamAlive = false;
// A background suspend (iOS / Telegram) drops the single-shot stream. Don't // A background suspend drops the single-shot stream. Keep the banner hidden while
// alarm the user with the connection banner while hidden — reconnect silently // backgrounded or just-resumed (bannerSuppressed); always schedule a retry.
// on return (the visibilitychange handler). Show the banner only on a failure if (!bannerSuppressed()) showToast(t('error.unavailable'), 'error');
// seen in the foreground, and retry it.
if (!documentHidden()) {
showToast(t('error.unavailable'), 'error');
scheduleReconnect(); scheduleReconnect();
}
}, },
); );
} }
@@ -114,7 +137,7 @@ function scheduleReconnect(): void {
if (reconnectTimer || !app.session) return; if (reconnectTimer || !app.session) return;
reconnectTimer = setTimeout(() => { reconnectTimer = setTimeout(() => {
reconnectTimer = null; reconnectTimer = null;
if (app.session && !streamAlive && !documentHidden()) openStream(); if (app.session && !streamAlive && !backgrounded && !documentHidden()) openStream();
}, 4000); }, 4000);
} }
@@ -353,14 +376,18 @@ export function setBoardLabels(mode: BoardLabelMode): void {
persistPrefs(); persistPrefs();
} }
// On return to the foreground: silently re-establish a stream dropped while the app // Background/foreground lifecycle: silence the reconnect banner during a suspend and
// was backgrounded (iOS/Telegram suspend it), and refresh the lobby badge for any // reconnect quietly on return (and refresh the lobby badge for any push missed while
// push 'notify' missed while hidden (poll + push, see §10). // hidden, §10). Several signals cover the platforms: the page Visibility API, the
// pageshow/pagehide pair (iOS), and Telegram's own activated/deactivated (Bot API 8.0).
if (typeof document !== 'undefined') { if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', () => { document.addEventListener('visibilitychange', () =>
if (document.visibilityState === 'visible' && app.session) { document.visibilityState === 'visible' ? goForeground() : goBackground(),
if (!streamAlive) openStream(); );
void refreshNotifications();
} }
}); if (typeof window !== 'undefined') {
window.addEventListener('pageshow', goForeground);
window.addEventListener('pagehide', goBackground);
} }
telegramOnEvent('activated', goForeground);
telegramOnEvent('deactivated', goBackground);
+10
View File
@@ -12,6 +12,7 @@ interface TelegramWebApp {
colorScheme?: 'light' | 'dark'; colorScheme?: 'light' | 'dark';
ready?: () => void; ready?: () => void;
expand?: () => void; expand?: () => void;
onEvent?: (event: string, handler: () => void) => void;
} }
function webApp(): TelegramWebApp | undefined { function webApp(): TelegramWebApp | undefined {
@@ -49,6 +50,15 @@ export function telegramLaunch(): TelegramLaunch {
return { initData: w.initData, startParam, theme: w.themeParams }; return { initData: w.initData, startParam, theme: w.themeParams };
} }
/**
* telegramOnEvent subscribes to a Telegram WebApp lifecycle event (e.g. 'activated' /
* 'deactivated', added in Bot API 8.0). It is a no-op outside Telegram or on a client
* that predates the event, so callers can register defensively.
*/
export function telegramOnEvent(event: string, handler: () => void): void {
webApp()?.onEvent?.(event, handler);
}
/** /**
* telegramColorScheme returns Telegram's active colour scheme ('light' | 'dark'), * telegramColorScheme returns Telegram's active colour scheme ('light' | 'dark'),
* or undefined outside Telegram. Inside the Mini App this — not the OS * or undefined outside Telegram. Inside the Mini App this — not the OS