fix(games): carry game kind on live events + fix the New Game lock funnel
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m56s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m56s
Address review of the active-game limit lock: - Live-event GameViews (opponent_moved, match_found, game_started, ...) did not carry game_kind, so a lobby patch from an event zeroed a game's kind and the client's per-kind count under-counted — a capped kind read as free (a disabled start instead of the lock, and a wrong per-kind result). Thread Kind through notify.GameSummary → the event GameView. - New Game screen refreshes the lobby games on mount so the per-kind count reflects the current set, not a stale cached snapshot. - The guest funnel's login button routes to the profile screen (the account controls), not settings. - Copy: the guest prompt is "sign in to use all the game's features"; the durable notice is "finish your active games to start a new one". Regression tests: the notify opponent-moved payload carries kind; the client lock locks only the reached kind (vs_ai at cap leaves random open).
This commit is contained in:
@@ -28,7 +28,7 @@ test('new game: a start locks at its per-kind cap and the funnel modal opens', a
|
||||
|
||||
// Tapping the locked start opens the modal instead of a game, and does not navigate away.
|
||||
await start.click();
|
||||
const notice = page.getByText(/reached the limit of simultaneous games/i);
|
||||
const notice = page.getByText(/reached the limit/i);
|
||||
await expect(notice).toBeVisible();
|
||||
await expect(page).toHaveURL(/\/new$/);
|
||||
await page.getByRole('button', { name: /^ok$/i }).click();
|
||||
|
||||
@@ -74,4 +74,12 @@ describe('isKindLocked', () => {
|
||||
it('a durable account stays well under its higher cap', () => {
|
||||
expect(isKindLocked([game(GameKind.random, 'active')], durable, GameKind.random)).toBe(false);
|
||||
});
|
||||
|
||||
it('locks only the reached kind: at the vs_ai cap, random with no games stays open', () => {
|
||||
const limits = { vsAi: 3, random: 2, friends: 1 };
|
||||
const games = [game(GameKind.vsAi, 'active'), game(GameKind.vsAi, 'active'), game(GameKind.vsAi, 'active')];
|
||||
expect(isKindLocked(games, limits, GameKind.vsAi)).toBe(true);
|
||||
expect(isKindLocked(games, limits, GameKind.random)).toBe(false);
|
||||
expect(isKindLocked(games, limits, GameKind.friends)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -380,9 +380,9 @@ export const en = {
|
||||
'new.start': 'Start game',
|
||||
'new.invited': 'Invitation sent.',
|
||||
'new.limitGuestTitle': 'More games?',
|
||||
'new.limitGuestBody': 'Sign in or create an account to play several games at once.',
|
||||
'new.limitGuestBody': 'Sign in or create an account to use all the game features.',
|
||||
'new.limitDurableTitle': 'Game limit',
|
||||
'new.limitDurableBody': 'You have reached the limit of simultaneous games — finish a current one first.',
|
||||
'new.limitDurableBody': 'You have reached the limit. Finish your active games to start a new one.',
|
||||
'new.limitLogin': 'Sign in',
|
||||
'new.noFriends': 'Add friends first to invite them.',
|
||||
'new.opponentAI': 'AI',
|
||||
|
||||
@@ -380,9 +380,9 @@ export const ru: Record<MessageKey, string> = {
|
||||
'new.start': 'Начать игру',
|
||||
'new.invited': 'Приглашение отправлено.',
|
||||
'new.limitGuestTitle': 'Больше партий?',
|
||||
'new.limitGuestBody': 'Войдите или создайте учётную запись, чтобы играть несколько партий одновременно.',
|
||||
'new.limitGuestBody': 'Войдите или создайте учётную запись, чтобы воспользоваться всеми функциями игры.',
|
||||
'new.limitDurableTitle': 'Лимит игр',
|
||||
'new.limitDurableBody': 'Вы достигли лимита одновременных игр, сначала завершите текущие.',
|
||||
'new.limitDurableBody': 'Вы достигли лимита. Доиграйте активные партии, чтобы начать новую.',
|
||||
'new.limitLogin': 'Вход',
|
||||
'new.noFriends': 'Сначала добавьте друзей, чтобы пригласить их.',
|
||||
'new.opponentAI': 'ИИ',
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
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, Variant } from '../lib/model';
|
||||
import type { AccountRef, GameView, Variant } from '../lib/model';
|
||||
import {
|
||||
availableVariants,
|
||||
VARIANT_FLAG,
|
||||
@@ -60,7 +60,10 @@
|
||||
// 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);
|
||||
const autoLocked = $derived(!offlineMode.active && isKindLocked(getLobby(false)?.games ?? [], app.profile?.gameLimits, autoKind));
|
||||
// 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.
|
||||
let lobbyGames = $state<GameView[]>(getLobby(false)?.games ?? []);
|
||||
const autoLocked = $derived(!offlineMode.active && isKindLocked(lobbyGames, app.profile?.gameLimits, autoKind));
|
||||
let limitOpen = $state(false);
|
||||
|
||||
// --- auto-match ---
|
||||
@@ -129,6 +132,16 @@
|
||||
);
|
||||
|
||||
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;
|
||||
@@ -349,7 +362,7 @@
|
||||
open={limitOpen}
|
||||
{guest}
|
||||
onclose={() => (limitOpen = false)}
|
||||
onlogin={() => { limitOpen = false; navigate('/settings'); }}
|
||||
onlogin={() => { limitOpen = false; navigate('/profile'); }}
|
||||
/>
|
||||
{:else if offlineMode.active}
|
||||
<!-- Offline pass-and-play (hotseat): a device-local 2-4 human game. The host sets a master
|
||||
|
||||
Reference in New Issue
Block a user