Connect Tleilaxu income to committed Ambassador revivals

Positive free force/leader returns share the existing beneficiary/turn free-income ledger under the explicit provisional source composition. Actual Ghola remains separately rewarded. Decline earns nothing. Bind a distinct revival-income response to the completed physical return and original Ambassador; allow/cancel finishes once and cancellation preserves the return and spent entitlement.

Add persistent preselection and income guidance. Admit the bare Basic Ecaz+Ix native Tleilaxu composition through existing coalition/Face Dance mechanics, retaining unsupported faction/module guards and existing start/battle assertions.

Verify focused tests, types/lint/build, four original public Basic native/copy allow/cancel programmes, actual control rendering and eight existing legal policies. Exact frozen evidence and boundaries are in docs/TLEILAXU_AMBASSADOR_RULES.md. Full rules/modes, wider combinations, assurance and deployed acceptance remain unfinished. No strategic calibration, saved-room operation, reset, restart or deployment.
This commit is contained in:
Quintine committed 2026-10-24 13:07:08 +11:00
1 parent f9e204c301
commit 6d30e7c5e1
15 files changed
+1125 -30

No files matched your search

@@ -0,0 +1,189 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { baseDeck } from '../game/cards';
import { createAmbassadors, validateAmbassadors } from '../game/ecaz-ambassadors';
import { createDukeVidal, DUKE_VIDAL_ID } from '../game/duke-vidal';
import { placeFixtureHand } from './fixture-hand';
import { createAmbassadorRevivalIncomeFixture, actAmbassadorRevivalIncome } from './fixture-ambassador-revival-income';
const player = (g: Game, id = 'ecaz') => g.players.find(p => p.id === id)!;
function allow(g: Game) {
for (let n = 0; g.response && n < 40; n++)
g = applyAction(g, g.players.find(p => !g.response!.passed.includes(p.id))!.id, { type: 'passResponse' });
assert.equal(g.response, null); return g;
}
/** Controlled consumer probes; the original-deal programme is separate. */
function fixture(copied = false, kwisatz = false) {
const g = createGame('AMBINCOMEPROBE', newPlayer('ecaz', 'Ecaz', 'ecaz'), false, ['ecaz', 'ix']);
g.players.push(newPlayer('entrant', 'Entrant', kwisatz ? 'harkonnen' : 'atreides'), newPlayer('t', 'Tleilaxu', 'tleilaxu'),
newPlayer('ally', 'Ally', kwisatz ? 'atreides' : 'emperor'));
Object.assign(g, { status: 'playing', phase: 5, turn: 2, storm: 18, active: 'entrant',
order: g.players.map(p => p.id), movementRemaining: g.players.map(p => p.id), deck: baseDeck() });
for (const p of g.players) { p.forces = {}; p.tanks = 3; p.reserves = 17; p.spice = 12; p.hand = []; }
placeFixtureHand(g, 1, [g.deck.find(c => c.effect === 'karama')!]);
const state = createAmbassadors(() => 0), effect = copied ? 'beneGesserit' : 'tleilaxu';
const cohort = [effect, ...state.tokens.map(t => t.effect).filter(e => e !== effect && e !== 'ecaz')].slice(0, 5);
state.cohort = state.tokens.filter(t => cohort.includes(t.effect)).map(t => t.id);
for (const t of state.tokens) { t.zone = t.effect === 'ecaz' || state.cohort.includes(t.id) ? 'supply' : 'pool'; t.location = null; }
const token = state.tokens.find(t => t.effect === effect)!;
token.zone = 'placed'; token.location = 'arrakeen'; validateAmbassadors(state); g.ecazAmbassadors = state;
return g;
}
function offer(g: Game, beneficiary = 'ecaz', copied = false) {
if (beneficiary !== 'ecaz') { player(g).ally = beneficiary; player(g, beneficiary).ally = 'ecaz'; }
g = allow(applyAction(g, 'entrant', { type: 'ship', amount: 1, territory: 'arrakeen', sector: 10 }));
for (let n = 0; g.decision && ['advisor', 'intrusion'].includes(g.decision.kind) && n < 5; n++)
g = allow(applyAction(g, g.decision.player, { type: 'decision', accept: false }));
assert.ok(g.pendingAmbassador, JSON.stringify(g.decision));
const event = g.pendingAmbassador!.event;
g = applyAction(g, 'ecaz', { type: 'decision', event, trigger: true, beneficiary, choice: 'effect' });
if (copied) g = applyAction(g, beneficiary, { type: 'decision', event, effect: 'tleilaxu' });
assert.equal(g.pendingAmbassador?.stage, 'revival'); return g;
}
const revive = (g: Game, choice: Action = { type: 'decision', forces: 2 }) => applyAction(g,
g.pendingAmbassador!.beneficiary!, { ...choice, type: 'decision', event: g.pendingAmbassador!.event });
const usage = (g: Game, id = 'ecaz') => {
const p = player(g, id); return { revived: p.revived, free: p.freeForcesRevived, leader: p.leaderRevived };
};
void test('a positive Ambassador return commits before the one-spice income response and resumes once', () => {
const offered = offer(fixture()), before = structuredClone(offered), g = revive(offered);
assert.equal(g.response?.kind, 'revivalIncome'); assert.equal(g.response?.source, 'ambassadorRevival');
assert.equal(g.response?.owner, 't'); assert.equal(g.response?.recipient, 'ecaz'); assert.equal(g.response?.amount, 1);
assert.equal(g.pendingAmbassador?.stage, 'income'); assert.equal(g.decision, null);
assert.equal(player(g).tanks, 1); assert.equal(player(g).reserves, 19);
assert.equal(player(g).spice, player(before).spice); assert.deepEqual(usage(g), usage(before));
assert.equal(g.revivalFreeIncome?.ecaz, g.turn); assert.equal(player(g, 't').spice, player(before, 't').spice);
const done = allow(g);
assert.equal(player(done, 't').spice, player(before, 't').spice + 1);
assert.equal(done.pendingAmbassador, null); assert.equal(done.active, 'entrant');
assert.ok(done.movementRemaining!.includes('entrant')); assert.equal(player(done).reserves, 19);
assert.throws(() => revive(done));
const ended = applyAction(done, 'entrant', { type: 'endMovement' });
assert.equal(ended.movementRemaining!.includes('entrant'), false);
});
void test('canceling Ambassador income consumes Karama but keeps the committed return and spent free entitlement', () => {
const source = fixture(), card = player(source, 'entrant').hand.find(c => c.effect === 'karama')!;
const g = revive(offer(source)), balance = player(g, 't').spice;
const done = applyAction(g, 'entrant', { type: 'card', card: card.id, mode: 'cancel' });
assert.equal(done.response, null); assert.equal(done.pendingAmbassador, null);
assert.equal(player(done, 't').spice, balance); assert.equal(player(done).reserves, 19);
assert.equal(done.revivalFreeIncome?.ecaz, done.turn); assert.equal(done.discard.filter(c => c.id === card.id).length, 1);
assert.equal(done.active, 'entrant'); assert.ok(done.movementRemaining!.includes('entrant'));
});
void test('countering a BG conversion restores the original income response without repeating revival', () => {
const source = fixture(); source.advanced = true;
source.players.push(newPlayer('bg', 'Bene Gesserit', 'beneGesserit'));
source.order.push('bg'); source.movementRemaining!.push('bg');
placeFixtureHand(source, 1, [...player(source, 'entrant').hand, source.deck.find(c => c.effect === 'karama')!]);
const worthless = source.deck.find(c => c.kind === 'worthless')!;
placeFixtureHand(source, 4, [worthless]);
const g = revive(offer(source)), balance = player(g, 't').spice;
const converted = applyAction(g, 'bg', { type: 'card', card: worthless.id, mode: 'cancel' });
assert.equal(converted.response?.kind, 'worthlessKarama'); assert.equal(converted.pendingAmbassador?.stage, 'income');
const counter = player(converted, 'entrant').hand.find(c => c.effect === 'karama')!;
const restored = applyAction(converted, 'entrant', { type: 'card', card: counter.id, mode: 'cancel' });
assert.equal(restored.response?.source, 'ambassadorRevival');
assert.deepEqual(restored.pendingAmbassador?.revivalReceipt, g.pendingAmbassador?.revivalReceipt);
assert.equal(player(restored).reserves, 19);
const done = allow(restored);
assert.equal(player(done, 't').spice, balance + 1); assert.equal(player(done).reserves, 19);
assert.equal(done.pendingAmbassador, null); assert.equal(done.pendingKarama, null);
});
void test('prior ordinary free revival shares the turn entitlement while decline leaves it unused', () => {
let ordinary = fixture(); ordinary.phase = 4;
ordinary = allow(applyAction(ordinary, 'ecaz', { type: 'revive', amount: 1 }));
const income = player(ordinary, 't').spice;
ordinary.phase = 5;
const done = revive(offer(ordinary));
assert.equal(done.response, null); assert.equal(done.pendingAmbassador, null);
assert.equal(player(done, 't').spice, income); assert.equal(player(done).reserves, 20);
const declined = revive(offer(fixture()), { type: 'decision', decline: true });
assert.equal(declined.response, null); assert.equal(declined.pendingAmbassador, null);
assert.equal(declined.revivalFreeIncome?.ecaz, undefined); assert.equal(player(declined).tanks, 3);
});
for (const kind of ['native', 'duke', 'kwisatz'] as const) void test(`${kind} leader return earns one bank spice for the actual beneficiary`, () => {
const source = fixture(false, kind === 'kwisatz'); let beneficiary = 'ecaz', leader: string;
if (kind === 'duke') {
source.dukeVidal = createDukeVidal(); Object.assign(source.dukeVidal.leader, { dead: true, deaths: 1 }); leader = DUKE_VIDAL_ID;
} else if (kind === 'kwisatz') {
beneficiary = 'ally'; const p = player(source, beneficiary);
source.advanced = true; p.battleLosses = 7; p.kwisatz = { dead: true, revivalCycle: 1 }; leader = 'kwisatz';
} else {
beneficiary = 'ally'; const disc = player(source, beneficiary).leaders[0];
Object.assign(disc, { dead: true, deaths: 1 }); leader = disc.id;
}
const g = revive(offer(source, beneficiary), { type: 'decision', leader });
assert.equal(g.response?.recipient, beneficiary); assert.equal(g.revivalFreeIncome?.[beneficiary], g.turn);
assert.equal(g.revivalFreeIncome?.ecaz, beneficiary === 'ecaz' ? g.turn : undefined);
const income = player(g, 't').spice, done = allow(g);
assert.equal(player(done, 't').spice, income + 1); assert.equal(done.pendingAmbassador, null);
});
void test('BG copied grants and Tleilaxu self-revival use the same beneficiary ledger', () => {
const copied = revive(offer(fixture(true), 'ally', true));
assert.equal(copied.response?.recipient, 'ally'); assert.equal(copied.pendingAmbassador?.revivalReceipt?.amount, 1);
assert.equal(copied.ecazAmbassadors!.tokens.find(t => t.effect === 'beneGesserit')!.zone, 'removed');
assert.equal(allow(copied).pendingAmbassador, null);
const self = revive(offer(fixture(), 't')), balance = player(self, 't').spice;
assert.equal(self.response?.owner, 't'); assert.equal(self.response?.recipient, 't');
assert.equal(player(allow(self), 't').spice, balance + 1);
});
void test('an actual Ghola before or after the Ambassador retains its separate per-use bank reward', () => {
for (const gholaFirst of [false, true]) {
let g = fixture(); const card = g.deck.find(c => c.effect === 'ghola')!;
placeFixtureHand(g, 0, [card]); const balance = player(g, 't').spice;
if (gholaFirst) {
g = allow(applyAction(g, 'ecaz', { type: 'card', card: card.id, amount: 1 }));
assert.equal(g.revivalFreeIncome?.ecaz, undefined);
}
g = allow(revive(offer(g)));
if (!gholaFirst) g = allow(applyAction(g, 'ecaz', { type: 'card', card: card.id, amount: 1 }));
assert.equal(player(g, 't').spice, balance + 2); assert.equal(g.revivalFreeIncome?.ecaz, g.turn);
assert.equal(player(g).tanks, 0); assert.equal(g.discard.filter(c => c.id === card.id).length, 1);
}
});
void test('income continuation rejects a mismatched parent or amount without replaying physical revival', () => {
const g = revive(offer(fixture()));
for (const change of [(copy: Game) => { copy.response!.amount = 2; },
(copy: Game) => { copy.pendingAmbassador!.event += ':stale'; },
(copy: Game) => { copy.pendingAmbassador!.revivalReceipt!.returned = { kind: 'forces', amount: 4, elite: 0 }; }]) {
const bad = structuredClone(g); change(bad); const before = structuredClone(bad);
assert.throws(() => viewGame(bad, 'ecaz')); assert.throws(() => applyAction(bad, 'entrant', { type: 'passResponse' }));
assert.deepEqual(bad, before);
}
});
for (const copied of [false, true]) for (const canceled of [false, true])
void test(`original Basic ${copied ? 'copied' : 'native'} grant ${canceled ? 'cancels' : 'allows'} bank income after the real Duke return`, () => {
const f = createAmbassadorRevivalIncomeFixture({ copied }), source = f.game;
assert.equal(source.advanced, false); assert.deepEqual(source.expansions, ['ecaz', 'ix']);
assert.equal(f.originalTreachery.length, 47); assert.equal(f.originalSpice.length, 22);
assert.equal(f.afterDeath.dukeVidal!.leader.dead, true); assert.equal(f.afterDeath.dukeVidal!.leader.deaths, 1);
assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0));
assert.deepEqual(f.beforeStartingDeal.deck.slice(0, 5).map(c => c.id), f.deal);
assert.ok(player(source, f.cancelActor).hand.some(c => c.id === f.cancelCard));
const balance = player(source, f.incomeOwner).spice, originalUsage = usage(source, f.owner);
const returned = actAmbassadorRevivalIncome(source, f.owner, { type: 'decision', event: f.event, leader: f.duke });
assert.equal(returned.dukeVidal!.leader.dead, false); assert.equal(returned.dukeVidal!.controller, null);
assert.equal(returned.pendingAmbassador?.stage, 'income'); assert.equal(returned.response?.amount, 1);
assert.equal(returned.response?.source, 'ambassadorRevival'); assert.equal(player(returned, f.incomeOwner).spice, balance);
const done = canceled ? actAmbassadorRevivalIncome(returned, f.cancelActor,
{ type: 'card', card: f.cancelCard, mode: 'cancel' }) : allow(returned);
assert.equal(done.response, null); assert.equal(done.pendingAmbassador, null);
assert.equal(player(done, f.incomeOwner).spice, balance + (canceled ? 0 : 1));
assert.deepEqual(usage(done, f.owner), originalUsage); assert.equal(done.revivalFreeIncome?.[f.owner], source.turn);
assert.equal(done.dukeVidal!.leader.dead, false); assert.equal(done.dukeVidal!.leader.deaths, 1);
assert.equal(done.dukeVidal!.controller, null); assert.equal(player(done, f.owner).leaders.length, 5);
assert.equal(done.active, f.entrant); assert.ok(done.movementRemaining!.includes(f.entrant));
assert.equal(done.discard.some(c => c.id === f.cancelCard), canceled);
const ended = actAmbassadorRevivalIncome(done, f.entrantContinuation.actor, f.entrantContinuation.action);
assert.equal(ended.movementRemaining?.includes(f.entrant) ?? false, false);
});
+211
View File
@@ -0,0 +1,211 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Game, type Player } from '../game/engine';
import { createAmbassadorRevivalIncomeFixture, actAmbassadorRevivalIncome,
advanceAmbassadorRevivalIncome, ambassadorRevivalIncomeClean,
type AmbassadorRevivalIncomeStep } from './fixture-ambassador-revival-income';
import { chooseEcazOccupyLead, openEcazOccupyPlans, stageEcazOccupyCard,
ecazOccupyTerritoryForces, type EcazOccupyFixture } from './fixture-ecaz-occupy';
const player = (game: Game, actor: string): Player => {
const result = game.players.find(candidate => candidate.id === actor); assert.ok(result); return result;
};
const cards = (game: Game) => [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand)]
.map(card => card.id).sort();
const traitors = (game: Game) => [...(game.traitorReserve ?? []), ...game.players.flatMap(p =>
[...p.traitors, ...p.traitorChoices, ...(p.faceDancers ?? []).map(card => card.leader)])].sort();
const totalForces = (game: Game) => game.players.map(p => ({ id: p.id,
total: p.reserves + p.tanks + Object.values(p.forces).reduce((sum, count) => sum + count, 0) }));
let originalSetup: Game | undefined;
function publicBasicSetup(): Game {
// This is the actual admitted public Basic start, not the old Advanced
// Occupy factory or a started Advanced game whose rules flag was changed.
originalSetup ??= createAmbassadorRevivalIncomeFixture().afterSetup;
const game = structuredClone(originalSetup);
assert.equal(game.advanced, false); assert.equal(game.status, 'playing');
assert.equal(game.turn, 1); assert.equal(game.phase, 0);
assert.deepEqual(game.expansions, ['ecaz', 'ix']); assert.equal(cards(game).length, 47);
return game;
}
function originalBlow(game: Game, worm: boolean) {
const from = game.spiceDeck.findIndex(card => worm ? 'worm' in card && !card.greatMaker && !card.suppressed
: 'territory' in card && !card.discovery);
assert.ok(from >= 0); game.spiceDeck.unshift(game.spiceDeck.splice(from, 1)[0]);
}
/** Explicit CONTROLLED consumer setup after the real Basic public setup:
* printed armies return to their own physical reserves, and later Treachery
* and Face Dancer identities are conserved swaps. No wallet, phase, alliance,
* battle frame, plan, casualty or outcome is installed. All subsequent native
* alliance, paid shipment and battle controls execute through applyAction. */
function controlledBasicCoalition(coside: boolean, lead: 'ecaz' | 'ally') {
let game = publicBasicSetup();
const initial = structuredClone(game), inventory = cards(game), physicalTraitors = traitors(game);
const physicalForces = totalForces(game), startingWallets = game.players.map(p => ({ id: p.id, spice: p.spice }));
for (const p of game.players) {
p.reserves += Object.values(p.forces).reduce((sum, count) => sum + count, 0); p.forces = {};
}
assert.deepEqual(totalForces(game), physicalForces);
assert.deepEqual(game.players.map(p => ({ id: p.id, spice: p.spice })), startingWallets);
const actions: AmbassadorRevivalIncomeStep[] = [];
const act = (actor: string, action: AmbassadorRevivalIncomeStep['action']) => {
game = actAmbassadorRevivalIncome(game, actor, action, actions);
};
const reach = (until: (state: Game) => boolean) => {
game = advanceAmbassadorRevivalIncome(game, until, actions);
};
originalBlow(game, false);
reach(state => state.turn === 2 && state.phase === 1);
assert.ok(!game.spiceSequence && !game.spiceResolution && !game.spiceWindow && !game.nexus);
originalBlow(game, true);
reach(state => state.phase === 1 && !!state.nexus && !state.spiceResolution &&
!state.spiceWindow && ambassadorRevivalIncomeClean(state));
const ecaz = 'ecaz', ally = coside ? 'tleilaxu' : 'guild', opponent = 'harkonnen';
act(ecaz, { type: 'alliance', target: ally }); act(ally, { type: 'alliance', target: ecaz });
assert.equal(player(game, ecaz).ally, ally); assert.equal(player(game, ally).ally, ecaz);
reach(state => state.phase === 5 && ambassadorRevivalIncomeClean(state));
const territory = 'arrakeen', location = 'arrakeen:10', paid: {
actor: string; amount: number; before: number; after: number;
}[] = [];
for (let count = 0; game.phase === 5 && count < 100; count++) {
if (!ambassadorRevivalIncomeClean(game)) {
reach(ambassadorRevivalIncomeClean); continue;
}
const actor = game.active!;
if (actor === ecaz || actor === ally || actor === opponent) {
const amount = actor === ecaz ? 3 : actor === ally ? 4 : 8;
const before = player(game, actor).spice;
act(actor, { type: 'ship', amount, territory, sector: 10 }); reach(ambassadorRevivalIncomeClean);
paid.push({ actor, amount, before, after: player(game, actor).spice });
assert.equal(player(game, actor).forces[location], amount);
}
act(actor, { type: 'endMovement' });
}
reach(state => state.phase === 6 && ambassadorRevivalIncomeClean(state));
assert.equal(paid.length, 3);
for (const shipment of paid)
assert.equal(shipment.before - shipment.after,
shipment.actor === 'guild' || ally === 'guild' && shipment.actor === ecaz
? Math.ceil(shipment.amount / 2) : shipment.amount,
'Original Basic stronghold rates fund each physical army without a staged wallet');
const actor = lead === 'ecaz' ? ecaz : ally;
const candidates = player(game, actor).leaders.filter(leader => !leader.dead && !leader.usedAt &&
physicalTraitors.includes(leader.id)).sort((a, b) => b.strength - a.strength);
const leader = candidates[0]; assert.ok(leader, 'Choose a native non-loyal leader with an actual physical Traitor Card');
const dancers = player(game, 'tleilaxu').faceDancers!;
if (!dancers.some(dancer => dancer.leader === leader.id && !dancer.revealed)) {
const returned = dancers[0].leader, reserve = game.traitorReserve!;
const index = reserve.indexOf(leader.id);
if (index >= 0) reserve[index] = returned;
else {
const donor = game.players.find(p => p.traitors.includes(leader.id));
assert.ok(donor, 'The selected physical target must retain reserve or native Traitor-hand custody');
donor.traitors[donor.traitors.indexOf(leader.id)] = returned;
}
// Labelled controlled Face Dancer custody exchange, not a natural deal.
dancers[0] = { leader: leader.id, revealed: false };
}
assert.deepEqual(traitors(game), physicalTraitors);
const weapon = stageEcazOccupyCard(game, actor, card => card.kind === 'projectile');
const shield = stageEcazOccupyCard(game, actor, card => card.kind === 'shield');
assert.deepEqual(cards(game), inventory);
assert.equal(game.advanced, false);
const choice = viewGame(game, game.active!).battleChoices.find(candidate => candidate.territory === territory);
assert.ok(choice); const chooser = choice.chooser;
act(chooser, { type: 'chooseBattle', territory,
target: choice.attacker === chooser ? choice.defender : choice.attacker });
assert.equal(game.decision?.kind, 'ecazBattleLead');
const fixture: EcazOccupyFixture = { game, initial, afterSetup: initial, ecaz, ally, opponent,
territory, location, ecazForces: 3 };
return { fixture, actor, leader: leader.id, weapon, shield, inventory, physicalTraitors, physicalForces, actions, paid,
staging: 'Controlled conserved board/card/Face Dancer positions after a genuine public Basic setup; original worm alliance, funded shipments and native battle controls.' };
}
function settle(state: Game): Game {
let game = state;
for (let count = 0; count < 100; count++) {
if (game.response) {
const actor = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(actor);
game = applyAction(game, actor.id, { type: 'passResponse' });
} else if (game.decision?.kind === 'battleLosses')
game = applyAction(game, game.decision.player, { type: 'decision', choice: 0 });
else if (game.pendingTreacheryDiscard)
game = applyAction(game, game.host, { type: 'advanceBots' });
else return game;
}
throw Error('The native Basic coalition aftermath did not reach its next control');
}
function winControlledBattle(source: ReturnType<typeof controlledBasicCoalition>, lead: 'ecaz' | 'ally') {
const { fixture, actor } = source;
let game = openEcazOccupyPlans(chooseEcazOccupyLead(fixture, lead));
const before = structuredClone(game), profile = viewGame(game, actor).battle!.ecazOccupy!.profile!;
assert.equal(profile.advanced, false); assert.equal(profile.fixedEcazDial, 2); assert.equal(profile.maxSupport, 0);
assert.equal(profile.planOwner, actor); assert.equal(profile.forceOwner, fixture.ally);
const loser = player(game, fixture.opponent).leaders.filter(leader => !leader.dead && !leader.usedAt)
.sort((a, b) => a.strength - b.strength)[0]; assert.ok(loser);
game = applyAction(game, actor, { type: 'battlePlan', dial: 4, support: 0,
leader: source.leader, weapon: source.weapon, defense: source.shield });
game = applyAction(game, fixture.opponent, { type: 'battlePlan', dial: 0, support: 0, leader: loser.id });
const voters = viewGame(game, actor).battle!.traitorVoters;
assert.ok(voters.includes(fixture.ecaz) && voters.includes(fixture.ally));
for (let count = 0; game.battle && count < 10; count++) {
const view = viewGame(game, actor).battle!;
const voter = view.traitorVoters.find(id => !view.traitorSubmitted.includes(id));
if (!voter) break;
game = applyAction(game, voter, { type: 'traitorCall', call: false });
}
game = settle(game); assert.equal(game.decision?.kind, 'battleCards');
assert.equal(game.decision.player, actor);
return { game, before, loser: loser.id, bounty: loser.strength };
}
function assertCompleted(game: Game, source: ReturnType<typeof controlledBasicCoalition>) {
assert.equal(game.advanced, false); assert.equal(game.battle, null); assert.equal(game.decision, null);
assert.deepEqual(cards(game), source.inventory); assert.deepEqual(traitors(game), source.physicalTraitors);
assert.deepEqual(totalForces(game), source.physicalForces);
assert.ok(player(game, source.actor).hand.some(card => card.id === source.weapon));
assert.ok(game.discard.some(card => card.id === source.shield));
const collection = advanceAmbassadorRevivalIncome(game,
state => state.phase === 7 && ambassadorRevivalIncomeClean(state));
assert.equal(collection.phase, 7, 'Original battle readiness continues to native collection');
}
const army = (game: Game, source: ReturnType<typeof controlledBasicCoalition>, actor: string) =>
ecazOccupyTerritoryForces(game, actor, source.fixture.territory);
for (const lead of ['ecaz', 'ally'] as const)
void test(`Basic admitted Ecaz/Tleilaxu Occupy suppresses Face Dance for ${lead} lead`, () => {
const source = controlledBasicCoalition(true, lead), win = winControlledBattle(source, lead);
let game = applyAction(win.game, source.actor, { type: 'decision', discard: [source.shield] });
game = settle(game);
assert.equal(army(game, source, 'ecaz'), 1); assert.equal(army(game, source, 'tleilaxu'), 2);
assert.equal(player(game, source.actor).leaders.find(leader => leader.id === source.leader)!.dead, false);
assert.ok(player(game, 'tleilaxu').faceDancers!.some(dancer => dancer.leader === source.leader && !dancer.revealed));
assert.equal(player(game, source.actor).spice, player(win.before, source.actor).spice + win.bounty,
'Basic support is zero and only the actual original leader bounty is credited');
assertCompleted(game, source);
});
for (const lead of ['ecaz', 'ally'] as const) for (const count of [0, 3])
void test(`Basic admitted external Tleilaxu Face Dance replaces both ${lead}-led armies with ${count} forces`, () => {
const source = controlledBasicCoalition(false, lead), win = winControlledBattle(source, lead);
let game = applyAction(win.game, source.actor, { type: 'decision', discard: [source.shield] });
game = settle(game);
assert.ok(game.decision?.kind === 'faceDance');
assert.equal(game.decision.player, 'tleilaxu'); assert.equal(game.decision.winner, source.actor);
assert.equal(game.decision.leader, source.leader);
assert.equal(army(game, source, 'ecaz'), 1); assert.equal(army(game, source, 'guild'), 2);
const before = structuredClone(game), other = source.actor === 'ecaz' ? 'guild' : 'ecaz';
game = applyAction(game, 'tleilaxu', { type: 'decision', reveal: true, sources: { reserves: count }, sector: 10 });
game = settle(game);
assert.equal(army(game, source, 'ecaz'), 0); assert.equal(army(game, source, 'guild'), 0);
assert.equal(army(game, source, 'tleilaxu'), count);
assert.equal(player(game, 'ecaz').reserves, player(before, 'ecaz').reserves + 1);
assert.equal(player(game, 'guild').reserves, player(before, 'guild').reserves + 2);
assert.equal(player(game, 'tleilaxu').reserves, player(before, 'tleilaxu').reserves - count);
assert.equal(player(game, source.actor).leaders.find(leader => leader.id === source.leader)!.dead, true);
assert.equal(player(game, source.fixture.opponent).leaders.find(leader => leader.id === win.loser)!.dead, true);
assert.equal(player(game, 'tleilaxu').spice, player(before, 'tleilaxu').spice);
assert.equal(player(game, source.actor).spice, player(before, source.actor).spice, 'No second bounty after replacement');
assert.deepEqual(player(game, other).hand, player(before, other).hand);
assert.ok(player(game, 'tleilaxu').faceDancers!.some(dancer => dancer.leader === source.leader && dancer.revealed));
assertCompleted(game, source);
});
+228
View File
@@ -0,0 +1,228 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { AMBASSADOR_EFFECTS, type AmbassadorEffect } from '../game/ecaz-ambassadors';
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
import {
actDukeAmbassador, dukeAmbassadorClean, dukeAmbassadorPlayer, nextDukeAmbassadorStep,
type DukeAmbassadorStep, type DukeAmbassadorTransition,
} from './fixture-duke-ambassador';
export type AmbassadorRevivalIncomeStep = DukeAmbassadorStep;
export type AmbassadorRevivalIncomeTransition = DukeAmbassadorTransition;
export const ambassadorRevivalIncomePlayer = dukeAmbassadorPlayer;
export const ambassadorRevivalIncomeClean = dukeAmbassadorClean;
export const actAmbassadorRevivalIncome = actDukeAmbassador;
/** The native Tleilaxu has its ordinary opportunity to decline a Face Dance.
* All other controls reuse the original Duke programme's no-spending driver. */
export function nextAmbassadorRevivalIncomeStep(game: Game): AmbassadorRevivalIncomeStep | null {
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard && game.decision?.kind === 'faceDance')
return { actor: game.decision.player, action: { type: 'decision', reveal: false } };
return nextDukeAmbassadorStep(game);
}
export function advanceAmbassadorRevivalIncome(game: Game, until: (state: Game) => boolean,
actions: AmbassadorRevivalIncomeStep[] = []): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original Ambassador income source ended before its next control');
const step = nextAmbassadorRevivalIncomeStep(game);
assert.ok(step, 'Choose the actual Ambassador effect or sealed plan before advancing');
game = actAmbassadorRevivalIncome(game, step.actor, step.action, actions);
}
throw Error('The original Ambassador income source did not reach its finite boundary');
}
/** Select only the original packet's nine Fisher–Yates draws when the final
* native traitor choice initializes starting forces. No live token is moved. */
function originalIncomePacket(copied: boolean) {
const pool = AMBASSADOR_EFFECTS.filter(effect => effect !== 'ecaz');
const supply: Exclude<AmbassadorEffect, 'ecaz'>[] = copied
? ['beneGesserit', 'atreides', 'choam', 'emperor', 'fremen']
: ['tleilaxu', 'atreides', 'choam', 'emperor', 'fremen'];
const desired = [...supply, ...pool.filter(effect => !supply.includes(effect))];
const current = [...pool], rolls: number[] = [];
for (let i = current.length - 1; i > 0; i--) {
const j = current.indexOf(desired[i]); assert.ok(j >= 0 && j <= i);
rolls.push(Math.floor((j + 0.5) * 0x100000000 / (i + 1)));
[current[i], current[j]] = [current[j], current[i]];
}
assert.deepEqual(current, desired); return { supply, rolls };
}
function withOriginalIncomePacket<T>(rolls: readonly number[], run: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const original = crypto.getRandomValues.bind(crypto); let cursor = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1)
array[0] = cursor < rolls.length ? rolls[cursor++] : 0xffffffff;
else Reflect.apply(original, crypto, [array]);
return array;
};
try {
const result = run(); assert.equal(cursor, rolls.length); return result;
} finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Public Basic Ecaz/Harkonnen/Tleilaxu/Guild with the original selected E3+Ix
* decks. Original poison plans kill the acquired shared Duke in a real battle;
* the zero-dial attacker tie leaves Ecaz no force or native-leader alternative.
* Guild's original dealt Karama stays held for a later genuine income response.
* Stop at the free revival grant, before choosing a return or earning income. */
export function createAmbassadorRevivalIncomeFixture(options: { copied?: boolean } = {}) {
const copied = options.copied ?? false, packet = originalIncomePacket(copied);
const owner = 'ecaz', opponent = 'harkonnen', tleilaxu = 'tleilaxu', entrant = 'guild';
let game = createGame('AMBASSADORREVIVALINCOME', newPlayer(owner, 'Ecaz', 'ecaz'), false, ['ecaz', 'ix']);
for (const faction of ['harkonnen', 'tleilaxu', 'guild'] as const)
joinGame(game, newPlayer(faction, faction, faction));
const actions: AmbassadorRevivalIncomeStep[] = [];
const act = (actor: string, action: Action) => { game = actAmbassadorRevivalIncome(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceAmbassadorRevivalIncome(game, until, actions); };
const transition = (step: AmbassadorRevivalIncomeStep,
until: (state: Game) => boolean): AmbassadorRevivalIncomeTransition => {
const before = structuredClone(game); act(step.actor, step.action); reach(until);
return { before, step: structuredClone(step), after: structuredClone(game) };
};
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, ['ecaz', 'ix']);
assert.ok(game.players.every(player => player.hand.length === 0));
const originalTreachery = game.deck.map(card => card.id).sort();
const poisons = game.deck.filter(card => card.kind === 'poison').slice(0, 2);
const fillers = game.deck.filter(card => card.kind === 'worthless').slice(0, 2);
const karama = game.deck.find(card => card.effect === 'karama');
assert.equal(poisons.length, 2); assert.equal(fillers.length, 2); assert.ok(karama);
const deal = [poisons[0].id, poisons[1].id, fillers[0].id, fillers[1].id, karama.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);
let packetCreation: AmbassadorRevivalIncomeTransition | null = null;
for (let count = 0; game.status === 'setup' && count < 80; count++) {
const step = nextAmbassadorRevivalIncomeStep(game); assert.ok(step);
const initializesForces = game.setupStage === 'traitors' && step.action.type === 'traitor' &&
game.players.filter(player => player.traitorChoices.length > 0).length === 1;
if (initializesForces) {
const before = structuredClone(game);
game = withOriginalIncomePacket(packet.rolls, () => applyAction(game, step.actor, step.action));
actions.push(structuredClone(step));
packetCreation = { before, step: structuredClone(step), after: structuredClone(game) };
} else act(step.actor, step.action);
}
assert.equal(game.status, 'playing'); assert.ok(packetCreation);
const afterSetup = structuredClone(game);
assert.ok(ambassadorRevivalIncomePlayer(game, owner).hand.some(card => card.id === poisons[0].id));
assert.ok(ambassadorRevivalIncomePlayer(game, opponent).hand.some(card => card.id === poisons[1].id));
assert.ok(ambassadorRevivalIncomePlayer(game, entrant).hand.some(card => card.id === karama.id));
assert.equal(ambassadorRevivalIncomePlayer(game, tleilaxu).faceDancers?.length, 3);
const ecazToken = game.ecazAmbassadors!.tokens.find(token => token.effect === 'ecaz')!;
const revivalToken = game.ecazAmbassadors!.tokens.find(token => token.effect === (copied ? 'beneGesserit' : 'tleilaxu'))!;
assert.equal(ecazToken.zone, 'supply'); assert.equal(revivalToken.zone, 'supply');
assert.equal(game.ecazAmbassadors!.tokens.find(token => token.effect === 'tleilaxu')!.zone, copied ? 'pool' : 'supply');
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 === 4 && !state.response && !state.phaseOpening &&
state.decision?.kind === 'ecazPlacement');
const placements = [
transition({ actor: owner, action: { type: 'decision', token: ecazToken.id, territory: 'arrakeen' } },
state => !state.response && state.decision?.kind === 'ecazPlacement'),
transition({ actor: owner, action: { type: 'decision', token: revivalToken.id, territory: 'habbanya_ridge_sietch' } },
state => !state.response && state.decision?.kind === 'ecazPlacement'),
];
assert.equal(ambassadorRevivalIncomePlayer(placements[0].after).spice,
ambassadorRevivalIncomePlayer(placements[0].before).spice - 1);
assert.equal(ambassadorRevivalIncomePlayer(placements[1].after).spice,
ambassadorRevivalIncomePlayer(placements[1].before).spice - 2);
act(owner, { type: 'decision', decline: true });
reach(state => state.phase === 5 && state.active === owner && ambassadorRevivalIncomeClean(state));
const ownShipment = transition({ actor: owner,
action: { type: 'ship', amount: 1, territory: 'carthag', sector: 11 } }, ambassadorRevivalIncomeClean);
reach(state => state.phase === 5 && state.active === opponent && ambassadorRevivalIncomeClean(state));
const acquisitionArrival = transition({ actor: opponent,
action: { type: 'ship', amount: 1, territory: 'arrakeen', sector: 10 } },
state => !state.response && state.pendingAmbassador?.stage === 'offer');
assert.equal(game.pendingAmbassador!.token, ecazToken.id);
const acquisition = transition({ actor: owner, action: { type: 'decision', event: game.pendingAmbassador!.event,
trigger: true, beneficiary: owner, choice: 'duke' } }, ambassadorRevivalIncomeClean);
assert.equal(game.dukeVidal!.controller, owner); assert.equal(game.dukeVidal!.source, 'ecaz');
assert.equal(game.dukeVidal!.leader.dead, false);
reach(state => state.phase === 6 && !state.battle && ambassadorRevivalIncomeClean(state));
assert.equal(game.active, owner, 'The actual first native circle gives Ecaz the battle choice');
const beforeBattle = structuredClone(game), battleStep: AmbassadorRevivalIncomeStep = {
actor: owner, action: { type: 'chooseBattle', territory: 'carthag', target: opponent },
};
act(battleStep.actor, battleStep.action);
reach(state => !!state.battle && ambassadorRevivalIncomeClean(state) && !state.battle.preparation &&
(!state.battle.preLeader || state.battle.preLeader.closed));
assert.equal(game.battle!.attacker, owner);
const enemyLeader = ambassadorRevivalIncomePlayer(game, opponent).leaders
.filter(leader => !leader.dead && !leader.usedAt).sort((a, b) => a.strength - b.strength)[0]; assert.ok(enemyLeader);
const planSteps: AmbassadorRevivalIncomeStep[] = [
{ actor: owner, action: { type: 'battlePlan', leader: DUKE_VIDAL_ID, dial: 0, support: 0, weapon: poisons[0].id } },
{ actor: opponent, action: { type: 'battlePlan', leader: enemyLeader.id, dial: 0, support: 0, weapon: poisons[1].id } },
];
for (const step of planSteps) {
act(step.actor, step.action);
reach(state => !state.response && !state.phaseOpening && !state.decision);
}
const revealed = structuredClone(game);
reach(state => !state.battle && ambassadorRevivalIncomeClean(state));
const afterDeath = structuredClone(game);
assert.equal(game.dukeVidal!.leader.dead, true); assert.equal(game.dukeVidal!.leader.deaths, 1);
assert.equal(game.dukeVidal!.controller, null);
assert.equal(ambassadorRevivalIncomePlayer(game).tanks, 0);
assert.ok(ambassadorRevivalIncomePlayer(game).leaders.every(leader => !leader.dead));
assert.equal(ambassadorRevivalIncomePlayer(game).forces['carthag:11'], 1);
assert.equal(ambassadorRevivalIncomePlayer(game, opponent).leaders.find(leader => leader.id === enemyLeader.id)!.dead, true);
reach(state => state.turn === 2 && state.phase === 5 && state.active === entrant && ambassadorRevivalIncomeClean(state));
const beforeArrival = structuredClone(game);
assert.equal(ambassadorRevivalIncomePlayer(game, entrant).shipped, false);
assert.equal(game.ecazAmbassadors!.tokens.find(token => token.id === revivalToken.id)!.zone, 'placed');
const arrival = transition({ actor: entrant,
action: { type: 'ship', amount: 1, territory: 'habbanya_ridge_sietch', sector: 17 } },
state => !state.response && state.pendingAmbassador?.stage === 'offer');
const offer = structuredClone(game), event = game.pendingAmbassador!.event;
assert.equal(game.pendingAmbassador!.token, revivalToken.id);
const triggerStep: AmbassadorRevivalIncomeStep = { actor: owner,
action: { type: 'decision', event, trigger: true, beneficiary: owner, choice: 'effect' } };
act(triggerStep.actor, triggerStep.action);
let beforeCopy: Game | null = null, copyStep: AmbassadorRevivalIncomeStep | null = null;
if (copied) {
assert.equal(game.pendingAmbassador?.stage, 'copy'); beforeCopy = structuredClone(game);
copyStep = { actor: owner, action: { type: 'decision', event, effect: 'tleilaxu' } };
act(copyStep.actor, copyStep.action);
}
assert.equal(game.pendingAmbassador?.stage, 'revival');
assert.equal(game.pendingAmbassador!.beneficiary, owner); assert.equal(game.pendingAmbassador!.entrant, entrant);
assert.equal(game.pendingAmbassador!.effect, 'tleilaxu'); assert.equal(game.decision?.player, owner);
assert.equal(game.active, entrant); assert.equal(game.turn, 2);
assert.equal(ambassadorRevivalIncomePlayer(game).tanks, 0);
assert.ok(ambassadorRevivalIncomePlayer(game).leaders.every(leader => !leader.dead));
assert.ok(ambassadorRevivalIncomePlayer(game, entrant).hand.some(card => card.id === karama.id));
assert.deepEqual([...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand)]
.map(card => card.id).sort(), originalTreachery);
const currentGrant = structuredClone(game), ownerView = viewGame(game, owner), entrantView = viewGame(game, entrant);
return { game, lobby, setup, beforeStartingDeal, afterSetup, packetCreation, packet, deal, dealOrder,
spiceOrder, originalTreachery, originalSpice, placements, ownShipment, acquisitionArrival, acquisition,
beforeBattle, battleStep, planSteps, revealed, afterDeath, beforeArrival, arrival, offer,
triggerStep, beforeCopy, copyStep, currentGrant, ownerView, entrantView,
copied, advanced: false as const, owner, beneficiary: owner, opponent, entrant, tleilaxu, incomeOwner: tleilaxu,
duke: DUKE_VIDAL_ID, enemyLeader: enemyLeader.id, token: revivalToken.id, acquisitionToken: ecazToken.id,
event, territory: 'habbanya_ridge_sietch', location: 'habbanya_ridge_sietch:17',
poison: { ecaz: poisons[0].id, harkonnen: poisons[1].id }, cancelCard: karama.id, cancelActor: entrant,
entrantContinuation: { actor: entrant, action: { type: 'endMovement' } } as AmbassadorRevivalIncomeStep,
actions, sourceActionCount: actions.length,
staging: ['Only five unopened starting-deal positions, two unopened ordinary land Spice positions and original scalar lotteries are selected. Nine recorded draws select the initial native Ambassador packet during the final original traitor choice. Other scalar draws preserve original Fisher–Yates order; UUID/byte entropy remains native. Public Basic Ecaz/Harkonnen/Tleilaxu/Guild setup selects original E3+Ix decks, deals actual Face Dancers and gives Guild the physical Karama. Paid 1+2 Ambassador placements, paid shipments, actual Ecaz-token Duke acquisition and sealed native poison plans produce his first death. Ecaz wins the 0–0 attacker tie at dial 0 with its force intact and no dead native leaders. Original readiness and movement endings reach the next turn; Guild pays and enters the previously placed Tleilaxu or BG marker. The native grant is open before any revival or income action; Guild still holds its original Karama for the owner-controlled cancellation programme. No dealt card, force, resource, death, token, clock, queue or effect receipt is installed. Native Tleilaxu voluntary Face Dance is declined through its original control.'] };
}
export type AmbassadorRevivalIncomeFixture = ReturnType<typeof createAmbassadorRevivalIncomeFixture>;
+123
View File
@@ -0,0 +1,123 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createAmbassadorRevivalReceipt,
validateAmbassadorRevivalReceipt,
AmbassadorRevivalIncomeError,
type AmbassadorRevivalSource,
type AmbassadorRevivalReturn,
type AmbassadorRevivalReceipt,
} from '../game/tleilaxu-ambassador-income';
const source = (): AmbassadorRevivalSource => ({
event: 'original-ambassador-event', owner: 'ecaz', entrant: 'guild', token: 'ambassador-tleilaxu',
territory: 'arrakeen', sector: 10, turn: 3, phase: 5, beneficiary: 'atreides', incomeOwner: 'tleilaxu',
});
const forces = (): AmbassadorRevivalReturn => ({ kind: 'forces', amount: 3, elite: 1 });
// Pure committed-return witnesses, not earned game histories or live custody
// checks. Native Ambassador and the original free-income ledger stay engine-owned.
void test('positive force, leader and KH returns produce detached one-spice Ambassador receipts', () => {
for (const returned of [forces(), { kind: 'leader', leader: 'duke-vidal', deaths: 2 },
{ kind: 'kwisatz', cycle: 1 }] as AmbassadorRevivalReturn[]) {
const originalSource = source(), before = structuredClone({ source: originalSource, returned });
Object.freeze(originalSource); Object.freeze(returned);
const receipt = createAmbassadorRevivalReceipt(originalSource, returned);
assert.equal(receipt.version, 1);
assert.equal(receipt.amount, 1);
assert.deepEqual(receipt.source, originalSource);
assert.deepEqual(receipt.returned, returned);
assert.notEqual(receipt.source, originalSource);
assert.notEqual(receipt.returned, returned);
validateAmbassadorRevivalReceipt(originalSource, JSON.parse(JSON.stringify(receipt)));
assert.deepEqual({ source: originalSource, returned }, before);
receipt.source.territory = 'carthag';
assert.equal(originalSource.territory, 'arrakeen');
if (receipt.returned.kind === 'forces') receipt.returned.amount = 4;
else if (receipt.returned.kind === 'leader') receipt.returned.deaths = 3;
else receipt.returned.cycle = 2;
assert.deepEqual(returned, before.returned);
}
});
void test('receipt binding is deterministic across equivalent public source property insertion orders', () => {
const original = source(), reordered = Object.fromEntries(Object.entries(original).reverse()) as AmbassadorRevivalSource;
const first = createAmbassadorRevivalReceipt(original, forces());
const second = createAmbassadorRevivalReceipt(reordered, forces());
assert.equal(first.signature, second.signature);
validateAmbassadorRevivalReceipt(original, second);
validateAmbassadorRevivalReceipt(reordered, first);
const firstPhase = { ...original, phase: 0, sector: 1, turn: 1 };
validateAmbassadorRevivalReceipt(firstPhase, createAmbassadorRevivalReceipt(firstPhase, { kind: 'forces', amount: 1, elite: 0 }));
const finalSector = { ...original, sector: 18, phase: Number.MAX_SAFE_INTEGER };
validateAmbassadorRevivalReceipt(finalSector, createAmbassadorRevivalReceipt(finalSector, { kind: 'forces', amount: 4, elite: 4 }));
});
void test('a separately valid receipt from another source cannot settle this original Ambassador income', () => {
const original = source();
const changes: Partial<AmbassadorRevivalSource>[] = [
{ event: 'another-event' }, { owner: 'another-owner' }, { entrant: 'another-entrant' },
{ token: 'copied-ambassador' }, { territory: 'carthag' }, { sector: 11 },
{ turn: 4 }, { phase: 1 }, { beneficiary: 'another-beneficiary' }, { incomeOwner: 'another-income-owner' },
];
for (const change of changes) {
const changed = { ...original, ...change }, receipt = createAmbassadorRevivalReceipt(changed, forces());
validateAmbassadorRevivalReceipt(changed, receipt);
assert.throws(() => validateAmbassadorRevivalReceipt(original, receipt), AmbassadorRevivalIncomeError);
}
});
void test('force types, leader identity and preserved death or KH cycle values remain bound after commitment', () => {
const original = source();
const changedForces = createAmbassadorRevivalReceipt(original, forces());
assert.ok(changedForces.returned.kind === 'forces');
changedForces.returned.elite = 0;
assert.throws(() => validateAmbassadorRevivalReceipt(original, changedForces), /committed return/);
for (const returned of [
{ kind: 'forces', amount: 4, elite: 1 },
{ kind: 'leader', leader: 'ecaz-0', deaths: 1 },
{ kind: 'leader', leader: 'duke-vidal', deaths: 3 },
{ kind: 'kwisatz', cycle: 2 },
] as AmbassadorRevivalReturn[]) {
const initial: AmbassadorRevivalReturn = returned.kind === 'forces' ? forces() :
returned.kind === 'leader' ? { kind: 'leader', leader: 'duke-vidal', deaths: 2 } : { kind: 'kwisatz', cycle: 1 };
const receipt = createAmbassadorRevivalReceipt(original, initial);
receipt.returned = returned;
assert.throws(() => validateAmbassadorRevivalReceipt(original, receipt), /committed return/);
}
});
void test('malformed public sources and zero, excessive or untyped returns cannot create income receipts', () => {
const original = source();
for (const invalid of [
{ ...original, event: '' }, { ...original, owner: ' ' }, { ...original, entrant: 1 },
{ ...original, token: undefined }, { ...original, territory: '' }, { ...original, beneficiary: '' },
{ ...original, incomeOwner: '' }, { ...original, sector: 0 }, { ...original, sector: 19 },
{ ...original, sector: 1.5 }, { ...original, turn: 0 }, { ...original, turn: NaN },
{ ...original, phase: -1 }, { ...original, phase: 0.5 }, { ...original, phase: Infinity },
{ ...original, phase: Number.MAX_SAFE_INTEGER + 1 }, { ...original, wallet: 1 },
]) assert.throws(() => createAmbassadorRevivalReceipt(invalid as AmbassadorRevivalSource, forces()), AmbassadorRevivalIncomeError);
for (const invalid of [
{ kind: 'forces', amount: 0, elite: 0 }, { kind: 'forces', amount: 5, elite: 0 },
{ kind: 'forces', amount: 1.5, elite: 0 }, { kind: 'forces', amount: 3, elite: -1 },
{ kind: 'forces', amount: 3, elite: 4 }, { kind: 'forces', amount: 3, elite: 0.5 },
{ kind: 'forces', amount: 3 }, { kind: 'forces', amount: 3, elite: 0, cost: 0 },
{ kind: 'leader', leader: '', deaths: 1 }, { kind: 'leader', leader: 'ecaz-0', deaths: 0 },
{ kind: 'leader', leader: 'ecaz-0', deaths: Number.MAX_SAFE_INTEGER + 1 },
{ kind: 'kwisatz', cycle: 0 }, { kind: 'kwisatz', cycle: NaN }, { kind: 'kwisatz', cycle: 1.5 },
{ kind: 'unknown', amount: 1 },
]) assert.throws(() => createAmbassadorRevivalReceipt(original, invalid as AmbassadorRevivalReturn), AmbassadorRevivalIncomeError);
});
void test('receipt reward, source shape, format version and signature cannot be changed into another bank award', () => {
const original = source(), receipt = createAmbassadorRevivalReceipt(original, forces());
for (const changed of [
{ ...receipt, version: 2 }, { ...receipt, amount: 0 }, { ...receipt, amount: 2 },
{ ...receipt, signature: '' }, { ...receipt, extra: true },
{ ...receipt, source: { ...receipt.source, hiddenBalance: 5 } },
{ ...receipt, returned: { kind: 'forces', amount: 0, elite: 0 } },
]) assert.throws(() => validateAmbassadorRevivalReceipt(original, changed as AmbassadorRevivalReceipt), AmbassadorRevivalIncomeError);
const before = structuredClone(receipt);
validateAmbassadorRevivalReceipt(original, receipt);
assert.deepEqual(receipt, before);
});