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
+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,