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>
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
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));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user