Files
fool-cardgame/tests/core/rules/intent.spec.ts
T
ftcandClaude Opus 5 4f3a26dc45 M5-M7: сцены, лобби, экран игры и цикл матча
Обе сцены собраны через Code Mode, не руками.

- loader: поднимает модули, прогревает арт карт, уходит в lobby
- lobby: обе UI-ветки в одной сцене (по ARCHITECTURE.md), матч — это
  смена видимого корня, а не загрузка сцены
- GameScreen рисует текущее состояние и доводит узлы твинами, а не
  проигрывает поток событий: узел карты живёт вместе со своей картой,
  поэтому "шестёрка ушла из руки на стол" не требует отдельного случая
- один тап = ход целиком; логика вынесена в core/rules/intent.ts
  и покрыта тестами, в том числе проверкой, что она никогда не предложит
  ход, который правила отвергнут
- арт карт переехал в assets/resources: .pac — это инструкция упаковки
  на сборке, а не рантайм-ассет, и без ссылок на кадры в сборку
  не попадало ничего. Теперь все 37 спрайтов в одной текстуре 2048x2048
- клики кнопок подписываются в коде, а не через clickEvents сцены
- строки интерфейса собраны в одном файле

Сборка web-mobile проходит чисто, все ссылки в сцене проверены.
Вживую не запускалось: браузер в этом окружении не поднимает
главный цикл движка. Подробности — в docs/TECHNICAL.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 11:30:28 +03:00

144 lines
5.0 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { Card } from '@core/cards/Card';
import { ActionType } from '@core/rules/Action';
import { Phase } from '@core/rules/GameState';
import { apply, currentActor, legalActions } from '@core/rules/DurakRules';
import { intentFor } from '@core/rules/intent';
import { makeState } from '../../support/fixtures';
const YOU = 'you';
const BOT = 'bot';
const tap = (state: Parameters<typeof intentFor>[0], code: string) =>
intentFor(state, YOU, Card.parse(code));
describe('intentFor when opening a turn', () => {
const position = () => makeState({
hands: { [YOU]: '6C 7D KH', [BOT]: '8C 9D QH' },
trump: 'S',
deck: '10C 10D',
attacker: YOU,
});
it('plays any card from hand', () => {
expect(tap(position(), '6C')).toMatchObject({ type: ActionType.Attack });
expect(tap(position(), 'KH')).toMatchObject({ type: ActionType.Attack });
});
it('refuses a card that is not in hand', () => {
expect(tap(position(), 'AS')).toBeNull();
});
it('refuses everything when it is not your move', () => {
const state = position();
expect(intentFor(state, BOT, Card.parse('8C'))).toBeNull();
});
});
describe('intentFor when defending', () => {
const position = () => makeState({
hands: { [YOU]: '8D 6S KC', [BOT]: 'AH AD' },
trump: 'S',
deck: '10C 10D',
attacker: BOT,
defender: YOU,
table: [{ attack: '7D' }],
phase: Phase.Defence,
});
it('works out which attack the card beats, with no second tap', () => {
expect(tap(position(), '8D')).toMatchObject({ type: ActionType.Defend, slot: 0 });
});
it('allows a trump', () => {
expect(tap(position(), '6S')).toMatchObject({ type: ActionType.Defend, slot: 0 });
});
it('refuses a card that beats nothing', () => {
expect(tap(position(), 'KC')).toBeNull();
});
it('picks the lowest slot the card can answer', () => {
const state = makeState({
hands: { [YOU]: 'AD', [BOT]: 'AH AC' },
trump: 'S',
deck: '10C 10D',
attacker: BOT,
defender: YOU,
table: [{ attack: '7C' }, { attack: '7D' }],
phase: Phase.Defence,
});
// The ace of diamonds cannot answer the clubs, so slot 1 is the answer.
expect(tap(state, 'AD')).toMatchObject({ slot: 1 });
});
});
describe('intentFor when adding to an attack', () => {
const position = () => makeState({
hands: { [YOU]: '7H 9C KD', [BOT]: 'AH AD' },
trump: 'S',
deck: '10C 10D',
attacker: YOU,
table: [{ attack: '7D', defence: '9D' }],
phase: Phase.Reinforce,
});
it('adds a card whose rank is on the table', () => {
expect(tap(position(), '7H')).toMatchObject({ type: ActionType.Attack });
expect(tap(position(), '9C')).toMatchObject({ type: ActionType.Attack });
});
it('refuses a rank that is not on the table', () => {
expect(tap(position(), 'KD')).toBeNull();
});
});
describe('intentFor agrees with the rules', () => {
it('never proposes an action the rules would reject', () => {
// Play many matches by tapping cards, and check every proposal is legal.
for (let seed = 0; seed < 400; seed++) {
let state = makeState({
hands: { [YOU]: '6C 7D 8H 9S 10C JD', [BOT]: '6D 7H 8S 9C 10D JH' },
trump: 'S',
deck: 'QC QD QH QS KC KD KH KS AC AD AH',
attacker: seed % 2 === 0 ? YOU : BOT,
turnNumber: 1 + (seed % 3),
});
for (let step = 0; step < 40 && state.phase !== Phase.Finished; step++) {
const actor = currentActor(state);
if (actor === null) break;
if (actor === YOU) {
const hand = state.players[0].hand;
for (const card of hand) {
const action = intentFor(state, YOU, card);
if (!action) continue;
// Whatever it proposes, the rules must accept it.
expect(() => apply(state, action)).not.toThrow();
}
}
const options = legalActions(state, actor);
state = apply(state, options[seed % options.length]).state;
}
}
});
it('proposes something for every card the rules would accept', () => {
const state = makeState({
hands: { [YOU]: '6C 7D 8H 9S 10C JD', [BOT]: '6D 7H 8S' },
trump: 'S',
deck: 'QC QD QH',
attacker: YOU,
});
const playable = state.players[0].hand.filter((c) => intentFor(state, YOU, c) !== null);
const legalCards = legalActions(state, YOU)
.filter((a) => a.type === ActionType.Attack)
.map((a) => (a as { card: Card }).card);
expect(new Set(playable)).toEqual(new Set(legalCards));
});
});