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>
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
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,
|
||||
};
|
||||
Reference in New Issue
Block a user