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>
|
||||
|
||||
Reference in New Issue
Block a user