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,136 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user