feat: honest AI opponent in quick game
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m12s

New Game's quick game gains an explicit opponent selector — 🤖 AI (default)
or 👤 Random player. AI starts a game seated with a pooled robot that joins
and moves at once: no per-move timeout (a 7-day inactivity loss reusing the
turn-timeout sweeper), chat/nudge disabled, no statistics, the opponent shown
as 🤖 everywhere. The random path (disguised robot) is unchanged.

Driven by one game flag (games.vs_ai), set only on AI-started games so the
disguised path is never revealed; Matchmaker.StartVsAI seats the robot
directly (no open pool); the robot replies event-driven via the game service's
after-commit/after-create hook. Wire: vs_ai on EnqueueRequest + GameView.
This commit is contained in:
Ilia Denisov
2026-06-15 20:14:24 +02:00
parent 91d5c341ef
commit aa765a0c06
56 changed files with 901 additions and 86 deletions
+31 -1
View File
@@ -10,7 +10,9 @@ test('quick game: enter immediately, wait for an opponent, then it joins', async
await page.getByRole('button', { name: /guest/i }).click();
await page.getByRole('button', { name: /New/ }).click(); // lobby tab bar -> auto-match
// Pick a variant and start; the player lands in the game at once (no "searching" screen).
// Choose a random human opponent (the default is the AI); pick a variant and start. The player
// lands in an open game at once (no "searching" screen).
await page.getByRole('button', { name: 'Random player' }).click();
await page.locator('.variant').first().click();
await page.getByRole('button', { name: /Start game/i }).click();
await expect(page.locator('[data-cell]').first()).toBeVisible();
@@ -30,6 +32,33 @@ test('quick game: enter immediately, wait for an opponent, then it joins', async
await expect(page.getByRole('button', { name: 'Drop game' })).toBeEnabled();
});
// The honest-AI quick game is the default opponent: the player starts a game already seated with a
// robot shown as 🤖, with no "searching" wait. Chat and nudge are disabled (the opponent is a robot),
// add-friend is never offered, and the dictionary word-check stays available. Mock transport only.
test('AI game: 🤖 opponent, no wait, chat disabled, dictionary still works', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: /guest/i }).click();
await page.getByRole('button', { name: /New/ }).click();
// AI is the default opponent: the move-clock line is replaced by the 7-day inactivity rule.
await expect(page.getByText(/Loss after 7 days of inactivity/)).toBeVisible();
await page.locator('.variant').first().click();
await page.getByRole('button', { name: /Start game/i }).click();
// The player lands in an active game at once (no "searching" wait); the opponent shows as 🤖.
await expect(page.locator('[data-cell]').first()).toBeVisible();
await expect(page.locator('.scoreboard').getByText('🤖')).toBeVisible();
await expect(page.getByText(/Searching for opponent/)).toHaveCount(0);
// Chat is disabled (the message field), while the dictionary word-check stays usable.
await page.locator('.scoreboard').click(); // open the history
await page.getByRole('button', { name: 'Chat' }).click(); // 💬 -> comms hub
await expect(page).toHaveURL(/\/game\/.+\/chat$/);
await expect(page.locator('.chat input')).toBeDisabled();
await page.getByRole('button', { name: 'Dictionary' }).click();
await expect(page.locator('.check input')).toBeEnabled();
});
// The opponent_joined push is best-effort and never replayed, so a join that lands while the live
// stream is down is lost. The waiting game screen recovers it without a push: a poll while the
// stream is down, and a refetch on stream reconnect. The __stream hook (lib/app.svelte.ts) drops /
@@ -39,6 +68,7 @@ async function enterOpenGame(page: import('@playwright/test').Page): Promise<voi
await page.goto('/');
await page.getByRole('button', { name: /guest/i }).click();
await page.getByRole('button', { name: /New/ }).click();
await page.getByRole('button', { name: 'Random player' }).click();
await page.locator('.variant').first().click();
await page.getByRole('button', { name: /Start game/i }).click();
await expect(page.getByText(/Searching for opponent/)).toBeVisible();
+8 -3
View File
@@ -12,6 +12,7 @@
canNudge = false,
waiting = false,
nudgeOnCooldown = false,
vsAi = false,
onsend,
onnudge,
}: {
@@ -34,6 +35,9 @@
// are disabled (there is no one to message or hurry yet).
waiting?: boolean;
nudgeOnCooldown?: boolean;
// vsAi disables both controls in an honest-AI game: the opponent is a robot, so there is
// nobody to message or hurry. The fields still show (turn-driven), but disabled.
vsAi?: boolean;
onsend: (text: string) => void;
onnudge: () => void;
} = $props();
@@ -67,9 +71,10 @@
maxlength="60"
placeholder={t('chat.placeholder')}
bind:value={text}
onkeydown={(e) => e.key === 'Enter' && !busy && send()}
disabled={vsAi}
onkeydown={(e) => e.key === 'Enter' && !busy && !vsAi && send()}
/>
<button class="iconbtn" onclick={send} disabled={busy || waiting || !connection.online} aria-label={t('chat.send')}>⬆️</button>
<button class="iconbtn" onclick={send} disabled={busy || waiting || vsAi || !connection.online} aria-label={t('chat.send')}>⬆️</button>
{:else if myTurn}
<!-- Own turn, the one allowed message already sent: a caption holds the row until the
next turn (no nudge — there is no one to hurry on your own turn). -->
@@ -77,7 +82,7 @@
{:else if canNudge}
<!-- A flex:1 caption keeps the nudge pinned right whether or not the cooldown text shows. -->
<span class="cooldown">{nudgeOnCooldown ? t('chat.awaitingReply') : ''}</span>
<button class="iconbtn" onclick={onnudge} disabled={busy || waiting || nudgeOnCooldown || !connection.online} aria-label={t('chat.nudgeAction')}>🛎️</button>
<button class="iconbtn" onclick={onnudge} disabled={busy || waiting || nudgeOnCooldown || vsAi || !connection.online} aria-label={t('chat.nudgeAction')}>🛎️</button>
{/if}
</div>
</div>
+13 -1
View File
@@ -113,4 +113,16 @@
}
</script>
<Chat {messages} {myId} {busy} myTurn={isMyTurn} {canSend} {canNudge} {waiting} {nudgeOnCooldown} onsend={sendChat} onnudge={nudge} />
<Chat
{messages}
{myId}
{busy}
myTurn={isMyTurn}
{canSend}
{canNudge}
{waiting}
{nudgeOnCooldown}
vsAi={!!view && view.game.vsAi}
onsend={sendChat}
onnudge={nudge}
/>
+5
View File
@@ -869,6 +869,8 @@
function seatName(s: { accountId: string; displayName: string } | undefined): string {
if (!s) return '';
if (s.accountId === app.session?.userId) return t('common.you');
// An honest-AI game shows the robot opponent as 🤖, never its (pooled human-like) name.
if (view?.game.vsAi) return '🤖';
if (!s.accountId) return t('game.searchingForOpponent');
return s.displayName;
}
@@ -876,6 +878,7 @@
// turnLabel is the under-board status when it is not the viewer's turn: the opponent's name
// once they have joined, or a generic "opponent's turn" while the seat is still empty (waiting).
function turnLabel(): string {
if (view?.game.vsAi) return '🤖';
const s = view?.game.seats[view?.game.toMove ?? -1];
if (s && s.accountId && s.accountId !== app.session?.userId) return s.displayName;
return t('game.opponentsTurn');
@@ -885,6 +888,8 @@
// (not the still-empty seat of an open game) who is not yet a friend (an already-requested
// opponent still shows it, but disabled).
function canAddFriend(accountId: string): boolean {
// Never offer add-friend against an AI opponent.
if (view?.game.vsAi) return false;
return !!accountId && !app.profile?.isGuest && accountId !== app.session?.userId && !friends.has(accountId);
}
</script>
+12 -2
View File
@@ -32,8 +32,13 @@ multipleWordsPerTurn():boolean {
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
}
vsAi():boolean {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
}
static startEnqueueRequest(builder:flatbuffers.Builder) {
builder.startObject(2);
builder.startObject(3);
}
static addVariant(builder:flatbuffers.Builder, variantOffset:flatbuffers.Offset) {
@@ -44,15 +49,20 @@ static addMultipleWordsPerTurn(builder:flatbuffers.Builder, multipleWordsPerTurn
builder.addFieldInt8(1, +multipleWordsPerTurn, +false);
}
static addVsAi(builder:flatbuffers.Builder, vsAi:boolean) {
builder.addFieldInt8(2, +vsAi, +false);
}
static endEnqueueRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createEnqueueRequest(builder:flatbuffers.Builder, variantOffset:flatbuffers.Offset, multipleWordsPerTurn:boolean):flatbuffers.Offset {
static createEnqueueRequest(builder:flatbuffers.Builder, variantOffset:flatbuffers.Offset, multipleWordsPerTurn:boolean, vsAi:boolean):flatbuffers.Offset {
EnqueueRequest.startEnqueueRequest(builder);
EnqueueRequest.addVariant(builder, variantOffset);
EnqueueRequest.addMultipleWordsPerTurn(builder, multipleWordsPerTurn);
EnqueueRequest.addVsAi(builder, vsAi);
return EnqueueRequest.endEnqueueRequest(builder);
}
}
+12 -2
View File
@@ -98,8 +98,13 @@ lastActivityUnix():bigint {
return offset ? this.bb!.readInt64(this.bb_pos + offset) : BigInt('0');
}
vsAi():boolean {
const offset = this.bb!.__offset(this.bb_pos, 28);
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
}
static startGameView(builder:flatbuffers.Builder) {
builder.startObject(12);
builder.startObject(13);
}
static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) {
@@ -162,12 +167,16 @@ static addLastActivityUnix(builder:flatbuffers.Builder, lastActivityUnix:bigint)
builder.addFieldInt64(11, lastActivityUnix, BigInt('0'));
}
static addVsAi(builder:flatbuffers.Builder, vsAi:boolean) {
builder.addFieldInt8(12, +vsAi, +false);
}
static endGameView(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset, variantOffset:flatbuffers.Offset, dictVersionOffset:flatbuffers.Offset, statusOffset:flatbuffers.Offset, players:number, toMove:number, turnTimeoutSecs:number, multipleWordsPerTurn:boolean, moveCount:number, endReasonOffset:flatbuffers.Offset, seatsOffset:flatbuffers.Offset, lastActivityUnix:bigint):flatbuffers.Offset {
static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset, variantOffset:flatbuffers.Offset, dictVersionOffset:flatbuffers.Offset, statusOffset:flatbuffers.Offset, players:number, toMove:number, turnTimeoutSecs:number, multipleWordsPerTurn:boolean, moveCount:number, endReasonOffset:flatbuffers.Offset, seatsOffset:flatbuffers.Offset, lastActivityUnix:bigint, vsAi:boolean):flatbuffers.Offset {
GameView.startGameView(builder);
GameView.addId(builder, idOffset);
GameView.addVariant(builder, variantOffset);
@@ -181,6 +190,7 @@ static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset,
GameView.addEndReason(builder, endReasonOffset);
GameView.addSeats(builder, seatsOffset);
GameView.addLastActivityUnix(builder, lastActivityUnix);
GameView.addVsAi(builder, vsAi);
return GameView.endGameView(builder);
}
}
+2 -1
View File
@@ -68,7 +68,8 @@ export interface GatewayClient {
gamesList(): Promise<GameList>;
// --- lobby ---
lobbyEnqueue(variant: Variant, multipleWords: boolean): Promise<MatchResult>;
/** Start a quick game: human auto-match, or an honest-AI game against a robot when vsAi. */
lobbyEnqueue(variant: Variant, multipleWords: boolean, vsAi: boolean): Promise<MatchResult>;
lobbyPoll(): Promise<MatchResult>;
/** Leave the auto-match pool (idempotent); a cancelled quick-match must not stay queued. */
lobbyCancel(): Promise<void>;
+14
View File
@@ -20,6 +20,7 @@ import {
encodeCheckWord,
encodeFeedbackSubmit,
encodeDraftSave,
encodeEnqueue,
encodeExchange,
encodeStateRequest,
encodeSubmitPlay,
@@ -27,6 +28,17 @@ import {
} from './codec';
describe('codec', () => {
it('encodes an enqueue request carrying the variant, word rule and vs_ai flag', () => {
for (const vsAi of [false, true]) {
const req = fb.EnqueueRequest.getRootAsEnqueueRequest(
new ByteBuffer(encodeEnqueue('scrabble_ru', false, vsAi)),
);
expect(req.variant()).toBe('scrabble_ru');
expect(req.multipleWordsPerTurn()).toBe(false);
expect(req.vsAi()).toBe(vsAi);
}
});
it('round-trips a draft save request and view', () => {
const json = '{"rack_order":"1,0","board_tiles":[]}';
const req = fb.DraftRequest.getRootAsDraftRequest(new ByteBuffer(encodeDraftSave('g1', json)));
@@ -205,6 +217,7 @@ describe('codec', () => {
fb.GameView.addEndReason(b, er);
fb.GameView.addSeats(b, seats);
fb.GameView.addLastActivityUnix(b, BigInt(1717000000));
fb.GameView.addVsAi(b, true);
const game = fb.GameView.endGameView(b);
const games = fb.GameList.createGamesVector(b, [game]);
fb.GameList.startGameList(b);
@@ -217,6 +230,7 @@ describe('codec', () => {
expect(gl.games[0].seats[0].displayName).toBe('Ann');
expect(gl.games[0].seats[0].score).toBe(13);
expect(gl.games[0].lastActivityUnix).toBe(1717000000);
expect(gl.games[0].vsAi).toBe(true);
});
it('decodes an OutgoingRequestList of account refs', () => {
+4 -1
View File
@@ -151,12 +151,13 @@ export function encodeComplaint(gameId: string, word: string, note: string): Uin
return finish(b, fb.ComplaintRequest.endComplaintRequest(b));
}
export function encodeEnqueue(variant: Variant, multipleWords: boolean): Uint8Array {
export function encodeEnqueue(variant: Variant, multipleWords: boolean, vsAi: boolean): Uint8Array {
const b = new Builder(64);
const v = b.createString(variant);
fb.EnqueueRequest.startEnqueueRequest(b);
fb.EnqueueRequest.addVariant(b, v);
fb.EnqueueRequest.addMultipleWordsPerTurn(b, multipleWords);
fb.EnqueueRequest.addVsAi(b, vsAi);
return finish(b, fb.EnqueueRequest.endEnqueueRequest(b));
}
@@ -244,6 +245,7 @@ function decodeGameView(g: fb.GameView): GameView {
moveCount: g.moveCount(),
endReason: s(g.endReason()),
lastActivityUnix: Number(g.lastActivityUnix()),
vsAi: g.vsAi(),
seats,
};
}
@@ -781,6 +783,7 @@ function emptyGame(): GameView {
moveCount: 0,
endReason: '',
lastActivityUnix: 0,
vsAi: false,
seats: [],
};
}
+1
View File
@@ -7,6 +7,7 @@ function gameView(id: string): GameView {
id,
variant: 'scrabble_en',
dictVersion: 'v1',
vsAi: false,
status: 'active',
players: 2,
toMove: 0,
+1
View File
@@ -8,6 +8,7 @@ function gameView(moveCount: number, over = false): GameView {
id: 'g1',
variant: 'scrabble_en',
dictVersion: 'v1',
vsAi: false,
status: over ? 'finished' : 'active',
players: 2,
toMove: 1,
+3 -1
View File
@@ -44,7 +44,6 @@ export const en = {
'lobby.vs': 'vs {opponents}',
'new.title': 'New game',
'new.subtitle': 'Auto-match with another player',
'new.english': 'Scrabble',
'new.russian': 'Скрэббл',
'new.erudit': 'Erudite',
@@ -265,6 +264,9 @@ export const en = {
'new.start': 'Start game',
'new.invited': 'Invitation sent.',
'new.noFriends': 'Add friends first to invite them.',
'new.opponentAI': 'AI',
'new.opponentRandom': 'Random player',
'new.aiInactiveLimit': 'Loss after 7 days of inactivity',
'stats.title': 'Statistics',
'stats.wins': 'Wins',
+3 -1
View File
@@ -45,7 +45,6 @@ export const ru: Record<MessageKey, string> = {
'lobby.vs': 'против {opponents}',
'new.title': 'Новая игра',
'new.subtitle': 'Автоподбор соперника',
'new.english': 'Scrabble',
'new.russian': 'Скрэббл',
'new.erudit': 'Эрудит',
@@ -266,6 +265,9 @@ export const ru: Record<MessageKey, string> = {
'new.start': 'Начать игру',
'new.invited': 'Приглашение отправлено.',
'new.noFriends': 'Сначала добавьте друзей, чтобы пригласить их.',
'new.opponentAI': 'ИИ',
'new.opponentRandom': 'Случайный игрок',
'new.aiInactiveLimit': 'Поражение, если 7 дней нет активности',
'stats.title': 'Статистика',
'stats.wins': 'Победы',
+1
View File
@@ -24,6 +24,7 @@ function gameView(id: string, status: GameView['status'] = 'active', toMove = 0)
id,
variant: 'scrabble_en',
dictVersion: 'v1',
vsAi: false,
status,
players: 2,
toMove,
+1
View File
@@ -17,6 +17,7 @@ function game(id: string, status: GameView['status'], toMove: number, lastActivi
id,
variant: 'scrabble_en',
dictVersion: 'v1',
vsAi: false,
status,
players: 2,
toMove,
+34 -2
View File
@@ -156,11 +156,42 @@ export class MockGateway implements GatewayClient {
}
// --- lobby ---
async lobbyEnqueue(variant: Variant, multipleWords: boolean): Promise<MatchResult> {
async lobbyEnqueue(variant: Variant, multipleWords: boolean, vsAi: boolean): Promise<MatchResult> {
const id = crypto.randomUUID();
if (vsAi) {
// An honest-AI game starts active, already seated with a robot (rendered as 🤖 from the
// vs_ai flag); there is no open/wait phase.
const ai: MockGame = {
view: {
id,
variant,
dictVersion: 'v1',
status: 'active',
players: 2,
toMove: 0,
turnTimeoutSecs: 604800,
multipleWordsPerTurn: multipleWords,
moveCount: 0,
endReason: '',
lastActivityUnix: Math.floor(Date.now() / 1000),
vsAi: true,
seats: [
{ seat: 0, accountId: ME, displayName: 'You', score: 0, hintsUsed: 0, isWinner: false },
{ seat: 1, accountId: 'robot', displayName: 'Robot', score: 0, hintsUsed: 0, isWinner: false },
],
},
moves: [],
rack: draw(variant, 7),
bagLen: 86,
hintsRemaining: 1,
chat: [],
};
this.games.set(id, ai);
return { matched: true, game: structuredClone(ai.view) };
}
// The player enters an open game immediately and waits inside it; a robot opponent takes
// the empty seat shortly (a sped-up version of the backend's 90180 s wait), pushing
// opponent_joined so the game UI restores from the "searching for opponent" state.
const id = crypto.randomUUID();
const g: MockGame = {
view: {
id,
@@ -174,6 +205,7 @@ export class MockGateway implements GatewayClient {
moveCount: 0,
endReason: '',
lastActivityUnix: Math.floor(Date.now() / 1000),
vsAi: false,
seats: [
{ seat: 0, accountId: ME, displayName: 'You', score: 0, hintsUsed: 0, isWinner: false },
{ seat: 1, accountId: '', displayName: '', score: 0, hintsUsed: 0, isWinner: false },
+3
View File
@@ -139,6 +139,7 @@ function activeGame(): MockGame {
id: 'g1',
variant: 'scrabble_en',
dictVersion: 'v1',
vsAi: false,
status: 'active',
players: 2,
toMove: 0,
@@ -174,6 +175,7 @@ function finishedG2(): MockGame {
id: 'g2',
variant: 'scrabble_en',
dictVersion: 'v1',
vsAi: false,
status: 'finished',
players: 2,
toMove: 0,
@@ -210,6 +212,7 @@ function finishedG3(): MockGame {
id: 'g3',
variant: 'scrabble_ru',
dictVersion: 'v1',
vsAi: false,
status: 'finished',
players: 2,
toMove: 0,
+2
View File
@@ -45,6 +45,8 @@ export interface GameView {
endReason: string;
/** Lobby sort key: the current turn's start (active) or the finish time (finished), Unix seconds. */
lastActivityUnix: number;
/** true = an honest-AI game: the opponent is shown as 🤖 and chat/nudge/add-friend are disabled. */
vsAi: boolean;
seats: Seat[];
}
+1
View File
@@ -19,6 +19,7 @@ function gameView(id: string, status: GameView['status'] = 'active'): GameView {
id,
variant: 'scrabble_en',
dictVersion: 'v1',
vsAi: false,
status,
players: 2,
toMove: 0,
+1
View File
@@ -16,6 +16,7 @@ function game(seats: Seat[], status = 'finished', toMove = 0): GameView {
id: 'g',
variant: 'scrabble_en',
dictVersion: 'v1',
vsAi: false,
status,
players: seats.length,
toMove,
+2 -2
View File
@@ -84,8 +84,8 @@ export function createTransport(baseUrl: string): GatewayClient {
return codec.decodeGameList(await exec('games.list', codec.empty()));
},
async lobbyEnqueue(variant, multipleWords) {
return codec.decodeMatchResult(await exec('lobby.enqueue', codec.encodeEnqueue(variant, multipleWords)));
async lobbyEnqueue(variant, multipleWords, vsAi) {
return codec.decodeMatchResult(await exec('lobby.enqueue', codec.encodeEnqueue(variant, multipleWords, vsAi)));
},
async lobbyPoll() {
return codec.decodeMatchResult(await exec('lobby.poll', codec.empty()));
+2
View File
@@ -63,6 +63,8 @@
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');
// An honest-AI game shows the robot opponent as 🤖, never its (pooled) name.
if (g.vsAi) return '🤖';
return g.seats
.filter((s) => s.accountId !== myId)
.map((s) => s.displayName)
+10 -4
View File
@@ -39,6 +39,9 @@
const guest = $derived(app.profile?.isGuest ?? true);
let mode = $state<'auto' | 'friends'>('auto');
// The quick-game opponent: an honest AI (a robot that joins and moves at once, shown as 🤖)
// or a random human via auto-match. AI is the default.
let opponent = $state<'ai' | 'random'>('ai');
// --- auto-match ---
// Enqueue drops the player straight into a real game — a freshly opened one awaiting an
@@ -51,7 +54,7 @@
if (starting) return;
starting = true;
try {
const r = await gateway.lobbyEnqueue(v, multipleWordsForRequest(v, multipleWords));
const r = await gateway.lobbyEnqueue(v, multipleWordsForRequest(v, multipleWords), opponent === 'ai');
if (r.game) navigate(`/game/${r.game.id}`);
} catch (e) {
handleError(e);
@@ -119,7 +122,10 @@
{/if}
{#if mode === 'auto'}
<p class="subtitle">{t('new.subtitle')}</p>
<div class="seg modes">
<button class="opt" class:active={opponent === 'ai'} onclick={() => (opponent = 'ai')}>🤖 {t('new.opponentAI')}</button>
<button class="opt" class:active={opponent === 'random'} onclick={() => (opponent = 'random')}>👤 {t('new.opponentRandom')}</button>
</div>
<div class="variants">
{#each variants as v (v.id)}
<button
@@ -146,8 +152,8 @@
<input type="checkbox" bind:checked={multipleWords} />
</label>
{/if}
<p class="movelimit">{t('new.moveLimit', { n: AUTO_MATCH_HOURS })}</p>
<p class="searchhint">{t('new.searchHint')}</p>
<p class="movelimit">{opponent === 'ai' ? t('new.aiInactiveLimit') : t('new.moveLimit', { n: AUTO_MATCH_HOURS })}</p>
{#if opponent === 'random'}<p class="searchhint">{t('new.searchHint')}</p>{/if}
<button
class="invite"
disabled={!selectedAuto || !connection.online || starting}