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
+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": {}
}