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
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:
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
@@ -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: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ function gameView(id: string): GameView {
|
||||
id,
|
||||
variant: 'scrabble_en',
|
||||
dictVersion: 'v1',
|
||||
vsAi: false,
|
||||
status: 'active',
|
||||
players: 2,
|
||||
toMove: 0,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Победы',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 90–180 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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user