diff --git a/backend/internal/game/eventwire.go b/backend/internal/game/eventwire.go index c9d2b31..54262d2 100644 --- a/backend/internal/game/eventwire.go +++ b/backend/internal/game/eventwire.go @@ -52,6 +52,7 @@ func gameSummary(g Game, names []string) notify.GameSummary { TurnTimeoutSecs: int(g.TurnTimeout.Seconds()), MultipleWordsPerTurn: g.MultipleWordsPerTurn, VsAI: g.VsAI, + Kind: int(g.Kind), MoveCount: g.MoveCount, EndReason: g.EndReason, Seats: seats, diff --git a/backend/internal/notify/encode.go b/backend/internal/notify/encode.go index d2b1914..eb9109f 100644 --- a/backend/internal/notify/encode.go +++ b/backend/internal/notify/encode.go @@ -37,6 +37,7 @@ func toWireGame(g GameSummary) wire.GameView { TurnTimeoutSecs: g.TurnTimeoutSecs, MultipleWordsPerTurn: g.MultipleWordsPerTurn, VsAI: g.VsAI, + Kind: g.Kind, MoveCount: g.MoveCount, EndReason: g.EndReason, Seats: seats, diff --git a/backend/internal/notify/notify_test.go b/backend/internal/notify/notify_test.go index 5b34d8c..fb7e26f 100644 --- a/backend/internal/notify/notify_test.go +++ b/backend/internal/notify/notify_test.go @@ -115,7 +115,7 @@ func TestGameOverPayloadRoundTrips(t *testing.T) { func TestOpponentMovedPayloadRoundTrips(t *testing.T) { uid, gid := uuid.New(), uuid.New() move := engine.MoveRecord{Player: 1, Action: engine.ActionPlay, Words: []string{"STOOL"}, Score: 24, Total: 130} - summary := notify.GameSummary{ID: gid.String(), MoveCount: 9, ToMove: 0, Seats: []notify.SeatStanding{{Seat: 1, Score: 130}}} + summary := notify.GameSummary{ID: gid.String(), MoveCount: 9, ToMove: 0, Kind: 2, Seats: []notify.SeatStanding{{Seat: 1, Score: 130}}} in := notify.OpponentMoved(uid, gid, move, summary, 42) if in.Kind != notify.KindOpponentMoved { t.Fatalf("kind = %q", in.Kind) @@ -132,7 +132,7 @@ func TestOpponentMovedPayloadRoundTrips(t *testing.T) { if m == nil || m.Player() != 1 || string(m.Action()) != "play" || m.Total() != 130 { t.Fatalf("move wrong: %+v", m) } - if g := ev.Game(nil); g == nil || g.MoveCount() != 9 || g.ToMove() != 0 { + if g := ev.Game(nil); g == nil || g.MoveCount() != 9 || g.ToMove() != 0 || g.Kind() != 2 { t.Fatalf("game summary wrong: %+v", ev.Game(nil)) } } diff --git a/backend/internal/notify/payload.go b/backend/internal/notify/payload.go index abb4c95..37641e5 100644 --- a/backend/internal/notify/payload.go +++ b/backend/internal/notify/payload.go @@ -35,6 +35,9 @@ type GameSummary struct { EndReason string Seats []SeatStanding LastActivityUnix int64 + // Kind is the game's origin for the active-game limits (0 unknown, 1 vs_ai, 2 random, 3 friends); + // it rides live events so a lobby patch keeps the per-kind count correct. + Kind int } // AlphabetLetter is one variant alphabet entry (a display-only row) embedded in an diff --git a/ui/e2e/gamelimit.spec.ts b/ui/e2e/gamelimit.spec.ts index 3e54f54..00b5a6c 100644 --- a/ui/e2e/gamelimit.spec.ts +++ b/ui/e2e/gamelimit.spec.ts @@ -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(); diff --git a/ui/src/lib/gamelimits.test.ts b/ui/src/lib/gamelimits.test.ts index 0098d50..12aa3c2 100644 --- a/ui/src/lib/gamelimits.test.ts +++ b/ui/src/lib/gamelimits.test.ts @@ -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); + }); }); diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 4453d39..97e7def 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -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', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index c35230b..63e1403 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -380,9 +380,9 @@ export const ru: Record = { 'new.start': 'Начать игру', 'new.invited': 'Приглашение отправлено.', 'new.limitGuestTitle': 'Больше партий?', - 'new.limitGuestBody': 'Войдите или создайте учётную запись, чтобы играть несколько партий одновременно.', + 'new.limitGuestBody': 'Войдите или создайте учётную запись, чтобы воспользоваться всеми функциями игры.', 'new.limitDurableTitle': 'Лимит игр', - 'new.limitDurableBody': 'Вы достигли лимита одновременных игр, сначала завершите текущие.', + 'new.limitDurableBody': 'Вы достигли лимита. Доиграйте активные партии, чтобы начать новую.', 'new.limitLogin': 'Вход', 'new.noFriends': 'Сначала добавьте друзей, чтобы пригласить их.', 'new.opponentAI': 'ИИ', diff --git a/ui/src/screens/NewGame.svelte b/ui/src/screens/NewGame.svelte index d93fe4b..54a4c4f 100644 --- a/ui/src/screens/NewGame.svelte +++ b/ui/src/screens/NewGame.svelte @@ -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(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}