Детерминированный конечный автомат: apply(state, action) -> {state, events}.
Весь набор правил из GAMEPLAY.md проверяется headless-тестами.
- карты: Suit/Rank/Card (флайвейт, 36 штук), колода
- GameState + клонирование и сериализация в JSON
- действия (attack/defend/take/pass) и события для слоя отображения
- раздача, выбор первого атакующего, лимиты подкидывания,
взятие карт, добор, выбывание игроков, определение дурака
- 64 теста, strict typecheck
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
89 lines
3.0 KiB
TypeScript
89 lines
3.0 KiB
TypeScript
import { Card } from '@core/cards/Card';
|
|
import { Suit } from '@core/cards/Suit';
|
|
import { GameState, Phase, PlayerId } from '@core/rules/GameState';
|
|
|
|
/**
|
|
* Builds an exact position from a readable description, so a rule test can say
|
|
* what it is testing instead of playing twenty moves to get there.
|
|
*
|
|
* Cards are written as codes: `'6C 10H KS'`. Seating order is the key order of
|
|
* `hands`.
|
|
*/
|
|
export interface StateSpec {
|
|
hands: Record<PlayerId, string>;
|
|
/** Suit letter of the trump, e.g. `'S'`. */
|
|
trump: string;
|
|
/** Draw pile, top card first. The trump card is appended at the bottom. */
|
|
deck?: string;
|
|
/** Explicit trump card; defaults to the six of the trump suit. */
|
|
trumpCard?: string;
|
|
table?: { attack: string; defence?: string }[];
|
|
discard?: string;
|
|
phase?: Phase;
|
|
attacker?: PlayerId;
|
|
defender?: PlayerId;
|
|
turnNumber?: number;
|
|
defenderTook?: boolean;
|
|
/** Attackers who have already declared they are done this turn. */
|
|
passed?: PlayerId[];
|
|
out?: PlayerId[];
|
|
seed?: number;
|
|
}
|
|
|
|
export function makeState(spec: StateSpec): GameState {
|
|
const ids = Object.keys(spec.hands);
|
|
const trumpSuit = Card.parse(`6${spec.trump}`).suit;
|
|
const trumpCard = spec.trumpCard ? Card.parse(spec.trumpCard) : Card.parse(`6${spec.trump}`);
|
|
const outSet = new Set(spec.out ?? []);
|
|
|
|
const drawPile = spec.deck ? Card.parseAll(spec.deck) : [];
|
|
// The trump card always sits at the bottom of a non-empty pile.
|
|
const deck = drawPile.length > 0 ? [...drawPile, trumpCard] : [];
|
|
|
|
const attacker = ids.indexOf(spec.attacker ?? ids[0]);
|
|
const defender = ids.indexOf(spec.defender ?? ids[(attacker + 1) % ids.length]);
|
|
|
|
return {
|
|
players: ids.map((id) => ({
|
|
id,
|
|
hand: Card.parseAll(spec.hands[id]),
|
|
out: outSet.has(id),
|
|
})),
|
|
deck,
|
|
trumpCard,
|
|
trumpSuit,
|
|
table: (spec.table ?? []).map((s) => ({
|
|
attack: Card.parse(s.attack),
|
|
defence: s.defence ? Card.parse(s.defence) : null,
|
|
})),
|
|
discard: spec.discard ? Card.parseAll(spec.discard) : [],
|
|
phase: spec.phase ?? Phase.Attack,
|
|
attacker,
|
|
defender,
|
|
turnNumber: spec.turnNumber ?? 2,
|
|
defenderTook: spec.defenderTook ?? false,
|
|
passed: ids.map((id) => (spec.passed ?? []).includes(id)),
|
|
rngState: spec.seed ?? 1,
|
|
loser: null,
|
|
};
|
|
}
|
|
|
|
/** Hand of `player` as sorted card codes, for readable assertions. */
|
|
export function handOf(state: GameState, player: PlayerId): string[] {
|
|
const p = state.players.find((x) => x.id === player);
|
|
if (!p) throw new Error(`No player ${player}`);
|
|
return p.hand.map((c) => c.code);
|
|
}
|
|
|
|
/** Table as `'6C/7C'` pairs (`'6C/-'` when unbeaten). */
|
|
export function tableOf(state: GameState): string[] {
|
|
return state.table.map((s) => `${s.attack.code}/${s.defence ? s.defence.code : '-'}`);
|
|
}
|
|
|
|
export const SUITS = {
|
|
clubs: Suit.Clubs,
|
|
diamonds: Suit.Diamonds,
|
|
hearts: Suit.Hearts,
|
|
spades: Suit.Spades,
|
|
};
|