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:
@@ -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()));
|
||||
|
||||
Reference in New Issue
Block a user