M2: бот и headless-прогон матчей

- PlayerView: проекция состояния, в которой боту видно только то,
  что видно живому игроку (своя рука, стол, размеры чужих рук, счётчики)
- BasicBot: политика из GAMEPLAY.md, полностью детерминированная
- Match: обёртка с состоянием и подпиской на события (её же возьмёт UI)
- runMatch: прогон матча без движка и без таймеров
- soak-тесты: 3500 полных матчей (боты и случайные легальные ходы,
  2-4 игрока), инварианты состояния проверяются после каждого действия
- npm run stats:bot — распределение исходов самоигры

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ftc
2026-09-13 10:43:06 +03:00
co-authored by Claude Opus 5
parent fddab60527
commit c60fbcbb94
19 changed files with 927 additions and 13 deletions
+119
View File
@@ -0,0 +1,119 @@
import { Card } from '../cards/Card';
import { Action, ActionType, AttackAction, DefendAction } from '../rules/Action';
import { Phase } from '../rules/GameState';
import { Bot } from './Bot';
import { isTrump, PlayerView } from './PlayerView';
/**
* The bot described in GAMEPLAY.md, implemented literally.
*
* - Opening a turn: the cheapest non-trump; a trump only if that is all it has.
* - Adding to an attack: every eligible non-trump it holds. Trumps go in only
* when the deck is empty and its hand is nothing but trumps.
* - Defending: the cheapest card that does the job, non-trumps first, and a
* take only when nothing in hand can beat the attack.
*
* Deliberately simple and entirely deterministic — it is the baseline a smarter
* opponent will be measured against, and the workhorse of the soak tests.
*
* Two points GAMEPLAY.md leaves open, decided here: cards are added one per
* decision, cheapest first (the rules core serialises additions anyway), and
* when several attacks are unbeaten the lowest slot is answered first.
*/
export class BasicBot implements Bot {
readonly name: string;
constructor(name = 'basic') {
this.name = name;
}
decide(view: PlayerView): Action {
if (!view.onMove || view.legalActions.length === 0) {
throw new Error(`${this.name} was asked to move out of turn`);
}
switch (view.phase) {
case Phase.Attack:
return this._openTurn(view);
case Phase.Reinforce:
return this._reinforce(view);
case Phase.Defence:
return this._defend(view);
default:
return view.legalActions[0];
}
}
/** Cheapest non-trump, or the cheapest trump when the hand is all trumps. */
private _openTurn(view: PlayerView): Action {
const attacks = view.legalActions.filter(isAttack);
return bestBy(attacks, (a) => cost(view, a.card));
}
private _reinforce(view: PlayerView): Action {
const attacks = view.legalActions.filter(isAttack);
const nonTrump = attacks.filter((a) => !isTrump(view, a.card));
if (nonTrump.length > 0) {
return bestBy(nonTrump, (a) => a.card.rank);
}
// Throwing trumps away is only worth it at the very end of a game, when
// the deck is exhausted and there is nothing else left to throw.
const holdingOnlyTrumps = view.hand.every((card) => isTrump(view, card));
if (attacks.length > 0 && view.deckRemaining === 0 && holdingOnlyTrumps) {
return bestBy(attacks, (a) => a.card.rank);
}
return pass(view);
}
private _defend(view: PlayerView): Action {
const defences = view.legalActions.filter(isDefend);
if (defences.length === 0) {
return take(view);
}
const firstSlot = Math.min(...defences.map((d) => d.slot));
const forSlot = defences.filter((d) => d.slot === firstSlot);
return bestBy(forSlot, (d) => cost(view, d.card));
}
}
/** Ranks a card for disposal: non-trumps first, then by value. */
function cost(view: PlayerView, card: Card): number {
return (isTrump(view, card) ? 100 : 0) + card.rank;
}
function isAttack(action: Action): action is AttackAction {
return action.type === ActionType.Attack;
}
function isDefend(action: Action): action is DefendAction {
return action.type === ActionType.Defend;
}
function pass(view: PlayerView): Action {
const action = view.legalActions.find((a) => a.type === ActionType.Pass);
if (!action) throw new Error('Expected passing to be legal');
return action;
}
function take(view: PlayerView): Action {
const action = view.legalActions.find((a) => a.type === ActionType.Take);
if (!action) throw new Error('Expected taking to be legal');
return action;
}
/** Lowest score wins; ties break on card id so the choice is reproducible. */
function bestBy<T extends AttackAction | DefendAction>(items: readonly T[], score: (item: T) => number): T {
let best = items[0];
let bestScore = score(best);
for (const item of items.slice(1)) {
const s = score(item);
if (s < bestScore || (s === bestScore && item.card.id < best.card.id)) {
best = item;
bestScore = s;
}
}
return best;
}
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "f5c8ab8d-419e-4ea8-a96a-64a2cdb24da0",
"files": [],
"subMetas": {},
"userData": {}
}
+15
View File
@@ -0,0 +1,15 @@
import { Action } from '../rules/Action';
import { PlayerView } from './PlayerView';
/**
* A decision maker for one seat.
*
* Synchronous and pure: given the same view, a bot returns the same action.
* Any pacing — the pause that makes a bot feel like it is thinking — belongs to
* the presentation layer, not here.
*/
export interface Bot {
readonly name: string;
/** Must return one of `view.legalActions`. */
decide(view: PlayerView): Action;
}
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "516a24e0-b9d2-4c08-9940-7c2d27db8cd2",
"files": [],
"subMetas": {},
"userData": {}
}
+78
View File
@@ -0,0 +1,78 @@
import { Card } from '../cards/Card';
import { Suit } from '../cards/Suit';
import { Action } from '../rules/Action';
import { GameState, Phase, PlayerId, TableSlot } from '../rules/GameState';
import { currentActor, legalActions, seatOf } from '../rules/DurakRules';
/** What one player can see about another. */
export interface OpponentView {
readonly id: PlayerId;
readonly handSize: number;
readonly out: boolean;
}
/**
* Everything a participant may legitimately know, and nothing else.
*
* Bots are handed one of these rather than the `GameState`, which makes
* cheating structurally impossible instead of a matter of discipline. It is
* also, not coincidentally, the shape a network protocol would send to a client.
*/
export interface PlayerView {
readonly me: PlayerId;
readonly hand: readonly Card[];
/** Opponents in clockwise order, starting with the seat after `me`. */
readonly opponents: readonly OpponentView[];
readonly table: readonly TableSlot[];
readonly trumpSuit: Suit;
/** The face-up card that set the trump. Visible all game, even once drawn. */
readonly trumpCard: Card;
readonly deckRemaining: number;
/** True while the trump card is still the bottom card of the draw pile. */
readonly trumpCardStillInDeck: boolean;
readonly discardCount: number;
readonly phase: Phase;
readonly turnNumber: number;
readonly attacker: PlayerId;
readonly defender: PlayerId;
readonly defenderTook: boolean;
/** True when the game is waiting on this player. */
readonly onMove: boolean;
/** Everything this player may do right now. Empty unless `onMove`. */
readonly legalActions: readonly Action[];
}
export function projectView(state: GameState, player: PlayerId): PlayerView {
const seat = seatOf(state, player);
const count = state.players.length;
const opponents: OpponentView[] = [];
for (let i = 1; i < count; i++) {
const other = state.players[(seat + i) % count];
opponents.push({ id: other.id, handSize: other.hand.length, out: other.out });
}
return {
me: player,
hand: state.players[seat].hand.slice(),
opponents,
table: state.table.map((s) => ({ attack: s.attack, defence: s.defence })),
trumpSuit: state.trumpSuit,
trumpCard: state.trumpCard,
deckRemaining: state.deck.length,
trumpCardStillInDeck: state.deck.length > 0,
discardCount: state.discard.length,
phase: state.phase,
turnNumber: state.turnNumber,
attacker: state.players[state.attacker].id,
defender: state.players[state.defender].id,
defenderTook: state.defenderTook,
onMove: currentActor(state) === player,
legalActions: legalActions(state, player),
};
}
/** Convenience: is this card a trump, from a view's point of view. */
export function isTrump(view: PlayerView, card: Card): boolean {
return card.suit === view.trumpSuit;
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "cc518671-becb-4644-96da-2a26cee93102",
"files": [],
"subMetas": {},
"userData": {}
}
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "1.2.0",
"importer": "directory",
"imported": true,
"uuid": "23c6c112-6468-4a31-acb9-ed860cafb8f8",
"files": [],
"subMetas": {},
"userData": {}
}
+84
View File
@@ -0,0 +1,84 @@
import { Action } from '../rules/Action';
import { GameEvent } from '../rules/GameEvent';
import { GameState, Phase, PlayerId } from '../rules/GameState';
import { apply, createGame, currentActor, GameOptions } from '../rules/DurakRules';
import { PlayerView, projectView } from '../bot/PlayerView';
export type MatchListener = (event: GameEvent) => void;
/**
* One match in progress.
*
* A thin stateful shell over the pure reducer: it remembers the current state,
* hands out player views, and broadcasts events. Still free of any engine
* dependency — the game screen holds one of these, and so does the headless
* soak test.
*/
export class Match {
private _state: GameState;
private _listeners: MatchListener[] = [];
private readonly _history: GameEvent[] = [];
constructor(options: GameOptions) {
const { state, events } = createGame(options);
this._state = state;
this._history.push(...events);
}
/** The full state. The UI must not show a player anything they cannot see — use `viewFor`. */
get state(): GameState {
return this._state;
}
/** Every event since the deal, oldest first. */
get history(): readonly GameEvent[] {
return this._history;
}
get finished(): boolean {
return this._state.phase === Phase.Finished;
}
/** The fool, or null while the game runs and on the rare mutual finish. */
get loser(): PlayerId | null {
return this._state.loser;
}
get currentActor(): PlayerId | null {
return currentActor(this._state);
}
viewFor(player: PlayerId): PlayerView {
return projectView(this._state, player);
}
/**
* Applies an action and broadcasts what happened. Throws `IllegalActionError`
* if the action is not permitted, leaving the match untouched.
*/
perform(action: Action): GameEvent[] {
const result = apply(this._state, action);
this._state = result.state;
this._history.push(...result.events);
for (const event of result.events) {
for (const listener of this._listeners) {
listener(event);
}
}
return result.events;
}
/** Subscribes to the event stream. Returns a function that unsubscribes. */
subscribe(listener: MatchListener): () => void {
this._listeners.push(listener);
return () => {
const i = this._listeners.indexOf(listener);
if (i >= 0) this._listeners.splice(i, 1);
};
}
/** Drops every listener. Call when tearing a match screen down. */
clearListeners(): void {
this._listeners.length = 0;
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "f362f5f4-1067-40b0-95e1-2793381e372a",
"files": [],
"subMetas": {},
"userData": {}
}
+56
View File
@@ -0,0 +1,56 @@
import { Bot } from '../bot/Bot';
import { describeAction } from '../rules/Action';
import { describeEvent } from '../rules/GameEvent';
import { PlayerId } from '../rules/GameState';
import { Match } from './Match';
export interface RunOptions {
/** Safety net: a match this long means the rules have a cycle in them. */
maxActions?: number;
/** Collects a human-readable trace, for debugging a bad seed. */
trace?: string[];
}
export interface RunResult {
loser: PlayerId | null;
actions: number;
turns: number;
}
/**
* Plays a match out between bots, with no engine and no clock. The soak test
* leans on this; so does anyone reproducing a bug from a seed.
*
* Stops early and returns if it reaches a seat with no bot — that is how the
* same driver can fast-forward a match that a human is also playing.
*/
export function runMatch(match: Match, bots: Record<PlayerId, Bot>, options: RunOptions = {}): RunResult {
const maxActions = options.maxActions ?? 2000;
let actions = 0;
while (!match.finished) {
const actor = match.currentActor;
if (actor === null) break;
const bot = bots[actor];
if (!bot) break;
const action = bot.decide(match.viewFor(actor));
if (options.trace) {
options.trace.push(describeAction(action));
}
const events = match.perform(action);
if (options.trace) {
for (const event of events) {
options.trace.push(` ${describeEvent(event)}`);
}
}
actions += 1;
if (actions > maxActions) {
throw new Error(`Match did not finish within ${maxActions} actions`);
}
}
return { loser: match.loser, actions, turns: match.state.turnNumber };
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "43fb0ffe-2e16-4efb-9542-6d3dccf0ae10",
"files": [],
"subMetas": {},
"userData": {}
}