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

137 lines
4.7 KiB
TypeScript

import { Card } from '../cards/Card';
import { Suit } from '../cards/Suit';
export type PlayerId = string;
/** Seat index into `GameState.players`. */
export type Seat = number;
export enum Phase {
/** The attacker must lay the opening card of the turn. Passing is not allowed. */
Attack = 'attack',
/** At least one attacking card is unbeaten; the defender must beat it or take. */
Defence = 'defence',
/** Nothing left to beat (or a take was declared); attackers may add or stop. */
Reinforce = 'reinforce',
Finished = 'finished',
}
/** One attacking card on the table, with the card that beat it if there is one. */
export interface TableSlot {
attack: Card;
defence: Card | null;
}
export interface PlayerState {
readonly id: PlayerId;
hand: Card[];
/** True once the player has run out of cards and left the game. */
out: boolean;
}
/**
* The complete state of a match. Plain data — no methods, no engine types, no
* wall-clock time. Everything that can vary is in here, including the RNG, so a
* state plus a sequence of actions replays exactly.
*/
export interface GameState {
/** Seating order, clockwise. Fixed for the whole game, including players who are out. */
players: PlayerState[];
/** Face-down draw pile. Index 0 is the next card drawn; the last entry is the trump card. */
deck: Card[];
/** The face-up card that set the trump suit. Kept for display after it has been drawn. */
trumpCard: Card;
trumpSuit: Suit;
table: TableSlot[];
discard: Card[];
phase: Phase;
attacker: Seat;
defender: Seat;
/** 1-based. The first turn of a game allows only five attacking cards. */
turnNumber: number;
/** True once the defender has declared a take for the current turn. */
defenderTook: boolean;
/** Per seat: this attacker has declared they are adding nothing more this turn. */
passed: boolean[];
rngState: number;
/** Set when the game is over: the "fool", or null for the rare mutual finish. */
loser: PlayerId | null;
}
export function cloneState(state: GameState): GameState {
return {
players: state.players.map((p) => ({ id: p.id, hand: p.hand.slice(), out: p.out })),
deck: state.deck.slice(),
trumpCard: state.trumpCard,
trumpSuit: state.trumpSuit,
table: state.table.map((s) => ({ attack: s.attack, defence: s.defence })),
discard: state.discard.slice(),
phase: state.phase,
attacker: state.attacker,
defender: state.defender,
turnNumber: state.turnNumber,
defenderTook: state.defenderTook,
passed: state.passed.slice(),
rngState: state.rngState,
loser: state.loser,
};
}
/** JSON-safe form of a state, for bug reports and future mid-match saves. */
export interface SerializedGameState {
players: { id: PlayerId; hand: number[]; out: boolean }[];
deck: number[];
trumpCard: number;
table: { attack: number; defence: number | null }[];
discard: number[];
phase: Phase;
attacker: Seat;
defender: Seat;
turnNumber: number;
defenderTook: boolean;
passed: boolean[];
rngState: number;
loser: PlayerId | null;
}
export function serializeState(state: GameState): SerializedGameState {
return {
players: state.players.map((p) => ({ id: p.id, hand: p.hand.map((c) => c.id), out: p.out })),
deck: state.deck.map((c) => c.id),
trumpCard: state.trumpCard.id,
table: state.table.map((s) => ({ attack: s.attack.id, defence: s.defence ? s.defence.id : null })),
discard: state.discard.map((c) => c.id),
phase: state.phase,
attacker: state.attacker,
defender: state.defender,
turnNumber: state.turnNumber,
defenderTook: state.defenderTook,
passed: state.passed.slice(),
rngState: state.rngState,
loser: state.loser,
};
}
export function deserializeState(data: SerializedGameState): GameState {
const trumpCard = Card.byId(data.trumpCard);
return {
players: data.players.map((p) => ({ id: p.id, hand: p.hand.map(Card.byId), out: p.out })),
deck: data.deck.map(Card.byId),
trumpCard,
trumpSuit: trumpCard.suit,
table: data.table.map((s) => ({
attack: Card.byId(s.attack),
defence: s.defence === null ? null : Card.byId(s.defence),
})),
discard: data.discard.map(Card.byId),
phase: data.phase,
attacker: data.attacker,
defender: data.defender,
turnNumber: data.turnNumber,
defenderTook: data.defenderTook,
passed: data.passed.slice(),
rngState: data.rngState,
loser: data.loser,
};
}