- PlayerView: проекция состояния, в которой боту видно только то, что видно живому игроку (своя рука, стол, размеры чужих рук, счётчики) - BasicBot: политика из GAMEPLAY.md, полностью детерминированная - Match: обёртка с состоянием и подпиской на события (её же возьмёт UI) - runMatch: прогон матча без движка и без таймеров - soak-тесты: 3500 полных матчей (боты и случайные легальные ходы, 2-4 игрока), инварианты состояния проверяются после каждого действия - npm run stats:bot — распределение исходов самоигры Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
109 lines
4.2 KiB
TypeScript
109 lines
4.2 KiB
TypeScript
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('; ')}`);
|
|
}
|
|
}
|