Files
ftcandClaude Opus 5 fddab60527 M1: ядро правил дурака (чистый TypeScript, без движка)
Детерминированный конечный автомат: 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>
2026-09-13 10:38:49 +03:00

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,
};