Files
scrabble-game/ui/src/lib/result.test.ts
T
Ilia Denisov aa765a0c06
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
feat: honest AI opponent in quick game
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.
2026-06-15 20:14:24 +02:00

76 lines
2.6 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { resultBadge } from './result';
import type { GameView, Seat } from './model';
const seat = (s: number, accountId: string, score: number, isWinner = false): Seat => ({
seat: s,
accountId,
displayName: accountId,
score,
hintsUsed: 0,
isWinner,
});
function game(seats: Seat[], status = 'finished', toMove = 0): GameView {
return {
id: 'g',
variant: 'scrabble_en',
dictVersion: 'v1',
vsAi: false,
status,
players: seats.length,
toMove,
turnTimeoutSecs: 0,
multipleWordsPerTurn: true,
moveCount: 0,
endReason: '',
lastActivityUnix: 0,
seats,
};
}
describe('resultBadge', () => {
it('active: your move vs opponent', () => {
const g = game([seat(0, 'me', 5), seat(1, 'a', 3)], 'active', 0);
expect(resultBadge(g, 'me')).toEqual({ key: 'result.yourMove', emoji: '🟢' });
expect(resultBadge({ ...g, toMove: 1 }, 'me').key).toBe('result.oppMove');
});
it('open (awaiting an opponent) reads as in-progress, not a finished result', () => {
const g = game([seat(0, 'me', 0), seat(1, '', 0)], 'open', 0);
expect(resultBadge(g, 'me')).toEqual({ key: 'result.yourMove', emoji: '🟢' });
expect(resultBadge({ ...g, toMove: 1 }, 'me').key).toBe('result.oppMove');
});
it('finished two-player: victory / defeat / draw', () => {
expect(resultBadge(game([seat(0, 'me', 300, true), seat(1, 'a', 200)]), 'me')).toEqual({
key: 'result.victory',
emoji: '🏆',
});
expect(resultBadge(game([seat(0, 'me', 200), seat(1, 'a', 300, true)]), 'me')).toEqual({
key: 'result.defeat',
emoji: '🥈',
});
expect(resultBadge(game([seat(0, 'me', 200), seat(1, 'a', 200)]), 'me')).toEqual({
key: 'result.draw',
emoji: '🏅',
});
});
it('finished two-player: a 0-0 resignation is a defeat, not a score-tied win', () => {
// The opponent won by resignation (isWinner) although neither side scored — the lobby
// must read this as a loss, matching the game-detail screen (regression).
expect(resultBadge(game([seat(0, 'me', 0), seat(1, 'a', 0, true)]), 'me')).toEqual({
key: 'result.defeat',
emoji: '🥈',
});
});
it('finished four-player: places by score', () => {
const last = game([seat(0, 'me', 100), seat(1, 'a', 400, true), seat(2, 'b', 300), seat(3, 'c', 200)]);
expect(resultBadge(last, 'me')).toEqual({ key: 'result.place4', emoji: '🏅' });
const second = game([seat(0, 'me', 300), seat(1, 'a', 400, true), seat(2, 'b', 200), seat(3, 'c', 100)]);
expect(resultBadge(second, 'me')).toEqual({ key: 'result.place2', emoji: '🥈' });
});
});