563 lines
19 KiB
TypeScript
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);
|
|
});
|