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