Обе сцены собраны через 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>
144 lines
5.0 KiB
TypeScript
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));
|
|
});
|
|
});
|