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,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;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "f5c8ab8d-419e-4ea8-a96a-64a2cdb24da0",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "516a24e0-b9d2-4c08-9940-7c2d27db8cd2",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "1.2.0",
|
||||
"importer": "directory",
|
||||
"imported": true,
|
||||
"uuid": "23c6c112-6468-4a31-acb9-ed860cafb8f8",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "f362f5f4-1067-40b0-95e1-2793381e372a",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
Reference in New Issue
Block a user