Files
Dune/tests/stone-burner-engine.test.ts
Quintine 19e554666e Admit Mirror and Stone in Basic native E3 battles
Reuse original copied-weapon and typed coalition accounting for bare Basic
Ecaz/Moritani and selected Ix. Preserve optional-module guards and physical
card identity, actual force-owner commitments and native continuations.
2026-10-24 16:34:19 +11:00

297 lines
11 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
newPlayer,
viewGame,
normalizeAutomaticGame,
type Game,
type Action,
} from '../game/engine';
import { baseDeck, ixBattleCards, type Card } from '../game/cards';
import { FACTIONS, type FactionId } from '../game/catalog';
import { richeseCards } from '../game/richese-cards';
import { finishBattleOutcomeWindow } from './fixture-battle-outcome-window';
import {
isStoneBurner,
isWeaponCard,
validBattleCardPair,
weaponTypes,
defaultVoiceMatch,
} from '../game/battle-cards';
const stoneId = 'richese-stone-burner';
function fixture(f: FactionId = 'harkonnen', advanced = false) {
let g = createGame('STONE', newPlayer('p', 'Stone holder', f), advanced, [
'choam',
]);
g.players.push(
newPlayer('q', 'Opponent', f === 'guild' ? 'emperor' : 'guild'),
);
if (f !== 'richese') g.players.push(newPlayer('r', 'Richese', 'richese'));
g.status = 'playing';
g.phase = 6;
g.turn = 2;
g.active = 'p';
g.order = g.players.map((p) => p.id);
g.storm = 18;
g.deck = baseDeck();
g.richeseCache = richeseCards();
for (const p of g.players) {
p.hand = [];
p.forces = {};
p.reserves = 20;
p.spice = 20;
p.traitors = [];
}
for (const p of g.players.slice(0, 2)) {
p.forces = { 'pasty_mesa:5': 5 };
p.reserves = 15;
}
g.players[0].hand = [g.richeseCache.find((c) => c.id === stoneId)!];
g.richeseCache = g.richeseCache.filter((c) => c.id !== stoneId);
g = applyAction(g, 'p', {
type: 'chooseBattle',
territory: 'pasty_mesa',
target: 'q',
});
for (const id of ['p', 'q'])
g = applyAction(g, id, {
type: 'battlePreparationReady',
event: g.battle!.event,
});
while (g.battle?.preparation)
g = applyAction(g, g.battle.preparation.owner, {
type: 'declineBattlePower',
});
if (g.decision?.kind === 'fullPlanOffer')
g = applyAction(g, g.decision.player, { type: 'decision', decline: true });
return g;
}
function give(g: Game, id: string, card: Card) {
g.players.find((p) => p.id === id)!.hand.push(card);
g.deck = g.deck.filter((c) => c.id !== card.id);
g.richeseCache = g.richeseCache?.filter((c) => c.id !== card.id);
}
function reveal(
g: Game,
own: Partial<Action> = {},
other: Partial<Action> = {},
) {
g = applyAction(g, 'p', {
type: 'battlePlan',
dial: 0,
leader: g.players[0].leaders[0].id,
weapon: stoneId,
...own,
});
return applyAction(g, 'q', {
type: 'battlePlan',
dial: g.advanced ? 2 : 4,
support: 0,
leader: g.players[1].leaders[0].id,
...other,
});
}
const mode = (g: Game, mode: 'kill' | 'ignore') =>
applyAction(g, 'p', { type: 'decision', event: g.battle!.event, mode });
function finish(g: Game) {
g = applyAction(g, 'q', { type: 'traitorCall', call: false });
g = applyAction(g, 'p', { type: 'traitorCall', call: false });
return finishBattleOutcomeWindow(g);
}
void test('canonical Stone is a named special weapon, never a generic special, poison or projectile', () => {
const card = richeseCards().find((c) => c.id === stoneId)!;
assert.equal(card.kind, 'special');
assert.equal(isWeaponCard(card), true);
assert.equal(validBattleCardPair(card), true);
assert.deepEqual(weaponTypes(card), ['stoneBurner']);
assert.equal(defaultVoiceMatch(card, 'stoneBurner'), true);
assert.equal(defaultVoiceMatch(card, 'poison'), false);
assert.equal(defaultVoiceMatch(card, 'projectile'), false);
for (const forged of [
{ ...card, id: 'fake' },
{ ...card, name: 'fake' },
{ ...card, effect: 'portableSnooper' },
])
assert.equal(isStoneBurner(forged), false);
assert.equal(
isWeaponCard(richeseCards().find((c) => c.effect === 'semutaDrug')!),
false,
);
});
void test('every faction can choose both Stone modes after reveal and physical undialed tokens replace ordinary strength', () => {
for (const faction of FACTIONS)
for (const advanced of [false, true])
for (const choice of ['kill', 'ignore'] as const) {
let g = reveal(fixture(faction.id, advanced));
assert.equal(g.decision?.kind, 'stoneBurner');
const plans = structuredClone(g.battle!.plans),
a = g.players[0].leaders[0],
d = g.players[1].leaders[0];
assert.equal(a.dead, false);
assert.equal(d.dead, false);
g = mode(g, choice);
assert.deepEqual(g.battle!.plans, plans);
g = finish(g);
assert.equal(
g.players[0].forces['pasty_mesa:5'],
5,
`${faction.id}/${advanced}/${choice}`,
);
assert.equal(g.players[1].forces['pasty_mesa:5'], undefined);
assert.equal(g.players[0].leaders[0].dead, choice === 'kill');
assert.equal(g.players[1].leaders[0].dead, choice === 'kill');
assert.equal(g.decision?.kind, 'battleCards');
const bounty =
choice === 'kill'
? (a.id === 'tleilaxu-0' ? d.strength : a.strength) +
(d.id === 'tleilaxu-0' ? a.strength : d.strength)
: 0;
assert.equal(g.players[0].spice, 20 + bounty);
assert.equal(g.discard.filter((c) => c.id === stoneId).length, 0);
for (const p of g.players)
assert.equal(
p.reserves +
p.tanks +
Object.values(p.forces).reduce((a, b) => a + b, 0),
20,
);
}
});
void test('Stone physical ties favor aggressor, winner keeps or discards normally, and an all-dialed loser loses all tokens', () => {
let g = finish(mode(reveal(fixture(), { dial: 2 }, { dial: 2 }), 'ignore'));
assert.equal(g.players[0].forces['pasty_mesa:5'], 3);
assert.equal(g.players[1].tanks, 5);
g = applyAction(g, 'p', { type: 'decision', discard: [] });
assert.equal(g.players[0].hand.filter((c) => c.id === stoneId).length, 1);
g = finish(mode(reveal(fixture(), { dial: 5 }, { dial: 0 }), 'ignore'));
assert.equal(g.players[1].forces['pasty_mesa:5'], 5);
assert.equal(g.players[0].tanks, 5);
assert.equal(g.discard.filter((c) => c.id === stoneId).length, 1);
g = finish(mode(reveal(fixture()), 'ignore'));
g = applyAction(g, 'p', { type: 'decision', discard: [stoneId] });
assert.equal(g.discard.filter((c) => c.id === stoneId).length, 1);
});
void test('ignore preserves ordinary attacks, kill ignores defenses, and Artillery suppresses bounty independently', () => {
for (const choice of ['kill', 'ignore'] as const) {
let g = fixture();
const poison = g.deck.find((c) => c.kind === 'poison')!,
shield = g.deck.find((c) => c.kind === 'shield')!;
give(g, 'q', poison);
give(g, 'q', shield);
g = finish(
mode(reveal(g, {}, { weapon: poison.id, defense: shield.id }), choice),
);
assert.equal(g.players[0].leaders[0].dead, true);
assert.equal(g.players[1].leaders[0].dead, choice === 'kill');
}
let g = fixture();
const artillery = ixBattleCards().find((c) => c.kind === 'artillery')!,
shield = g.deck.find((c) => c.kind === 'shield')!;
give(g, 'q', artillery);
give(g, 'p', shield);
g = finish(
mode(reveal(g, { defense: shield.id }, { weapon: artillery.id }), 'kill'),
);
assert.equal(g.players[0].leaders[0].dead, true);
assert.equal(g.players[1].leaders[0].dead, true);
assert.equal(g.players[0].spice, 20);
});
void test('traitor and explosion precedence remain intact, while Stone never kills or scores the Kwisatz Haderach', () => {
let g = mode(reveal(fixture()), 'kill');
g.players[1].traitors = [g.battle!.plans.p.leader!];
g = applyAction(g, 'q', { type: 'traitorCall', call: true });
g = applyAction(g, 'p', { type: 'traitorCall', call: false });
g = finishBattleOutcomeWindow(g);
assert.equal(g.players[1].leaders[0].dead, false);
assert.equal(g.players[1].forces['pasty_mesa:5'], 5);
g = fixture();
const laser = g.deck.find((c) => c.kind === 'lasgun')!,
shield = g.deck.find((c) => c.kind === 'shield')!;
give(g, 'q', laser);
give(g, 'p', shield);
g.spice['pasty_mesa:5'] = 8;
g = finish(
mode(reveal(g, { defense: shield.id }, { weapon: laser.id }), 'kill'),
);
assert.equal(g.players[0].tanks, 5);
assert.equal(g.players[1].tanks, 5);
assert.equal(g.spice['pasty_mesa:5'], undefined);
assert.equal(g.players[0].spice, 20);
assert.equal(g.players[1].spice, 20);
for (const choice of ['kill', 'ignore'] as const) {
g = fixture('atreides', true);
g.players[0].battleLosses = 7;
g = finish(
mode(
reveal(g, { dial: 1, support: 1, kwisatz: true }, { dial: 0 }),
choice,
),
);
assert.equal(g.players[0].kwisatz?.dead, false);
assert.equal(
g.players[0].forces['pasty_mesa:5'],
undefined,
'KH bonus cannot turn four undialed tokens into six',
);
assert.equal(g.players[1].forces['pasty_mesa:5'], 5);
}
});
void test('mode is owner-only, immutable, event-bound and JSON recoverable before any leader death or payment', () => {
let g = reveal(
fixture('harkonnen', true),
{ dial: 1, support: 1 },
{ dial: 2, support: 2 },
);
const before = structuredClone(g);
for (const action of [
{ type: 'decision', mode: 'kill', event: 'old' },
{ type: 'decision', mode: 'wrong', event: g.battle!.event },
{ type: 'decision', mode: 'ignore', event: g.battle!.event, extra: 1 },
])
assert.throws(() => applyAction(g, 'p', action));
assert.throws(() =>
applyAction(g, 'q', {
type: 'decision',
event: g.battle!.event,
mode: 'kill',
}),
);
assert.deepEqual(g, before);
assert.deepEqual(normalizeAutomaticGame(JSON.parse(JSON.stringify(g))), g);
g = mode(JSON.parse(JSON.stringify(g)), 'kill');
for (const p of g.players)
assert.equal(viewGame(g, p.id).battle!.stoneBurner.p, 'kill');
assert.throws(() => mode(g, 'ignore'));
assert.equal(g.players[0].spice, 20);
assert.equal(g.players[0].leaders[0].deaths, 0);
g = finish(JSON.parse(JSON.stringify(g)));
assert.equal(g.players[0].leaders[0].deaths, 1);
assert.equal(g.players[1].leaders[0].deaths, 1);
const after = structuredClone(g);
assert.deepEqual(normalizeAutomaticGame(g), after);
});
void test('reserved Stone custody corruption fails before mutation in action and automatic recovery', () => {
const g = reveal(fixture());
for (const corrupt of ['missing', 'duplicate', 'mode'] as const) {
const damaged = structuredClone(g);
if (corrupt === 'missing') damaged.players[0].hand = [];
if (corrupt === 'duplicate')
damaged.discard.push(damaged.players[0].hand[0]);
if (corrupt === 'mode') damaged.battle!.stoneBurner = { q: 'kill' };
const before = structuredClone(damaged);
assert.throws(() => mode(damaged, 'kill'), /Stone Burner/);
assert.throws(() => normalizeAutomaticGame(damaged), /Stone Burner/);
assert.deepEqual(damaged, before);
}
});