Release v1.14.0 — monetization launch (E5-E8) #237

Merged
developer merged 54 commits from development into master 2026-07-10 10:11:02 +00:00
9 changed files with 36 additions and 10 deletions
Showing only changes of commit e40adfb0c7 - Show all commits
+1
View File
@@ -52,6 +52,7 @@ func gameSummary(g Game, names []string) notify.GameSummary {
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()), TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
MultipleWordsPerTurn: g.MultipleWordsPerTurn, MultipleWordsPerTurn: g.MultipleWordsPerTurn,
VsAI: g.VsAI, VsAI: g.VsAI,
Kind: int(g.Kind),
MoveCount: g.MoveCount, MoveCount: g.MoveCount,
EndReason: g.EndReason, EndReason: g.EndReason,
Seats: seats, Seats: seats,
+1
View File
@@ -37,6 +37,7 @@ func toWireGame(g GameSummary) wire.GameView {
TurnTimeoutSecs: g.TurnTimeoutSecs, TurnTimeoutSecs: g.TurnTimeoutSecs,
MultipleWordsPerTurn: g.MultipleWordsPerTurn, MultipleWordsPerTurn: g.MultipleWordsPerTurn,
VsAI: g.VsAI, VsAI: g.VsAI,
Kind: g.Kind,
MoveCount: g.MoveCount, MoveCount: g.MoveCount,
EndReason: g.EndReason, EndReason: g.EndReason,
Seats: seats, Seats: seats,
+2 -2
View File
@@ -115,7 +115,7 @@ func TestGameOverPayloadRoundTrips(t *testing.T) {
func TestOpponentMovedPayloadRoundTrips(t *testing.T) { func TestOpponentMovedPayloadRoundTrips(t *testing.T) {
uid, gid := uuid.New(), uuid.New() uid, gid := uuid.New(), uuid.New()
move := engine.MoveRecord{Player: 1, Action: engine.ActionPlay, Words: []string{"STOOL"}, Score: 24, Total: 130} 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) in := notify.OpponentMoved(uid, gid, move, summary, 42)
if in.Kind != notify.KindOpponentMoved { if in.Kind != notify.KindOpponentMoved {
t.Fatalf("kind = %q", in.Kind) 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 { if m == nil || m.Player() != 1 || string(m.Action()) != "play" || m.Total() != 130 {
t.Fatalf("move wrong: %+v", m) 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)) t.Fatalf("game summary wrong: %+v", ev.Game(nil))
} }
} }
+3
View File
@@ -35,6 +35,9 @@ type GameSummary struct {
EndReason string EndReason string
Seats []SeatStanding Seats []SeatStanding
LastActivityUnix int64 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 // AlphabetLetter is one variant alphabet entry (a display-only row) embedded in an
+1 -1
View File
@@ -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. // Tapping the locked start opens the modal instead of a game, and does not navigate away.
await start.click(); 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(notice).toBeVisible();
await expect(page).toHaveURL(/\/new$/); await expect(page).toHaveURL(/\/new$/);
await page.getByRole('button', { name: /^ok$/i }).click(); await page.getByRole('button', { name: /^ok$/i }).click();
+8
View File
@@ -74,4 +74,12 @@ describe('isKindLocked', () => {
it('a durable account stays well under its higher cap', () => { it('a durable account stays well under its higher cap', () => {
expect(isKindLocked([game(GameKind.random, 'active')], durable, GameKind.random)).toBe(false); 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);
});
}); });
+2 -2
View File
@@ -380,9 +380,9 @@ export const en = {
'new.start': 'Start game', 'new.start': 'Start game',
'new.invited': 'Invitation sent.', 'new.invited': 'Invitation sent.',
'new.limitGuestTitle': 'More games?', '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.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.limitLogin': 'Sign in',
'new.noFriends': 'Add friends first to invite them.', 'new.noFriends': 'Add friends first to invite them.',
'new.opponentAI': 'AI', 'new.opponentAI': 'AI',
+2 -2
View File
@@ -380,9 +380,9 @@ export const ru: Record<MessageKey, string> = {
'new.start': 'Начать игру', 'new.start': 'Начать игру',
'new.invited': 'Приглашение отправлено.', 'new.invited': 'Приглашение отправлено.',
'new.limitGuestTitle': 'Больше партий?', 'new.limitGuestTitle': 'Больше партий?',
'new.limitGuestBody': 'Войдите или создайте учётную запись, чтобы играть несколько партий одновременно.', 'new.limitGuestBody': 'Войдите или создайте учётную запись, чтобы воспользоваться всеми функциями игры.',
'new.limitDurableTitle': 'Лимит игр', 'new.limitDurableTitle': 'Лимит игр',
'new.limitDurableBody': 'Вы достигли лимита одновременных игр, сначала завершите текущие.', 'new.limitDurableBody': 'Вы достигли лимита. Доиграйте активные партии, чтобы начать новую.',
'new.limitLogin': 'Вход', 'new.limitLogin': 'Вход',
'new.noFriends': 'Сначала добавьте друзей, чтобы пригласить их.', 'new.noFriends': 'Сначала добавьте друзей, чтобы пригласить их.',
'new.opponentAI': 'ИИ', 'new.opponentAI': 'ИИ',
+16 -3
View File
@@ -18,7 +18,7 @@
import { validDisplayName } from '../lib/profileValidation'; import { validDisplayName } from '../lib/profileValidation';
import { verifyPin, type PinLock } from '../lib/pin'; import { verifyPin, type PinLock } from '../lib/pin';
import { commitName, rosterReady, buildSeats, shuffleSeeded, type RosterRow } from '../lib/roster'; 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 { import {
availableVariants, availableVariants,
VARIANT_FLAG, VARIANT_FLAG,
@@ -60,7 +60,10 @@
// funnel modal instead of enqueuing (the server also refuses it); offline never locks (local // 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. // 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 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); let limitOpen = $state(false);
// --- auto-match --- // --- auto-match ---
@@ -129,6 +132,16 @@
); );
onMount(async () => { 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 // 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. // is off, so skip the friend fetch — it would be refused by the transport and toast an error.
if (guest || offlineMode.active) return; if (guest || offlineMode.active) return;
@@ -349,7 +362,7 @@
open={limitOpen} open={limitOpen}
{guest} {guest}
onclose={() => (limitOpen = false)} onclose={() => (limitOpen = false)}
onlogin={() => { limitOpen = false; navigate('/settings'); }} onlogin={() => { limitOpen = false; navigate('/profile'); }}
/> />
{:else if offlineMode.active} {:else if offlineMode.active}
<!-- Offline pass-and-play (hotseat): a device-local 2-4 human game. The host sets a master <!-- Offline pass-and-play (hotseat): a device-local 2-4 human game. The host sets a master