Files
Dune/tests/diplomat-defense.test.ts

563 lines
19 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Game, type Action } from '../game/engine';
import { baseDeck, ixBattleCards, ixStandardCards, type Card } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import {
battleWeaponsExplode,
defenseTypes,
isShield,
weaponKills,
} from '../game/battle-cards';
import {
quoteDiplomatDefense,
copiedDiplomatDefense,
} from '../game/diplomat-defense';
import { leaderSkillBattleBonus } from '../game/leader-skill-combat';
import {
diplomatDefenseGame,
revealDiplomatPlans,
takeBattleCard,
} from './diplomat-defense-fixture';
import {
completedAdvancedNativeSkillsGame,
stageAdvancedNativeSkillBattle,
openAdvancedNativeSkillBattle,
} from './fixture-advanced-native-skills';
function choose(game: Game, card: string | null): Game {
assert.equal(game.decision?.kind, 'diplomatDefense');
return applyAction(JSON.parse(JSON.stringify(game)), 'a', {
type: 'decision',
event: game.decision.event,
card,
});
}
function finish(game: Game, call = false): Game {
let next = applyAction(game, 'a', { type: 'traitorCall', call });
next = applyAction(next, 'd', { type: 'traitorCall', call: false });
for (const p of next.players)
assert.equal(
p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
20,
);
const ids = [
...next.deck,
...next.discard,
...next.players.flatMap((p) => p.hand),
].map((c) => c.id);
assert.equal(new Set(ids).size, 33);
assert.equal(ids.length, 33);
return next;
}
function reject(game: Game, owner: string, action: Action) {
const before = JSON.stringify(game);
assert.throws(() => applyAction(game, owner, action));
assert.equal(JSON.stringify(game), before);
}
void test('Diplomat quotes one physical Worthless from either slot and never copies absent or unsupported defenses', () => {
const cards = baseDeck(),
worthless = cards.filter((c) => c.kind === 'worthless'),
snooper = cards.find((c) => c.kind === 'snooper')!;
const assignments = [
{
skill: 'diplomat' as const,
leader: 'trainer',
faceUp: true,
captured: false,
},
];
const quote = quoteDiplomatDefense({
assignments,
selectedLeader: 'other',
weapon: worthless[0],
defense: worthless[1],
opposingDefense: snooper,
})!;
assert.deepEqual(
quote.cards,
worthless.slice(0, 2).map((c) => c.id),
);
assert.equal(copiedDiplomatDefense(quote, worthless[0].id).kind, 'snooper');
assert.equal(worthless[0].kind, 'worthless');
assert.throws(() => copiedDiplomatDefense(quote, 'unplayed'));
for (const opposingDefense of [
undefined,
worthless[0],
{ ...snooper, id: 'unrecognized' },
{ ...snooper, kind: 'shieldSnooper' as const },
])
assert.equal(
quoteDiplomatDefense({
assignments,
selectedLeader: 'other',
weapon: worthless[0],
opposingDefense,
}),
null,
);
assert.equal(
quoteDiplomatDefense({
assignments,
selectedLeader: 'other',
weapon: worthless[0],
defense: snooper,
opposingDefense: snooper,
}),
null,
);
assert.equal(
quoteDiplomatDefense({
assignments: [{ ...assignments[0], faceUp: false }],
selectedLeader: 'other',
weapon: worthless[0],
opposingDefense: snooper,
}),
null,
);
assert.ok(
quoteDiplomatDefense({
assignments: [{ ...assignments[0], faceUp: false }],
selectedLeader: 'trainer',
weapon: worthless[0],
opposingDefense: snooper,
}),
);
assert.equal(
quoteDiplomatDefense({
assignments: [{ ...assignments[0], captured: true }],
selectedLeader: 'trainer',
weapon: worthless[0],
opposingDefense: snooper,
}),
null,
);
});
void test('native face-up and selected Diplomats copy a Snooper after reveal in Basic and Advanced without rewriting sealed plans', () => {
for (const advanced of [false, true])
for (const hide of [false, true])
for (const slot of ['weapon', 'defense'] as const) {
const game = diplomatDefenseGame(hide, advanced),
card = game.players[0].hand[0].id;
const pending = revealDiplomatPlans(game, slot),
plans = structuredClone(pending.battle!.plans);
assert.equal(pending.decision?.kind, 'diplomatDefense');
reject(pending, 'd', {
type: 'decision',
event: pending.decision.event,
card,
});
reject(pending, 'a', { type: 'traitorCall', call: false });
reject(pending, 'a', { type: 'decision', event: 'stale', card });
const copied = choose(pending, card);
assert.deepEqual(copied.battle!.plans, plans);
assert.equal(
copied.players[0].hand.find((c) => c.id === card)!.kind,
'worthless',
);
const done = finish(copied),
leader = done.players[0].leaders.find(
(l) => l.id === (hide ? 'emperor-0' : 'emperor-1'),
)!;
assert.equal(leader.dead, false);
assert.ok(!done.players[0].hand.some((c) => c.id === card));
assert.equal(done.discard.filter((c) => c.id === card).length, 1);
reject(done, 'a', {
type: 'decision',
event: pending.decision.event,
card,
});
}
});
void test('declining the visible copy leaves ordinary weapon deaths and card custody unchanged', () => {
const game = diplomatDefenseGame(),
card = game.players[0].hand[0].id;
const pending = revealDiplomatPlans(game);
const done = finish(choose(pending, null));
assert.equal(
done.players[0].leaders.find((l) => l.id === 'emperor-1')!.dead,
true,
);
assert.equal(done.players[0].forces['arrakeen:10'] ?? 0, 0);
assert.equal(done.discard.filter((c) => c.id === card).length, 1);
});
void test('two committed Worthless cards offer one choice and only the chosen winner card is mandatory discard', () => {
let game = diplomatDefenseGame();
const first = game.players[0].hand[0],
second = takeBattleCard(game, 'a', 'worthless');
game = applyAction(game, 'a', {
type: 'battlePlan',
dial: 0,
leader: 'emperor-1',
weapon: first.id,
defense: second.id,
});
game = applyAction(game, 'd', {
type: 'battlePlan',
dial: 0,
leader: 'guild-1',
weapon: game.players[1].hand.find((c) => c.kind === 'poison')!.id,
defense: game.players[1].hand.find((c) => c.kind === 'snooper')!.id,
});
assert.equal(game.decision?.kind, 'diplomatDefense');
assert.deepEqual(
new Set(game.decision.cards),
new Set([first.id, second.id]),
);
const done = finish(choose(game, second.id));
assert.equal(done.discard.filter((c) => c.id === second.id).length, 1);
assert.ok(done.players[0].hand.some((c) => c.id === first.id));
assert.equal(done.decision?.kind, 'battleCards');
assert.ok(done.decision.cards.includes(first.id));
assert.ok(!done.decision.cards.includes(second.id));
});
void test('an actual defense or absent opposing defense creates no Diplomat choice', () => {
for (const ownDefense of [false, true]) {
let game = diplomatDefenseGame();
const defense = ownDefense
? takeBattleCard(game, 'a', 'snooper').id
: undefined;
game = applyAction(game, 'a', {
type: 'battlePlan',
dial: 0,
leader: 'emperor-1',
weapon: game.players[0].hand[0].id,
defense,
});
game = applyAction(game, 'd', {
type: 'battlePlan',
dial: 0,
leader: 'guild-1',
defense: ownDefense
? game.players[1].hand.find((c) => c.kind === 'snooper')!.id
: undefined,
});
assert.notEqual(game.decision?.kind, 'diplomatDefense');
finish(game);
}
});
void test('copying a Shield preserves the existing Lasgun explosion instead of making a survival loop', () => {
let game = diplomatDefenseGame(true);
const card = game.players[0].hand[0].id,
laser = takeBattleCard(game, 'd', 'lasgun'),
shield = takeBattleCard(game, 'd', 'shield');
// The staged opponent only needs its played pair; return other physical cards to the deck.
game.deck.push(
...game.players[1].hand.filter(
(c) => ![laser.id, shield.id].includes(c.id),
),
);
game.players[1].hand = game.players[1].hand.filter((c) =>
[laser.id, shield.id].includes(c.id),
);
game = applyAction(game, 'a', {
type: 'battlePlan',
dial: 0,
leader: 'emperor-0',
defense: card,
});
game = applyAction(game, 'd', {
type: 'battlePlan',
dial: 0,
leader: 'guild-1',
weapon: laser.id,
defense: shield.id,
});
const done = finish(choose(game, card));
for (const p of done.players) assert.equal(p.tanks, 5);
assert.equal(
done.players[0].leaders.find((l) => l.id === 'emperor-0')!.dead,
true,
);
assert.equal(
done.players[1].leaders.find((l) => l.id === 'guild-1')!.dead,
true,
);
assert.equal(done.discard.filter((c) => c.id === card).length, 1);
});
void test('copied defense role supports captured Medic or Prana while the physical Worthless still supports Warmaster', () => {
const card = baseDeck().find((c) => c.kind === 'worthless')!;
for (const [skill, kind] of [
['killer-medic', 'snooper'],
['prana-bindu-adept', 'shield'],
['warmaster', 'snooper'],
] as const) {
const bonus = leaderSkillBattleBonus({
assignments: [
{ skill, leader: 'captive', faceUp: false, captured: true },
],
selectedLeader: { id: 'captive', kind: 'disc' },
weapon: undefined,
defense: card,
effectiveDefense: { id: card.id, name: 'Copied defense', kind },
skilledLeaderSurvives: true,
});
assert.equal(bonus.bonus, 3, skill);
}
});
void test('old unmarked revealed battles retain their original continuation without inventing a copy', () => {
const unsealed = diplomatDefenseGame();
delete unsealed.battle!.diplomatDefenseVersion;
const game = revealDiplomatPlans(unsealed);
assert.notEqual(game.decision?.kind, 'diplomatDefense');
assert.equal(
viewGame(JSON.parse(JSON.stringify(game)), 'a').battle!.revealed,
true,
);
const done = finish(game);
assert.equal(
done.players[0].leaders.find((l) => l.id === 'emperor-1')!.dead,
true,
);
});
void test('a copied printed role keeps Shield Snooper hybrid, defensive Weirding Way and Chemistry distinct', () => {
const worthless = baseDeck().filter((c) => c.kind === 'worthless');
const ix = ixBattleCards();
const hybrid = ix.find((c) => c.kind === 'shieldSnooper')!,
weirding = ix.find((c) => c.kind === 'weirdingWay')!,
chemistry = ix.find((c) => c.kind === 'chemistry')!,
poisonTooth = ix.find((c) => c.kind === 'poisonTooth')!;
const deck = baseDeck();
const projectile = deck.find((c) => c.kind === 'projectile')!,
lasgun = deck.find((c) => c.kind === 'lasgun')!,
poison = deck.find((c) => c.kind === 'poison')!,
snooper = deck.find((c) => c.kind === 'snooper')!;
const assignments = [
{
skill: 'diplomat' as const,
leader: 'trainer',
faceUp: true,
captured: false,
},
];
const copy = (opposingDefense: Card, opposingWeapon?: Card) => {
const quote = quoteDiplomatDefense({
assignments,
selectedLeader: 'other',
weapon: worthless[0],
opposingDefense,
opposingWeapon,
});
assert.ok(quote, `${opposingDefense.name} should supply a copied role`);
const copied = copiedDiplomatDefense(quote, worthless[0].id);
assert.equal(copied.id, worthless[0].id);
return copied;
};
const hybridCopy = copy(hybrid, lasgun);
assert.equal(hybridCopy.kind, 'shieldSnooper');
assert.deepEqual(defenseTypes(hybridCopy), ['shield', 'snooper']);
assert.ok(isShield(hybridCopy));
assert.ok(battleWeaponsExplode(lasgun, undefined, undefined, hybridCopy));
// Defensive Weirding Way keeps its original paired-weapon prerequisite.
assert.equal(
quoteDiplomatDefense({
assignments,
selectedLeader: 'other',
weapon: worthless[0],
opposingDefense: weirding,
}),
null,
);
const weirdingCopy = copy(weirding, projectile);
assert.equal(weirdingCopy.kind, 'weirdingWay');
assert.equal(isShield(weirdingCopy), false);
assert.equal(weaponKills(projectile, weirdingCopy), false);
assert.equal(battleWeaponsExplode(lasgun, undefined, undefined, weirdingCopy), false);
// Copying the used role does not copy its own-slot prerequisite onto this plan.
assert.ok(
quoteDiplomatDefense({
assignments,
selectedLeader: 'other',
weapon: worthless[0],
opposingDefense: weirding,
opposingWeapon: worthless[1],
}),
);
const chemistryCopy = copy(chemistry, poisonTooth);
assert.equal(chemistryCopy.kind, 'chemistry');
assert.equal(weaponKills(poisonTooth, chemistryCopy), false);
assert.equal(weaponKills(poisonTooth, hybridCopy), true);
assert.equal(weaponKills(poison, chemistryCopy), false);
assert.equal(weaponKills(projectile, chemistryCopy), true);
const snooperCopy = copy(snooper, poison);
assert.equal(snooperCopy.kind, 'snooper');
assert.equal(weaponKills(poison, snooperCopy), false);
assert.equal(weaponKills(poisonTooth, snooperCopy), true);
});
void test('a sealed Portable Snooper supplies only its original poison-defense role', () => {
const portable = richeseCards().find((c) => c.effect === 'portableSnooper')!;
const worthless = baseDeck().filter((c) => c.kind === 'worthless');
const deck = baseDeck();
const poison = deck.find((c) => c.kind === 'poison')!,
projectile = deck.find((c) => c.kind === 'projectile')!,
shield = deck.find((c) => c.kind === 'shield')!;
const assignments = [
{
skill: 'diplomat' as const,
leader: 'trainer',
faceUp: true,
captured: false,
},
];
const quote = quoteDiplomatDefense({
assignments,
selectedLeader: 'other',
weapon: worthless[0],
defense: worthless[1],
opposingDefense: portable,
})!;
assert.equal(quote.kind, 'snooper');
const copied = copiedDiplomatDefense(quote, worthless[1].id);
assert.equal(copied.kind, 'snooper');
assert.equal(copied.id, worthless[1].id);
assert.equal(isShield(copied), false);
assert.equal(weaponKills(poison, copied), false);
assert.equal(weaponKills(projectile, copied), true);
for (const forged of [
{ ...portable, name: 'Snooper' },
{ ...portable, kind: 'snooper' as const },
{ ...portable, id: 'richese-portable-snooper-copy' },
{ ...portable, effect: undefined },
])
assert.equal(
quoteDiplomatDefense({
assignments,
selectedLeader: 'other',
weapon: worthless[0],
opposingDefense: forged,
}),
null,
forged.id,
);
const ix = ixBattleCards();
for (const unsupported of [
poison,
projectile,
worthless[0],
ix.find((c) => c.kind === 'poisonTooth')!,
ix.find((c) => c.kind === 'artillery')!,
ix.find((c) => c.kind === 'poisonBlade')!,
])
assert.equal(
quoteDiplomatDefense({
assignments,
selectedLeader: 'other',
weapon: worthless[0],
opposingDefense: unsupported,
}),
null,
unsupported.id,
);
// A forged face cannot inherit the role by claiming a printed name alone.
for (const forged of [
{ ...ix.find((c) => c.kind === 'weirdingWay')!, kind: 'shield' as const },
{ ...ix.find((c) => c.kind === 'chemistry')!, kind: 'snooper' as const },
{ ...shield, kind: 'shieldSnooper' as const },
{ ...shield, id: 'ix-shield', kind: 'shieldSnooper' as const },
])
assert.equal(
quoteDiplomatDefense({
assignments,
selectedLeader: 'other',
weapon: worthless[0],
opposingDefense: forged,
opposingWeapon: projectile,
}),
null,
forged.id,
);
});
void test('an older battle admits only its original base Shield and Snooper sources', () => {
const worthless = baseDeck().filter((c) => c.kind === 'worthless');
const deck = baseDeck();
const shield = deck.find((c) => c.kind === 'shield')!,
snooper = deck.find((c) => c.kind === 'snooper')!,
projectile = deck.find((c) => c.kind === 'projectile')!;
const ix = ixBattleCards();
const ixStandard = ixStandardCards();
const portable = richeseCards().find((c) => c.effect === 'portableSnooper')!;
const assignments = [
{
skill: 'diplomat' as const,
leader: 'trainer',
faceUp: true,
captured: false,
},
];
const legacy = (opposingDefense: Card, opposingWeapon?: Card) =>
quoteDiplomatDefense({
assignments,
selectedLeader: 'other',
weapon: worthless[0],
opposingDefense,
opposingWeapon,
expandedDefenses: false,
});
assert.equal(legacy(shield)?.kind, 'shield');
assert.equal(legacy(snooper, projectile)?.kind, 'snooper');
for (const unavailable of [
ix.find((c) => c.kind === 'shieldSnooper')!,
ix.find((c) => c.kind === 'weirdingWay')!,
ix.find((c) => c.kind === 'chemistry')!,
ixStandard.find((c) => c.id === 'ix-shield')!,
ixStandard.find((c) => c.id === 'ix-snooper')!,
portable,
])
assert.equal(
legacy(unavailable, unavailable.kind === 'weirdingWay' ? projectile : undefined),
null,
unavailable.id,
);
for (const unavailable of [
ix.find((c) => c.kind === 'shieldSnooper')!,
ix.find((c) => c.kind === 'weirdingWay')!,
ix.find((c) => c.kind === 'chemistry')!,
portable,
])
assert.ok(
quoteDiplomatDefense({
assignments,
selectedLeader: 'other',
weapon: worthless[0],
opposingDefense: unavailable,
opposingWeapon:
unavailable.kind === 'weirdingWay' ? projectile : undefined,
}),
unavailable.id,
);
});
void test('an already-open base-only battle finishes without a retroactive hybrid copy or missing-receipt deadlock', () => {
const initialized = completedAdvancedNativeSkillsGame({
family: 'ixians', requestedSkill: 'diplomat', skillOwner: 'emperor', opponents: ['guild'],
});
let game = stageAdvancedNativeSkillBattle(initialized, 'emperor', 'guild');
const worthless = takeBattleCard(game, 'emperor', 'worthless');
const weapon = takeBattleCard(game, 'guild', 'poisonBlade');
const defense = takeBattleCard(game, 'guild', 'shieldSnooper');
game = openAdvancedNativeSkillBattle(game, 'emperor', 'guild', true);
// An in-flight battle created before printed-defense capability was enabled.
game.battle!.diplomatDefenseVersion = 1;
game = applyAction(game, 'emperor', { type: 'battlePlan', dial: 0, leader: 'emperor-0', defense: worthless.id });
game = applyAction(game, 'guild', { type: 'battlePlan', dial: 0, leader: 'guild-1', weapon: weapon.id, defense: defense.id });
assert.equal(game.decision, null);
assert.equal(game.battle!.diplomatDefense, undefined);
game = applyAction(game, 'emperor', { type: 'traitorCall', call: false });
game = applyAction(game, 'guild', { type: 'traitorCall', call: false });
assert.equal(game.players.find(player => player.id === 'emperor')!.leaders.find(leader => leader.id === 'emperor-0')!.dead, true);
});