Allow native Advanced use after declining the first offer, including a sealed target. Reuse projected card/target permission in persistent controls and minimal bots; retain sealed plans and finish target-last public reveal once. Source: authorized supplied physical27 F. Ordinary preparation priority and target-first ordering remain provisional; no new reading acknowledgement. Verified: 11 affected offline files126/126, types/lint, production build, five actual control/gameplay/GameTable SSR journeys and eight legal profile paths. No saved-room, D1, reset, restart or deployment operation.
650 lines
23 KiB
TypeScript
650 lines
23 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction,
|
|
createGame,
|
|
initializeBaseGameForAudit,
|
|
newPlayer,
|
|
joinGame,
|
|
viewGame,
|
|
type Game,
|
|
type Action,
|
|
} from '../game/engine';
|
|
import { baseDeck } from '../game/cards';
|
|
import { botActions } from '../game/bots';
|
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
|
import type { PlanClaim } from '../game/battle-promises';
|
|
const cards = baseDeck();
|
|
const karama = cards.find((c) => c.effect === 'karama')!;
|
|
const poison = cards.find((c) => c.kind === 'poison')!;
|
|
const shield = cards.find((c) => c.kind === 'shield')!;
|
|
const snooper = cards.find((c) => c.kind === 'snooper')!;
|
|
const worthless = cards.find((c) => c.kind === 'worthless')!;
|
|
function fixture(outsider = false) {
|
|
let g = createGame('FULLPLAN', newPlayer('a', 'Atreides', 'atreides'), true);
|
|
joinGame(g, newPlayer('e', 'Emperor', 'emperor'));
|
|
joinGame(g, newPlayer('g', 'Guild', 'guild'));
|
|
joinGame(g, newPlayer('b', 'Bene Gesserit', 'beneGesserit'));
|
|
g.players.forEach((p) => (p.ready = true));
|
|
g = initializeBaseGameForAudit(g);
|
|
g = applyAction(g, 'b', { type: 'predict', faction: 'atreides', turn: 3 });
|
|
for (const p of g.players)
|
|
if (p.traitorChoices.length)
|
|
g = applyAction(g, p.id, {
|
|
type: 'traitor',
|
|
leader: p.traitorChoices[0],
|
|
});
|
|
g = applyAction(g, 'b', {
|
|
type: 'advisorSetup',
|
|
territory: 'polar_sink',
|
|
sector: 0,
|
|
});
|
|
assert.equal(g.status, 'playing');
|
|
assert.equal(g.advanced, true);
|
|
g.phase = 6;
|
|
g.active = outsider ? 'e' : 'a';
|
|
g.order = outsider ? ['e', 'g', 'a', 'b'] : ['a', 'e', 'g', 'b'];
|
|
g.storm = 18;
|
|
for (const p of g.players) {
|
|
const combatant = p.id === 'e' || p.id === (outsider ? 'g' : 'a');
|
|
p.forces = combatant ? { 'arrakeen:10': 5 } : {};
|
|
p.reserves = combatant ? 15 : 20;
|
|
p.spice = 10;
|
|
p.hand = [];
|
|
p.traitors = [];
|
|
p.advisors = {};
|
|
if (p.elites) {
|
|
p.elites.reserves = 5;
|
|
p.elites.tanks = 0;
|
|
p.elites.forces = {};
|
|
}
|
|
}
|
|
g.players[0].hand = [karama, snooper];
|
|
g.players[1].hand = [poison, shield, worthless];
|
|
g.discard = [];
|
|
const held = new Set(g.players.flatMap((p) => p.hand.map((c) => c.id)));
|
|
g.deck = baseDeck().filter((c) => !held.has(c.id));
|
|
g.traitorReserve = g.players.flatMap((p) => p.leaders.map((l) => l.id));
|
|
return g;
|
|
}
|
|
function responses(state: Game) {
|
|
let g = state;
|
|
while (g.response)
|
|
g = applyAction(
|
|
g,
|
|
g.players.find((p) => !g.response!.passed.includes(p.id))!.id,
|
|
{ type: 'passResponse' },
|
|
);
|
|
return g;
|
|
}
|
|
function choose(g: Game) {
|
|
return applyAction(g, g.active!, {
|
|
type: 'chooseBattle',
|
|
territory: 'arrakeen',
|
|
target: g.active === 'a' ? 'e' : 'g',
|
|
});
|
|
}
|
|
function offer(state = fixture()) {
|
|
let g = responses(choose(state));
|
|
while (g.battle?.preparation)
|
|
g = applyAction(g, g.battle.preparation.owner, {
|
|
type: 'declineBattlePower',
|
|
});
|
|
return g;
|
|
}
|
|
const inspect = (g: Game, target = 'e') =>
|
|
applyAction(g, 'a', {
|
|
type: 'card',
|
|
mode: 'special',
|
|
card: karama.id,
|
|
target,
|
|
});
|
|
const targetPlan: Action = {
|
|
type: 'battlePlan',
|
|
dial: 2.5,
|
|
support: 2,
|
|
leader: 'emperor-0',
|
|
weapon: poison.id,
|
|
defense: shield.id,
|
|
};
|
|
const ownPlan: Action = {
|
|
type: 'battlePlan',
|
|
dial: 1,
|
|
support: 1,
|
|
leader: 'atreides-0',
|
|
defense: snooper.id,
|
|
};
|
|
void test('full-plan offer follows Voice and the answer to ordinary prescience', () => {
|
|
const before = fixture();
|
|
before.players[0].ally = 'b';
|
|
before.players[3].ally = 'a';
|
|
let g = responses(choose(before));
|
|
assert.equal(g.battle?.preparation?.kind, 'voice');
|
|
assert.equal(g.decision, null);
|
|
assert.throws(() => inspect(g), /preparation/);
|
|
g = applyAction(g, 'b', { type: 'voice', kind: 'snooper', must: false });
|
|
g = responses(g);
|
|
g = applyAction(g, 'a', { type: 'prescience', field: 'weapon' });
|
|
assert.throws(() => inspect(g), /preparation/);
|
|
g = responses(g);
|
|
assert.equal(g.decision, null);
|
|
g = applyAction(g, 'e', { type: 'prescienceAnswer', value: poison.id });
|
|
assert.deepEqual(g.decision, { kind: 'fullPlanOffer', player: 'a' });
|
|
assert.equal(g.battle?.preparation, undefined);
|
|
g = inspect(g);
|
|
assert.throws(
|
|
() => applyAction(g, 'e', { ...targetPlan, weapon: null }),
|
|
/remain unchanged/,
|
|
);
|
|
assert.throws(() =>
|
|
applyAction(g, 'e', { ...targetPlan, defense: snooper.id }),
|
|
);
|
|
g = applyAction(g, 'e', targetPlan);
|
|
assert.equal(g.decision, null);
|
|
});
|
|
void test('target commits first and only Atreides receives the inspected plan and its selected cards', () => {
|
|
const offered = offer();
|
|
const g = inspect(offered);
|
|
assert.equal(g.players[0].specialKaramaUsed, true);
|
|
assert.equal(
|
|
g.players[0].hand.some((c) => c.id === karama.id),
|
|
false,
|
|
);
|
|
assert.equal(g.response, null);
|
|
assert.equal(g.decision, null);
|
|
assert.equal(g.discard.filter((c) => c.id === karama.id).length, 1);
|
|
assert.throws(() => applyAction(g, 'a', ownPlan), /requested by special/);
|
|
const sealed = applyAction(g, 'e', targetPlan);
|
|
assert.equal(sealed.battle?.revealed, false);
|
|
assert.equal(sealed.players[1].spice, 10);
|
|
const atreides = viewGame(JSON.parse(JSON.stringify(sealed)), 'a');
|
|
assert.deepEqual(
|
|
atreides.battle?.fullPlanInsight?.plan,
|
|
sealed.battle!.plans.e,
|
|
);
|
|
assert.deepEqual(
|
|
new Set(atreides.battle?.fullPlanInsight?.cards.map((c) => c.id)),
|
|
new Set([poison.id, shield.id]),
|
|
);
|
|
assert.equal(atreides.players[1].hand, undefined);
|
|
assert.equal(atreides.battle?.plans.e, undefined);
|
|
for (const id of ['e', 'g', 'b'])
|
|
assert.equal(viewGame(sealed, id).battle?.fullPlanInsight, null);
|
|
assert.deepEqual(
|
|
viewGame(sealed, 'e').battle?.plans.e,
|
|
sealed.battle?.plans.e,
|
|
);
|
|
assert.equal(viewGame(sealed, 'g').battle?.plans.e, undefined);
|
|
assert.ok(
|
|
sealed.log.every(
|
|
(l) => !l.text.includes(poison.name) && !l.text.includes('2.5'),
|
|
),
|
|
);
|
|
});
|
|
void test('inspection needs no acknowledgement and public reveal waits for the second committed plan', () => {
|
|
const reading = applyAction(inspect(offer()), 'e', targetPlan);
|
|
assert.equal(reading.decision, null);
|
|
for (const id of ['g', 'a'])
|
|
assert.throws(
|
|
() => applyAction(reading, id, { type: 'decision', continue: true }),
|
|
/not available/,
|
|
);
|
|
const reconnected = JSON.parse(JSON.stringify(reading)) as Game;
|
|
assert.equal(reconnected.battle?.revealed, false);
|
|
assert.throws(
|
|
() => applyAction(reconnected, 'e', { ...targetPlan, dial: 1 }),
|
|
/not available/,
|
|
);
|
|
const revealed = applyAction(reconnected, 'a', ownPlan);
|
|
assert.equal(revealed.battle?.revealed, true);
|
|
assert.deepEqual(
|
|
viewGame(revealed, 'g').battle?.plans.e,
|
|
reading.battle?.plans.e,
|
|
);
|
|
assert.equal(viewGame(revealed, 'a').battle?.fullPlanInsight, null);
|
|
});
|
|
|
|
void test('Atreides may inspect an unrelated battle without giving its ally the private plan', () => {
|
|
let g = offer(fixture(true));
|
|
g.players[0].ally = 'b';
|
|
g.players[3].ally = 'a';
|
|
g = inspect(g, 'g');
|
|
assert.throws(() => applyAction(g, 'e', targetPlan), /requested by special/);
|
|
g = applyAction(g, 'g', {
|
|
type: 'battlePlan',
|
|
dial: 0,
|
|
support: 0,
|
|
leader: 'guild-0',
|
|
});
|
|
assert.equal(viewGame(g, 'a').battle?.fullPlanInsight?.target, 'g');
|
|
assert.equal(viewGame(g, 'b').battle?.fullPlanInsight, null);
|
|
assert.equal(viewGame(g, 'e').battle?.fullPlanInsight, null);
|
|
g = applyAction(g, 'e', targetPlan);
|
|
assert.equal(g.battle?.revealed, true);
|
|
});
|
|
void test('unrelated Atreides may decline then inspect an already sealed combatant without granting its ally access', () => {
|
|
let g = offer(fixture(true));
|
|
g.players[0].ally = 'b';
|
|
g.players[3].ally = 'a';
|
|
g = applyAction(g, 'a', { type: 'decision', decline: true });
|
|
g = applyAction(g, 'g', { type: 'battlePlan', dial: 0, support: 0, leader: 'guild-0' });
|
|
const sealed = structuredClone(g.battle!.plans.g);
|
|
g = inspect(g, 'g');
|
|
assert.deepEqual(viewGame(g, 'a').battle?.fullPlanInsight?.plan, sealed);
|
|
for (const actor of ['b', 'e', 'g'])
|
|
assert.equal(viewGame(g, actor).battle?.fullPlanInsight, null);
|
|
assert.deepEqual(g.battle?.plans.g, sealed);
|
|
g = applyAction(g, 'e', targetPlan);
|
|
assert.equal(g.battle?.revealed, true);
|
|
});
|
|
void test('declining preserves the power and card and allows later use before reveal without reopening the offer', () => {
|
|
const before = fixture();
|
|
const offered = offer(before);
|
|
before.players[0].hand = [];
|
|
assert.deepEqual(offer(before).decision, offered.decision);
|
|
const declined = applyAction(offered, 'a', {
|
|
type: 'decision',
|
|
decline: true,
|
|
});
|
|
assert.equal(declined.players[0].specialKaramaUsed, undefined);
|
|
assert.ok(declined.players[0].hand.some((c) => c.id === karama.id));
|
|
assert.equal(declined.decision, null);
|
|
assert.equal(inspect(declined).battle?.fullPlan?.target, 'e');
|
|
assert.ok(applyAction(declined, 'a', ownPlan).battle?.plans.a);
|
|
});
|
|
void test('ordinary prescience cancellation does not suppress special inspection', () => {
|
|
let g = responses(choose(fixture()));
|
|
const otherKarama = cards.find(
|
|
(c) => c.effect === 'karama' && c.id !== karama.id,
|
|
)!;
|
|
g.players[2].hand = [otherKarama];
|
|
g = applyAction(g, 'a', { type: 'prescience', field: 'leader' });
|
|
g = applyAction(g, 'g', {
|
|
type: 'card',
|
|
mode: 'cancel',
|
|
card: otherKarama.id,
|
|
});
|
|
assert.equal(g.battle?.prescience, undefined);
|
|
assert.equal(g.decision?.kind, 'fullPlanOffer');
|
|
g = inspect(g);
|
|
assert.equal(g.battle?.fullPlan?.target, 'e');
|
|
assert.equal(g.response, null);
|
|
});
|
|
void test('invalid target, phase, card, and answer choices preserve state atomically', () => {
|
|
const g = offer();
|
|
const copy = structuredClone(g);
|
|
assert.throws(() => inspect(g, 'b'), /in this battle/);
|
|
assert.throws(
|
|
() =>
|
|
applyAction(g, 'a', {
|
|
type: 'card',
|
|
mode: 'special',
|
|
card: snooper.id,
|
|
target: 'e',
|
|
}),
|
|
/Karama card/,
|
|
);
|
|
const basic = structuredClone(g);
|
|
basic.advanced = false;
|
|
assert.throws(() => inspect(basic), /advanced game/);
|
|
assert.deepEqual(g, copy);
|
|
const waiting = inspect(g);
|
|
for (const extra of [
|
|
{ dial: 9 },
|
|
{ support: 11 },
|
|
{ leader: 'guild-0' },
|
|
{ weapon: shield.id },
|
|
{ defense: poison.id },
|
|
{ kwisatz: true },
|
|
])
|
|
assert.throws(() => applyAction(waiting, 'e', { ...targetPlan, ...extra }));
|
|
assert.deepEqual(waiting.battle?.plans, {});
|
|
assert.equal(waiting.players[1].spice, 10);
|
|
});
|
|
void test('once-per-game usage survives later battles and does not offer special prescience in basic mode', () => {
|
|
let g = inspect(offer());
|
|
g.battle = null;
|
|
g.turn++;
|
|
g.active = 'a';
|
|
g = responses(choose(g));
|
|
g = applyAction(g, 'a', { type: 'declineBattlePower' });
|
|
assert.equal(g.decision, null);
|
|
assert.throws(() => inspect(g), /already been used/);
|
|
const basic = fixture();
|
|
basic.advanced = false;
|
|
assert.equal(offer(basic).decision, null);
|
|
});
|
|
void test('selected Worthless cards cannot be converted to Karama after the inspected plan is committed', () => {
|
|
const before = fixture();
|
|
before.players[1].forces = {};
|
|
before.players[1].reserves = 20;
|
|
before.players[3].forces = { 'arrakeen:10': 5 };
|
|
before.players[3].reserves = 15;
|
|
before.players[3].hand = [worthless];
|
|
let g = responses(
|
|
applyAction(before, 'a', {
|
|
type: 'chooseBattle',
|
|
territory: 'arrakeen',
|
|
target: 'b',
|
|
}),
|
|
);
|
|
while (g.battle?.preparation)
|
|
g = applyAction(g, g.battle.preparation.owner, {
|
|
type: 'declineBattlePower',
|
|
});
|
|
g = inspect(g, 'b');
|
|
g = applyAction(g, 'b', {
|
|
...targetPlan,
|
|
leader: 'beneGesserit-0',
|
|
weapon: worthless.id,
|
|
defense: null,
|
|
});
|
|
g.response = { kind: 'guildIncome', owner: 'g', amount: 1, passed: [] };
|
|
assert.throws(
|
|
() =>
|
|
applyAction(g, 'b', { type: 'card', mode: 'cancel', card: worthless.id }),
|
|
/sealed battle card/,
|
|
);
|
|
});
|
|
void test('AI initiates, answers and uses full-plan inspection at every difficulty using private views', () => {
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const before = fixture();
|
|
before.players[0].bot = difficulty;
|
|
before.players[1].bot = difficulty;
|
|
let g = offer(before);
|
|
const special = botActions(viewGame(g, 'a'))[0];
|
|
assert.equal(special.mode, 'special');
|
|
g = applyAction(g, 'a', special);
|
|
assert.deepEqual(botActions(viewGame(g, 'a')), []);
|
|
const candidates = botActions(viewGame(g, 'e'));
|
|
let answered: Game | undefined;
|
|
for (const action of candidates) {
|
|
try {
|
|
answered = applyAction(g, 'e', action);
|
|
break;
|
|
} catch {}
|
|
}
|
|
assert.ok(answered, difficulty);
|
|
g = answered;
|
|
assert.equal(g.decision, null);
|
|
assert.ok(
|
|
botActions(viewGame(g, 'a')).some((a) => a.type === 'battlePlan'),
|
|
);
|
|
const empty = offer(before);
|
|
empty.players[0].hand = [];
|
|
assert.deepEqual(botActions(viewGame(empty, 'a'))[0], {
|
|
type: 'decision',
|
|
decline: true,
|
|
});
|
|
}
|
|
});
|
|
void test('Hard Atreides uses inspected weapon and dial while unseen cards and traitors cannot influence its plan', () => {
|
|
const before = fixture();
|
|
before.players[0].bot = 'Hard';
|
|
let g = applyAction(inspect(offer(before)), 'e', targetPlan);
|
|
const first = botActions(viewGame(g, 'a'));
|
|
const legal = first.find((action) => {
|
|
try {
|
|
applyAction(g, 'a', action);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
});
|
|
assert.equal(legal?.defense, snooper.id);
|
|
assert.equal(legal?.dial, 3.5); // Emperor leader 6 + dial 2.5 ties Atreides leader 5 + dial 3.5.
|
|
g = structuredClone(g);
|
|
g.players[1].hand = [
|
|
poison,
|
|
shield,
|
|
...cards.filter((c) => c.kind === 'hero').slice(0, 1),
|
|
];
|
|
g.players[1].traitors = ['atreides-0'];
|
|
g.deck.reverse();
|
|
assert.deepEqual(botActions(viewGame(g, 'a')), first);
|
|
});
|
|
void test('full inspection includes KH and can target either combatant under any-player wording', () => {
|
|
const before = fixture();
|
|
before.players[0].battleLosses = 7;
|
|
let g = inspect(offer(before), 'a');
|
|
g = applyAction(g, 'a', { ...ownPlan, kwisatz: true });
|
|
assert.equal(viewGame(g, 'a').battle?.fullPlanInsight?.plan.kwisatz, true);
|
|
assert.equal(viewGame(g, 'e').battle?.plans.a, undefined);
|
|
g = applyAction(g, 'e', targetPlan);
|
|
assert.equal(g.battle?.revealed, true);
|
|
assert.equal(viewGame(g, 'g').battle?.plans.a.kwisatz, true);
|
|
});
|
|
|
|
function take(g: Game, id: string, name: string) {
|
|
const index = g.deck.findIndex((card) => card.name === name);
|
|
assert.ok(index >= 0, name);
|
|
const card = g.deck.splice(index, 1)[0];
|
|
g.players.find((p) => p.id === id)!.hand.push(card);
|
|
return card.id;
|
|
}
|
|
function question(g: Game, claim: PlanClaim) {
|
|
g = applyAction(g, 'a', {
|
|
type: 'card',
|
|
card: g.players[0].hand.find((c) => c.effect === 'truthtrance')!.id,
|
|
});
|
|
while (g.truthtrance?.stage === 'priority')
|
|
g = applyAction(
|
|
g,
|
|
g.players.find((p) => !g.truthtrance!.passed.includes(p.id))!.id,
|
|
{ type: 'truthPass' },
|
|
);
|
|
return applyAction(g, 'a', {
|
|
type: 'truthAsk',
|
|
question: { kind: 'battlePlan', target: 'e', claim },
|
|
});
|
|
}
|
|
function answer(g: Game, value: 'yes' | 'no' = 'yes') {
|
|
return applyAction(JSON.parse(JSON.stringify(g)), 'e', {
|
|
type: 'truthAnswer',
|
|
answer: value,
|
|
});
|
|
}
|
|
function unchanged(g: Game, id: string, action: Action) {
|
|
const copy = structuredClone(g);
|
|
assert.throws(() => applyAction(g, id, action));
|
|
assert.deepEqual(g, copy);
|
|
}
|
|
function custody(g: Game) {
|
|
const all = [...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand)]
|
|
.map((c) => c.id)
|
|
.sort();
|
|
assert.equal(all.length, new Set(all).size);
|
|
return all;
|
|
}
|
|
|
|
void test('Truthtrance and Ghola survive Voice, ordinary prescience and full-plan inspection from genuine Advanced setup', () => {
|
|
for (const level of DIFFICULTIES) {
|
|
const initial = fixture();
|
|
initial.players[0].ally = 'b';
|
|
initial.players[3].ally = 'a';
|
|
initial.players[1].bot = level;
|
|
initial.players[1].leaders[0].dead = true;
|
|
initial.players[1].leaders[0].deaths = 1;
|
|
take(initial, 'a', 'Truthtrance');
|
|
const ghola = take(initial, 'e', 'Tleilaxu Ghola');
|
|
const ids = custody(initial);
|
|
let g = responses(choose(initial));
|
|
assert.equal(g.battle?.preparation?.kind, 'voice');
|
|
const originalPreparation = structuredClone(g.battle!.preparation);
|
|
g = question(g, {
|
|
kind: 'and',
|
|
terms: [
|
|
{ kind: 'leader', leader: 'emperor-0' },
|
|
{ kind: 'weapon', name: poison.name },
|
|
],
|
|
});
|
|
assert.deepEqual(viewGame(g, 'e').truthBattleAnswers, ['yes', 'no']);
|
|
assert.deepEqual(g.battle!.preparation, originalPreparation);
|
|
unchanged(g, 'e', { type: 'card', card: ghola, leader: 'emperor-0' });
|
|
g = answer(g);
|
|
g = responses(
|
|
applyAction(g, 'b', { type: 'voice', kind: 'snooper', must: false }),
|
|
);
|
|
g = responses(applyAction(g, 'a', { type: 'prescience', field: 'leader' }));
|
|
g = applyAction(g, 'e', { type: 'prescienceAnswer', value: 'emperor-0' });
|
|
assert.equal(g.decision?.kind, 'fullPlanOffer');
|
|
assert.equal(g.battle!.truthPromises![0].released, undefined);
|
|
assert.equal(g.players[1].leaders[0].dead, true);
|
|
g = inspect(g);
|
|
const actions = botActions(viewGame(g, 'e'));
|
|
assert.equal(actions[0].type, 'card', level);
|
|
assert.equal(actions[0].card, ghola, level);
|
|
assert.equal(actions[0].leader, 'emperor-0', level);
|
|
for (const id of ['a', 'g', 'b'])
|
|
assert.equal(viewGame(g, id).battle!.compliantPreparation, null);
|
|
g = applyAction(g, 'e', actions[0]);
|
|
unchanged(g, 'e', { ...targetPlan, leader: 'emperor-1' });
|
|
unchanged(g, 'e', { ...targetPlan, weapon: null });
|
|
g = applyAction(g, 'e', targetPlan);
|
|
assert.equal(
|
|
viewGame(g, 'a').battle!.fullPlanInsight?.plan.leader,
|
|
'emperor-0',
|
|
);
|
|
assert.equal(g.battle!.revealed, false);
|
|
assert.equal(g.decision, null);
|
|
g = applyAction(JSON.parse(JSON.stringify(g)), 'a', ownPlan);
|
|
assert.equal(g.battle!.revealed, true);
|
|
assert.deepEqual(custody(g), ids);
|
|
assert.equal(g.discard.filter((c) => c.id === ghola).length, 1);
|
|
}
|
|
});
|
|
|
|
void test('Truthtrance at the special offer preserves the offer and both revival-dependent answers until a real plan is sealed', () => {
|
|
for (const useSpecial of [false, true]) {
|
|
const before = fixture();
|
|
take(before, 'a', 'Truthtrance');
|
|
const ghola = take(before, 'e', 'Tleilaxu Ghola');
|
|
before.players[1].leaders[0].dead = true;
|
|
before.players[1].leaders[0].deaths = 1;
|
|
let g = offer(before);
|
|
const offered = structuredClone(g.decision);
|
|
g = question(g, { kind: 'leader', leader: 'emperor-0' });
|
|
assert.deepEqual(viewGame(g, 'e').truthBattleAnswers, ['yes', 'no']);
|
|
g = answer(g);
|
|
assert.deepEqual(g.decision, offered);
|
|
unchanged(g, 'e', targetPlan);
|
|
g = useSpecial
|
|
? inspect(g)
|
|
: applyAction(g, 'a', { type: 'decision', decline: true });
|
|
assert.equal(g.battle!.truthPromises![0].released, undefined);
|
|
g = applyAction(g, 'e', { type: 'card', card: ghola, leader: 'emperor-0' });
|
|
g = applyAction(g, 'e', targetPlan);
|
|
assert.equal(!!viewGame(g, 'a').battle!.fullPlanInsight, useSpecial);
|
|
assert.equal(!!g.players[0].specialKaramaUsed, useSpecial);
|
|
}
|
|
});
|
|
|
|
void test('Truthtrance after inspection reports only the sealed plan and cannot reopen its fields or physical card custody', () => {
|
|
const before = fixture();
|
|
take(before, 'a', 'Truthtrance');
|
|
take(before, 'a', 'Truthtrance');
|
|
let g = applyAction(inspect(offer(before)), 'e', targetPlan);
|
|
const sealed = structuredClone(g.battle!.plans.e),
|
|
ids = custody(g);
|
|
for (const [claim, expected] of [
|
|
[{ kind: 'weapon', name: poison.name }, 'yes'],
|
|
[{ kind: 'dial', compare: 'eq', value: 1 }, 'no'],
|
|
] as const) {
|
|
g = question(g, claim);
|
|
assert.deepEqual(viewGame(g, 'e').truthBattleAnswers, [expected]);
|
|
unchanged(g, 'e', {
|
|
type: 'truthAnswer',
|
|
answer: expected === 'yes' ? 'no' : 'yes',
|
|
});
|
|
g = answer(g, expected);
|
|
assert.deepEqual(g.battle!.plans.e, sealed);
|
|
assert.equal(g.battle!.truthPromises?.length ?? 0, 0);
|
|
assert.deepEqual(viewGame(g, 'a').battle!.fullPlanInsight?.plan, sealed);
|
|
for (const id of ['g', 'b']) {
|
|
assert.equal(viewGame(g, id).battle!.fullPlanInsight, null);
|
|
assert.equal(viewGame(g, id).battle!.plans.e, undefined);
|
|
}
|
|
}
|
|
unchanged(g, 'e', { ...targetPlan, defense: null });
|
|
unchanged(g, 'e', { type: 'bribe', target: 'g', amount: 9 });
|
|
assert.deepEqual(custody(g), ids);
|
|
g = applyAction(g, 'a', ownPlan);
|
|
assert.equal(g.battle!.revealed, true);
|
|
assert.deepEqual(g.battle!.plans.e, sealed);
|
|
});
|
|
|
|
void test('Truthtrance suspends and restores every native pre-plan window without choosing or dropping another power', () => {
|
|
const initial = fixture();
|
|
initial.players[0].ally = 'b';
|
|
initial.players[3].ally = 'a';
|
|
take(initial, 'a', 'Truthtrance');
|
|
take(initial, 'g', 'Karama');
|
|
let g = responses(choose(initial));
|
|
const frames = [g]; // Voice selection.
|
|
g = applyAction(g, 'b', { type: 'voice', kind: 'snooper', must: false });
|
|
frames.push(g); // Voice cancellation response.
|
|
g = responses(g);
|
|
frames.push(g); // Prescience selection.
|
|
g = applyAction(g, 'a', { type: 'prescience', field: 'defense' });
|
|
frames.push(g); // Prescience cancellation response.
|
|
g = responses(g);
|
|
frames.push(g); // Prescience answer.
|
|
g = applyAction(g, 'e', { type: 'prescienceAnswer', value: shield.id });
|
|
frames.push(g); // Special full-plan offer.
|
|
frames.push(inspect(g)); // Special chosen; target has not sealed.
|
|
for (const frame of frames) {
|
|
const copy = structuredClone(frame);
|
|
const asked = question(frame, { kind: 'defense', name: 'Shield' });
|
|
assert.ok(viewGame(asked, 'e').truthBattleAnswers!.includes('yes'));
|
|
const resumed = answer(asked);
|
|
for (const key of [
|
|
'response',
|
|
'decision',
|
|
'active',
|
|
'phase',
|
|
'turn',
|
|
] as const)
|
|
assert.deepEqual(resumed[key], frame[key], key);
|
|
for (const key of [
|
|
'preparation',
|
|
'prescience',
|
|
'voice',
|
|
'fullPlan',
|
|
'plans',
|
|
] as const)
|
|
assert.deepEqual(resumed.battle![key], frame.battle![key], key);
|
|
assert.deepEqual(frame, copy);
|
|
assert.equal(resumed.battle!.truthPromises![0].released, undefined);
|
|
assert.equal(
|
|
resumed.players[0].specialKaramaUsed,
|
|
frame.players[0].specialKaramaUsed,
|
|
);
|
|
assert.deepEqual(custody(resumed), custody(frame));
|
|
}
|
|
});
|
|
|
|
void test('Atreides may revive its own leader after reading the target plan without changing or redisclosing that plan', () => {
|
|
const initial = fixture();
|
|
const ghola = take(initial, 'a', 'Tleilaxu Ghola');
|
|
initial.players[0].leaders[0].dead = true;
|
|
initial.players[0].leaders[0].deaths = 1;
|
|
let g = applyAction(inspect(offer(initial)), 'e', targetPlan);
|
|
const inspected = structuredClone(viewGame(g, 'a').battle!.fullPlanInsight);
|
|
const ids = custody(g);
|
|
g = applyAction(JSON.parse(JSON.stringify(g)), 'a', {
|
|
type: 'card',
|
|
card: ghola,
|
|
leader: 'atreides-0',
|
|
});
|
|
assert.equal(g.players[0].leaders[0].dead, false);
|
|
assert.equal(g.players[0].leaderRevived, false);
|
|
assert.deepEqual(viewGame(g, 'a').battle!.fullPlanInsight, inspected);
|
|
for (const id of ['e', 'g', 'b'])
|
|
assert.equal(viewGame(g, id).battle!.fullPlanInsight, null);
|
|
assert.equal(g.discard.filter((c) => c.id === ghola).length, 1);
|
|
assert.deepEqual(custody(g), ids);
|
|
g = applyAction(g, 'a', ownPlan);
|
|
assert.equal(g.battle!.plans.a.leader, 'atreides-0');
|
|
assert.equal(g.battle!.revealed, true);
|
|
});
|