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": {}
|
||||||
|
}
|
||||||
+5
-5
@@ -44,11 +44,11 @@ progress. The Cocos layer only renders state and turns taps into actions.
|
|||||||
- [x] End conditions and loser ("fool") determination
|
- [x] End conditions and loser ("fool") determination
|
||||||
- [x] Unit tests covering every rule clause, including the add-card limits
|
- [x] Unit tests covering every rule clause, including the add-card limits
|
||||||
|
|
||||||
### M2 — Bot and headless match runner `[ ]`
|
### M2 — Bot and headless match runner `[x]`
|
||||||
- [ ] `PlayerView` projection — a bot sees only what a player legitimately sees
|
- [x] `PlayerView` projection — a bot sees only what a player legitimately sees
|
||||||
- [ ] `BasicBot` exactly per GAMEPLAY.md "Basic bot"
|
- [x] `BasicBot` exactly per GAMEPLAY.md "Basic bot"
|
||||||
- [ ] `MatchRunner` driving a full headless match
|
- [x] `MatchRunner` driving a full headless match
|
||||||
- [ ] Soak test: thousands of seeded self-play matches finish with no illegal state
|
- [x] Soak test: thousands of seeded self-play matches finish with no illegal state
|
||||||
|
|
||||||
### M3 — Savegame module `[ ]`
|
### M3 — Savegame module `[ ]`
|
||||||
- [ ] `ISaveSection` contract, `SaveManager` registry, JSON keyed by section name
|
- [ ] `ISaveSection` contract, `SaveManager` registry, JSON keyed by section name
|
||||||
|
|||||||
+27
-7
@@ -45,8 +45,9 @@ assets/
|
|||||||
core/ PURE TS — no engine imports
|
core/ PURE TS — no engine imports
|
||||||
cards/ Suit, Rank, Card, Deck
|
cards/ Suit, Rank, Card, Deck
|
||||||
rules/ GameState, actions, events, the reducer
|
rules/ GameState, actions, events, the reducer
|
||||||
bot/ PlayerView projection, BasicBot
|
bot/ PlayerView projection, Bot contract, BasicBot
|
||||||
util/ seeded RNG, shuffle, assertions
|
match/ Match (stateful shell), runMatch (headless driver)
|
||||||
|
util/ seeded RNG, shuffle
|
||||||
modules/
|
modules/
|
||||||
save/ SaveManager, ISaveSection, StatsSection, storage
|
save/ SaveManager, ISaveSection, StatsSection, storage
|
||||||
app/ Bootstrap, ServiceLocator, SceneRouter
|
app/ Bootstrap, ServiceLocator, SceneRouter
|
||||||
@@ -54,7 +55,10 @@ assets/
|
|||||||
lobby/ lobby screen controller
|
lobby/ lobby screen controller
|
||||||
game/ match screen: hand, table, deck, animations
|
game/ match screen: hand, table, deck, animations
|
||||||
common/ reusable widgets, safe-area root
|
common/ reusable widgets, safe-area root
|
||||||
tests/ vitest specs against assets/scripts/core
|
tests/
|
||||||
|
core/ vitest specs against assets/scripts/core
|
||||||
|
support/ position fixtures and the state invariant checker
|
||||||
|
tools/ measuring tools, not part of the suite
|
||||||
docs/ specs, this file, plan, decisions
|
docs/ specs, this file, plan, decisions
|
||||||
local/ scratch art storage; contents get consumed
|
local/ scratch art storage; contents get consumed
|
||||||
extensions/cocos-code-mode/ editor tool server
|
extensions/cocos-code-mode/ editor tool server
|
||||||
@@ -83,9 +87,16 @@ apply(state: GameState, action: Action): { state: GameState; events: GameEvent[]
|
|||||||
|
|
||||||
## Bots
|
## Bots
|
||||||
|
|
||||||
A bot is a function `(view: PlayerView) => Action`. `PlayerView` is a projection
|
A bot is `decide(view: PlayerView) => Action`. `PlayerView` is a projection of
|
||||||
of `GameState` containing only legitimately visible information. `BasicBot`
|
`GameState` containing only legitimately visible information — own hand, table,
|
||||||
implements the policy in [GAMEPLAY.md](GAMEPLAY.md) verbatim.
|
trump, opponents' hand *sizes*, deck and discard counts — plus the list of legal
|
||||||
|
actions. `BasicBot` implements the policy in [GAMEPLAY.md](GAMEPLAY.md).
|
||||||
|
|
||||||
|
`Match` wraps the reducer with the current state and an event subscription; the
|
||||||
|
game screen and the headless `runMatch` driver both hold one. As a reference
|
||||||
|
point, 5000 `BasicBot` self-play matches split roughly 43/43/14 between the two
|
||||||
|
seats and draws, with the opening attacker losing about 37% of the time —
|
||||||
|
`npm run stats:bot` reprints these.
|
||||||
|
|
||||||
## Savegame
|
## Savegame
|
||||||
|
|
||||||
@@ -114,7 +125,16 @@ Keys are explicit string constants, not class names — release builds minify.
|
|||||||
npm test
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
Runs vitest over `tests/` against `assets/scripts/core`. No editor required.
|
Runs vitest over `tests/core` against `assets/scripts/core`. No editor required.
|
||||||
|
The soak specs play several thousand complete matches and re-check every state
|
||||||
|
invariant after every single action, so a rule regression surfaces at the move
|
||||||
|
that caused it.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run typecheck # strict tsc over core/ and the tests
|
||||||
|
npm run check:core-purity # fails if core/ ever imports the engine
|
||||||
|
npm run stats:bot # BasicBot self-play distribution, not a test
|
||||||
|
```
|
||||||
|
|
||||||
### Driving the editor
|
### Driving the editor
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -16,6 +16,7 @@
|
|||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"typecheck": "tsc -p tsconfig.test.json",
|
"typecheck": "tsc -p tsconfig.test.json",
|
||||||
"check:core-purity": "node scripts/check-core-purity.mjs"
|
"check:core-purity": "node scripts/check-core-purity.mjs",
|
||||||
|
"stats:bot": "vitest run --config vitest.stats.mts"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { ActionType } from '@core/rules/Action';
|
||||||
|
import { Phase } from '@core/rules/GameState';
|
||||||
|
import { projectView } from '@core/bot/PlayerView';
|
||||||
|
import { BasicBot } from '@core/bot/BasicBot';
|
||||||
|
import { makeState } from '../../support/fixtures';
|
||||||
|
|
||||||
|
const YOU = 'you';
|
||||||
|
const BOT = 'bot';
|
||||||
|
|
||||||
|
const bot = new BasicBot();
|
||||||
|
|
||||||
|
function decideFor(state: Parameters<typeof projectView>[0], player: string) {
|
||||||
|
return bot.decide(projectView(state, player));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BasicBot opening a turn', () => {
|
||||||
|
it('leads with its cheapest non-trump', () => {
|
||||||
|
const state = makeState({
|
||||||
|
hands: { [BOT]: '9C 6D KH 7S', [YOU]: 'AH AD' },
|
||||||
|
trump: 'S',
|
||||||
|
deck: '10C 10D',
|
||||||
|
attacker: BOT,
|
||||||
|
});
|
||||||
|
|
||||||
|
const action = decideFor(state, BOT);
|
||||||
|
expect(action.type).toBe(ActionType.Attack);
|
||||||
|
expect(action).toMatchObject({ card: expect.objectContaining({ code: '6♦' }) });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps its trumps back even when they are cheaper', () => {
|
||||||
|
const state = makeState({
|
||||||
|
hands: { [BOT]: '6S 9C', [YOU]: 'AH AD' },
|
||||||
|
trump: 'S',
|
||||||
|
deck: '10C 10D',
|
||||||
|
attacker: BOT,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(decideFor(state, BOT)).toMatchObject({
|
||||||
|
card: expect.objectContaining({ code: '9♣' }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leads with its cheapest trump when that is all it holds', () => {
|
||||||
|
const state = makeState({
|
||||||
|
hands: { [BOT]: 'KS 7S', [YOU]: 'AH AD' },
|
||||||
|
trump: 'S',
|
||||||
|
deck: '10C 10D',
|
||||||
|
attacker: BOT,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(decideFor(state, BOT)).toMatchObject({
|
||||||
|
card: expect.objectContaining({ code: '7♠' }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('BasicBot defending', () => {
|
||||||
|
it('beats with the cheapest card that works', () => {
|
||||||
|
const state = makeState({
|
||||||
|
hands: { [YOU]: 'AC', [BOT]: '10D 8D QD 6S' },
|
||||||
|
trump: 'S',
|
||||||
|
deck: '10C 10H',
|
||||||
|
attacker: YOU,
|
||||||
|
table: [{ attack: '7D' }],
|
||||||
|
phase: Phase.Defence,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(decideFor(state, BOT)).toMatchObject({
|
||||||
|
type: ActionType.Defend,
|
||||||
|
card: expect.objectContaining({ code: '8♦' }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers any non-trump over a cheap trump', () => {
|
||||||
|
const state = makeState({
|
||||||
|
hands: { [YOU]: 'AC', [BOT]: 'AD 6S' },
|
||||||
|
trump: 'S',
|
||||||
|
deck: '10C 10H',
|
||||||
|
attacker: YOU,
|
||||||
|
table: [{ attack: '7D' }],
|
||||||
|
phase: Phase.Defence,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(decideFor(state, BOT)).toMatchObject({
|
||||||
|
card: expect.objectContaining({ code: 'A♦' }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('spends a trump when nothing else beats the attack', () => {
|
||||||
|
const state = makeState({
|
||||||
|
hands: { [YOU]: 'AC', [BOT]: '6D KS 7S' },
|
||||||
|
trump: 'S',
|
||||||
|
deck: '10C 10H',
|
||||||
|
attacker: YOU,
|
||||||
|
table: [{ attack: '7D' }],
|
||||||
|
phase: Phase.Defence,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(decideFor(state, BOT)).toMatchObject({
|
||||||
|
card: expect.objectContaining({ code: '7♠' }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes when it cannot beat the attack', () => {
|
||||||
|
const state = makeState({
|
||||||
|
hands: { [YOU]: 'AC', [BOT]: '6D 6H' },
|
||||||
|
trump: 'S',
|
||||||
|
deck: '10C 10H',
|
||||||
|
attacker: YOU,
|
||||||
|
table: [{ attack: '7D' }],
|
||||||
|
phase: Phase.Defence,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(decideFor(state, BOT).type).toBe(ActionType.Take);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('BasicBot adding to an attack', () => {
|
||||||
|
it('throws in every eligible non-trump, cheapest first', () => {
|
||||||
|
const state = makeState({
|
||||||
|
hands: { [BOT]: '7H 7C KD', [YOU]: 'AH AD AC' },
|
||||||
|
trump: 'S',
|
||||||
|
deck: '10C 10D',
|
||||||
|
attacker: BOT,
|
||||||
|
table: [{ attack: '7D', defence: '9D' }],
|
||||||
|
phase: Phase.Reinforce,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(decideFor(state, BOT)).toMatchObject({
|
||||||
|
type: ActionType.Attack,
|
||||||
|
card: expect.objectContaining({ code: '7♣' }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds its trumps back while the deck still has cards', () => {
|
||||||
|
const state = makeState({
|
||||||
|
hands: { [BOT]: '7S KD', [YOU]: 'AH AD AC' },
|
||||||
|
trump: 'S',
|
||||||
|
deck: '10C 10D',
|
||||||
|
attacker: BOT,
|
||||||
|
table: [{ attack: '7D', defence: '9D' }],
|
||||||
|
phase: Phase.Reinforce,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(decideFor(state, BOT).type).toBe(ActionType.Pass);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds its trumps back at the end if it still has other cards', () => {
|
||||||
|
const state = makeState({
|
||||||
|
hands: { [BOT]: '7S KD', [YOU]: 'AH AD AC' },
|
||||||
|
trump: 'S',
|
||||||
|
attacker: BOT,
|
||||||
|
table: [{ attack: '7D', defence: '9D' }],
|
||||||
|
phase: Phase.Reinforce,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(state.deck).toHaveLength(0);
|
||||||
|
expect(decideFor(state, BOT).type).toBe(ActionType.Pass);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws in a trump once the deck is empty and trumps are all it has', () => {
|
||||||
|
const state = makeState({
|
||||||
|
hands: { [BOT]: '7S KS', [YOU]: 'AH AD AC' },
|
||||||
|
trump: 'S',
|
||||||
|
attacker: BOT,
|
||||||
|
table: [{ attack: '7D', defence: '9D' }],
|
||||||
|
phase: Phase.Reinforce,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(decideFor(state, BOT)).toMatchObject({
|
||||||
|
type: ActionType.Attack,
|
||||||
|
card: expect.objectContaining({ code: '7♠' }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('BasicBot contract', () => {
|
||||||
|
it('refuses to move out of turn', () => {
|
||||||
|
const state = makeState({
|
||||||
|
hands: { [YOU]: '6C', [BOT]: '8C' },
|
||||||
|
trump: 'S',
|
||||||
|
deck: '10C',
|
||||||
|
attacker: YOU,
|
||||||
|
});
|
||||||
|
expect(() => decideFor(state, BOT)).toThrow(/out of turn/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { BasicBot } from '@core/bot/BasicBot';
|
||||||
|
import { Match } from '@core/match/Match';
|
||||||
|
import { runMatch } from '@core/match/runMatch';
|
||||||
|
import { legalActions } from '@core/rules/DurakRules';
|
||||||
|
import { PlayerId } from '@core/rules/GameState';
|
||||||
|
import { Rng } from '@core/util/Rng';
|
||||||
|
import { assertInvariants } from '../../support/invariants';
|
||||||
|
|
||||||
|
/** Plays one seeded match, checking every reachable state along the way. */
|
||||||
|
function playChecked(players: PlayerId[], seed: number, pick?: (n: number, rng: Rng) => number) {
|
||||||
|
const match = new Match({ players, seed });
|
||||||
|
const bots = Object.fromEntries(players.map((p) => [p, new BasicBot(p)]));
|
||||||
|
const rng = new Rng(seed ^ 0x5f3759df);
|
||||||
|
|
||||||
|
assertInvariants(match.state);
|
||||||
|
|
||||||
|
let actions = 0;
|
||||||
|
while (!match.finished) {
|
||||||
|
const actor = match.currentActor;
|
||||||
|
expect(actor).not.toBeNull();
|
||||||
|
|
||||||
|
const options = legalActions(match.state, actor as PlayerId);
|
||||||
|
expect(options.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const action = pick
|
||||||
|
? options[pick(options.length, rng)]
|
||||||
|
: bots[actor as PlayerId].decide(match.viewFor(actor as PlayerId));
|
||||||
|
|
||||||
|
match.perform(action);
|
||||||
|
assertInvariants(match.state);
|
||||||
|
|
||||||
|
if (++actions > 4000) {
|
||||||
|
throw new Error(`Match ${seed} will not end`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { match, actions };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('soak: two basic bots', () => {
|
||||||
|
it('finishes 2000 seeded matches with every invariant intact', () => {
|
||||||
|
const outcomes = new Map<string, number>();
|
||||||
|
|
||||||
|
for (let seed = 0; seed < 2000; seed++) {
|
||||||
|
const { match } = playChecked(['you', 'bot'], seed);
|
||||||
|
const key = match.loser ?? 'draw';
|
||||||
|
outcomes.set(key, (outcomes.get(key) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every match must have produced a verdict.
|
||||||
|
const total = [...outcomes.values()].reduce((a, b) => a + b, 0);
|
||||||
|
expect(total).toBe(2000);
|
||||||
|
|
||||||
|
// Two identical bots: neither seat should be losing almost every game.
|
||||||
|
// A wide band — this is a smoke check on the rules, not a balance claim.
|
||||||
|
for (const seat of ['you', 'bot']) {
|
||||||
|
expect(outcomes.get(seat) ?? 0).toBeGreaterThan(2000 * 0.2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finishes three-player matches too', () => {
|
||||||
|
for (let seed = 0; seed < 300; seed++) {
|
||||||
|
const { match } = playChecked(['a', 'b', 'c'], seed);
|
||||||
|
expect(match.finished).toBe(true);
|
||||||
|
if (match.loser !== null) {
|
||||||
|
expect(['a', 'b', 'c']).toContain(match.loser);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('soak: random legal play', () => {
|
||||||
|
it('survives 1000 matches of arbitrary legal moves', () => {
|
||||||
|
for (let seed = 0; seed < 1000; seed++) {
|
||||||
|
const { match } = playChecked(['you', 'bot'], seed, (n, rng) => rng.nextInt(n));
|
||||||
|
expect(match.finished).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('survives random play at a crowded table', () => {
|
||||||
|
for (let seed = 0; seed < 200; seed++) {
|
||||||
|
const { match } = playChecked(['a', 'b', 'c', 'd'], seed, (n, rng) => rng.nextInt(n));
|
||||||
|
expect(match.finished).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('match determinism', () => {
|
||||||
|
it('replays a seed move for move', () => {
|
||||||
|
const first: string[] = [];
|
||||||
|
const second: string[] = [];
|
||||||
|
|
||||||
|
for (const trace of [first, second]) {
|
||||||
|
const match = new Match({ players: ['you', 'bot'], seed: 31337 });
|
||||||
|
runMatch(match, { you: new BasicBot('you'), bot: new BasicBot('bot') }, { trace });
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(first).toEqual(second);
|
||||||
|
expect(first.length).toBeGreaterThan(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports the same loser for the same seed', () => {
|
||||||
|
const play = () => {
|
||||||
|
const match = new Match({ players: ['you', 'bot'], seed: 555 });
|
||||||
|
return runMatch(match, { you: new BasicBot('you'), bot: new BasicBot('bot') });
|
||||||
|
};
|
||||||
|
expect(play()).toEqual(play());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('runMatch', () => {
|
||||||
|
it('stops at a seat it has no bot for', () => {
|
||||||
|
const match = new Match({ players: ['you', 'bot'], seed: 8 });
|
||||||
|
const result = runMatch(match, { bot: new BasicBot('bot') });
|
||||||
|
|
||||||
|
expect(match.finished).toBe(false);
|
||||||
|
expect(match.currentActor).toBe('you');
|
||||||
|
expect(result.loser).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to loop forever', () => {
|
||||||
|
const match = new Match({ players: ['you', 'bot'], seed: 8 });
|
||||||
|
expect(() => runMatch(
|
||||||
|
match,
|
||||||
|
{ you: new BasicBot('you'), bot: new BasicBot('bot') },
|
||||||
|
{ maxActions: 3 },
|
||||||
|
)).toThrow(/did not finish/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { Card } from '@core/cards/Card';
|
||||||
|
import { GameState, Phase } from '@core/rules/GameState';
|
||||||
|
import { attackSeats, currentActor, legalActions, maxTableSlots, unbeatenSlots } from '@core/rules/DurakRules';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything that must be true of any reachable state. Called after every single
|
||||||
|
* action in the soak test, so a rule bug is caught at the move that caused it
|
||||||
|
* rather than a hundred moves later.
|
||||||
|
*
|
||||||
|
* Throws with a description of the first violation.
|
||||||
|
*/
|
||||||
|
export function assertInvariants(state: GameState): void {
|
||||||
|
const problems: string[] = [];
|
||||||
|
|
||||||
|
// Cards are conserved: all 36, each exactly once, somewhere.
|
||||||
|
const seen: Card[] = [
|
||||||
|
...state.deck,
|
||||||
|
...state.discard,
|
||||||
|
...state.players.flatMap((p) => p.hand),
|
||||||
|
];
|
||||||
|
for (const slot of state.table) {
|
||||||
|
seen.push(slot.attack);
|
||||||
|
if (slot.defence) seen.push(slot.defence);
|
||||||
|
}
|
||||||
|
if (seen.length !== 36) {
|
||||||
|
problems.push(`${seen.length} cards in play, expected 36`);
|
||||||
|
}
|
||||||
|
if (new Set(seen).size !== seen.length) {
|
||||||
|
problems.push('the same card exists in two places');
|
||||||
|
}
|
||||||
|
|
||||||
|
// The trump card is the bottom of the deck until it is drawn.
|
||||||
|
if (state.deck.length > 0 && state.deck[state.deck.length - 1] !== state.trumpCard) {
|
||||||
|
problems.push('the trump card is not at the bottom of the deck');
|
||||||
|
}
|
||||||
|
if (state.trumpSuit !== state.trumpCard.suit) {
|
||||||
|
problems.push('the trump suit does not match the trump card');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Table shape.
|
||||||
|
if (state.table.length > maxTableSlots(state)) {
|
||||||
|
problems.push(`${state.table.length} attacking cards exceeds the limit`);
|
||||||
|
}
|
||||||
|
for (const slot of state.table) {
|
||||||
|
if (slot.defence && slot.defence === slot.attack) {
|
||||||
|
problems.push('a card beat itself');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seats.
|
||||||
|
if (state.attacker === state.defender) {
|
||||||
|
problems.push('the attacker is also the defender');
|
||||||
|
}
|
||||||
|
if (state.phase !== Phase.Finished) {
|
||||||
|
if (state.players[state.attacker].out) problems.push('the attacker has left the game');
|
||||||
|
if (state.players[state.defender].out) problems.push('the defender has left the game');
|
||||||
|
}
|
||||||
|
|
||||||
|
// A player who is out holds nothing; a player who holds nothing while the
|
||||||
|
// deck still has cards should have refilled.
|
||||||
|
for (const player of state.players) {
|
||||||
|
if (player.out && player.hand.length > 0) {
|
||||||
|
problems.push(`${player.id} is out but still holds cards`);
|
||||||
|
}
|
||||||
|
if (!player.out && player.hand.length === 0 && state.phase === Phase.Attack) {
|
||||||
|
problems.push(`${player.id} is at the table with an empty hand`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase consistency.
|
||||||
|
switch (state.phase) {
|
||||||
|
case Phase.Attack:
|
||||||
|
if (state.table.length !== 0) problems.push('a turn opened with cards already down');
|
||||||
|
if (state.defenderTook) problems.push('a turn opened mid-take');
|
||||||
|
break;
|
||||||
|
case Phase.Defence:
|
||||||
|
if (unbeatenSlots(state).length === 0) problems.push('defending with nothing to beat');
|
||||||
|
if (state.defenderTook) problems.push('defending after declaring a take');
|
||||||
|
break;
|
||||||
|
case Phase.Reinforce:
|
||||||
|
if (!state.defenderTook && unbeatenSlots(state).length > 0) {
|
||||||
|
problems.push('adding cards while an attack is unanswered');
|
||||||
|
}
|
||||||
|
if (attackSeats(state).every((s) => state.passed[s])) {
|
||||||
|
problems.push('every attacker has passed but the turn is still open');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case Phase.Finished:
|
||||||
|
if (state.players.filter((p) => !p.out).length > 1) {
|
||||||
|
problems.push('the game ended with more than one player left');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whoever is on move must have something to do.
|
||||||
|
const actor = currentActor(state);
|
||||||
|
if (state.phase !== Phase.Finished) {
|
||||||
|
if (actor === null) {
|
||||||
|
problems.push('nobody is on move in an unfinished game');
|
||||||
|
} else if (legalActions(state, actor).length === 0) {
|
||||||
|
problems.push(`${actor} is on move with no legal action`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (problems.length > 0) {
|
||||||
|
throw new Error(`Broken state: ${problems.join('; ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Not part of the suite — a measuring tool. Run with `npm run stats:bot`.
|
||||||
|
*
|
||||||
|
* Plays a large number of seeded matches between two BasicBots and prints how
|
||||||
|
* they came out. Useful for sanity-checking a rules change (a sudden jump in
|
||||||
|
* draws or in the first-attacker advantage means something moved) and as the
|
||||||
|
* baseline any smarter bot has to beat.
|
||||||
|
*/
|
||||||
|
import { describe, it } from 'vitest';
|
||||||
|
import { BasicBot } from '@core/bot/BasicBot';
|
||||||
|
import { Match } from '@core/match/Match';
|
||||||
|
import { runMatch } from '@core/match/runMatch';
|
||||||
|
|
||||||
|
const MATCHES = 5000;
|
||||||
|
|
||||||
|
describe('BasicBot vs BasicBot', () => {
|
||||||
|
it(`plays ${MATCHES} matches`, () => {
|
||||||
|
const tally: Record<string, number> = { you: 0, bot: 0, draw: 0 };
|
||||||
|
let firstAttackerLost = 0;
|
||||||
|
let actions = 0;
|
||||||
|
let turns = 0;
|
||||||
|
|
||||||
|
for (let seed = 0; seed < MATCHES; seed++) {
|
||||||
|
const match = new Match({ players: ['you', 'bot'], seed });
|
||||||
|
const firstAttacker = match.state.players[match.state.attacker].id;
|
||||||
|
const result = runMatch(match, { you: new BasicBot('you'), bot: new BasicBot('bot') });
|
||||||
|
|
||||||
|
tally[result.loser ?? 'draw'] += 1;
|
||||||
|
if (result.loser === firstAttacker) firstAttackerLost += 1;
|
||||||
|
actions += result.actions;
|
||||||
|
turns += result.turns;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pct = (n: number) => `${((100 * n) / MATCHES).toFixed(1)}%`;
|
||||||
|
console.log([
|
||||||
|
`matches ${MATCHES}`,
|
||||||
|
`seat 'you' lost ${tally.you} (${pct(tally.you)})`,
|
||||||
|
`seat 'bot' lost ${tally.bot} (${pct(tally.bot)})`,
|
||||||
|
`draws ${tally.draw} (${pct(tally.draw)})`,
|
||||||
|
`first attacker lost ${pct(firstAttackerLost)}`,
|
||||||
|
`average actions ${(actions / MATCHES).toFixed(1)}`,
|
||||||
|
`average turns ${(turns / MATCHES).toFixed(1)}`,
|
||||||
|
].join('\n'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// Config for the measuring tools under tests/tools, which are deliberately not
|
||||||
|
// part of the suite: `npm run stats:bot`.
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@core': resolve(__dirname, 'assets/scripts/core'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
include: ['tests/tools/**/*.stats.ts'],
|
||||||
|
environment: 'node',
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user