Files
Dune/tests/ambassador-revival-income-engine.test.ts
Quintine 6d30e7c5e1 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.
2026-10-24 13:07:08 +11:00

190 lines
13 KiB
TypeScript

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);
});