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