Files
Dune/tests/full-plan.test.ts
T
2026-09-08 07:37:52 +10:00

384 lines
13 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
newPlayer,
joinGame,
viewGame,
type Game,
type Action,
} from '../game/engine';
import { baseDeck } from '../game/cards';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
const cards = baseDeck();
const karama = cards.find((c) => c.effect === 'karama')!;
const poison = cards.find((c) => c.kind === 'poison')!;
const shield = cards.find((c) => c.kind === 'shield')!;
const snooper = cards.find((c) => c.kind === 'snooper')!;
const worthless = cards.find((c) => c.kind === 'worthless')!;
function fixture(outsider = false) {
let g = createGame('FULLPLAN', newPlayer('a', 'Atreides', 'atreides'));
joinGame(g, newPlayer('e', 'Emperor', 'emperor'));
joinGame(g, newPlayer('g', 'Guild', 'guild'));
joinGame(g, newPlayer('b', 'Bene Gesserit', 'beneGesserit'));
g.players.forEach((p) => (p.ready = true));
g = applyAction(g, 'a', { type: 'start' });
g = applyAction(g, 'b', { type: 'predict', faction: 'atreides', turn: 3 });
for (const p of g.players)
if (p.traitorChoices.length)
g = applyAction(g, p.id, {
type: 'traitor',
leader: p.traitorChoices[0],
});
g.advanced = true;
g.phase = 6;
g.active = outsider ? 'e' : 'a';
g.order = outsider ? ['e', 'g', 'a', 'b'] : ['a', 'e', 'g', 'b'];
g.storm = 18;
for (const p of g.players) {
const combatant = p.id === 'e' || p.id === (outsider ? 'g' : 'a');
p.forces = combatant ? { 'arrakeen:10': 5 } : {};
p.reserves = combatant ? 15 : 20;
p.spice = 10;
p.hand = [];
p.traitors = [];
}
g.players[0].hand = [karama, snooper];
g.players[1].hand = [poison, shield, worthless];
g.discard = [];
return g;
}
function responses(state: Game) {
let g = state;
while (g.response)
g = applyAction(
g,
g.players.find((p) => !g.response!.passed.includes(p.id))!.id,
{ type: 'passResponse' },
);
return g;
}
function choose(g: Game) {
return applyAction(g, g.active!, {
type: 'chooseBattle',
territory: 'arrakeen',
target: g.active === 'a' ? 'e' : 'g',
});
}
function offer(state = fixture()) {
let g = responses(choose(state));
while (g.battle?.preparation)
g = applyAction(g, g.battle.preparation.owner, {
type: 'declineBattlePower',
});
return g;
}
const inspect = (g: Game, target = 'e') =>
applyAction(g, 'a', {
type: 'card',
mode: 'special',
card: karama.id,
target,
});
const targetPlan: Action = {
type: 'battlePlan',
dial: 2.5,
support: 2,
leader: 'emperor-0',
weapon: poison.id,
defense: shield.id,
};
const ownPlan: Action = {
type: 'battlePlan',
dial: 1,
support: 1,
leader: 'atreides-0',
defense: snooper.id,
};
void test('full-plan offer follows Voice and the answer to ordinary prescience', () => {
const before = fixture();
before.players[0].ally = 'b';
before.players[3].ally = 'a';
let g = responses(choose(before));
assert.equal(g.battle?.preparation?.kind, 'voice');
assert.equal(g.decision, null);
assert.throws(() => inspect(g), /preparation/);
g = applyAction(g, 'b', { type: 'voice', kind: 'snooper', must: false });
g = responses(g);
g = applyAction(g, 'a', { type: 'prescience', field: 'weapon' });
assert.throws(() => inspect(g), /preparation/);
g = responses(g);
assert.equal(g.decision, null);
g = applyAction(g, 'e', { type: 'prescienceAnswer', value: poison.id });
assert.deepEqual(g.decision, { kind: 'fullPlanOffer', player: 'a' });
assert.equal(g.battle?.preparation, undefined);
g = inspect(g);
assert.throws(
() => applyAction(g, 'e', { ...targetPlan, weapon: null }),
/remain unchanged/,
);
assert.throws(() =>
applyAction(g, 'e', { ...targetPlan, defense: snooper.id }),
);
g = applyAction(g, 'e', targetPlan);
assert.equal(g.decision, null);
});
void test('target commits first and only Atreides receives the inspected plan and its selected cards', () => {
const offered = offer();
const g = inspect(offered);
assert.equal(g.players[0].specialKaramaUsed, true);
assert.equal(
g.players[0].hand.some((c) => c.id === karama.id),
false,
);
assert.equal(g.response, null);
assert.equal(g.decision, null);
assert.equal(g.discard.filter((c) => c.id === karama.id).length, 1);
assert.throws(() => applyAction(g, 'a', ownPlan), /requested by special/);
const sealed = applyAction(g, 'e', targetPlan);
assert.equal(sealed.battle?.revealed, false);
assert.equal(sealed.players[1].spice, 10);
const atreides = viewGame(JSON.parse(JSON.stringify(sealed)), 'a');
assert.deepEqual(
atreides.battle?.fullPlanInsight?.plan,
sealed.battle!.plans.e,
);
assert.deepEqual(
new Set(atreides.battle?.fullPlanInsight?.cards.map((c) => c.id)),
new Set([poison.id, shield.id]),
);
assert.equal(atreides.players[1].hand, undefined);
assert.equal(atreides.battle?.plans.e, undefined);
for (const id of ['e', 'g', 'b'])
assert.equal(viewGame(sealed, id).battle?.fullPlanInsight, null);
assert.deepEqual(
viewGame(sealed, 'e').battle?.plans.e,
sealed.battle?.plans.e,
);
assert.equal(viewGame(sealed, 'g').battle?.plans.e, undefined);
assert.ok(
sealed.log.every(
(l) => !l.text.includes(poison.name) && !l.text.includes('2.5'),
),
);
});
void test('inspection needs no acknowledgement and public reveal waits for the second committed plan', () => {
const reading = applyAction(inspect(offer()), 'e', targetPlan);
assert.equal(reading.decision, null);
for (const id of ['g', 'a'])
assert.throws(
() => applyAction(reading, id, { type: 'decision', continue: true }),
/not available/,
);
const reconnected = JSON.parse(JSON.stringify(reading)) as Game;
assert.equal(reconnected.battle?.revealed, false);
assert.throws(
() => applyAction(reconnected, 'e', { ...targetPlan, dial: 1 }),
/not available/,
);
const revealed = applyAction(reconnected, 'a', ownPlan);
assert.equal(revealed.battle?.revealed, true);
assert.deepEqual(
viewGame(revealed, 'g').battle?.plans.e,
reading.battle?.plans.e,
);
assert.equal(viewGame(revealed, 'a').battle?.fullPlanInsight, null);
});
void test('Atreides may inspect an unrelated battle without giving its ally the private plan', () => {
let g = offer(fixture(true));
g.players[0].ally = 'b';
g.players[3].ally = 'a';
g = inspect(g, 'g');
assert.throws(() => applyAction(g, 'e', targetPlan), /requested by special/);
g = applyAction(g, 'g', {
type: 'battlePlan',
dial: 0,
support: 0,
leader: 'guild-0',
});
assert.equal(viewGame(g, 'a').battle?.fullPlanInsight?.target, 'g');
assert.equal(viewGame(g, 'b').battle?.fullPlanInsight, null);
assert.equal(viewGame(g, 'e').battle?.fullPlanInsight, null);
g = applyAction(g, 'e', targetPlan);
assert.equal(g.battle?.revealed, true);
});
void test('declining preserves the power and card, offers independently of hidden cards, and cannot be reopened mid-battle', () => {
const before = fixture();
const offered = offer(before);
before.players[0].hand = [];
assert.deepEqual(offer(before).decision, offered.decision);
const declined = applyAction(offered, 'a', {
type: 'decision',
decline: true,
});
assert.equal(declined.players[0].specialKaramaUsed, undefined);
assert.ok(declined.players[0].hand.some((c) => c.id === karama.id));
assert.throws(() => inspect(declined), /preparation/);
assert.ok(applyAction(declined, 'a', ownPlan).battle?.plans.a);
});
void test('ordinary prescience cancellation does not suppress special inspection', () => {
let g = responses(choose(fixture()));
const otherKarama = cards.find(
(c) => c.effect === 'karama' && c.id !== karama.id,
)!;
g.players[2].hand = [otherKarama];
g = applyAction(g, 'a', { type: 'prescience', field: 'leader' });
g = applyAction(g, 'g', {
type: 'card',
mode: 'cancel',
card: otherKarama.id,
});
assert.equal(g.battle?.prescience, undefined);
assert.equal(g.decision?.kind, 'fullPlanOffer');
g = inspect(g);
assert.equal(g.battle?.fullPlan?.target, 'e');
assert.equal(g.response, null);
});
void test('invalid target, phase, card, and answer choices preserve state atomically', () => {
const g = offer();
const copy = structuredClone(g);
assert.throws(() => inspect(g, 'b'), /in this battle/);
assert.throws(
() =>
applyAction(g, 'a', {
type: 'card',
mode: 'special',
card: snooper.id,
target: 'e',
}),
/Karama card/,
);
const basic = structuredClone(g);
basic.advanced = false;
assert.throws(() => inspect(basic), /advanced game/);
assert.deepEqual(g, copy);
const waiting = inspect(g);
for (const extra of [
{ dial: 9 },
{ support: 11 },
{ leader: 'guild-0' },
{ weapon: shield.id },
{ defense: poison.id },
{ kwisatz: true },
])
assert.throws(() => applyAction(waiting, 'e', { ...targetPlan, ...extra }));
assert.deepEqual(waiting.battle?.plans, {});
assert.equal(waiting.players[1].spice, 10);
});
void test('once-per-game usage survives later battles and does not offer special prescience in basic mode', () => {
let g = inspect(offer());
g.battle = null;
g.turn++;
g.active = 'a';
g = responses(choose(g));
g = applyAction(g, 'a', { type: 'declineBattlePower' });
assert.equal(g.decision, null);
assert.throws(() => inspect(g), /already been used/);
const basic = fixture();
basic.advanced = false;
assert.equal(offer(basic).decision, null);
});
void test('selected Worthless cards cannot be converted to Karama after the inspected plan is committed', () => {
const before = fixture();
before.players[1].forces = {};
before.players[1].reserves = 20;
before.players[3].forces = { 'arrakeen:10': 5 };
before.players[3].reserves = 15;
before.players[3].hand = [worthless];
let g = responses(
applyAction(before, 'a', {
type: 'chooseBattle',
territory: 'arrakeen',
target: 'b',
}),
);
while (g.battle?.preparation)
g = applyAction(g, g.battle.preparation.owner, {
type: 'declineBattlePower',
});
g = inspect(g, 'b');
g = applyAction(g, 'b', {
...targetPlan,
leader: 'beneGesserit-0',
weapon: worthless.id,
defense: null,
});
g.response = { kind: 'guildIncome', owner: 'g', amount: 1, passed: [] };
assert.throws(
() =>
applyAction(g, 'b', { type: 'card', mode: 'cancel', card: worthless.id }),
/sealed battle card/,
);
});
void test('AI initiates, answers and uses full-plan inspection at every difficulty using private views', () => {
for (const difficulty of DIFFICULTIES) {
const before = fixture();
before.players[0].bot = difficulty;
before.players[1].bot = difficulty;
let g = offer(before);
const special = botActions(viewGame(g, 'a'))[0];
assert.equal(special.mode, 'special');
g = applyAction(g, 'a', special);
assert.deepEqual(botActions(viewGame(g, 'a')), []);
const candidates = botActions(viewGame(g, 'e'));
let answered: Game | undefined;
for (const action of candidates) {
try {
answered = applyAction(g, 'e', action);
break;
} catch {}
}
assert.ok(answered, difficulty);
g = answered;
assert.equal(g.decision, null);
assert.ok(
botActions(viewGame(g, 'a')).some((a) => a.type === 'battlePlan'),
);
const empty = offer(before);
empty.players[0].hand = [];
assert.deepEqual(botActions(viewGame(empty, 'a'))[0], {
type: 'decision',
decline: true,
});
}
});
void test('Hard Atreides uses inspected weapon and dial while unseen cards and traitors cannot influence its plan', () => {
const before = fixture();
before.players[0].bot = 'Hard';
let g = applyAction(inspect(offer(before)), 'e', targetPlan);
const first = botActions(viewGame(g, 'a'));
const legal = first.find((action) => {
try {
applyAction(g, 'a', action);
return true;
} catch {
return false;
}
});
assert.equal(legal?.defense, snooper.id);
assert.equal(legal?.dial, 3.5); // Emperor leader 6 + dial 2.5 ties Atreides leader 5 + dial 3.5.
g = structuredClone(g);
g.players[1].hand = [
poison,
shield,
...cards.filter((c) => c.kind === 'hero').slice(0, 1),
];
g.players[1].traitors = ['atreides-0'];
g.deck.reverse();
assert.deepEqual(botActions(viewGame(g, 'a')), first);
});
void test('full inspection includes KH and can target either combatant under any-player wording', () => {
const before = fixture();
before.players[0].battleLosses = 7;
let g = inspect(offer(before), 'a');
g = applyAction(g, 'a', { ...ownPlan, kwisatz: true });
assert.equal(viewGame(g, 'a').battle?.fullPlanInsight?.plan.kwisatz, true);
assert.equal(viewGame(g, 'e').battle?.plans.a, undefined);
g = applyAction(g, 'e', targetPlan);
assert.equal(g.battle?.revealed, true);
assert.equal(viewGame(g, 'g').battle?.plans.a.kwisatz, true);
});