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.
190 lines
13 KiB
TypeScript
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);
|
|
});
|