Files
Dune/tests/mirror-weapon-engine.test.ts
T
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

184 lines
8.1 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, createGame, newPlayer, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { treacheryDeck, type Card } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import { finishBattleOutcomeWindow } from './fixture-battle-outcome-window';
const mirror = richeseCards().find((card) => card.effect === 'mirrorWeapon')!;
function fixture(advanced = false) {
let g = createGame('MIRROR', newPlayer('p', 'Mirror holder', 'emperor'), advanced, ['choam']);
g.players.push(newPlayer('q', 'Opponent', 'guild'), newPlayer('r', 'Richese', 'richese'));
g.status = 'playing';
g.phase = 6;
g.turn = 2;
g.active = 'p';
g.order = ['p', 'q', 'r'];
g.storm = 18;
g.deck = treacheryDeck(['choam']);
g.richeseCache = richeseCards().filter((card) => card.id !== mirror.id);
for (const player of g.players) {
player.hand = [];
player.forces = {};
player.reserves = 20;
player.spice = 20;
player.traitors = [];
}
for (const player of g.players.slice(0, 2)) {
player.forces = { 'pasty_mesa:5': 5 };
player.reserves = 15;
}
g.players[0].hand = [mirror];
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, kind: Card['kind'] | 'stoneBurner') {
const card = kind === 'stoneBurner'
? richeseCards().find((item) => item.effect === 'stoneBurner')!
: g.deck.find((item) => item.kind === kind)!;
g.players[1].hand.push(card);
g.deck = g.deck.filter((item) => item.id !== card.id);
g.richeseCache = g.richeseCache?.filter((item) => item.id !== card.id);
return card;
}
function reveal(g: Game, source?: Card, ownDial = 2, otherDial = 2) {
g = applyAction(g, 'p', {
type: 'battlePlan', dial: ownDial, support: g.advanced ? ownDial : 0,
leader: g.players[0].leaders[0].id, weapon: mirror.id,
});
return applyAction(g, 'q', {
type: 'battlePlan', dial: otherDial, support: g.advanced ? otherDial : 0,
leader: g.players[1].leaders[0].id, weapon: source?.id,
});
}
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('copied Tooth chooses before its original despite reverse storm order; restored choices keep physical custody', () => {
let g = fixture();
const tooth = give(g, 'poisonTooth');
g.order = ['q', 'p', 'r'];
g = reveal(g, tooth, 4, 2);
assert.equal(g.decision?.kind, 'poisonTooth');
assert.equal(g.decision.player, 'p');
const reordered = structuredClone(g);
reordered.decision = { kind: 'poisonTooth', player: 'q', event: g.battle!.event,
physicalId: tooth.id, copiedFrom: null };
assert.throws(() => viewGame(reordered, 'q'));
assert.throws(() => applyAction(reordered, 'q', {
type: 'decision', event: g.battle!.event, activate: false,
}));
const preanswered = structuredClone(g);
preanswered.battle!.poisonTooth = { q: true };
assert.throws(() => viewGame(preanswered, 'p'));
assert.deepEqual(reordered.battle, g.battle);
const before = structuredClone(g);
assert.throws(() => applyAction(g, 'r', { type: 'decision', event: g.battle!.event, activate: true }));
assert.deepEqual(g, before);
g = JSON.parse(JSON.stringify(g));
g = applyAction(g, 'p', { type: 'decision', event: g.battle!.event, activate: true });
assert.equal(g.decision?.kind, 'poisonTooth');
assert.equal(g.decision.player, 'q');
assert.equal(g.battle!.poisonTooth?.p, true);
const wrong = structuredClone(g);
assert.throws(() => applyAction(g, 'q', { type: 'decision', event: 'stale', activate: true }));
assert.deepEqual(g, wrong);
g = JSON.parse(JSON.stringify(g));
g = applyAction(g, 'q', { type: 'decision', event: g.battle!.event, activate: false });
g = finish(g);
assert.equal(g.lastBattleContext?.winner, 'p');
assert.equal(g.players[0].leaders[0].dead, true);
assert.equal(g.players[1].leaders[0].dead, true);
assert.equal(g.decision?.kind, 'battleCards');
g = applyAction(g, 'p', { type: 'decision', discard: [] });
assert.equal(g.players[0].hand.filter((card) => card.id === mirror.id).length, 1);
assert.equal(g.discard.filter((card) => card.id === tooth.id).length, 1);
});
void test('copied Stone has two independent modes in copy-first order and the original never becomes a second Mirror', () => {
let g = fixture();
const stone = give(g, 'stoneBurner');
g = reveal(g, stone, 2, 4);
assert.equal(g.decision?.kind, 'stoneBurner');
assert.equal(g.decision.player, 'p');
const reordered = structuredClone(g);
reordered.decision = { kind: 'stoneBurner', player: 'q', event: g.battle!.event!,
physicalId: stone.id, copiedFrom: null };
assert.throws(() => viewGame(reordered, 'q'));
const preanswered = structuredClone(g);
preanswered.battle!.stoneBurner = { q: 'kill' };
assert.throws(() => viewGame(preanswered, 'p'));
g = applyAction(g, 'p', { type: 'decision', event: g.battle!.event, mode: 'ignore' });
assert.equal(g.decision?.kind, 'stoneBurner');
assert.equal(g.decision.player, 'q');
g = JSON.parse(JSON.stringify(g));
g = applyAction(g, 'q', { type: 'decision', event: g.battle!.event, mode: 'kill' });
g = finish(g);
assert.equal(g.lastBattleContext?.winner, 'p');
assert.equal(g.players[0].leaders[0].dead, true);
assert.equal(g.players[1].leaders[0].dead, true);
g = applyAction(g, 'p', { type: 'decision', discard: [] });
assert.equal(g.players[0].hand.filter((card) => card.id === mirror.id).length, 1);
assert.equal(g.discard.filter((card) => card.id === stone.id).length, 1);
});
void test('all bot profiles can commit legal Mirror plans in Basic and Advanced without inspecting hidden weapons', () => {
for (const advanced of [false, true])
for (const difficulty of DIFFICULTIES) {
const g = fixture(advanced);
const view = viewGame(g, 'p');
view.players[0].bot = difficulty;
const before = structuredClone(view);
const plans = botActions(view).filter((action) => action.type === 'battlePlan' && action.weapon === mirror.id);
assert.ok(plans.length, `${difficulty}/${advanced}`);
for (const plan of plans) assert.doesNotThrow(() => applyAction(g, 'p', plan));
assert.deepEqual(view, before);
}
});
void test('inspected ordinary copy is scored as its attack rather than as undialed Stone', () => {
const g = fixture(true);
const projectile = give(g, 'projectile');
const view = viewGame(g, 'p');
view.battle!.fullPlanInsight = {
target: 'q',
plan: { dial: 3, support: 3, leader: g.players[1].leaders[0].id,
weapon: projectile.id, defense: null },
cards: [projectile],
};
view.players[0].bot = 'Hard';
const candidate = botActions(view).find((action) =>
action.type === 'battlePlan' && action.weapon === mirror.id);
assert.ok(candidate);
assert.ok(Number(candidate.dial) >= 3,
'the copied projectile leaves both leaders dead, so the aggressor must dial for the tie');
assert.doesNotThrow(() => applyAction(g, 'p', candidate));
});
void test('a saved Mirror commitment rejects a changed public module configuration', () => {
const initial = fixture();
const sealed = applyAction(initial, 'p', {
type: 'battlePlan', dial: 1, leader: initial.players[0].leaders[0].id,
weapon: mirror.id,
});
const altered = structuredClone(sealed);
altered.expansions = ['ix'];
const before = structuredClone(altered);
assert.throws(() => viewGame(altered, 'p'));
assert.throws(() => applyAction(altered, 'q', {
type: 'battlePlan', dial: 1, leader: altered.players[1].leaders[0].id,
}));
assert.deepEqual(altered, before);
});