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:
ftc
2026-09-13 10:38:49 +03:00
co-authored by Claude Opus 5
parent c980440856
commit fddab60527
22 changed files with 1836 additions and 8 deletions
+114
View File
@@ -0,0 +1,114 @@
import { ALL_RANKS, Rank, rankLabel, rankName } from './Rank';
import { ALL_SUITS, Suit, suitName, suitSymbol } from './Suit';
const RANKS_PER_SUIT = ALL_RANKS.length;
/**
* A playing card.
*
* Cards are flyweights: exactly 36 instances exist and `Card.of` always returns
* the same object for the same suit and rank. That buys three things — identity
* comparison (`a === b`) instead of field comparison, a cheap `id` for
* serialisation, and the impossibility of two "different" six of clubs objects
* drifting apart inside one game state.
*/
export class Card {
/** Every card, in suit then ascending rank order. */
private static readonly _registry: Card[] = Card._buildRegistry();
/** Stable 0..35 index, used for serialisation. */
readonly id: number;
readonly suit: Suit;
readonly rank: Rank;
private constructor(suit: Suit, rank: Rank) {
this.suit = suit;
this.rank = rank;
this.id = suit * RANKS_PER_SUIT + (rank - Rank.Six);
}
private static _buildRegistry(): Card[] {
const registry: Card[] = [];
for (const suit of ALL_SUITS) {
for (const rank of ALL_RANKS) {
const card = new Card(suit, rank);
registry[card.id] = card;
}
}
return registry;
}
/** Short human-readable code, e.g. `6♣` or `A♠`. Used in logs and tests. */
get code(): string {
return `${rankLabel(this.rank)}${suitSymbol(this.suit)}`;
}
/** Art asset base name, e.g. `ace_of_spades`. */
get assetName(): string {
return `${rankName(this.rank)}_of_${suitName(this.suit)}`;
}
toString(): string {
return this.code;
}
static of(suit: Suit, rank: Rank): Card {
return Card._registry[suit * RANKS_PER_SUIT + (rank - Rank.Six)];
}
static byId(id: number): Card {
const card = Card._registry[id];
if (!card) {
throw new RangeError(`No card with id ${id}`);
}
return card;
}
/**
* Parses a code such as `6C`, `10H`, `KS` or `A♦`. Lenient about case and
* about symbol versus letter, because it exists to make test fixtures
* readable rather than to validate user input.
*/
static parse(code: string): Card {
const text = code.trim().toUpperCase();
const suit = SUIT_BY_CHAR[text.slice(-1)];
if (suit === undefined) {
throw new SyntaxError(`Unknown suit in card code '${code}'`);
}
const rank = RANK_BY_LABEL[text.slice(0, -1)];
if (rank === undefined) {
throw new SyntaxError(`Unknown rank in card code '${code}'`);
}
return Card.of(suit, rank);
}
/** Parses a space-separated list, e.g. `'6C 10H KS'`. */
static parseAll(codes: string): Card[] {
return codes.split(/\s+/).filter((s) => s.length > 0).map(Card.parse);
}
/** All 36 cards, in suit then rank order. */
static get all(): readonly Card[] {
return Card._registry;
}
}
const SUIT_BY_CHAR: Readonly<Record<string, Suit>> = {
C: Suit.Clubs, '\u2663': Suit.Clubs,
D: Suit.Diamonds, '\u2666': Suit.Diamonds,
H: Suit.Hearts, '\u2665': Suit.Hearts,
S: Suit.Spades, '\u2660': Suit.Spades,
};
const RANK_BY_LABEL: Record<string, Rank> = {};
for (const rank of ALL_RANKS) {
RANK_BY_LABEL[rankLabel(rank)] = rank;
}
/** All 36 cards, in suit then rank order. */
export const ALL_CARDS: readonly Card[] = Card.all;
/** A new unshuffled 36-card deck as a mutable array. */
export function buildDeck(): Card[] {
return Card.all.slice();
}
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "37e27ebe-a388-487e-a30e-2c8ba1475e45",
"files": [],
"subMetas": {},
"userData": {}
}
+54
View File
@@ -0,0 +1,54 @@
/**
* Card ranks present in a 36-card deck. The numeric values are the comparison
* order, so `a > b` is a valid "beats within suit" test.
*/
export enum Rank {
Six = 6,
Seven = 7,
Eight = 8,
Nine = 9,
Ten = 10,
Jack = 11,
Queen = 12,
King = 13,
Ace = 14,
}
export const ALL_RANKS: readonly Rank[] = [
Rank.Six, Rank.Seven, Rank.Eight, Rank.Nine, Rank.Ten,
Rank.Jack, Rank.Queen, Rank.King, Rank.Ace,
];
/** Short label, as printed on the card corner. */
const RANK_LABELS: Record<Rank, string> = {
[Rank.Six]: '6',
[Rank.Seven]: '7',
[Rank.Eight]: '8',
[Rank.Nine]: '9',
[Rank.Ten]: '10',
[Rank.Jack]: 'J',
[Rank.Queen]: 'Q',
[Rank.King]: 'K',
[Rank.Ace]: 'A',
};
/** Rank names as used by the card art file names. */
const RANK_NAMES: Record<Rank, string> = {
[Rank.Six]: '6',
[Rank.Seven]: '7',
[Rank.Eight]: '8',
[Rank.Nine]: '9',
[Rank.Ten]: '10',
[Rank.Jack]: 'jack',
[Rank.Queen]: 'queen',
[Rank.King]: 'king',
[Rank.Ace]: 'ace',
};
export function rankLabel(rank: Rank): string {
return RANK_LABELS[rank];
}
export function rankName(rank: Rank): string {
return RANK_NAMES[rank];
}
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "e301a4f0-1703-47ba-9a1c-fa73e4d2935e",
"files": [],
"subMetas": {},
"userData": {}
}
+36
View File
@@ -0,0 +1,36 @@
/** The four suits of a standard deck. */
export enum Suit {
Clubs = 0,
Diamonds = 1,
Hearts = 2,
Spades = 3,
}
export const ALL_SUITS: readonly Suit[] = [Suit.Clubs, Suit.Diamonds, Suit.Hearts, Suit.Spades];
const SUIT_SYMBOLS: Record<Suit, string> = {
[Suit.Clubs]: '\u2663',
[Suit.Diamonds]: '\u2666',
[Suit.Hearts]: '\u2665',
[Suit.Spades]: '\u2660',
};
/** Suit names as used by the card art file names. */
const SUIT_NAMES: Record<Suit, string> = {
[Suit.Clubs]: 'clubs',
[Suit.Diamonds]: 'diamonds',
[Suit.Hearts]: 'hearts',
[Suit.Spades]: 'spades',
};
export function suitSymbol(suit: Suit): string {
return SUIT_SYMBOLS[suit];
}
export function suitName(suit: Suit): string {
return SUIT_NAMES[suit];
}
export function isRedSuit(suit: Suit): boolean {
return suit === Suit.Diamonds || suit === Suit.Hearts;
}
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "7056e716-7d05-461f-ba29-1716db6674ad",
"files": [],
"subMetas": {},
"userData": {}
}
+3
View File
@@ -0,0 +1,3 @@
export { Card, ALL_CARDS, buildDeck } from './Card';
export { Rank, ALL_RANKS, rankLabel, rankName } from './Rank';
export { Suit, ALL_SUITS, suitName, suitSymbol, isRedSuit } from './Suit';
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "b99118e1-0ff8-43f8-a6ac-02f26f154af7",
"files": [],
"subMetas": {},
"userData": {}
}
+71
View File
@@ -0,0 +1,71 @@
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';
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "9fd385cc-d496-485c-a5d8-64332887084f",
"files": [],
"subMetas": {},
"userData": {}
}
+471
View File
@@ -0,0 +1,471 @@
import { buildDeck, Card } from '../cards/Card';
import { Rank } from '../cards/Rank';
import { Suit } from '../cards/Suit';
import { Rng } from '../util/Rng';
import { Action, Actions, ActionType, IllegalActionError } from './Action';
import { GameEvent, GameEventType } from './GameEvent';
import {
cloneState, GameState, Phase, PlayerId, PlayerState, Seat, TableSlot,
} from './GameState';
/** Cards a player refills to at the end of a turn. */
export const HAND_SIZE = 6;
/** Most attacking cards allowed in one turn. */
export const MAX_TABLE_SLOTS = 6;
/** The opening turn of a game allows one fewer. */
export const FIRST_TURN_MAX_TABLE_SLOTS = 5;
export interface GameOptions {
/** Seating order, clockwise. At least two. */
players: PlayerId[];
seed: number;
}
export interface ApplyResult {
state: GameState;
events: GameEvent[];
}
/** Whether `defence` beats `attack` given the trump suit. */
export function beats(defence: Card, attack: Card, trumpSuit: Suit): boolean {
if (defence.suit === attack.suit) {
return defence.rank > attack.rank;
}
return defence.suit === trumpSuit;
}
// ---------------------------------------------------------------------------
// Setup
// ---------------------------------------------------------------------------
export function createGame(options: GameOptions): ApplyResult {
if (options.players.length < 2) {
throw new RangeError('A game needs at least two players');
}
if (new Set(options.players).size !== options.players.length) {
throw new RangeError('Player ids must be unique');
}
if (options.players.length * HAND_SIZE + 1 > 36) {
throw new RangeError('Too many players for a 36-card deck');
}
const rng = new Rng(options.seed);
const shuffled = rng.shuffle(buildDeck());
const events: GameEvent[] = [];
const players: PlayerState[] = options.players.map((id) => ({ id, hand: [], out: false }));
for (const player of players) {
player.hand = shuffled.splice(0, HAND_SIZE);
events.push({ type: GameEventType.Dealt, player: player.id, cards: player.hand.slice() });
}
// The next card is turned face up to set the trump, then slid under the draw
// pile: it is the very last card anyone will draw.
const trumpCard = shuffled.shift() as Card;
const deck = [...shuffled, trumpCard];
events.push({ type: GameEventType.TrumpRevealed, card: trumpCard });
const attacker = chooseFirstAttacker(players, trumpCard.suit, rng);
const defender = (attacker + 1) % players.length;
const state: GameState = {
players,
deck,
trumpCard,
trumpSuit: trumpCard.suit,
table: [],
discard: [],
phase: Phase.Attack,
attacker,
defender,
turnNumber: 1,
defenderTook: false,
passed: players.map(() => false),
rngState: rng.state,
loser: null,
};
events.push({
type: GameEventType.TurnStarted,
turnNumber: 1,
attacker: players[attacker].id,
defender: players[defender].id,
});
return { state, events };
}
/**
* Lowest trump goes first; if nobody holds a trump, the lowest card of any suit;
* a remaining tie is broken at random. See GAMEPLAY.md.
*/
function chooseFirstAttacker(players: PlayerState[], trumpSuit: Suit, rng: Rng): Seat {
const lowestTrump = players.map((p) => lowestRankOfSuit(p.hand, trumpSuit));
let candidates = argMin(lowestTrump);
if (lowestTrump[candidates[0]] === Infinity) {
candidates = argMin(players.map((p) => lowestRank(p.hand)));
}
return candidates.length === 1 ? candidates[0] : rng.pick(candidates);
}
function lowestRankOfSuit(hand: readonly Card[], suit: Suit): number {
let best = Infinity;
for (const card of hand) {
if (card.suit === suit && card.rank < best) best = card.rank;
}
return best;
}
function lowestRank(hand: readonly Card[]): number {
let best = Infinity;
for (const card of hand) {
if (card.rank < best) best = card.rank;
}
return best;
}
function argMin(values: readonly number[]): Seat[] {
const min = Math.min(...values);
const result: Seat[] = [];
values.forEach((v, i) => {
if (v === min) result.push(i);
});
return result;
}
// ---------------------------------------------------------------------------
// Queries
// ---------------------------------------------------------------------------
export function seatOf(state: GameState, player: PlayerId): Seat {
const seat = state.players.findIndex((p) => p.id === player);
if (seat < 0) {
throw new RangeError(`No player '${player}' in this game`);
}
return seat;
}
/** Attacking seats this turn: the attacker first, then clockwise, defender excluded. */
export function attackSeats(state: GameState): Seat[] {
const seats: Seat[] = [];
for (let i = 0; i < state.players.length; i++) {
const seat = (state.attacker + i) % state.players.length;
if (seat === state.defender || state.players[seat].out) continue;
seats.push(seat);
}
return seats;
}
/**
* The single participant the game is waiting on, or null when the game is over.
*
* Real durak lets attackers throw cards in at any moment. Serialising that into
* one actor at a time costs nothing at this table size and makes a whole match a
* plain sequence of decisions. See docs/DECISIONS.md.
*/
export function currentActor(state: GameState): PlayerId | null {
switch (state.phase) {
case Phase.Attack:
return state.players[state.attacker].id;
case Phase.Defence:
return state.players[state.defender].id;
case Phase.Reinforce: {
const seat = attackSeats(state).find((s) => !state.passed[s]);
return seat === undefined ? null : state.players[seat].id;
}
case Phase.Finished:
return null;
}
}
/** Indices of table slots that still need to be beaten. */
export function unbeatenSlots(state: GameState): number[] {
const result: number[] = [];
state.table.forEach((slot, i) => {
if (slot.defence === null) result.push(i);
});
return result;
}
export function maxTableSlots(state: GameState): number {
return state.turnNumber === 1 ? FIRST_TURN_MAX_TABLE_SLOTS : MAX_TABLE_SLOTS;
}
function ranksOnTable(state: GameState): Set<Rank> {
const ranks = new Set<Rank>();
for (const slot of state.table) {
ranks.add(slot.attack.rank);
if (slot.defence) ranks.add(slot.defence.rank);
}
return ranks;
}
/**
* Why a card may not be added right now, or null if it may. The three limits are
* taken from GAMEPLAY.md; how they are read is recorded in docs/DECISIONS.md.
*/
export function whyCannotAdd(state: GameState, card: Card): string | null {
if (state.table.length >= maxTableSlots(state)) {
return `the table already holds ${state.table.length} attacking cards`;
}
if (unbeatenSlots(state).length >= state.players[state.defender].hand.length) {
return 'the defender does not hold enough cards to answer another one';
}
if (state.table.length > 0 && !ranksOnTable(state).has(card.rank)) {
return `nothing of that rank is on the table`;
}
return null;
}
function hasAddableCard(state: GameState, seat: Seat): boolean {
return state.players[seat].hand.some((card) => whyCannotAdd(state, card) === null);
}
/** Every action `player` may legally take right now. Empty when it is not their move. */
export function legalActions(state: GameState, player: PlayerId): Action[] {
if (currentActor(state) !== player) {
return [];
}
const hand = state.players[seatOf(state, player)].hand;
switch (state.phase) {
case Phase.Attack:
// The opening card of a turn: anything in hand, and no option to skip.
return hand.map((card) => Actions.attack(player, card));
case Phase.Defence: {
const actions: Action[] = [Actions.take(player)];
for (const slot of unbeatenSlots(state)) {
for (const card of hand) {
if (beats(card, state.table[slot].attack, state.trumpSuit)) {
actions.push(Actions.defend(player, card, slot));
}
}
}
return actions;
}
case Phase.Reinforce: {
const actions: Action[] = [Actions.pass(player)];
for (const card of hand) {
if (whyCannotAdd(state, card) === null) {
actions.push(Actions.attack(player, card));
}
}
return actions;
}
case Phase.Finished:
return [];
}
}
// ---------------------------------------------------------------------------
// Transition
// ---------------------------------------------------------------------------
/**
* The one way a game moves forward. Pure: `state` is not modified; a new state
* and the events describing the change are returned.
*/
export function apply(state: GameState, action: Action): ApplyResult {
const next = cloneState(state);
const events: GameEvent[] = [];
if (next.phase === Phase.Finished) {
throw new IllegalActionError(action, 'the game is over');
}
const actor = currentActor(next);
if (actor !== action.player) {
throw new IllegalActionError(action, `it is not their move`);
}
const seat = seatOf(next, action.player);
const hand = next.players[seat].hand;
switch (action.type) {
case ActionType.Attack: {
if (next.phase !== Phase.Attack && next.phase !== Phase.Reinforce) {
throw new IllegalActionError(action, `cannot attack during the ${next.phase} phase`);
}
requireInHand(action, hand, action.card);
const refusal = whyCannotAdd(next, action.card);
if (refusal) {
throw new IllegalActionError(action, refusal);
}
removeCard(hand, action.card);
next.table.push({ attack: action.card, defence: null });
events.push({
type: GameEventType.Attacked,
player: action.player,
card: action.card,
slot: next.table.length - 1,
});
// A new card on the table reopens the decision for everyone who had
// already declared they were done.
next.passed.fill(false);
next.phase = next.defenderTook ? Phase.Reinforce : Phase.Defence;
break;
}
case ActionType.Defend: {
if (next.phase !== Phase.Defence) {
throw new IllegalActionError(action, `cannot defend during the ${next.phase} phase`);
}
const slot: TableSlot | undefined = next.table[action.slot];
if (!slot) {
throw new IllegalActionError(action, `there is no slot ${action.slot}`);
}
if (slot.defence !== null) {
throw new IllegalActionError(action, `slot ${action.slot} is already beaten`);
}
requireInHand(action, hand, action.card);
if (!beats(action.card, slot.attack, next.trumpSuit)) {
throw new IllegalActionError(action, `${action.card.code} does not beat ${slot.attack.code}`);
}
removeCard(hand, action.card);
slot.defence = action.card;
events.push({
type: GameEventType.Defended,
player: action.player,
card: action.card,
slot: action.slot,
});
next.passed.fill(false);
if (unbeatenSlots(next).length === 0) {
next.phase = Phase.Reinforce;
}
break;
}
case ActionType.Take: {
if (next.phase !== Phase.Defence) {
throw new IllegalActionError(action, `cannot take during the ${next.phase} phase`);
}
next.defenderTook = true;
next.passed.fill(false);
next.phase = Phase.Reinforce;
events.push({ type: GameEventType.TookDeclared, player: action.player });
break;
}
case ActionType.Pass: {
if (next.phase !== Phase.Reinforce) {
throw new IllegalActionError(action, `cannot pass during the ${next.phase} phase`);
}
next.passed[seat] = true;
events.push({ type: GameEventType.Passed, player: action.player });
break;
}
}
settle(next, events);
return { state: next, events };
}
function requireInHand(action: Action, hand: readonly Card[], card: Card): void {
if (!hand.includes(card)) {
throw new IllegalActionError(action, `${card.code} is not in hand`);
}
}
function removeCard(hand: Card[], card: Card): void {
hand.splice(hand.indexOf(card), 1);
}
/**
* Advances anything that needs no decision: an attacker with nothing legal to
* add is passed silently, and a turn where every attacker is done is resolved.
*/
function settle(state: GameState, events: GameEvent[]): void {
while (state.phase === Phase.Reinforce) {
const pending = attackSeats(state).filter((s) => !state.passed[s]);
if (pending.length === 0) {
resolveTurn(state, events);
return;
}
if (hasAddableCard(state, pending[0])) {
return; // A real choice — wait for it.
}
state.passed[pending[0]] = true;
}
}
function resolveTurn(state: GameState, events: GameEvent[]): void {
const tableCards: Card[] = [];
for (const slot of state.table) {
tableCards.push(slot.attack);
if (slot.defence) tableCards.push(slot.defence);
}
if (state.defenderTook) {
state.players[state.defender].hand.push(...tableCards);
events.push({
type: GameEventType.TableTaken,
player: state.players[state.defender].id,
cards: tableCards.slice(),
});
} else if (tableCards.length > 0) {
state.discard.push(...tableCards);
events.push({ type: GameEventType.TableDiscarded, cards: tableCards.slice() });
}
state.table = [];
// Refill: the attacker first, then clockwise, and the defender last.
for (const seat of [...attackSeats(state), state.defender]) {
const player = state.players[seat];
if (player.out) continue;
const wanted = HAND_SIZE - player.hand.length;
if (wanted <= 0 || state.deck.length === 0) continue;
const drawn = state.deck.splice(0, wanted);
player.hand.push(...drawn);
events.push({
type: GameEventType.Drew,
player: player.id,
cards: drawn.slice(),
deckRemaining: state.deck.length,
});
}
// Anyone who could not refill has run out of cards and leaves the game.
for (const player of state.players) {
if (!player.out && player.hand.length === 0) {
player.out = true;
events.push({ type: GameEventType.PlayerOut, player: player.id });
}
}
const active = state.players.filter((p) => !p.out);
if (active.length <= 1) {
state.phase = Phase.Finished;
state.loser = active.length === 1 ? active[0].id : null;
events.push({ type: GameEventType.GameOver, loser: state.loser });
return;
}
// A successful defence hands the attack to the defender; a defender who took
// is skipped, so the attack passes to the seat after them.
const base = state.defenderTook ? state.defender + 1 : state.defender;
state.attacker = nextActiveSeat(state, base);
state.defender = nextActiveSeat(state, state.attacker + 1);
state.turnNumber += 1;
state.defenderTook = false;
state.passed.fill(false);
state.phase = Phase.Attack;
events.push({
type: GameEventType.TurnStarted,
turnNumber: state.turnNumber,
attacker: state.players[state.attacker].id,
defender: state.players[state.defender].id,
});
}
/** The first seat at or after `from` (wrapping) that is still in the game. */
function nextActiveSeat(state: GameState, from: Seat): Seat {
const n = state.players.length;
for (let i = 0; i < n; i++) {
const seat = (((from + i) % n) + n) % n;
if (!state.players[seat].out) return seat;
}
throw new Error('No active players left');
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "e28254c5-32f3-4bcd-a3ad-383bac9b2598",
"files": [],
"subMetas": {},
"userData": {}
}
+57
View File
@@ -0,0 +1,57 @@
import { Card } from '../cards/Card';
import { PlayerId } from './GameState';
/**
* What happened, as opposed to what someone wanted to do. The presentation layer
* animates this stream; it is the only thing the view needs in order to stay in
* sync with the rules.
*
* The stream is a god's-eye view — `Dealt` and `Drew` carry real cards. It is
* the view's job to show only what the local player may see. Anything that
* genuinely must not know (a bot) reads a `PlayerView` instead.
*/
export enum GameEventType {
Dealt = 'dealt',
TrumpRevealed = 'trumpRevealed',
TurnStarted = 'turnStarted',
Attacked = 'attacked',
Defended = 'defended',
TookDeclared = 'tookDeclared',
Passed = 'passed',
TableDiscarded = 'tableDiscarded',
TableTaken = 'tableTaken',
Drew = 'drew',
PlayerOut = 'playerOut',
GameOver = 'gameOver',
}
export type GameEvent =
| { type: GameEventType.Dealt; player: PlayerId; cards: Card[] }
| { type: GameEventType.TrumpRevealed; card: Card }
| { type: GameEventType.TurnStarted; turnNumber: number; attacker: PlayerId; defender: PlayerId }
| { type: GameEventType.Attacked; player: PlayerId; card: Card; slot: number }
| { type: GameEventType.Defended; player: PlayerId; card: Card; slot: number }
| { type: GameEventType.TookDeclared; player: PlayerId }
| { type: GameEventType.Passed; player: PlayerId }
| { type: GameEventType.TableDiscarded; cards: Card[] }
| { type: GameEventType.TableTaken; player: PlayerId; cards: Card[] }
| { type: GameEventType.Drew; player: PlayerId; cards: Card[]; deckRemaining: number }
| { type: GameEventType.PlayerOut; player: PlayerId }
| { type: GameEventType.GameOver; loser: PlayerId | null };
export function describeEvent(event: GameEvent): string {
switch (event.type) {
case GameEventType.Dealt: return `${event.player} was dealt ${event.cards.length} cards`;
case GameEventType.TrumpRevealed: return `trump is ${event.card.code}`;
case GameEventType.TurnStarted: return `turn ${event.turnNumber}: ${event.attacker} attacks ${event.defender}`;
case GameEventType.Attacked: return `${event.player} played ${event.card.code}`;
case GameEventType.Defended: return `${event.player} beat slot ${event.slot} with ${event.card.code}`;
case GameEventType.TookDeclared: return `${event.player} declared a take`;
case GameEventType.Passed: return `${event.player} passed`;
case GameEventType.TableDiscarded: return `${event.cards.length} cards discarded`;
case GameEventType.TableTaken: return `${event.player} took ${event.cards.length} cards`;
case GameEventType.Drew: return `${event.player} drew ${event.cards.length} (${event.deckRemaining} left)`;
case GameEventType.PlayerOut: return `${event.player} is out`;
case GameEventType.GameOver: return event.loser ? `${event.loser} is the fool` : 'a draw';
}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "5953fa1f-076c-4745-be88-e1f887983c72",
"files": [],
"subMetas": {},
"userData": {}
}
+136
View File
@@ -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,
};
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "96d082c1-3dc2-4552-91ff-a73be57f7295",
"files": [],
"subMetas": {},
"userData": {}
}