Bind current Traitor declarations with Truthtrance

This commit is contained in:
Quintine committed 2026-10-25 19:23:14 +11:00
1 parent 724eabf99c
commit 86608f7ea9
20 files changed
+1362 -35

No files matched your search

+192
View File
@@ -0,0 +1,192 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
import {
actTruthMovement, truthMovementClean, truthMovementInventory, truthMovementPlayer,
type TruthMovementStep, type TruthMovementTransition,
} from './fixture-truth-movement';
export type TruthTraitorDecisionStep = TruthMovementStep;
export const actTruthTraitorDecision = actTruthMovement;
export const truthTraitorDecisionClean = truthMovementClean;
export const truthTraitorDecisionInventory = truthMovementInventory;
export const truthTraitorDecisionPlayer = truthMovementPlayer;
export const truthTraitorDecisionTraitors = (game: Game) => {
const cards = [...(game.traitorReserve ?? []), ...game.players.flatMap(player =>
[...player.traitors, ...player.traitorChoices])];
assert.equal(new Set(cards).size, cards.length, 'Each original Traitor has one current location');
return cards.sort();
};
/** Never declare or answer Truthtrance, seal a private plan, or submit a
* revealed-battle Traitor vote on behalf of the caller. */
export function nextTruthTraitorDecision(game: Game): TruthTraitorDecisionStep | null {
if (game.truthtrance) return null;
if (game.battle?.revealed && truthTraitorDecisionClean(game) && viewGame(game, game.host).battle!.traitorVoters
.some(actor => game.battle!.traitorCalls[actor] === undefined)) return null;
return nextStrongholdFactionsNativeStep(game);
}
export const nextTruthTraitorDecisionNativeStep = nextTruthTraitorDecision;
export function advanceTruthTraitorDecision(game: Game, until: (state: Game) => boolean,
actions: TruthTraitorDecisionStep[] = []): Game {
for (let count = 0; count < 800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original battle ended before its requested control');
const next = nextTruthTraitorDecision(game);
assert.ok(next, `Choose the original Truthtrance, private plan or Traitor vote: ${JSON.stringify({
turn: game.turn, phase: game.phase, decision: game.decision?.kind,
truth: game.truthtrance?.stage, revealed: game.battle?.revealed,
})}`);
game = actTruthTraitorDecision(game, next.actor, next.action, actions);
}
throw Error('The genuine Basic Traitor-decision source did not reach its finite boundary');
}
/** Genuine public Basic two-seat source. Only original unopened Treachery and
* Spice positions and original scalar lotteries are selected. Emperor keeps
* an actually offered opposing Traitor; that living native disc is really
* sealed in Atreides' unarmed plan. Stop before every original Traitor vote. */
export function createTruthTraitorDecisionFixture() {
const atreides = 'atreides', emperor = 'emperor', asker = atreides, target = emperor;
let game = createGame('TRUTHTRAITORDECISION', newPlayer(atreides, 'Atreides', 'atreides'), false);
joinGame(game, newPlayer(emperor, 'Emperor', 'emperor'));
const initial = structuredClone(game), actions: TruthTraitorDecisionStep[] = [];
const act = (actor: string, action: Action) => {
game = actTruthTraitorDecision(game, actor, action, actions);
};
const reach = (until: (state: Game) => boolean) => {
game = advanceTruthTraitorDecision(game, until, actions);
};
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game);
act(game.host, { type: 'start' });
const setup = structuredClone(game);
assert.equal(game.status, 'setup'); assert.equal(game.advanced, false);
assert.deepEqual(game.expansions, []); assert.equal(game.deck.length, 33);
assert.ok(game.players.every(player => player.hand.length === 0));
const originalCards = truthTraitorDecisionInventory(game);
const originalTraitors = truthTraitorDecisionTraitors(game);
assert.equal(originalTraitors.length, 10);
const truthtrance = game.deck.find(card => card.effect === 'truthtrance');
const ordinary = game.deck.find(card => card.kind === 'projectile');
assert.ok(truthtrance && ordinary);
const deal = [truthtrance.id, ordinary.id];
const dealOrder = deal.map((id, position) => {
const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
return { position, from, card: id };
});
const beforeStartingDeal = structuredClone(game);
const traitorOffers = Object.fromEntries(game.players.map(player => [player.id, [...player.traitorChoices]]));
const traitorHeld = truthTraitorDecisionPlayer(game, target).traitorChoices
.find(id => truthTraitorDecisionPlayer(game, atreides).leaders.some(leader => leader.id === id));
assert.ok(traitorHeld, 'The native Emperor packet offers an opposing Atreides Traitor');
const atreidesTraitor = truthTraitorDecisionPlayer(game, atreides).traitorChoices[0];
assert.ok(atreidesTraitor);
const traitorSteps: TruthTraitorDecisionStep[] = [
{ actor: atreides, action: { type: 'traitor', leader: atreidesTraitor } },
{ actor: emperor, action: { type: 'traitor', leader: traitorHeld } },
];
for (const step of traitorSteps) act(step.actor, step.action);
reach(state => state.status === 'playing');
const afterSetup = structuredClone(game);
assert.deepEqual(truthTraitorDecisionPlayer(game, asker).hand.map(card => card.id), [truthtrance.id]);
assert.deepEqual(truthTraitorDecisionPlayer(game, target).hand.map(card => card.id), [ordinary.id]);
assert.deepEqual(truthTraitorDecisionPlayer(game, atreides).forces, { 'arrakeen:10': 10 });
assert.deepEqual(truthTraitorDecisionPlayer(game, emperor).forces, {});
assert.equal(truthTraitorDecisionPlayer(game, emperor).reserves, 20);
assert.deepEqual(truthTraitorDecisionPlayer(game, target).traitors, [traitorHeld]);
const startingPositions = Object.fromEntries(game.players.map(player => [player.id, {
forces: structuredClone(player.forces), reserves: player.reserves, spice: player.spice,
}]));
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const spiceOrder = [0, 1].map(position => {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) };
});
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
reach(state => state.turn === 1 && state.phase === 5 && state.active === atreides && truthTraitorDecisionClean(state));
const beforeAtreidesEnd = structuredClone(game);
act(atreides, { type: 'endMovement' });
reach(state => state.turn === 1 && state.phase === 5 && state.active === emperor && truthTraitorDecisionClean(state));
const beforeShipment = structuredClone(game);
const shipmentStep: TruthTraitorDecisionStep = { actor: emperor, action: {
type: 'ship', amount: 1, territory: 'arrakeen', sector: 10, elite: 0, allyPayment: 0,
} };
act(shipmentStep.actor, shipmentStep.action);
reach(truthTraitorDecisionClean);
const shipment: TruthMovementTransition = { before: beforeShipment, step: shipmentStep, after: structuredClone(game) };
assert.equal(truthTraitorDecisionPlayer(game, emperor).forces['arrakeen:10'], 1);
assert.equal(truthTraitorDecisionPlayer(game, emperor).reserves, 19);
assert.equal(truthTraitorDecisionPlayer(beforeShipment, emperor).spice - truthTraitorDecisionPlayer(game, emperor).spice, 1);
act(emperor, { type: 'endMovement' });
reach(state => state.phase === 6 && truthTraitorDecisionClean(state) && !!state.active);
const beforeBattle = structuredClone(game);
const choice = viewGame(game, game.active!).battleChoices.find(candidate => candidate.territory === 'arrakeen');
assert.ok(choice);
const battleStep: TruthTraitorDecisionStep = { actor: choice.chooser, action: {
type: 'chooseBattle', territory: 'arrakeen',
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker,
} };
act(battleStep.actor, battleStep.action);
const beforePreparation = structuredClone(game);
reach(state => !!state.battle && truthTraitorDecisionClean(state) && !state.battle.preparation &&
state.battle.preLeader?.closed !== false);
const beforePlans = structuredClone(game);
assert.ok(game.battle && !game.battle.revealed && !Object.keys(game.battle.plans).length);
const opposingLeader = truthTraitorDecisionPlayer(game, emperor).leaders
.filter(leader => !leader.dead && !leader.usedAt)
.sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id))[0];
assert.ok(opposingLeader);
const atreidesPlan: Action = { type: 'battlePlan', leader: traitorHeld, dial: 0, support: 0, weapon: null, defense: null };
const emperorPlan: Action = { type: 'battlePlan', leader: opposingLeader.id, dial: 0, support: 0, weapon: null, defense: null };
const planSteps: TruthTraitorDecisionStep[] = [
{ actor: atreides, action: atreidesPlan }, { actor: emperor, action: emperorPlan },
];
act(planSteps[0].actor, planSteps[0].action);
const beforeReveal = structuredClone(game);
act(planSteps[1].actor, planSteps[1].action);
assert.ok(game.battle?.revealed && truthTraitorDecisionClean(game));
assert.deepEqual(game.battle.traitorCalls, {});
const traitorVoters = [...viewGame(game, game.host).battle!.traitorVoters];
const voter = traitorVoters.find(actor => game.battle!.traitorCalls[actor] === undefined);
assert.ok(voter && traitorVoters.includes(target));
assert.ok(truthTraitorDecisionPlayer(game, asker).hand.some(card => card.id === truthtrance.id));
assert.deepEqual(truthTraitorDecisionInventory(game), originalCards);
assert.deepEqual(truthTraitorDecisionTraitors(game), originalTraitors);
const source = structuredClone(game);
const truthStep: TruthTraitorDecisionStep = { actor: asker, action: { type: 'card', card: truthtrance.id } };
const callStep: TruthTraitorDecisionStep = { actor: target, action: { type: 'traitorCall', call: true } };
const declineStep: TruthTraitorDecisionStep = { actor: target, action: { type: 'traitorCall', call: false } };
return { game, source, initial, lobby, setup, beforeStartingDeal, afterSetup, beforeAtreidesEnd,
beforeShipment, shipment, shipmentStep, beforeBattle, battleStep, beforePreparation, beforePlans, beforeReveal,
asker, target, atreides, emperor, owner: asker, respondent: target, voter, currentVoter: voter, traitorVoters,
truth: truthtrance.id, truthtrance: truthtrance.id, card: truthtrance.id, ordinary: ordinary.id,
traitorHeld, traitorCard: traitorHeld, traitorOffers, traitorSteps,
leaders: { [atreides]: traitorHeld, [emperor]: opposingLeader.id },
atreidesPlan, emperorPlan, ownPlan: atreidesPlan, targetPlan: emperorPlan,
plans: { [atreides]: atreidesPlan, [emperor]: emperorPlan }, planSteps,
territory: 'arrakeen', sector: 10, location: 'arrakeen:10', event: game.battle.event,
truthStep, callStep, declineStep, deal, dealOrder, spiceOrder, originalSpice, startingPositions,
originalCards, originalInventory: originalCards, originalTraitors,
actions, steps: actions, sourceActionCount: actions.length,
staging: [
'Actual public Basic Atreides/Emperor ready/start with the original base33, native ten-leader Traitor deck and no optional modules. Only native scalar lotteries, two unopened starting Treachery positions and two unopened ordinary Spice positions are selected. UUID and non-scalar entropy remain native.',
'Atreides is actually dealt Truthtrance; Emperor receives an ordinary projectile. Emperor actually chooses its offered opposing Atreides Traitor during original setup. No Traitor exchange or played hand, disc, phase, force, wallet or receipt is installed.',
'Complete actual safe Storm, Spice Blow, Charity, Bidding and Revival. Atreides ends its unused Movement; Emperor pays one spice to ship one original reserve into native Atreides ten at Arrakeen10 and really ends Movement. Choose the resulting native battle, decline original preparation and seal both actual zero-dial unarmed plans with living original leaders.',
'The Atreides plan uses Emperor’s actually held opposing Traitor. Stop immediately after original plan reveal, with Truthtrance held and no Traitor votes submitted. Both possible declaration choices remain caller-owned; this source makes no promise about the eventual winner.',
],
};
}
export type TruthTraitorDecisionFixture = ReturnType<typeof createTruthTraitorDecisionFixture>;
export function askTruthTraitorDecision(game: Game, asker: string, card: string, target: string) {
const battle = game.battle; assert.ok(battle?.event);
game = actTruthTraitorDecision(game, asker, { type: 'card', card });
for (const player of game.players) if (!game.truthtrance!.passed.includes(player.id))
game = actTruthTraitorDecision(game, player.id, { type: 'truthPass' });
return actTruthTraitorDecision(game, asker, { type: 'truthAsk', question: {
kind: 'traitorCall', target, battle: battle.event, territory: battle.territory,
} });
}
+260
View File
@@ -0,0 +1,260 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { NEXUS_FACTIONS } from '../game/nexus-cards';
import { nexusTraitorDrawAction, nexusTraitorReturnAction } from '../game/nexus-traitor-options';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
import {
actTruthTraitorDecision, truthTraitorDecisionClean, truthTraitorDecisionInventory,
truthTraitorDecisionPlayer, truthTraitorDecisionTraitors, type TruthTraitorDecisionStep,
} from './fixture-truth-traitor-decision';
export type TruthTraitorHarkonnenStep = TruthTraitorDecisionStep;
export type TruthTraitorHarkonnenTransition = { before: Game; steps: TruthTraitorHarkonnenStep[]; after: Game };
export const actTruthTraitorHarkonnen = actTruthTraitorDecision;
export const truthTraitorHarkonnenClean = truthTraitorDecisionClean;
export const truthTraitorHarkonnenInventory = truthTraitorDecisionInventory;
export const truthTraitorHarkonnenPlayer = truthTraitorDecisionPlayer;
export const truthTraitorHarkonnenTraitors = truthTraitorDecisionTraitors;
export function truthTraitorHarkonnenNexusInventory(game: Game) {
const cards = game.nexusCards?.cards;
if (!cards) return [];
const ids = [...cards.deck, ...cards.discard, ...Object.values(cards.hands).filter(card => card !== null)].sort();
assert.equal(new Set(ids).size, ids.length, 'Each original Nexus singleton retains one current slot');
return ids;
}
/** Preserve Truthtrance, every revealed Traitor vote and any native Cunning
* return. A closing Nexus choice is kept through its real public action. */
export function nextTruthTraitorHarkonnen(game: Game): TruthTraitorHarkonnenStep | null {
if (game.truthtrance || game.nexusTraitorPending) return null;
if (game.battle?.revealed && truthTraitorHarkonnenClean(game) && viewGame(game, game.host).battle!.traitorVoters
.some(actor => game.battle!.traitorCalls[actor] === undefined)) return null;
if (game.nexusCards?.phase?.stage === 'drawing') {
const phase = game.nexusCards.phase, actor = phase.eligible.find(id => !phase.done.includes(id));
assert.ok(actor);
return { actor, action: { type: 'nexusCardChoice', turn: phase.turn,
card: game.nexusCards.cards!.hands[actor], choice: 'keep', ownRedraws: 0 } };
}
return nextStrongholdFactionsNativeStep(game);
}
export const nextTruthTraitorHarkonnenNativeStep = nextTruthTraitorHarkonnen;
export function advanceTruthTraitorHarkonnen(game: Game, until: (state: Game) => boolean,
actions: TruthTraitorHarkonnenStep[] = []): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original Harkonnen chronology ended before its requested control');
const next = nextTruthTraitorHarkonnen(game);
assert.ok(next, `Choose the original Truthtrance, plan, Traitor vote or Nexus return at ${game.turn}/${game.phase}/${game.decision?.kind}`);
game = actTruthTraitorHarkonnen(game, next.actor, next.action, actions);
}
throw Error('The genuine Harkonnen voter source did not reach its finite boundary');
}
/** Public Basic A/H/E seats earn the original Harkonnen opposing Traitor,
* Atreides Truthtrance and a one-spice Karama purchase. A real turn-two worm
* alliance supplies either Harkonnen ally support or an unallied Cunning draw.
* No played cards, alliances, armies, phases or pending controls are installed. */
export function createTruthTraitorHarkonnenFixture(options: { kind?: 'ally' | 'cunning' } = {}) {
const kind = options.kind ?? 'ally', atreides = 'atreides', harkonnen = 'harkonnen', emperor = 'emperor';
const asker = atreides, target = harkonnen, beneficiary = kind === 'ally' ? emperor : harkonnen;
let game = createGame('TRUTHTRAITORHARKONNEN', newPlayer(atreides, 'Atreides', 'atreides'), false);
joinGame(game, newPlayer(harkonnen, 'Harkonnen', 'harkonnen'));
joinGame(game, newPlayer(emperor, 'Emperor', 'emperor'));
const initial = structuredClone(game), actions: TruthTraitorHarkonnenStep[] = [];
const act = (actor: string, action: Action) => { game = actTruthTraitorHarkonnen(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceTruthTraitorHarkonnen(game, until, actions); };
const transition = (run: () => void): TruthTraitorHarkonnenTransition => {
const before = structuredClone(game), start = actions.length; run();
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
};
const enableNexus = kind === 'cunning' ? transition(() => {
act(game.host, { type: 'moduleOption', module: 'nexusCards', enabled: true });
}) : null;
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game); act(game.host, { type: 'start' });
const setup = structuredClone(game);
assert.equal(game.status, 'setup'); assert.equal(game.advanced, false);
assert.deepEqual(game.expansions, []); assert.equal(game.deck.length, 33);
assert.equal(!!game.nexusCards, kind === 'cunning');
assert.ok(!game.homeworlds && !game.leaderSkills && !game.techTokens && !game.strongholdCards &&
!game.discoveryEnabled && !game.ecazTreachery);
assert.ok(game.players.every(player => !player.hand.length));
const originalCards = truthTraitorHarkonnenInventory(game);
const originalTraitors = truthTraitorHarkonnenTraitors(game); assert.equal(originalTraitors.length, 15);
const originalNexusInventory = truthTraitorHarkonnenNexusInventory(game);
assert.deepEqual(originalNexusInventory, kind === 'cunning' ? [...NEXUS_FACTIONS].sort() : []);
const truthtrance = game.deck.find(card => card.effect === 'truthtrance');
const karama = game.deck.find(card => card.effect === 'karama');
const ordinary = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind)).slice(0, 3);
assert.ok(truthtrance && karama); assert.equal(ordinary.length, 3);
const selected = [truthtrance, ...ordinary, karama];
const dealOrder = selected.map((card, position) => {
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
return { position, from, card: card.id };
});
const beforeStartingDeal = structuredClone(game);
const traitorOffers = Object.fromEntries(game.players.map(player => [player.id, [...player.traitorChoices]]));
const heldOriginal = [...truthTraitorHarkonnenPlayer(game, harkonnen).traitors];
assert.equal(heldOriginal.length, 4);
const traitorHeld = heldOriginal.find(id => truthTraitorHarkonnenPlayer(game, atreides).leaders.some(leader => leader.id === id));
assert.ok(traitorHeld, 'The native four-card Harkonnen packet holds an opposing Atreides identity');
const traitorSteps: TruthTraitorHarkonnenStep[] = [atreides, emperor].map(actor => {
const leader = truthTraitorHarkonnenPlayer(game, actor).traitorChoices[0]; assert.ok(leader);
return { actor, action: { type: 'traitor', leader } };
});
for (const step of traitorSteps) act(step.actor, step.action);
reach(state => state.status === 'playing');
const afterSetup = structuredClone(game);
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), selected.slice(0, 4).map(card => card.id));
assert.deepEqual(truthTraitorHarkonnenPlayer(game, atreides).forces, { 'arrakeen:10': 10 });
assert.deepEqual(truthTraitorHarkonnenPlayer(game, harkonnen).forces, { 'carthag:11': 10 });
assert.equal(truthTraitorHarkonnenPlayer(game, emperor).reserves, 20);
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const spiceOrder = [0, 1].map(position => {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) };
});
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
reach(state => state.turn === 1 && state.phase === 3 && truthTraitorHarkonnenClean(state) &&
state.auction?.active === atreides && state.auction.cards[state.auction.index]?.id === karama.id);
const beforeKaramaPurchase = structuredClone(game);
const karamaPurchase = transition(() => {
act(atreides, { type: 'bid', amount: 1, allyPayment: 0 });
reach(state => truthTraitorHarkonnenClean(state) && truthTraitorHarkonnenPlayer(state, atreides).hand.some(card => card.id === karama.id));
});
assert.equal(truthTraitorHarkonnenPlayer(beforeKaramaPurchase, atreides).spice - truthTraitorHarkonnenPlayer(game, atreides).spice, 1);
reach(state => state.turn === 2 && state.phase === 1 && truthTraitorHarkonnenClean(state) &&
!state.spiceSequence && !state.spiceResolution && !state.spiceWindow && !state.nexus && state.ready.length === 0);
const beforeWorm = structuredClone(game), beforeWormInventory = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const wormIndex = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper);
assert.ok(wormIndex >= 0); const worm = game.spiceDeck.splice(wormIndex, 1)[0]; game.spiceDeck.unshift(worm);
const wormOrder = { position: 0, from: wormIndex, card: structuredClone(worm) };
const wormFollowupOrder = [1, 2].map(position => {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) };
});
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), beforeWormInventory);
reach(state => state.nexus && !state.spiceWindow && !state.spiceResolution && truthTraitorHarkonnenClean(state));
const beforeAlliance = structuredClone(game);
const partners = kind === 'ally' ? [emperor, harkonnen] : [atreides, emperor];
const alliance = transition(() => {
act(partners[0], { type: 'alliance', target: partners[1] });
act(partners[1], { type: 'alliance', target: partners[0] });
});
assert.equal(truthTraitorHarkonnenPlayer(game, partners[0]).ally, partners[1]);
assert.equal(truthTraitorHarkonnenPlayer(game, partners[1]).ally, partners[0]);
let beforeNexusDraw: Game | null = null, nexusDraw: TruthTraitorHarkonnenTransition | null = null;
let nexusOrder: { card: 'harkonnen'; position: number; from: number } | null = null;
if (kind === 'cunning') {
assert.equal(truthTraitorHarkonnenPlayer(game, harkonnen).ally, null);
reach(state => state.nexusCards?.phase?.stage === 'drawing' && truthTraitorHarkonnenClean(state));
const cards = game.nexusCards!.cards!, from = cards.deck.indexOf('harkonnen'); assert.ok(from >= 0);
assert.equal(cards.hands[harkonnen], null);
cards.deck.unshift(cards.deck.splice(from, 1)[0]);
nexusOrder = { card: 'harkonnen', position: 0, from };
beforeNexusDraw = structuredClone(game);
nexusDraw = transition(() => { act(harkonnen, { type: 'nexusCardChoice', turn: game.turn,
card: null, choice: 'draw', ownRedraws: 0 }); });
assert.equal(game.nexusCards!.cards!.hands[harkonnen], 'harkonnen');
}
reach(state => state.turn === 2 && state.phase === 5 && state.active === beneficiary && truthTraitorHarkonnenClean(state));
const beforeShipment = structuredClone(game);
const shipmentStep: TruthTraitorHarkonnenStep = { actor: beneficiary, action: {
type: 'ship', amount: 1, territory: 'arrakeen', sector: 10, elite: 0, allyPayment: 0,
} };
const shipment = transition(() => { act(shipmentStep.actor, shipmentStep.action); reach(truthTraitorHarkonnenClean); });
assert.equal(truthTraitorHarkonnenPlayer(beforeShipment, beneficiary).spice - truthTraitorHarkonnenPlayer(game, beneficiary).spice, 1);
assert.equal(truthTraitorHarkonnenPlayer(game, beneficiary).forces['arrakeen:10'], 1);
assert.equal(truthTraitorHarkonnenPlayer(game, atreides).forces['arrakeen:10'], 10);
act(beneficiary, { type: 'endMovement' });
reach(state => state.turn === 2 && state.phase === 6 && truthTraitorHarkonnenClean(state) && !!state.active);
const beforeBattle = structuredClone(game);
const choice = viewGame(game, game.active!).battleChoices.find(candidate => candidate.territory === 'arrakeen'); assert.ok(choice);
const battleStep: TruthTraitorHarkonnenStep = { actor: choice.chooser, action: {
type: 'chooseBattle', territory: 'arrakeen',
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker,
} };
act(battleStep.actor, battleStep.action);
const beforePreparation = structuredClone(game);
reach(state => !!state.battle && truthTraitorHarkonnenClean(state) && !state.battle.preparation && state.battle.preLeader?.closed !== false);
const beforePlans = structuredClone(game);
assert.ok(game.battle && !game.battle.revealed && !Object.keys(game.battle.plans).length);
const beneficiaryLeader = truthTraitorHarkonnenPlayer(game, beneficiary).leaders
.filter(leader => !leader.dead && !leader.usedAt).sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id))[0];
assert.ok(beneficiaryLeader);
const atreidesPlan: Action = { type: 'battlePlan', leader: traitorHeld, dial: 0, support: 0, weapon: null, defense: null };
const beneficiaryPlan: Action = { type: 'battlePlan', leader: beneficiaryLeader.id, dial: 0, support: 0, weapon: null, defense: null };
const planSteps: TruthTraitorHarkonnenStep[] = [
{ actor: atreides, action: atreidesPlan }, { actor: beneficiary, action: beneficiaryPlan },
];
act(planSteps[0].actor, planSteps[0].action);
const beforeReveal = structuredClone(game); act(planSteps[1].actor, planSteps[1].action);
assert.ok(game.battle?.revealed && truthTraitorHarkonnenClean(game));
assert.deepEqual(game.battle.traitorCalls, {});
const traitorVoters = [...viewGame(game, game.host).battle!.traitorVoters];
assert.ok(traitorVoters.includes(harkonnen));
assert.equal([game.battle.attacker, game.battle.defender].includes(harkonnen), kind === 'cunning');
const beforeCunning = structuredClone(game), beforeCunningActionCount = actions.length;
let drawStep: TruthTraitorHarkonnenStep | null = null, cunningDraw: TruthTraitorHarkonnenTransition | null = null;
let returnHeld: Action | null = null, returnDrawn: Action | null = null, pendingEvent: string | null = null;
let drawn: string[] = [];
if (kind === 'cunning') {
const view = viewGame(game, harkonnen), offer = view.nexusTraitors?.offer; assert.ok(offer && !offer.blocked);
const action = nexusTraitorDrawAction(view, offer.event, 'cunning'); assert.ok(action);
drawStep = { actor: harkonnen, action };
cunningDraw = transition(() => { act(harkonnen, action); });
const pending = viewGame(game, harkonnen).nexusTraitors?.pending;
assert.ok(pending && pending.owner === harkonnen && pending.count === 1);
pendingEvent = pending.event;
drawn = pending.choices.filter(choice => choice.drawn).map(choice => choice.id); assert.equal(drawn.length, 1);
returnHeld = nexusTraitorReturnAction(viewGame(game, harkonnen), pending.event, [traitorHeld]);
returnDrawn = nexusTraitorReturnAction(viewGame(game, harkonnen), pending.event, drawn);
assert.ok(returnHeld && returnDrawn);
assert.equal(game.nexusCards!.cards!.hands[harkonnen], null);
assert.ok(game.nexusCards!.cards!.discard.includes('harkonnen'));
}
for (const card of [truthtrance, karama])
assert.ok(truthTraitorHarkonnenPlayer(game, atreides).hand.some(held => held.id === card.id));
assert.ok(truthTraitorHarkonnenPlayer(game, harkonnen).traitors.includes(traitorHeld));
assert.deepEqual(truthTraitorHarkonnenInventory(game), originalCards);
assert.deepEqual(truthTraitorHarkonnenTraitors(game), originalTraitors);
assert.deepEqual(truthTraitorHarkonnenNexusInventory(game), originalNexusInventory);
const source = structuredClone(game);
const truthStep: TruthTraitorHarkonnenStep = { actor: asker, action: { type: 'card', card: truthtrance.id } };
const callStep: TruthTraitorHarkonnenStep = { actor: harkonnen, action: { type: 'traitorCall', call: true } };
const declineStep: TruthTraitorHarkonnenStep = { actor: harkonnen, action: { type: 'traitorCall', call: false } };
return { game, source, initial, enableNexus, lobby, setup, beforeStartingDeal, afterSetup,
beforeKaramaPurchase, karamaPurchase, beforeWorm, wormOrder, wormFollowupOrder, beforeAlliance, alliance,
beforeNexusDraw, nexusDraw, nexusOrder, beforeShipment, shipmentStep, shipment,
beforeBattle, battleStep, beforePreparation, beforePlans, beforeReveal,
beforeCunning, beforeDraw: beforeCunning, beforeCunningActionCount, drawStep, cunningDraw,
pendingEvent, drawn, returnHeld, returnDrawn,
kind, asker, target, respondent: target, owner: asker, atreides, emperor, harkonnen,
beneficiary, opponent: atreides, voter: harkonnen, currentVoter: harkonnen,
firstVoter: traitorVoters[0], traitorVoters, traitorHeld, traitorCard: traitorHeld, heldOriginal, traitorOffers, traitorSteps,
truth: truthtrance.id, truthtrance: truthtrance.id, card: truthtrance.id, karama: karama.id,
leaders: { [atreides]: traitorHeld, [beneficiary]: beneficiaryLeader.id },
atreidesPlan, beneficiaryPlan, ownPlan: atreidesPlan, targetPlan: beneficiaryPlan, planSteps,
plans: { [atreides]: atreidesPlan, [beneficiary]: beneficiaryPlan },
territory: 'arrakeen', sector: 10, location: 'arrakeen:10', event: game.battle!.event,
truthStep, callStep, declineStep, originalCards, originalInventory: originalCards, originalTraitors,
originalNexusInventory, originalSpice, dealOrder, spiceOrder, actions, steps: actions, sourceActionCount: actions.length,
staging: [
'Actual public Basic Atreides/Harkonnen/Emperor seat order uses original base33 and fifteen native Traitors. Harkonnen keeps its original four-card packet, which genuinely contains the living Atreides leader later sealed by Atreides. No held Traitor exchange is required.',
'Only native scalar lotteries, five unopened Treachery positions and unopened ordinary Spice/worm positions are selected. Atreides receives its original starting Truthtrance; Harkonnen receives two original ordinary cards and Emperor one. Atreides wins the first original Karama auction lot for one own spice and retains both special cards.',
'The peaceful first turn completes through actual Storm, Spice, Charity, normal auction, Revival, Movement, Collection and Mentat. A real original turn-two Shai-Hulud opens Nexus. The returned alliance contains both original proposal and acceptance actions.',
kind === 'ally'
? 'No optional module is enabled. Emperor and Harkonnen actually become reciprocal allies at Nexus. Emperor pays one to ship one original reserve into Atreides’ native ten Arrakeen counters and ends Movement. Harkonnen remains outside this battle but is its original allied-support Traitor voter.'
: 'Only Nexus Cards is enabled by the actual lobby moduleOption. Atreides and Emperor actually ally, leaving Harkonnen unallied. Move only the unopened Harkonnen Nexus singleton before its real closing draw; retain the original all12 inventory. Harkonnen pays one to ship one original reserve into Atreides’ native ten Arrakeen counters and ends Movement.',
'Choose the actual Arrakeen battle, decline original preparation and seal living native leaders with zero dial, zero support and no weapon or defense. Atreides uses the exact opposing identity originally held by Harkonnen. No revealed-battle Traitor vote has been submitted; the pre-Cunning snapshot is directly usable.',
kind === 'cunning'
? 'The final source actually declares Harkonnen Nexus Cunning after reveal, physically draws one native reserve Traitor, discards its original Nexus singleton and stops at the original pending return. Returned payloads can return the original matching identity or the new draw. Truthtrance, the return and all Traitor votes remain caller-owned.'
: 'The final source stops before Harkonnen’s allied declaration and its original Karama response. The returned Atreides Karama is genuinely bought and held; no response is preinstalled.',
'No played phase, wallet, army, leader, hand, alliance, sealed plan, draw history or pending receipt is assigned. Counts are derived from the returned native action log; source construction is not a final assurance campaign.',
],
};
}
export type TruthTraitorHarkonnenFixture = ReturnType<typeof createTruthTraitorHarkonnenFixture>;
+200
View File
@@ -0,0 +1,200 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
TraitorDecisionError,
bindTraitorDecisionPromise,
reconcileTraitorDecisionPromises,
settleTraitorDecisionPromises,
traitorDecisionChoices,
validateTraitorDecisionPromise,
type TraitorDecisionContext,
type TraitorDecisionPromise,
} from '../game/traitor-decision-promises';
function context(overrides: Partial<TraitorDecisionContext> = {}): TraitorDecisionContext {
return {
turn: 4, battle: 'original-public-battle', player: 'harkonnen',
beneficiary: 'atreides', target: 'emperor', leader: 'aramsham',
canReveal: true, ...overrides,
};
}
function promise(overrides: Partial<TraitorDecisionPromise> = {}): TraitorDecisionPromise {
return {
turn: 4, battle: 'original-public-battle', player: 'harkonnen',
beneficiary: 'atreides', target: 'emperor', leader: 'aramsham',
asker: 'beneGesserit', answer: true, ...overrides,
};
}
void test('native eligibility supplies ordered declaration choices and a submitted decision has none', () => {
assert.deepEqual(traitorDecisionChoices(context(), []), [false, true]);
assert.deepEqual(traitorDecisionChoices(context({ canReveal: false }), []), [false]);
assert.deepEqual(traitorDecisionChoices(context({ leader: null, canReveal: false }), []), [false]);
for (const canReveal of [false, true]) {
assert.deepEqual(traitorDecisionChoices(context({ canReveal, submitted: true }), []), []);
}
assert.deepEqual(traitorDecisionChoices(context({ submitted: false }), []), []);
});
void test('compatible definite answers bind independently while contradiction and impossible Yes reject without mutation', () => {
const ctx = context();
const first = bindTraitorDecisionPromise(ctx, [], 'beneGesserit', true);
const before = structuredClone({ ctx, first });
const second = bindTraitorDecisionPromise(ctx, first, 'fremen', true);
assert.deepEqual(second, [promise(), promise({ asker: 'fremen' })]);
assert.deepEqual(traitorDecisionChoices(ctx, second), [true]);
assert.deepEqual({ ctx, first }, before);
assert.notEqual(second, first);
assert.notEqual(second[0], first[0]);
assert.throws(() => bindTraitorDecisionPromise(ctx, first, 'fremen', false), TraitorDecisionError);
assert.deepEqual({ ctx, first }, before);
const no = bindTraitorDecisionPromise(context({ canReveal: false }), [], 'beneGesserit', false);
assert.deepEqual(traitorDecisionChoices(context(), no), [false]);
assert.throws(() => bindTraitorDecisionPromise(context({ canReveal: false }), [], 'fremen', true), TraitorDecisionError);
assert.throws(() => bindTraitorDecisionPromise(context({ submitted: true }), [], 'fremen', false), TraitorDecisionError);
});
void test('only the complete original public source constrains a current declaration', () => {
const changes: Partial<TraitorDecisionPromise>[] = [
{ turn: 3 }, { battle: 'other-battle' }, { player: 'other-voter' },
{ beneficiary: 'other-beneficiary' }, { target: 'other-target' }, { leader: 'other-leader' },
{ leader: null }, { fulfilled: true }, { released: true },
];
for (const change of changes) {
const ledger = [promise(change)];
const before = structuredClone(ledger);
assert.deepEqual(traitorDecisionChoices(context(), ledger), [false, true]);
assert.deepEqual(reconcileTraitorDecisionPromises(context({ canReveal: false }), ledger), ledger);
assert.deepEqual(ledger, before);
}
assert.deepEqual(traitorDecisionChoices(context(), [promise(), promise({ asker: 'fremen', answer: false })]), []);
});
void test('settlement validates before the actual assignment and fulfills either matching declaration exactly once', () => {
for (const call of [false, true]) {
const ctx = context();
const ledger = [
promise({ answer: call }), promise({ answer: call, asker: 'fremen' }),
promise({ battle: 'older-battle', answer: !call }),
promise({ beneficiary: 'other-beneficiary', answer: !call }),
promise({ released: true, answer: !call }), promise({ fulfilled: true, answer: !call }),
];
const before = structuredClone({ ctx, ledger });
assert.throws(() => settleTraitorDecisionPromises(ctx, ledger, !call), TraitorDecisionError);
assert.deepEqual({ ctx, ledger }, before);
const settled = settleTraitorDecisionPromises(ctx, ledger, call);
assert.equal(settled[0].fulfilled, true);
assert.equal(settled[1].fulfilled, true);
assert.deepEqual(settled.slice(2), ledger.slice(2));
assert.deepEqual({ ctx, ledger }, before);
assert.equal(ctx.submitted, undefined, 'The caller still owns actual declaration assignment');
assert.throws(() => settleTraitorDecisionPromises({ ...ctx, submitted: true }, settled, call), TraitorDecisionError);
}
assert.throws(() => settleTraitorDecisionPromises(context({ canReveal: false }), [], true), TraitorDecisionError);
assert.deepEqual(settleTraitorDecisionPromises(context({ canReveal: false }), [promise({ answer: false })], false),
[promise({ answer: false, fulfilled: true })]);
});
void test('native impossibility releases only a current live Yes and keeps No, historical sources and fulfilled decisions', () => {
const ctx = context({ canReveal: false });
const ledger = [
promise(), promise({ answer: false, asker: 'fremen' }),
promise({ battle: 'another-original-source' }),
promise({ fulfilled: true }), promise({ released: true }),
];
const before = structuredClone({ ctx, ledger });
assert.deepEqual(traitorDecisionChoices(ctx, ledger), []);
const reconciled = reconcileTraitorDecisionPromises(ctx, ledger);
assert.deepEqual(reconciled, [promise({ released: true }), ...ledger.slice(1)]);
assert.deepEqual(traitorDecisionChoices(ctx, reconciled), [false]);
assert.deepEqual(reconcileTraitorDecisionPromises(ctx, reconciled), reconciled);
assert.deepEqual(reconcileTraitorDecisionPromises(context(), ledger), ledger);
assert.deepEqual({ ctx, ledger }, before);
});
void test('an accepted declaration remains fulfilled if its later Traitor effect is prevented', () => {
const accepted = bindTraitorDecisionPromise(context(), [], 'beneGesserit', true);
const declared = settleTraitorDecisionPromises(context(), accepted, true);
const before = structuredClone(declared);
const afterPrevention = reconcileTraitorDecisionPromises(
context({ canReveal: false, submitted: true }), declared,
);
assert.equal(afterPrevention[0].answer, true);
assert.equal(afterPrevention[0].fulfilled, true);
assert.equal(afterPrevention[0].released, undefined);
assert.deepEqual(afterPrevention, before);
assert.deepEqual(traitorDecisionChoices(context({ canReveal: false, submitted: true }), afterPrevention), []);
assert.deepEqual(accepted, [promise()]);
});
void test('promise validation accepts only the bounded public source and typed flags, including a null leader', () => {
assert.equal(validateTraitorDecisionPromise(promise()), true);
assert.equal(validateTraitorDecisionPromise(promise({ leader: null, answer: false })), true);
assert.equal(validateTraitorDecisionPromise(promise({ fulfilled: false, released: false })), true);
assert.equal(validateTraitorDecisionPromise(promise({ fulfilled: true })), true);
assert.equal(validateTraitorDecisionPromise(promise({ released: true })), true);
for (const invalid of [
null, [], 'promise', 4, {}, { ...promise(), turn: 0 }, { ...promise(), turn: 1.5 },
{ ...promise(), turn: Number.MAX_SAFE_INTEGER + 1 }, { ...promise(), battle: '' },
{ ...promise(), player: ' ' }, { ...promise(), beneficiary: null }, { ...promise(), target: 4 },
{ ...promise(), leader: '' }, { ...promise(), asker: '' }, { ...promise(), answer: 'yes' },
{ ...promise(), fulfilled: 1 }, { ...promise(), released: 'false' },
{ ...promise(), canReveal: true }, { ...promise(), submitted: false },
{ ...promise(), card: 'private-card' }, { ...promise(), hand: [] },
]) {
assert.equal(validateTraitorDecisionPromise(invalid), false);
}
for (const key of ['turn', 'battle', 'player', 'beneficiary', 'target', 'leader', 'asker', 'answer']) {
const missing = { ...promise() } as Record<string, unknown>;
delete missing[key];
assert.equal(validateTraitorDecisionPromise(missing), false, key);
}
});
void test('operational guards reject malformed contexts or ledger values with the named error and unchanged input', () => {
for (const malformed of [
null, [], { ...context(), turn: 0 }, { ...context(), battle: '' },
{ ...context(), canReveal: 'yes' }, { ...context(), submitted: 1 },
{ ...context(), leader: undefined },
]) {
const ctx = malformed as unknown as TraitorDecisionContext;
assert.throws(() => traitorDecisionChoices(ctx, []), (error: unknown) =>
error instanceof TraitorDecisionError && error.name === 'TraitorDecisionError');
}
const ledger = [{ ...promise(), privateCard: 'secret' }] as unknown as TraitorDecisionPromise[];
const before = structuredClone(ledger);
for (const operation of [
() => traitorDecisionChoices(context(), ledger),
() => bindTraitorDecisionPromise(context(), ledger, 'fremen', true),
() => settleTraitorDecisionPromises(context(), ledger, true),
() => reconcileTraitorDecisionPromises(context({ canReveal: false }), ledger),
]) assert.throws(operation, TraitorDecisionError);
assert.deepEqual(ledger, before);
assert.throws(() => bindTraitorDecisionPromise(context(), [], '', true), TraitorDecisionError);
assert.throws(() => bindTraitorDecisionPromise(context(), [], 'fremen', 'yes' as unknown as boolean), TraitorDecisionError);
assert.throws(() => settleTraitorDecisionPromises(context(), [], 1 as unknown as boolean), TraitorDecisionError);
});
void test('binding copies only the original public decision fields and never reads private native matching or power data', () => {
const ctx = context();
for (const field of ['card', 'hand', 'traitors', 'kh', 'leaders', 'faction', 'ally', 'nativeContext', 'response']) {
Object.defineProperty(ctx, field, {
enumerable: true,
get() { throw new Error(`Pure Traitor decision read private native ${field}`); },
});
}
const bound = bindTraitorDecisionPromise(ctx, [], 'beneGesserit', true);
assert.deepEqual(bound, [promise()]);
assert.deepEqual(Object.keys(bound[0]).sort(), [
'answer', 'asker', 'battle', 'beneficiary', 'leader', 'player', 'target', 'turn',
]);
assert.deepEqual(traitorDecisionChoices(ctx, bound), [true]);
assert.equal(settleTraitorDecisionPromises(ctx, bound, true)[0].fulfilled, true);
assert.equal(reconcileTraitorDecisionPromises(ctx, bound)[0].released, undefined);
const privateReceipt = promise();
Object.defineProperty(privateReceipt, 'card', {
enumerable: true, get() { throw new Error('Validation inspected private card payload'); },
});
assert.equal(validateTraitorDecisionPromise(privateReceipt), false);
});
@@ -0,0 +1,41 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement, type ReactElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { Truthtrance } from '../components/truthtrance';
import { TraitorDecisionAnswers, TraitorDecisionPromises } from '../components/traitor-decision-promises';
import { viewGame, type Action } from '../game/engine';
import { createTruthTraitorDecisionFixture as fixture, actTruthTraitorDecision as act,
askTruthTraitorDecision as ask } from './fixture-truth-traitor-decision';
function callbacks(node: unknown): (() => void)[] {
if (Array.isArray(node)) return node.flatMap(callbacks);
if (!node || typeof node !== 'object' || !('props' in node)) return [];
const props = (node as ReactElement<{ children?: unknown; onClick?: () => void }>).props;
return [...(props.onClick ? [props.onClick] : []), ...callbacks(props.children)];
}
void test('actual current-decision answer controls and persistent declaration notice explain later prevention', () => {
const f = fixture(); let g = ask(f.game, f.asker, f.truth, f.target);
const html = renderToStaticMarkup(createElement(Truthtrance,
{ game: viewGame(g, f.target), busy: false, act() {} }));
assert.match(html, /Answer Yes/); assert.match(html, /Answer No/);
assert.match(html, /later Karama or betrayal response/);
const actions: Action[] = [], props = { game: viewGame(g, f.target), busy: false, act: (action: Action) => { actions.push(action); } };
callbacks(TraitorDecisionAnswers(props))[1]();
assert.deepEqual(actions, [{ type: 'truthAnswer', answer: 'yes' }]);
callbacks(TraitorDecisionAnswers({ ...props, busy: true }))[1](); assert.equal(actions.length, 1);
g = act(g, f.target, actions[0]);
const notice = renderToStaticMarkup(createElement(TraitorDecisionPromises, { game: viewGame(g, f.target) }));
assert.match(notice, /requires Reveal traitor/);
assert.match(notice, /original declaration fulfills your answer/);
assert.equal(TraitorDecisionPromises({ game: viewGame(g, f.asker) }), null);
});
void test('only the current declared battle has the new question authoring choice', () => {
const f = fixture(); let g = act(f.game, f.asker, { type: 'card', card: f.truth });
for (const p of g.players) if (!g.truthtrance!.passed.includes(p.id)) g = act(g, p.id, { type: 'truthPass' });
const html = renderToStaticMarkup(createElement(Truthtrance, { game: viewGame(g, f.asker), busy: false, act() {} }));
assert.match(html, /Bind this battle’s Traitor declaration/);
assert.ok(viewGame(g, f.asker).truthTraitorTargets.includes(f.target));
});
@@ -0,0 +1,88 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { createTruthTraitorDecisionFixture as fixture, actTruthTraitorDecision as act,
truthTraitorDecisionPlayer as player, truthTraitorDecisionInventory as inventory,
askTruthTraitorDecision as ask } from './fixture-truth-traitor-decision';
function declare(game: Game, asker: string, card: string) {
game = act(game, asker, { type: 'card', card });
for (const p of game.players) if (!game.truthtrance!.passed.includes(p.id))
game = act(game, p.id, { type: 'truthPass' });
return game;
}
for (const answer of ['yes', 'no'] as const) void test(`native revealed-battle Truthtrance ${answer} binds the actual original Traitor declaration`, () => {
const f = fixture(); let g = ask(f.game, f.asker, f.truth, f.target);
assert.deepEqual(viewGame(g, f.target).truthTraitorAnswers, ['no', 'yes']);
assert.equal(viewGame(g, f.asker).truthTraitorAnswers, null);
g = act(g, f.target, { type: 'truthAnswer', answer });
const promises = g.battle!.traitorDecisionPromises; assert.equal(promises?.length, 1);
assert.deepEqual(viewGame(g, f.target).battle!.traitorCallChoices, [answer === 'yes']);
const before = structuredClone(g);
assert.throws(() => act(g, f.target, { type: 'traitorCall', call: answer !== 'yes' }));
assert.deepEqual(g, before);
g = act(g, f.target, { type: 'traitorCall', call: answer === 'yes' });
assert.equal(g.battle!.traitorDecisionPromises![0].fulfilled, true);
assert.equal(g.battle!.traitorCalls[f.target], answer === 'yes');
g = act(g, f.asker, { type: 'traitorCall', call: false });
assert.equal(g.battle, null); assert.equal(g.discard.filter(card => card.id === f.truth).length, 1);
assert.deepEqual(inventory(g), f.originalCards);
});
void test('an already submitted No cannot be reopened as a future Traitor decision', () => {
const f = fixture(); let g = act(f.game, f.target, { type: 'traitorCall', call: false });
assert.ok(!viewGame(g, f.asker).truthTraitorTargets.includes(f.target));
g = declare(g, f.asker, f.truth);
const before = structuredClone(g);
assert.throws(() => act(g, f.asker, { type: 'truthAsk', question: { kind: 'traitorCall', target: f.target,
battle: g.battle!.event, territory: g.battle!.territory } }), /unsubmitted/);
assert.deepEqual(g, before); assert.equal(g.battle!.traitorCalls[f.target], false);
assert.ok(player(g, f.asker).hand.some(card => card.id === f.truth));
});
void test('unrevealed plans cannot be queried as a current Traitor decision', () => {
const f = fixture(), g = declare(f.beforeReveal, f.asker, f.truth), before = structuredClone(g);
assert.throws(() => act(g, f.asker, { type: 'truthAsk', question: { kind: 'traitorCall', target: f.target,
battle: g.battle!.event, territory: g.battle!.territory } }), /revealed battle/);
assert.deepEqual(g, before);
});
void test('controlled original Traitor exchange offers only No when the revealed opposing identity is absent', () => {
const f = fixture(), source = structuredClone(f.game), target = player(source, f.target);
// Conserved original-card exchange isolates native inability; not a claimed
// actual exchange action or native source provenance for this changed hand.
const at = source.traitorReserve!.findIndex(id => id !== f.traitorHeld); assert.ok(at >= 0);
const held = target.traitors.indexOf(f.traitorHeld); assert.ok(held >= 0);
[target.traitors[held], source.traitorReserve![at]] = [source.traitorReserve![at], target.traitors[held]];
const g = ask(source, f.asker, f.truth, f.target);
assert.deepEqual(viewGame(g, f.target).truthTraitorAnswers, ['no']);
assert.throws(() => act(g, f.target, { type: 'truthAnswer', answer: 'yes' }));
});
void test('all four minimal policies honor both native Yes and No declaration promises', () => {
const f = fixture();
for (const answer of ['yes', 'no'] as const) for (const difficulty of DIFFICULTIES) {
let g = ask(f.game, f.asker, f.truth, f.target);
g = act(g, f.target, { type: 'truthAnswer', answer });
const view = viewGame(g, f.target); view.players.find(p => p.id === f.target)!.bot = difficulty;
const choice = botActions(view)[0]; assert.ok(choice);
assert.deepEqual(choice, { type: 'traitorCall', call: answer === 'yes' });
g = act(g, f.target, choice); assert.equal(g.battle!.traitorDecisionPromises![0].fulfilled, true);
}
});
void test('all four minimal policies answer the native current-decision question then declare legally', () => {
const f = fixture();
for (const difficulty of DIFFICULTIES) {
let g = ask(f.game, f.asker, f.truth, f.target);
const answerView = viewGame(g, f.target); answerView.players.find(p => p.id === f.target)!.bot = difficulty;
const answer = botActions(answerView)[0]; assert.equal(answer?.type, 'truthAnswer');
g = act(g, f.target, answer);
const callView = viewGame(g, f.target); callView.players.find(p => p.id === f.target)!.bot = difficulty;
g = act(g, f.target, botActions(callView)[0]);
assert.equal(g.battle!.traitorDecisionPromises![0].fulfilled, true);
}
});
@@ -0,0 +1,69 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame } from '../game/engine';
import { nexusTraitorBotActions, nexusTraitorReturnAction } from '../game/nexus-traitor-options';
import { createTruthTraitorHarkonnenFixture as fixture, actTruthTraitorHarkonnen as act,
advanceTruthTraitorHarkonnen as advance, truthTraitorHarkonnenClean as clean,
truthTraitorHarkonnenInventory as inventory } from './fixture-truth-traitor-harkonnen';
import { askTruthTraitorDecision as ask } from './fixture-truth-traitor-decision';
for (const canceled of [false, true]) void test(`native allied Harkonnen declaration fulfills Yes before its ${canceled ? 'prevented' : 'allowed'} effect`, () => {
const f = fixture(); let g = ask(f.game, f.asker, f.truth, f.target);
assert.ok(viewGame(g, f.target).truthTraitorAnswers!.includes('yes'));
g = act(g, f.target, { type: 'truthAnswer', answer: 'yes' });
const promise = g.battle!.traitorDecisionPromises![0];
assert.equal(promise.beneficiary, f.beneficiary); assert.equal(promise.player, f.target);
g = act(g, f.target, { type: 'traitorCall', call: true });
assert.equal(g.response?.kind, 'harkonnenTraitor');
assert.equal(g.battle!.traitorDecisionPromises![0].fulfilled, true);
if (canceled) g = act(g, f.asker, { type: 'card', card: f.karama, mode: 'cancel' });
else g = advance(g, clean);
assert.equal(g.battle!.traitorCalls[f.target], !canceled);
assert.equal(g.battle!.traitorDecisionPromises![0].fulfilled, true);
assert.ok(!g.battle!.traitorDecisionPromises![0].released);
assert.deepEqual(inventory(g), f.originalCards);
});
void test('native Cunning return retains a promised matching identity through Truthtrance and permits another original choice', () => {
const f = fixture({ kind: 'cunning' });
const returnHeld = f.returnHeld; assert.ok(returnHeld);
let g = ask(f.game, f.asker, f.truth, f.target);
g = act(g, f.target, { type: 'truthAnswer', answer: 'yes' });
const view = viewGame(g, f.target), pending = view.nexusTraitors!.pending!;
assert.ok(pending.choices.find(choice => choice.id === f.traitorHeld)?.blocked);
assert.equal(nexusTraitorReturnAction(view, pending.event, [f.traitorHeld]), null);
const before = structuredClone(g);
assert.throws(() => act(g, f.target, returnHeld)); assert.deepEqual(g, before);
const safe = nexusTraitorBotActions(view)[0]; assert.ok(safe); assert.notDeepEqual(safe, returnHeld);
g = act(g, f.target, safe);
assert.equal(g.nexusTraitorPending, null);
g = act(g, f.target, { type: 'traitorCall', call: true });
assert.equal(g.battle!.traitorDecisionPromises![0].fulfilled, true);
assert.deepEqual(inventory(g), f.originalCards);
});
void test('native Cunning may return the matching identity after No without evading that declaration choice', () => {
const f = fixture({ kind: 'cunning' }); let g = ask(f.game, f.asker, f.truth, f.target);
assert.ok(f.returnHeld);
g = act(g, f.target, { type: 'truthAnswer', answer: 'no' });
assert.ok(!viewGame(g, f.target).nexusTraitors!.pending!.choices.find(choice => choice.id === f.traitorHeld)?.blocked);
g = act(g, f.target, f.returnHeld);
g = act(g, f.target, { type: 'traitorCall', call: false });
assert.equal(g.battle!.traitorDecisionPromises![0].fulfilled, true);
});
void test('native fulfilled declaration survives a later Cunning return of its original matching identity', () => {
const f = fixture({ kind: 'cunning' });
let g = ask(f.beforeCunning, f.asker, f.truth, f.target);
g = act(g, f.target, { type: 'truthAnswer', answer: 'yes' });
g = act(g, f.target, { type: 'traitorCall', call: true });
assert.equal(g.battle!.traitorDecisionPromises![0].fulfilled, true);
const offer = viewGame(g, f.target).nexusTraitors!.offer!; assert.ok(!offer.blocked);
g = act(g, f.target, { type: 'nexusTraitorDraw', mode: 'cunning', event: offer.event });
const pending = viewGame(g, f.target).nexusTraitors!.pending!;
assert.ok(!pending.choices.find(choice => choice.id === f.traitorHeld)?.blocked);
const returned = nexusTraitorReturnAction(viewGame(g, f.target), pending.event, [f.traitorHeld]); assert.ok(returned);
g = act(g, f.target, returned);
assert.equal(g.battle!.traitorCalls[f.target], true);
assert.equal(g.battle!.traitorDecisionPromises![0].fulfilled, true);
});