305 lines
9.6 KiB
TypeScript
305 lines
9.6 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction,
|
|
createGame,
|
|
newPlayer,
|
|
viewGame,
|
|
type Game,
|
|
type Action,
|
|
} from '../game/engine';
|
|
import { botActions } from '../game/bots';
|
|
import { DIFFICULTIES, type Difficulty } from '../game/bot-profiles';
|
|
import { baseDeck, ixBattleCards, leaders, type Card } from '../game/cards';
|
|
import { richeseCards } from '../game/richese-cards';
|
|
|
|
type VoiceKind = NonNullable<NonNullable<Game['battle']>['voice']>['kind'];
|
|
|
|
function fixture(advanced = false): Game {
|
|
const g = createGame(
|
|
'PLANLEGALREVIEW',
|
|
newPlayer('p', 'Planner', 'emperor'),
|
|
advanced,
|
|
);
|
|
g.players.push(newPlayer('o', 'Opponent', 'guild'));
|
|
Object.assign(g, {
|
|
status: 'playing',
|
|
phase: 6,
|
|
turn: 2,
|
|
storm: 18,
|
|
active: 'p',
|
|
order: ['p', 'o'],
|
|
});
|
|
for (const p of g.players)
|
|
Object.assign(p, {
|
|
forces: { 'arrakeen:10': 4 },
|
|
reserves: 16,
|
|
spice: 10,
|
|
hand: [],
|
|
traitors: [],
|
|
});
|
|
g.battle = {
|
|
territory: 'arrakeen',
|
|
attacker: 'p',
|
|
defender: 'o',
|
|
prepared: true,
|
|
plans: {},
|
|
revealed: false,
|
|
traitorCalls: {},
|
|
};
|
|
return g;
|
|
}
|
|
function card(kind: Card['kind']) {
|
|
const c = [...baseDeck(), ...ixBattleCards()].find((c) => c.kind === kind);
|
|
assert.ok(c, kind);
|
|
return c;
|
|
}
|
|
function unavailable(g: Game) {
|
|
g.players[0].leaders.forEach((l) => {
|
|
l.usedAt = 'carthag';
|
|
});
|
|
}
|
|
function projection(g: Game, difficulty: Difficulty) {
|
|
const v = viewGame(g, 'p');
|
|
v.players[0].bot = difficulty;
|
|
return v;
|
|
}
|
|
function plans(g: Game, difficulty: Difficulty) {
|
|
const v = projection(g, difficulty),
|
|
before = structuredClone(v);
|
|
const actions = botActions(v).filter((a) => a.type === 'battlePlan');
|
|
assert.ok(
|
|
actions.length > 0,
|
|
`${difficulty}: a legal battle must remain finishable`,
|
|
);
|
|
assert.deepEqual(
|
|
v,
|
|
before,
|
|
'Planning must not mutate the private projection.',
|
|
);
|
|
for (const action of actions) {
|
|
assert.doesNotThrow(
|
|
() => applyAction(g, 'p', action),
|
|
`${difficulty}: ${JSON.stringify(action)}`,
|
|
);
|
|
assert.ok(
|
|
!action.weapon || action.weapon !== action.defense,
|
|
'One physical card cannot occupy both slots.',
|
|
);
|
|
}
|
|
return actions;
|
|
}
|
|
|
|
void test('independent plan review: every profile uses available leaders and offers only empty slots when no leader can fight', () => {
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const g = fixture();
|
|
g.players[0].hand = [card('projectile'), card('shield'), card('worthless')];
|
|
assert.ok(plans(g, difficulty).every((a) => a.leader));
|
|
unavailable(g);
|
|
const empty = plans(g, difficulty);
|
|
assert.ok(
|
|
empty.every((a) => !a.leader && !a.weapon && !a.defense && !a.kwisatz),
|
|
);
|
|
g.players[0].leaders[0].usedAt = 'arrakeen';
|
|
assert.ok(
|
|
plans(g, difficulty).every(
|
|
(a) => a.leader === g.players[0].leaders[0].id,
|
|
),
|
|
'A disc used in this territory remains available.',
|
|
);
|
|
}
|
|
});
|
|
|
|
void test('independent plan review: a sole Cheap Hero is mandatory except when forbidden by Voice', () => {
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const g = fixture();
|
|
unavailable(g);
|
|
const hero = card('hero');
|
|
g.players[0].hand = [hero, card('poison'), card('snooper')];
|
|
assert.ok(plans(g, difficulty).every((a) => a.leader === hero.id));
|
|
g.battle!.voice = { target: 'p', kind: 'hero', must: false };
|
|
assert.ok(
|
|
plans(g, difficulty).every((a) => !a.leader && !a.weapon && !a.defense),
|
|
);
|
|
g.players[0].leaders[0].usedAt = 'arrakeen';
|
|
assert.ok(
|
|
plans(g, difficulty).every(
|
|
(a) => a.leader === g.players[0].leaders[0].id,
|
|
),
|
|
);
|
|
g.battle!.voice.must = true;
|
|
assert.ok(plans(g, difficulty).every((a) => a.leader === hero.id));
|
|
}
|
|
});
|
|
|
|
void test('independent plan review: Voice compulsion and prohibition use actual Weirding Way and Chemistry roles', () => {
|
|
const configurations: {
|
|
hand: Card[];
|
|
kind: VoiceKind;
|
|
must: boolean;
|
|
required: (a: Action) => boolean;
|
|
optional?: (a: Action) => boolean;
|
|
}[] = [
|
|
{
|
|
hand: [card('weirdingWay'), card('poison'), card('shield')],
|
|
kind: 'projectile',
|
|
must: true,
|
|
required: (a) => a.weapon === card('weirdingWay').id,
|
|
},
|
|
{
|
|
hand: [card('weirdingWay'), card('poison')],
|
|
kind: 'projectile',
|
|
must: false,
|
|
required: (a) => a.weapon !== card('weirdingWay').id,
|
|
optional: (a) => a.defense === card('weirdingWay').id,
|
|
},
|
|
{
|
|
hand: [card('weirdingWay'), card('poison')],
|
|
kind: 'shield',
|
|
must: true,
|
|
required: () => true,
|
|
optional: (a) => !a.defense,
|
|
},
|
|
{
|
|
hand: [card('chemistry'), card('shield')],
|
|
kind: 'snooper',
|
|
must: true,
|
|
required: (a) => a.defense === card('chemistry').id,
|
|
},
|
|
{
|
|
hand: [card('chemistry'), card('shield')],
|
|
kind: 'snooper',
|
|
must: false,
|
|
required: (a) => a.defense !== card('chemistry').id,
|
|
optional: (a) => a.weapon === card('chemistry').id,
|
|
},
|
|
{
|
|
hand: [card('chemistry'), card('shield')],
|
|
kind: 'poison',
|
|
must: true,
|
|
required: () => true,
|
|
optional: (a) => !a.weapon,
|
|
},
|
|
{
|
|
hand: [card('shieldSnooper')],
|
|
kind: 'snooper',
|
|
must: true,
|
|
required: (a) => a.defense === card('shieldSnooper').id,
|
|
},
|
|
{
|
|
hand: [card('poisonBlade')],
|
|
kind: 'projectile',
|
|
must: true,
|
|
required: (a) => a.weapon === card('poisonBlade').id,
|
|
},
|
|
];
|
|
for (const difficulty of DIFFICULTIES)
|
|
for (const configuration of configurations) {
|
|
const g = fixture();
|
|
g.players[0].hand = configuration.hand;
|
|
g.battle!.voice = {
|
|
target: 'p',
|
|
kind: configuration.kind,
|
|
must: configuration.must,
|
|
};
|
|
const actions = plans(g, difficulty);
|
|
assert.ok(
|
|
actions.every(configuration.required),
|
|
`${difficulty} ${configuration.kind} ${configuration.must}`,
|
|
);
|
|
if (configuration.optional)
|
|
assert.ok(
|
|
actions.some(configuration.optional),
|
|
'Voice cannot force an alternate role or prohibit a different played role.',
|
|
);
|
|
}
|
|
});
|
|
|
|
void test('independent plan review: canonical Portable Snooper obeys ordinary Snooper Voice and an absent required type does not force cards', () => {
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const g = fixture(),
|
|
portable = richeseCards().find((c) => c.effect === 'portableSnooper')!;
|
|
g.players[0].hand = [portable, card('projectile')];
|
|
g.battle!.voice = { target: 'p', kind: 'snooper', must: true };
|
|
assert.ok(plans(g, difficulty).every((a) => a.defense === portable.id));
|
|
g.battle!.voice.must = false;
|
|
assert.ok(plans(g, difficulty).every((a) => a.defense !== portable.id));
|
|
g.battle!.voice = { target: 'p', kind: 'hero', must: true };
|
|
assert.ok(plans(g, difficulty).some((a) => !a.weapon && !a.defense));
|
|
g.battle!.voice = { target: 'o', kind: 'projectile', must: false };
|
|
assert.ok(
|
|
plans(g, difficulty).some((a) => a.weapon === card('projectile').id),
|
|
'Voice targeting the opponent must not restrict this player.',
|
|
);
|
|
}
|
|
});
|
|
|
|
void test('independent plan review: captured and foreign ghola custody replace unavailable native discs without inventing ownership', () => {
|
|
for (const difficulty of DIFFICULTIES)
|
|
for (const custody of ['capturedBy', 'gholaBy'] as const) {
|
|
const g = fixture(true);
|
|
unavailable(g);
|
|
g.players[0].leaders[0].usedAt = undefined;
|
|
g.players[0].leaders[0].capturedBy = 'o';
|
|
const foreign = g.players[1].leaders[0];
|
|
foreign[custody] = 'p';
|
|
if (custody === 'capturedBy')
|
|
foreign.concealed = { captor: 'p', dead: false, deaths: 0 };
|
|
const actions = plans(g, difficulty);
|
|
assert.ok(actions.every((a) => a.leader === foreign.id));
|
|
foreign.usedAt = 'carthag';
|
|
assert.ok(plans(g, difficulty).every((a) => !a.leader));
|
|
}
|
|
});
|
|
|
|
void test('independent plan review: committed Cheap Hero and leaderless Prescience states recompute KH from the final leader', () => {
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const g = fixture(true);
|
|
g.players[0].faction = 'atreides';
|
|
g.players[0].leaders = leaders('atreides');
|
|
g.players[0].battleLosses = 7;
|
|
g.players[0].kwisatz = { dead: false };
|
|
const hero = card('hero');
|
|
g.players[0].hand = [hero, card('shield')];
|
|
g.battle!.prescience = { player: 'o', field: 'leader', value: hero.id };
|
|
assert.ok(plans(g, difficulty).every((a) => a.leader === hero.id));
|
|
g.players[0].hand = [card('shield')];
|
|
unavailable(g);
|
|
g.battle!.prescience.value = null;
|
|
assert.ok(
|
|
plans(g, difficulty).every(
|
|
(a) => !a.leader && !a.weapon && !a.defense && !a.kwisatz,
|
|
),
|
|
);
|
|
}
|
|
});
|
|
|
|
void test('independent plan review: uninspected opposing hand, balance and sealed plan cannot alter candidates', () => {
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const g = fixture();
|
|
g.players[0].hand = [card('projectile'), card('shield'), card('worthless')];
|
|
g.players[1].hand = [card('poison')];
|
|
g.battle!.plans.o = {
|
|
dial: 0,
|
|
support: 0,
|
|
leader: 'guild-0',
|
|
weapon: null,
|
|
defense: null,
|
|
};
|
|
const before = botActions(projection(g, difficulty));
|
|
const changed = structuredClone(g);
|
|
changed.players[1].spice = 99999;
|
|
changed.players[1].hand = [card('snooper')];
|
|
changed.battle!.plans.o = {
|
|
dial: 4,
|
|
support: 0,
|
|
leader: 'guild-4',
|
|
weapon: null,
|
|
defense: card('snooper').id,
|
|
};
|
|
assert.deepEqual(botActions(projection(changed, difficulty)), before);
|
|
plans(g, difficulty);
|
|
plans(changed, difficulty);
|
|
}
|
|
});
|