Детерминированный конечный автомат: 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>
72 lines
2.2 KiB
TypeScript
72 lines
2.2 KiB
TypeScript
import { Card } from '../cards/Card';
|
|
import { PlayerId } from './GameState';
|
|
|
|
export enum ActionType {
|
|
/** Lay an attacking card — either the opening card of a turn or an addition. */
|
|
Attack = 'attack',
|
|
/** Beat one attacking card. */
|
|
Defend = 'defend',
|
|
/** Give up the defence and pick the whole table up. */
|
|
Take = 'take',
|
|
/** Declare that nothing more will be added this turn. */
|
|
Pass = 'pass',
|
|
}
|
|
|
|
export interface AttackAction {
|
|
readonly type: ActionType.Attack;
|
|
readonly player: PlayerId;
|
|
readonly card: Card;
|
|
}
|
|
|
|
export interface DefendAction {
|
|
readonly type: ActionType.Defend;
|
|
readonly player: PlayerId;
|
|
readonly card: Card;
|
|
/** Index into `GameState.table` of the slot being beaten. */
|
|
readonly slot: number;
|
|
}
|
|
|
|
export interface TakeAction {
|
|
readonly type: ActionType.Take;
|
|
readonly player: PlayerId;
|
|
}
|
|
|
|
export interface PassAction {
|
|
readonly type: ActionType.Pass;
|
|
readonly player: PlayerId;
|
|
}
|
|
|
|
export type Action = AttackAction | DefendAction | TakeAction | PassAction;
|
|
|
|
export const Actions = {
|
|
attack(player: PlayerId, card: Card): AttackAction {
|
|
return { type: ActionType.Attack, player, card };
|
|
},
|
|
defend(player: PlayerId, card: Card, slot: number): DefendAction {
|
|
return { type: ActionType.Defend, player, card, slot };
|
|
},
|
|
take(player: PlayerId): TakeAction {
|
|
return { type: ActionType.Take, player };
|
|
},
|
|
pass(player: PlayerId): PassAction {
|
|
return { type: ActionType.Pass, player };
|
|
},
|
|
};
|
|
|
|
export function describeAction(action: Action): string {
|
|
switch (action.type) {
|
|
case ActionType.Attack: return `${action.player} attacks with ${action.card.code}`;
|
|
case ActionType.Defend: return `${action.player} beats slot ${action.slot} with ${action.card.code}`;
|
|
case ActionType.Take: return `${action.player} takes`;
|
|
case ActionType.Pass: return `${action.player} passes`;
|
|
}
|
|
}
|
|
|
|
/** Thrown when an action is not permitted in the current state. */
|
|
export class IllegalActionError extends Error {
|
|
constructor(action: Action, reason: string) {
|
|
super(`Illegal action (${describeAction(action)}): ${reason}`);
|
|
this.name = 'IllegalActionError';
|
|
}
|
|
}
|