653 lines
20 KiB
TypeScript
653 lines
20 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { webcrypto } from 'node:crypto';
|
|
import { readFileSync } from 'node:fs';
|
|
import { createRequire } from 'node:module';
|
|
import { runInNewContext } from 'node:vm';
|
|
import ts from 'typescript';
|
|
import { baseDeck } from '../game/cards';
|
|
import type { FactionId } from '../game/catalog';
|
|
import {
|
|
applyAction,
|
|
createGame,
|
|
newPlayer,
|
|
viewGame,
|
|
type Game,
|
|
} from '../game/engine';
|
|
import {
|
|
quoteCombatResponse,
|
|
CombatResponseQuoteError,
|
|
type CombatResponseInput,
|
|
type CombatResponseKind,
|
|
} from '../game/combat-response-quote';
|
|
|
|
function fixture(): CombatResponseInput {
|
|
return {
|
|
status: 'playing',
|
|
phase: 6,
|
|
advanced: true,
|
|
territoryIds: ['red_chasm'],
|
|
players: [
|
|
{ id: 'f', faction: 'fremen' },
|
|
{ id: 'a', faction: 'atreides', ally: 'c' },
|
|
{ id: 'b', faction: 'beneGesserit', ally: 'f' },
|
|
{ id: 'c', faction: 'choam', ally: 'a' },
|
|
],
|
|
battle: {
|
|
attacker: 'f',
|
|
defender: 'a',
|
|
territory: 'red_chasm',
|
|
revealed: false,
|
|
plans: {},
|
|
powerChecks: [
|
|
{ kind: 'eliteStrength', owner: 'f' },
|
|
{ kind: 'fremenSupport', owner: 'f' },
|
|
{ kind: 'kwisatz', owner: 'a' },
|
|
{ kind: 'choamBattleAid', owner: 'c' },
|
|
],
|
|
},
|
|
};
|
|
}
|
|
const next = { kind: 'next' } as const;
|
|
void test('the finite quote follows the existing declaration order without mutation or private fields', () => {
|
|
const input = fixture(),
|
|
before = structuredClone(input),
|
|
q = quoteCombatResponse(input, next);
|
|
assert.deepEqual(q.response, {
|
|
kind: 'eliteStrength',
|
|
owner: 'f',
|
|
passed: [],
|
|
});
|
|
assert.deepEqual(q.powerChecks, [
|
|
{ kind: 'fremenSupport', owner: 'f' },
|
|
{ kind: 'kwisatz', owner: 'a' },
|
|
{ kind: 'choamBattleAid', owner: 'c' },
|
|
]);
|
|
assert.deepEqual(input, before);
|
|
q.powerChecks!.reverse();
|
|
assert.deepEqual(input, before);
|
|
assert.deepEqual(Object.keys(q).sort(), [
|
|
'decision',
|
|
'patches',
|
|
'powerChecks',
|
|
'response',
|
|
]);
|
|
});
|
|
void test('each allowed or canceled check updates only its own flag and advances exactly one response', () => {
|
|
for (const canceled of [false, true])
|
|
for (const kind of [
|
|
'eliteStrength',
|
|
'fremenSupport',
|
|
'kwisatz',
|
|
'choamBattleAid',
|
|
] as const) {
|
|
const input = fixture(),
|
|
checks = input.battle!.powerChecks as {
|
|
kind: CombatResponseKind;
|
|
owner: string;
|
|
}[],
|
|
index = checks.findIndex((c) => c.kind === kind),
|
|
response = checks[index];
|
|
input.battle!.powerChecks = checks.slice(index + 1);
|
|
const q = quoteCombatResponse(input, {
|
|
kind: 'response',
|
|
response,
|
|
canceled,
|
|
});
|
|
const expected =
|
|
kind === 'eliteStrength'
|
|
? canceled
|
|
? { eliteBlocked: ['f'] }
|
|
: {}
|
|
: {
|
|
[kind === 'fremenSupport'
|
|
? 'fremenSupportBlocked'
|
|
: kind === 'kwisatz'
|
|
? 'kwisatzBlocked'
|
|
: 'choamAidBlocked']: canceled,
|
|
};
|
|
assert.deepEqual(q.patches, expected);
|
|
assert.equal(q.response?.kind ?? null, checks[index + 1]?.kind ?? null);
|
|
assert.equal(q.decision, null);
|
|
}
|
|
});
|
|
void test('Basic Ix elite checks survive, Advanced Ix checks do not; Emperor elites are unavailable against Fremen', () => {
|
|
const input = fixture();
|
|
input.advanced = false;
|
|
input.players[0].faction = 'ixians';
|
|
input.battle!.powerChecks = [{ kind: 'eliteStrength', owner: 'f' }];
|
|
assert.equal(
|
|
quoteCombatResponse(input, next).response?.kind,
|
|
'eliteStrength',
|
|
);
|
|
input.advanced = true;
|
|
assert.throws(() => quoteCombatResponse(input, next), /faction benefit/);
|
|
input.players[0].faction = 'emperor';
|
|
input.players[1].faction = 'fremen';
|
|
assert.throws(() => quoteCombatResponse(input, next), /faction benefit/);
|
|
});
|
|
void test('declared checks require no current elite count, unlocked KH token or CHOAM payer funds', () => {
|
|
const input = fixture();
|
|
// No force, tank, KH or spice data is supplied to this finite successor.
|
|
input.battle!.powerChecks = [
|
|
{ kind: 'kwisatz', owner: 'a' },
|
|
{ kind: 'choamBattleAid', owner: 'c' },
|
|
];
|
|
assert.equal(quoteCombatResponse(input, next).response?.kind, 'kwisatz');
|
|
input.players[1].ally = undefined;
|
|
assert.throws(() => quoteCombatResponse(input, next), /faction benefit/);
|
|
});
|
|
void test('empty and legacy absent queues preserve their distinct shapes', () => {
|
|
const input = fixture();
|
|
delete input.battle!.powerChecks;
|
|
assert.equal(quoteCombatResponse(input, next).powerChecks, undefined);
|
|
assert.equal(quoteCombatResponse(input, next).response, null);
|
|
input.battle!.powerChecks = [];
|
|
assert.deepEqual(quoteCombatResponse(input, next).powerChecks, []);
|
|
});
|
|
void test('malformed battle, flags, foreign kinds and repeated/reversed queue owners reject atomically', () => {
|
|
const mutations: ((g: CombatResponseInput) => void)[] = [
|
|
(g) => {
|
|
g.phase = 5;
|
|
},
|
|
(g) => {
|
|
g.battle = null;
|
|
},
|
|
(g) => {
|
|
g.battle!.revealed = true;
|
|
},
|
|
(g) => {
|
|
g.battle!.territory = 'unknown';
|
|
},
|
|
(g) => {
|
|
g.battle!.territory = 'hidden_mobile_stronghold';
|
|
},
|
|
(g) => {
|
|
g.battle!.defender = 'f';
|
|
},
|
|
(g) => {
|
|
g.battle!.plans = [];
|
|
},
|
|
(g) => {
|
|
g.battle!.plans = { outsider: {} };
|
|
},
|
|
(g) => {
|
|
g.battle!.powerChecks = {};
|
|
},
|
|
(g) => {
|
|
g.battle!.powerChecks = [{ kind: 'advisor', owner: 'b' }];
|
|
},
|
|
(g) => {
|
|
g.battle!.powerChecks = [{ kind: 'kwisatz', owner: 'f' }];
|
|
},
|
|
(g) => {
|
|
g.battle!.powerChecks = [
|
|
{ kind: 'eliteStrength', owner: 'f' },
|
|
{ kind: 'eliteStrength', owner: 'f' },
|
|
];
|
|
},
|
|
(g) => {
|
|
g.battle!.powerChecks = [
|
|
{ kind: 'kwisatz', owner: 'a' },
|
|
{ kind: 'fremenSupport', owner: 'f' },
|
|
];
|
|
},
|
|
(g) => {
|
|
g.battle!.eliteBlocked = ['outsider'];
|
|
},
|
|
(g) => {
|
|
g.battle!.eliteBlocked = ['f', 'f'];
|
|
},
|
|
(g) => {
|
|
g.battle!.kwisatzBlocked = 'yes';
|
|
},
|
|
];
|
|
for (const mutate of mutations) {
|
|
const input = fixture();
|
|
mutate(input);
|
|
const before = structuredClone(input);
|
|
assert.throws(
|
|
() => quoteCombatResponse(input, next),
|
|
CombatResponseQuoteError,
|
|
);
|
|
assert.deepEqual(input, before);
|
|
}
|
|
const input = fixture();
|
|
assert.throws(
|
|
() =>
|
|
quoteCombatResponse(input, {
|
|
kind: 'response',
|
|
response: { kind: 'kwisatz', owner: 'a' },
|
|
canceled: true,
|
|
}),
|
|
/precede/,
|
|
);
|
|
});
|
|
void test('finishing preparation offers any seated eligible Advanced Atreides once, including an unrelated spectator', () => {
|
|
const input = fixture();
|
|
input.players[1].faction = 'guild';
|
|
input.players = [...input.players, { id: 'spectator', faction: 'atreides' }];
|
|
input.battle!.powerChecks = [];
|
|
assert.deepEqual(
|
|
quoteCombatResponse(input, { kind: 'finishPreparation' }).decision,
|
|
{ kind: 'fullPlanOffer', player: 'spectator' },
|
|
);
|
|
input.battle!.fullPlanOffered = true;
|
|
assert.equal(
|
|
quoteCombatResponse(input, { kind: 'finishPreparation' }).decision,
|
|
null,
|
|
);
|
|
input.battle!.fullPlanOffered = false;
|
|
input.players.at(-1)!.specialKaramaUsed = true;
|
|
assert.equal(
|
|
quoteCombatResponse(input, { kind: 'finishPreparation' }).decision,
|
|
null,
|
|
);
|
|
input.players.at(-1)!.specialKaramaUsed = false;
|
|
input.battle!.plans = { f: { dial: 0 } };
|
|
assert.equal(
|
|
quoteCombatResponse(input, { kind: 'finishPreparation' }).decision,
|
|
null,
|
|
);
|
|
});
|
|
void test('Voice cancellation preserves already advanced allied Prescience preparation and all plan values', () => {
|
|
const input = fixture();
|
|
input.battle!.powerChecks = [];
|
|
input.battle!.voice = { target: 'a', kind: 'poison', must: false };
|
|
input.battle!.preparation = {
|
|
kind: 'prescience',
|
|
owner: 'a',
|
|
beneficiary: 'a',
|
|
};
|
|
input.battle!.plans = { f: { dial: 2, weapon: 'private-weapon' } };
|
|
const before = structuredClone(input);
|
|
const q = quoteCombatResponse(input, {
|
|
kind: 'response',
|
|
response: { kind: 'voice', owner: 'b' },
|
|
canceled: true,
|
|
});
|
|
assert.deepEqual(q.patches, { deleteVoice: true });
|
|
assert.equal(q.decision, null);
|
|
assert.deepEqual(input, before);
|
|
assert.ok(!JSON.stringify(q).includes('private-weapon'));
|
|
input.battle!.preparation = {
|
|
kind: 'prescienceAnswer',
|
|
owner: 'a',
|
|
beneficiary: 'a',
|
|
};
|
|
assert.throws(
|
|
() =>
|
|
quoteCombatResponse(input, {
|
|
kind: 'response',
|
|
response: { kind: 'voice', owner: 'b' },
|
|
canceled: true,
|
|
}),
|
|
/remaining Prescience/,
|
|
);
|
|
delete input.battle!.preparation;
|
|
assert.deepEqual(
|
|
quoteCombatResponse(input, {
|
|
kind: 'response',
|
|
response: { kind: 'voice', owner: 'b' },
|
|
canceled: true,
|
|
}).patches,
|
|
{ deleteVoice: true },
|
|
);
|
|
});
|
|
void test('allied Prescience cancellation removes the answer restriction and finishes preparation without exposing its value', () => {
|
|
const input = fixture();
|
|
input.players[1].faction = 'guild';
|
|
input.players = [
|
|
...input.players,
|
|
{ id: 'provider', faction: 'atreides', ally: 'a' },
|
|
];
|
|
input.battle!.powerChecks = [];
|
|
input.battle!.prescience = {
|
|
player: 'a',
|
|
field: 'weapon',
|
|
value: 'private-weapon',
|
|
};
|
|
input.battle!.preparation = {
|
|
kind: 'prescienceAnswer',
|
|
owner: 'f',
|
|
beneficiary: 'a',
|
|
};
|
|
const op = {
|
|
kind: 'response',
|
|
response: { kind: 'prescience', owner: 'provider' },
|
|
canceled: true,
|
|
} as const;
|
|
const q = quoteCombatResponse(input, op);
|
|
assert.deepEqual(q.patches, {
|
|
deletePrescience: true,
|
|
deletePreparation: true,
|
|
fullPlanOffered: true,
|
|
});
|
|
assert.deepEqual(q.decision, { kind: 'fullPlanOffer', player: 'provider' });
|
|
assert.ok(!JSON.stringify(q).includes('private-weapon'));
|
|
input.battle!.prescience = { player: 'f', field: 'weapon' };
|
|
assert.throws(() => quoteCombatResponse(input, op), /beneficiary/);
|
|
});
|
|
|
|
// Observes only unchanged internal validation; no production test seam.
|
|
const observed = {} as {
|
|
validateKaramaUse: (
|
|
g: Game,
|
|
p: Game['players'][number],
|
|
use: { kind: 'cancel'; response: NonNullable<Game['response']> },
|
|
card: Game['deck'][number],
|
|
) => void;
|
|
};
|
|
const engineURL = new URL('../game/engine.ts', import.meta.url);
|
|
runInNewContext(
|
|
ts.transpileModule(
|
|
readFileSync(engineURL, 'utf8') + '\nexport {validateKaramaUse};',
|
|
{
|
|
compilerOptions: {
|
|
module: ts.ModuleKind.CommonJS,
|
|
target: ts.ScriptTarget.ES2022,
|
|
},
|
|
},
|
|
).outputText,
|
|
{
|
|
exports: observed,
|
|
require: createRequire(engineURL),
|
|
crypto: webcrypto,
|
|
structuredClone,
|
|
TextEncoder,
|
|
JSON,
|
|
},
|
|
);
|
|
function actual(
|
|
factions: FactionId[] = [
|
|
'fremen',
|
|
'atreides',
|
|
'beneGesserit',
|
|
'choam',
|
|
'guild',
|
|
],
|
|
advanced = true,
|
|
) {
|
|
const ids = ['f', 'a', 'b', 'c', 'g'];
|
|
let g = createGame(
|
|
'COMBATQUOTE',
|
|
newPlayer(ids[0], ids[0], factions[0]),
|
|
advanced,
|
|
['ix', 'choam'],
|
|
);
|
|
for (let i = 1; i < factions.length; i++)
|
|
g.players.push(newPlayer(ids[i], ids[i], factions[i]));
|
|
Object.assign(g, {
|
|
status: 'playing',
|
|
phase: 6,
|
|
turn: 2,
|
|
storm: 18,
|
|
active: 'f',
|
|
order: g.players.map((p) => p.id),
|
|
deck: baseDeck(),
|
|
discard: [],
|
|
response: null,
|
|
decision: null,
|
|
phaseOpening: null,
|
|
});
|
|
for (const p of g.players) {
|
|
p.hand = [];
|
|
p.spice = 10;
|
|
p.traitors = [];
|
|
p.forces = ['f', 'a'].includes(p.id) ? { 'red_chasm:7': 3 } : {};
|
|
p.reserves = ['f', 'a'].includes(p.id) ? 17 : 20;
|
|
if (['fremen', 'emperor', 'ixians'].includes(p.faction))
|
|
p.elites = {
|
|
reserves: 2,
|
|
tanks: 0,
|
|
forces: p.id === 'f' ? { 'red_chasm:7': 1 } : {},
|
|
revived: 0,
|
|
};
|
|
}
|
|
const give = (id: string, effect: string) => {
|
|
const at = g.deck.findIndex((c) =>
|
|
effect === 'worthless' ? c.kind === 'worthless' : c.effect === effect,
|
|
);
|
|
assert.ok(at >= 0);
|
|
const card = g.deck.splice(at, 1)[0];
|
|
g.players.find((p) => p.id === id)!.hand.push(card);
|
|
return card;
|
|
};
|
|
const printed = give('g', 'karama'),
|
|
bg = give('b', 'worthless');
|
|
g.players[2].ally = 'f';
|
|
if (factions[3] === 'choam') {
|
|
g.players[1].ally = 'c';
|
|
g.players[3].ally = 'a';
|
|
}
|
|
g = applyAction(g, 'f', {
|
|
type: 'chooseBattle',
|
|
territory: 'red_chasm',
|
|
target: 'a',
|
|
});
|
|
if (g.decision?.kind === 'choamBattleFunding')
|
|
g = applyAction(g, 'c', { type: 'decision', amount: 0 });
|
|
return { g, printed, bg };
|
|
}
|
|
function passOne(state: Game) {
|
|
let g = state;
|
|
const original = g.response!.kind;
|
|
for (let i = 0; g.response?.kind === original && i < 20; i++) {
|
|
const p = g.players.find((p) => !g.response!.passed.includes(p.id));
|
|
assert.ok(p);
|
|
g = applyAction(g, p.id, { type: 'passResponse' });
|
|
}
|
|
return g;
|
|
}
|
|
function toCheck(state: Game, kind: CombatResponseKind) {
|
|
let g = state;
|
|
for (let i = 0; g.response && g.response.kind !== kind && i < 10; i++)
|
|
g = passOne(g);
|
|
assert.equal(g.response?.kind, kind);
|
|
return g;
|
|
}
|
|
for (const form of ['printed', 'bg'] as const) {
|
|
void test(`genuine ${form} declarations cancel every combat check with one physical cost and keep the next declaration order`, () => {
|
|
for (const kind of [
|
|
'eliteStrength',
|
|
'fremenSupport',
|
|
'kwisatz',
|
|
'choamBattleAid',
|
|
] as const) {
|
|
const f = actual();
|
|
let g = toCheck(f.g, kind);
|
|
const actor = form === 'printed' ? 'g' : 'b',
|
|
card = f[form],
|
|
before = structuredClone(g);
|
|
g = applyAction(g, actor, {
|
|
type: 'card',
|
|
card: card.id,
|
|
mode: 'cancel',
|
|
});
|
|
assert.deepEqual(before, toCheck(f.g, kind));
|
|
if (form === 'bg') {
|
|
assert.equal(g.response?.kind, 'worthlessKarama');
|
|
g = passOne(JSON.parse(JSON.stringify(g)) as Game);
|
|
}
|
|
assert.equal(g.discard.filter((c) => c.id === card.id).length, 1);
|
|
const flags = {
|
|
eliteStrength: g.battle!.eliteBlocked?.includes('f'),
|
|
fremenSupport: g.battle!.fremenSupportBlocked,
|
|
kwisatz: g.battle!.kwisatzBlocked,
|
|
choamBattleAid: g.battle!.choamAidBlocked,
|
|
};
|
|
assert.equal(flags[kind], true);
|
|
assert.equal(g.battle!.revealed, false);
|
|
for (const p of g.players)
|
|
assert.equal(viewGame(g, p.id).battle!.revealed, false);
|
|
}
|
|
});
|
|
void test(`genuine ${form} cancellation rejects malformed source and next checks before cost, RNG or event creation`, (t) => {
|
|
const f = actual(),
|
|
actor = form === 'printed' ? 'g' : 'b',
|
|
card = f[form];
|
|
const random = t.mock.method(crypto, 'getRandomValues', () => {
|
|
throw Error('No random draw');
|
|
}),
|
|
uuid = t.mock.method(crypto, 'randomUUID', () => {
|
|
throw Error('No event');
|
|
});
|
|
const mutations: ((g: Game) => void)[] = [
|
|
(g) => {
|
|
g.response!.owner = 'g';
|
|
},
|
|
(g) => {
|
|
g.battle!.powerChecks![0] = { kind: 'eliteStrength', owner: 'g' };
|
|
},
|
|
(g) => {
|
|
g.battle!.powerChecks![0] = { kind: 'advisor', owner: 'b' } as never;
|
|
},
|
|
(g) => {
|
|
g.battle!.powerChecks!.reverse();
|
|
},
|
|
(g) => {
|
|
g.battle!.territory = 'unknown';
|
|
},
|
|
(g) => {
|
|
g.battle!.territory = 'hidden_mobile_stronghold';
|
|
},
|
|
(g) => {
|
|
g.battle!.plans = { outsider: {} } as never;
|
|
},
|
|
];
|
|
for (const mutate of mutations) {
|
|
const bad = structuredClone(f.g);
|
|
mutate(bad);
|
|
const before = structuredClone(bad);
|
|
assert.throws(() =>
|
|
observed.validateKaramaUse(
|
|
bad,
|
|
bad.players.find((p) => p.id === actor)!,
|
|
{ kind: 'cancel', response: bad.response! },
|
|
card,
|
|
),
|
|
);
|
|
assert.throws(() =>
|
|
applyAction(bad, actor, {
|
|
type: 'card',
|
|
card: card.id,
|
|
mode: 'cancel',
|
|
}),
|
|
);
|
|
assert.deepEqual(bad, before);
|
|
assert.equal(
|
|
bad.discard.some((c) => c.id === card.id),
|
|
false,
|
|
);
|
|
}
|
|
assert.equal(random.mock.callCount(), 0);
|
|
assert.equal(uuid.mock.callCount(), 0);
|
|
});
|
|
}
|
|
function allowAll(state: Game) {
|
|
let g = state;
|
|
for (let i = 0; g.response && i < 20; i++) g = passOne(g);
|
|
assert.equal(g.response, null);
|
|
return g;
|
|
}
|
|
void test('an actual printed Voice cancellation preserves the already advanced Prescience decision and can continue to legal plans', () => {
|
|
const f = actual();
|
|
let g = allowAll(f.g);
|
|
assert.equal(g.battle!.preparation?.kind, 'voice');
|
|
g = applyAction(g, 'b', { type: 'voice', kind: 'poison', must: false });
|
|
assert.equal(g.response?.kind, 'voice');
|
|
assert.equal(g.battle!.preparation?.kind, 'prescience');
|
|
const preparation = structuredClone(g.battle!.preparation);
|
|
const corruptPreparation = structuredClone(g);
|
|
corruptPreparation.battle!.preparation!.kind = 'voice';
|
|
const corruptBefore = structuredClone(corruptPreparation);
|
|
assert.throws(
|
|
() =>
|
|
applyAction(corruptPreparation, 'g', {
|
|
type: 'card',
|
|
card: f.printed.id,
|
|
mode: 'cancel',
|
|
}),
|
|
/remaining Prescience/,
|
|
);
|
|
assert.deepEqual(corruptPreparation, corruptBefore);
|
|
const bad = structuredClone(g);
|
|
bad.battle!.voice!.target = 'f';
|
|
assert.throws(
|
|
() =>
|
|
applyAction(bad, 'g', {
|
|
type: 'card',
|
|
card: f.printed.id,
|
|
mode: 'cancel',
|
|
}),
|
|
/Voice/,
|
|
);
|
|
g = applyAction(g, 'g', { type: 'card', card: f.printed.id, mode: 'cancel' });
|
|
assert.equal(g.battle!.voice, undefined);
|
|
assert.deepEqual(g.battle!.preparation, preparation);
|
|
g = applyAction(g, 'a', { type: 'declineBattlePower' });
|
|
assert.equal(g.decision?.kind, 'fullPlanOffer');
|
|
g = applyAction(g, 'a', { type: 'decision', decline: true });
|
|
g = applyAction(g, 'f', {
|
|
type: 'battlePlan',
|
|
leader: g.players[0].leaders[0].id,
|
|
dial: 0,
|
|
support: 0,
|
|
});
|
|
assert.equal(g.battle!.plans.f.dial, 0);
|
|
assert.equal(g.discard.filter((c) => c.id === f.printed.id).length, 1);
|
|
});
|
|
for (const form of ['printed', 'bg'] as const)
|
|
void test(`actual ${form} Prescience cancellation clears its unanswered element and opens the existing full-plan offer once`, () => {
|
|
const f = actual();
|
|
let g = allowAll(f.g);
|
|
g = applyAction(g, 'b', { type: 'declineBattlePower' });
|
|
g = applyAction(g, 'a', { type: 'prescience', field: 'weapon' });
|
|
assert.equal(g.response?.kind, 'prescience');
|
|
const actor = form === 'printed' ? 'g' : 'b',
|
|
card = f[form];
|
|
const bad = structuredClone(g);
|
|
bad.battle!.preparation!.owner = 'a';
|
|
const before = structuredClone(bad);
|
|
assert.throws(
|
|
() =>
|
|
applyAction(bad, actor, {
|
|
type: 'card',
|
|
card: card.id,
|
|
mode: 'cancel',
|
|
}),
|
|
/answer/,
|
|
);
|
|
assert.deepEqual(bad, before);
|
|
g = applyAction(g, actor, { type: 'card', card: card.id, mode: 'cancel' });
|
|
if (form === 'bg') g = passOne(JSON.parse(JSON.stringify(g)) as Game);
|
|
assert.equal(g.battle!.prescience, undefined);
|
|
assert.equal(g.battle!.preparation, undefined);
|
|
assert.equal(g.battle!.fullPlanOffered, true);
|
|
assert.deepEqual(g.decision, { kind: 'fullPlanOffer', player: 'a' });
|
|
assert.equal(g.discard.filter((c) => c.id === card.id).length, 1);
|
|
for (const viewer of ['f', 'a', 'c', 'g'])
|
|
if (viewer !== actor)
|
|
assert.equal(
|
|
viewGame(g, viewer).players.find((p) => p.id === actor)!.hand,
|
|
undefined,
|
|
);
|
|
});
|
|
void test('a genuine Basic Ix elite cancellation uses the same finite successor and retains its physical cyborg', () => {
|
|
const f = actual(
|
|
['ixians', 'emperor', 'beneGesserit', 'harkonnen', 'guild'],
|
|
false,
|
|
);
|
|
assert.equal(f.g.response?.kind, 'eliteStrength');
|
|
const before = structuredClone(f.g.players[0].elites);
|
|
const g = applyAction(f.g, 'g', {
|
|
type: 'card',
|
|
card: f.printed.id,
|
|
mode: 'cancel',
|
|
});
|
|
assert.deepEqual(g.battle!.eliteBlocked, ['f']);
|
|
assert.deepEqual(g.players[0].elites, before);
|
|
assert.equal(g.response, null);
|
|
assert.equal(g.decision, null);
|
|
});
|