Connect Fremen Nexus fixed quota to native partial revival

This commit is contained in:
Quintine committed 2026-10-15 06:09:36 +11:00
1 parent 985cf85e12
commit 25996ec8a7
26 files changed
+1386 -354

No files matched your search

@@ -42,7 +42,7 @@ for (const advanced of [false, true]) {
const before = classicNexusSkillsPaymentsPlayer(f.game, f.actor);
rejected(f.game, f.opponent, f.paymentAction);
rejected(f.game, f.actor, { ...f.paymentAction, event: 'stale' });
let paid = payClassicNexusSkillsPaymentsFixture(f);
let paid = finishClassicNexusSkillsPaymentsFixture(f, payClassicNexusSkillsPaymentsFixture(f));
let actor = classicNexusSkillsPaymentsPlayer(paid, f.actor);
assert.equal(actor.reserves, before.reserves + 3);
assert.equal(actor.tanks, before.tanks - 3);
@@ -51,10 +51,17 @@ for (const advanced of [false, true]) {
assert.equal(actor.freeForcesRevived ?? 0, kind === 'fremen-revival' ? 3 : (before.freeForcesRevived ?? 0));
assert.equal(paid.nexusCards!.cards!.hands[f.actor], null);
assert.equal(paid.nexusCards!.cards!.discard.filter(card => card === (kind === 'emperor-revival' ? 'emperor' : 'fremen')).length, 1);
const receipt = kind === 'emperor-revival' ? paid.nexusEmperorSecretHistory![0] : paid.nexusFremenRevivalHistory![0];
assert.equal(receipt.owner, f.actor); assert.equal(receipt.turn, 2); assert.equal(receipt.phase, 4);
assert.equal(receipt.before.tanks - receipt.after.tanks, 3);
assert.equal(receipt.before.spice, receipt.after.spice);
if (kind === 'emperor-revival') {
const receipt = paid.nexusEmperorSecretHistory![0];
assert.equal(receipt.owner, f.actor); assert.equal(receipt.turn, 2); assert.equal(receipt.phase, 4);
assert.equal(receipt.before.tanks - receipt.after.tanks, 3);
assert.equal(receipt.before.spice, receipt.after.spice);
} else {
const receipt = paid.nexusFremenRevivalHistory![0];
assert.equal(receipt.owner, f.actor); assert.equal(receipt.turn, 2); assert.equal(receipt.phase, 4);
assert.equal(receipt.revivedAtUse, before.revived);
assert.equal(receipt.freeAtUse, before.freeForcesRevived ?? 0);
}
sameSkillCustody(f.game, paid);
rejected(paid, f.actor, f.paymentAction);
if (kind === 'fremen-revival') {
@@ -65,6 +72,7 @@ for (const advanced of [false, true]) {
const quote = forceRevivalQuote(paid, actor, 3);
assert.equal(quote.cost, 2, 'Atreides retains its original two-free plus one-paid invoice');
paid = applyAction(paid, f.actor, { type: 'revive', amount: 3 });
paid = finishClassicNexusSkillsPaymentsFixture(f, paid);
actor = classicNexusSkillsPaymentsPlayer(paid, f.actor);
assert.equal(actor.reserves, before.reserves + 6);
assert.equal(actor.tanks, before.tanks - 6);
@@ -91,12 +99,17 @@ for (const advanced of [false, true]) {
});
}
void test(`${advanced ? 'Advanced' : 'Basic'} Fremen free-three remains early-only and both grants require an exact three`, () => {
void test(`${advanced ? 'Advanced' : 'Basic'} Fremen quota accounts for prior free returns while Emperor remains an additional exact-three return`, () => {
const f = createClassicNexusSkillsPaymentsFixture({ advanced, kind: 'fremen-revival' });
const late = applyAction(f.game, f.actor, { type: 'revive', amount: 1 });
assert.match(viewGame(late, f.actor).nexusFremenRevival!.blocked!, /before ordinary force revivals/);
rejected(late, f.actor, f.paymentAction);
sameSkillCustody(f.game, late);
const late = finishClassicNexusSkillsPaymentsFixture(f,
applyAction(f.game, f.actor, { type: 'revive', amount: 1 }));
const granted = applyAction(late, f.actor, f.paymentAction);
assert.equal(classicNexusSkillsPaymentsPlayer(granted, f.actor).tanks, classicNexusSkillsPaymentsPlayer(late, f.actor).tanks);
const returned = finishClassicNexusSkillsPaymentsFixture(f,
applyAction(granted, f.actor, { type: 'revive', amount: 2 }));
assert.equal(classicNexusSkillsPaymentsPlayer(returned, f.actor).revived, 3);
assert.equal(classicNexusSkillsPaymentsPlayer(returned, f.actor).freeForcesRevived, 3);
sameSkillCustody(f.game, returned);
for (const kind of ['emperor-revival', 'fremen-revival'] as const) {
const short = createClassicNexusSkillsPaymentsFixture({ advanced, kind, tanks: 2 });
rejected(short.game, short.actor, short.paymentAction);
@@ -104,7 +117,8 @@ for (const advanced of [false, true]) {
classicNexusSkillsPaymentsInventory(short.game);
}
const extra = createClassicNexusSkillsPaymentsFixture({ advanced, kind: 'emperor-revival' });
let ordinary = applyAction(extra.game, extra.actor, { type: 'revive', amount: 3 });
let ordinary = finishClassicNexusSkillsPaymentsFixture(extra,
applyAction(extra.game, extra.actor, { type: 'revive', amount: 3 }));
const pools = classicNexusSkillsPaymentsPlayer(ordinary, extra.actor);
const wallet = pools.spice;
ordinary = applyAction(ordinary, extra.actor, extra.paymentAction);
@@ -132,7 +146,7 @@ for (const advanced of [false, true]) {
const botView = viewGame(f.game, f.actor);
botView.players.find(player => player.id === f.actor)!.bot = 'Hard';
const bot = botActions(botView).find(action => action.type === 'nexusEmperorPurchase');
assert.deepEqual(bot, f.paymentAction);
assert.ok(bot);
const paid = applyAction(f.game, f.actor, bot!);
assert.deepEqual(wallets(paid), wallets(f.game));
assert.equal(classicNexusSkillsPaymentsPlayer(paid, f.actor).hand.filter(card => card.id === lot.id).length, 1);
@@ -160,7 +174,7 @@ for (const advanced of [false, true]) {
const view = viewGame(f.game, f.actor);
view.players.find(player => player.id === f.actor)!.bot = 'Hard';
const bot = botActions(view).find(action => action.type === 'nexusTraitorDraw');
assert.deepEqual(bot, f.paymentAction);
assert.ok(bot);
const drawn = applyAction(f.game, f.actor, bot!);
assert.deepEqual(classicNexusSkillsPaymentsPlayer(drawn, f.actor).traitors, [...owner.traitors, top]);
assert.deepEqual(drawn.traitorReserve, reserve.slice(1));
@@ -212,17 +226,20 @@ for (const advanced of [false, true]) {
}
for (const kind of ['emperor-revival', 'fremen-revival'] as const) {
void test(`${kind} genuinely preserves Advanced native elite counters and the original one-per-turn cap`, () => {
void test(`${kind} bot activation and selected return preserve Advanced native elite counters and the original one-per-turn cap`, () => {
const f = createClassicNexusSkillsPaymentsFixture({ kind, advanced: true,
ownerFaction: kind === 'emperor-revival' ? 'fremen' : 'emperor', eliteTanks: 2 });
const view = viewGame(f.game, f.actor);
const offer = kind === 'emperor-revival' ? view.nexusEmperorSecretAlly!.revival : view.nexusFremenRevival!;
assert.deepEqual(offer.eliteOptions, [0, 1]);
rejected(f.game, f.actor, { ...f.paymentAction, elite: 2 });
view.players.find(player => player.id === f.actor)!.bot = 'Hard';
const bot = botActions(view).find(action => action.type === f.paymentAction.type);
assert.ok(bot, 'The newly supported original profile must offer a real legal bot action');
const paid = applyAction(f.game, f.actor, bot);
let paid = applyAction(f.game, f.actor, bot);
if (f.revivalAction) {
rejected(paid, f.actor, { type: 'revive', amount: 3, elite: 2 });
paid = applyAction(paid, f.actor, f.revivalAction);
}
paid = finishClassicNexusSkillsPaymentsFixture(f, paid);
const before = classicNexusSkillsPaymentsPlayer(f.game, f.actor);
const actor = classicNexusSkillsPaymentsPlayer(paid, f.actor);
assert.equal(actor.elites!.reserves, before.elites!.reserves + 1);
+43 -17
View File
@@ -78,8 +78,8 @@ for (const configuration of configurations) for (const kind of ['emperor-revival
sourceTransitions(fixture);
if (configuration.strongholds) assert.equal(fixture.afterEntry.strongholdCards!.owners.arrakeen, fixture.actor);
const privateView = viewGame(fixture.game, fixture.actor);
const control = kind === 'emperor-revival' ? nexusEmperorRevivalAction(privateView, 0) : nexusFremenRevivalAction(privateView, 0);
assert.deepEqual(control, fixture.paymentAction, 'Existing human controls bind the actual private physical card offer');
const control = kind === 'emperor-revival' ? nexusEmperorRevivalAction(privateView, 0) : nexusFremenRevivalAction(privateView);
assert.ok(control, 'Human controls expose the real owner-only card offer');
assert.equal(kind === 'emperor-revival' ? viewGame(fixture.game, fixture.opponent).nexusEmperorSecretAlly
: viewGame(fixture.game, fixture.opponent).nexusFremenRevival, null);
legalPolicies(fixture.game, fixture.actor);
@@ -87,7 +87,13 @@ for (const configuration of configurations) for (const kind of ['emperor-revival
rejected(fixture.game, fixture.actor, { ...fixture.paymentAction, event: 'stale' });
rejected(fixture.game, fixture.actor, { ...fixture.paymentAction, bonus: true });
const before = discoveryClassicNexusEffectsPlayer(fixture.game, fixture.actor);
let paid = payDiscoveryClassicNexusEffectsFixture(fixture, structuredClone(fixture.game));
const activated = applyAction(structuredClone(fixture.game), fixture.actor, control);
if (kind === 'fremen-revival') {
assert.deepEqual(discoveryClassicNexusEffectsPlayer(activated, fixture.actor), before, 'Quota activation returns no forces and changes no ordinary ledger');
assert.deepEqual(activated.techTokens, fixture.game.techTokens, 'Only the subsequent native return can earn Axlotl income');
}
let paid = finishDiscoveryClassicNexusEffectsInteraction(fixture.revivalAction
? applyAction(activated, fixture.actor, fixture.revivalAction) : activated);
const after = discoveryClassicNexusEffectsPlayer(paid, fixture.actor);
assert.equal(after.reserves, before.reserves + 3); assert.equal(after.tanks, before.tanks - 3);
assert.deepEqual(wallets(paid), wallets(fixture.game));
@@ -100,8 +106,15 @@ for (const configuration of configurations) for (const kind of ['emperor-revival
const history = kind === 'emperor-revival' ? paid.nexusEmperorSecretHistory! : paid.nexusFremenRevivalHistory!;
assert.equal(history.length, 1); assert.equal(history[0].owner, fixture.actor);
assert.equal(history[0].turn, 2); assert.equal(history[0].phase, 4);
assert.equal(history[0].before.tanks - history[0].after.tanks, 3);
assert.equal(history[0].before.spice, history[0].after.spice);
if (kind === 'emperor-revival') {
const receipt = paid.nexusEmperorSecretHistory![0];
assert.equal(receipt.before.tanks - receipt.after.tanks, 3);
assert.equal(receipt.before.spice, receipt.after.spice);
} else {
const grant = paid.nexusFremenRevivalHistory![0];
assert.equal(grant.revivedAtUse, before.revived);
assert.equal(grant.freeAtUse, before.freeForcesRevived ?? 0);
}
unchangedCustody(fixture.game, paid); rejected(paid, fixture.actor, fixture.paymentAction);
if (kind === 'emperor-revival') {
const quote = forceRevivalQuote(paid, after, 3);
@@ -140,12 +153,12 @@ void test('Advanced Discovery Emperor extra-three and Fremen free-three retain a
for (const kind of ['emperor-revival', 'fremen-revival'] as const) {
const fixture = createDiscoveryClassicNexusEffectsFixture({ kind, advanced: true, tech: true, strongholds: true,
ownerFaction: kind === 'emperor-revival' ? 'fremen' : 'emperor', eliteTanks: 2 });
const view = viewGame(fixture.game, fixture.actor);
assert.deepEqual(kind === 'emperor-revival' ? view.nexusEmperorSecretAlly!.revival.eliteOptions : view.nexusFremenRevival!.eliteOptions, [0, 1]);
rejected(fixture.game, fixture.actor, { ...fixture.paymentAction, elite: 2 });
const activated = kind === 'fremen-revival' ? applyAction(structuredClone(fixture.game), fixture.actor, fixture.paymentAction) : fixture.game;
rejected(activated, fixture.actor, kind === 'fremen-revival'
? { type: 'revive', amount: 3, elite: 2 } : { ...fixture.paymentAction, elite: 2 });
legalPolicies(fixture.game, fixture.actor);
const before = discoveryClassicNexusEffectsPlayer(fixture.game, fixture.actor);
const paid = payDiscoveryClassicNexusEffectsFixture(fixture);
const paid = finishDiscoveryClassicNexusEffectsInteraction(payDiscoveryClassicNexusEffectsFixture(fixture));
const after = discoveryClassicNexusEffectsPlayer(paid, fixture.actor);
assert.equal(after.elites!.reserves, before.elites!.reserves + 1);
assert.equal(after.elites!.tanks, before.elites!.tanks - 1); assert.equal(after.elites!.revived, 1);
@@ -156,7 +169,7 @@ void test('Advanced Discovery Emperor extra-three and Fremen free-three retain a
}
});
void test('Discovery ordinary-first Emperor return is extra; ordinary-first Fremen is genuinely late and still rejected', () => {
void test('Discovery ordinary-first Emperor return stays extra; Fremen grants only the remaining ordinary free quota', () => {
const emperor = createDiscoveryClassicNexusEffectsFixture({ advanced: true, tech: true, strongholds: true });
const ordinary = finishDiscoveryClassicNexusEffectsInteraction(applyAction(emperor.game, emperor.actor, { type: 'revive', amount: 3 }));
const paid = payDiscoveryClassicNexusEffectsFixture(emperor, ordinary);
@@ -166,13 +179,26 @@ void test('Discovery ordinary-first Emperor return is extra; ordinary-first Frem
assert.deepEqual(paid.techTokens, ordinary.techTokens);
const fremen = createDiscoveryClassicNexusEffectsFixture({ kind: 'fremen-revival', advanced: true, tech: true });
const late = finishDiscoveryClassicNexusEffectsInteraction(applyAction(fremen.game, fremen.actor, { type: 'revive', amount: 1 }));
assert.match(viewGame(late, fremen.actor).nexusFremenRevival!.blocked ?? '', /before ordinary force revivals/);
rejected(late, fremen.actor, fremen.paymentAction);
for (const kind of ['emperor-revival', 'fremen-revival'] as const) {
const short = createDiscoveryClassicNexusEffectsFixture({ kind, tanks: 2 });
rejected(short.game, short.actor, short.paymentAction);
assert.equal(discoveryClassicNexusEffectsPlayer(short.game, short.actor).tanks, 2);
}
const granted = applyAction(late, fremen.actor, fremen.paymentAction);
assert.deepEqual(discoveryClassicNexusEffectsPlayer(granted, fremen.actor), discoveryClassicNexusEffectsPlayer(late, fremen.actor));
rejected(granted, fremen.actor, { type: 'revive', amount: 3 });
const returned = finishDiscoveryClassicNexusEffectsInteraction(applyAction(granted, fremen.actor, { type: 'revive', amount: 2, elite: 0 }));
assert.equal(discoveryClassicNexusEffectsPlayer(returned, fremen.actor).tanks, 3);
assert.equal(discoveryClassicNexusEffectsPlayer(returned, fremen.actor).revived, 3);
assert.equal(discoveryClassicNexusEffectsPlayer(returned, fremen.actor).freeForcesRevived, 3);
assert.equal(discoveryClassicNexusEffectsPlayer(returned, fremen.actor).spice, discoveryClassicNexusEffectsPlayer(late, fremen.actor).spice);
unchangedCustody(late, returned);
const shortEmperor = createDiscoveryClassicNexusEffectsFixture({ tanks: 2 });
rejected(shortEmperor.game, shortEmperor.actor, shortEmperor.paymentAction);
const shortFremen = createDiscoveryClassicNexusEffectsFixture({ kind: 'fremen-revival', tanks: 2 });
rejected(shortFremen.game, shortFremen.actor, shortFremen.paymentAction);
const shortReturn = finishDiscoveryClassicNexusEffectsInteraction(applyAction(shortFremen.game, shortFremen.actor, { type: 'revive', amount: 2, elite: 0 }));
assert.equal(discoveryClassicNexusEffectsPlayer(shortReturn, shortFremen.actor).tanks, 0);
assert.equal(discoveryClassicNexusEffectsPlayer(shortReturn, shortFremen.actor).revived, 2);
assert.equal(discoveryClassicNexusEffectsPlayer(shortReturn, shortFremen.actor).freeForcesRevived, 2);
assert.equal(shortReturn.nexusCards!.cards!.hands[shortFremen.actor], 'fremen',
'Both available counters already have native free entitlement; keep the unused Nexus card');
unchangedCustody(shortFremen.game, shortReturn);
});
void test('an earned Discovery/Nexus Axlotl pile cannot settle through an unsettled real printed Truthtrance', () => {
@@ -159,11 +159,20 @@ for (const advanced of [false, true]) for (const kind of ['emperor', 'fremen'] a
const closing = closeDiscoveryClassicNexusSkills(fixture, kind);
const game = advanceDiscoveryClassicNexusSkills(closing.game, game => game.phase === 4 && discoveryClassicNexusSkillsClean(game));
assert.equal(player(game, fixture.owner).tanks, 6, 'Tanks arise from the original worm action, never an injected death.');
const view = viewGame(game, fixture.owner), action: Action = { type: kind === 'emperor' ? 'nexusEmperorRevive' : 'nexusFremenRevive',
event: kind === 'emperor' ? view.nexusEmperorSecretAlly!.event : view.nexusFremenRevival!.event, elite: 0 };
const view = viewGame(game, fixture.owner), action: Action = kind === 'emperor'
? { type: 'nexusEmperorRevive', event: view.nexusEmperorSecretAlly!.event, elite: 0 }
: { type: 'nexusFremenRevival', event: view.nexusFremenRevival!.event };
reject(game, fixture.opponent, action);
reject(game, fixture.owner, { ...action, event: 'stale-native-revival' });
if (kind === 'fremen') assert.equal(viewGame(game, fixture.opponent).nexusFremenRevival, null);
const before = player(game, fixture.owner);
let paid = stepDiscoveryClassicNexusSkills(structuredClone(game), { actor: fixture.owner, action });
if (kind === 'fremen') {
assert.deepEqual(player(paid, fixture.owner), before, 'Spending grants quota without returning casualties');
assert.deepEqual(paid.techTokens, game.techTokens);
paid = stepDiscoveryClassicNexusSkills(paid, { actor: fixture.owner, action: { type: 'revive', amount: 3, elite: 0 } });
paid = advanceDiscoveryClassicNexusSkills(paid, discoveryClassicNexusSkillsClean);
}
const after = player(paid, fixture.owner);
assert.equal(after.reserves, before.reserves + 3); assert.equal(after.tanks, 3); assert.equal(after.spice, before.spice);
assert.equal(after.revived, kind === 'fremen' ? 3 : 0);
@@ -174,7 +183,13 @@ for (const advanced of [false, true]) for (const kind of ['emperor', 'fremen'] a
assert.deepEqual(paid.leaderSkills, game.leaderSkills);
const history = kind === 'emperor' ? paid.nexusEmperorSecretHistory! : paid.nexusFremenRevivalHistory!;
assert.equal(history.length, 1); assert.equal(history[0].owner, fixture.owner);
assert.equal(history[0].before.tanks - history[0].after.tanks, 3);
if (kind === 'emperor') {
const receipt = paid.nexusEmperorSecretHistory![0];
assert.equal(receipt.before.tanks - receipt.after.tanks, 3);
} else {
const grant = paid.nexusFremenRevivalHistory![0];
assert.equal(grant.revivedAtUse, 0); assert.equal(grant.freeAtUse, 0);
}
reject(paid, fixture.owner, action);
if (kind === 'emperor') {
paid = stepDiscoveryClassicNexusSkills(paid, { actor: fixture.owner, action: { type: 'revive', amount: 3 } });
@@ -201,10 +216,19 @@ for (const advanced of [false, true]) for (const kind of ['emperor', 'fremen'] a
assert.deepEqual(extra.techTokens, ordinaryFirst.techTokens, 'Extra three cannot retrigger the already earned original Axlotl activity.');
inventory(extra);
} else {
reject(ordinaryFirst, fixture.owner, action);
assert.equal(player(ordinaryFirst, fixture.owner).tanks, 5);
assert.equal(player(ordinaryFirst, fixture.owner).revived, 1);
assert.equal(ordinaryFirst.nexusCards!.cards!.hands[fixture.owner], 'fremen');
const granted = stepDiscoveryClassicNexusSkills(ordinaryFirst, { actor: fixture.owner, action });
assert.deepEqual(player(granted, fixture.owner), player(ordinaryFirst, fixture.owner));
reject(granted, fixture.owner, { type: 'revive', amount: 3, elite: 0 });
const returned = advanceDiscoveryClassicNexusSkills(stepDiscoveryClassicNexusSkills(granted,
{ actor: fixture.owner, action: { type: 'revive', amount: 2, elite: 0 } }), discoveryClassicNexusSkillsClean);
assert.equal(player(returned, fixture.owner).tanks, 3);
assert.equal(player(returned, fixture.owner).revived, 3);
assert.equal(player(returned, fixture.owner).freeForcesRevived, 3);
assert.equal(player(returned, fixture.owner).spice, player(ordinaryFirst, fixture.owner).spice);
assert.equal(returned.nexusCards!.cards!.hands[fixture.owner], null);
assert.deepEqual(returned.techTokens, ordinaryFirst.techTokens, 'The ordinary return cannot retrigger already earned Axlotl activity');
assert.deepEqual(returned.leaderSkills, game.leaderSkills);
inventory(returned);
}
});
}
+12 -6
View File
@@ -41,6 +41,7 @@ export type ClassicNexusSkillsPaymentsFixture = {
opponent: string;
kind: ClassicNexusSkillsPaymentKind;
paymentAction: Action;
revivalAction?: Action;
actions: ClassicNexusSkillsPaymentStep[];
planActions: ClassicNexusSkillsPaymentStep[];
staging: string[];
@@ -258,7 +259,7 @@ export function createClassicNexusSkillsPaymentsFixture(options: ClassicNexusSki
assert.equal(game.nexusCards!.cards!.hands[actor], card);
const afterClosingDraw = structuredClone(game);
game = advanceClassicNexusSkillsPayments(game, state => state.nexusCards?.phase?.stage !== 'drawing', actions);
let paymentAction: Action, declaredIdentity: string | undefined;
let paymentAction: Action, revivalAction: Action | undefined, declaredIdentity: string | undefined;
if (kind === 'emperor-purchase') {
game = advanceClassicNexusSkillsPaymentsToPhase(game, 3, actions);
for (let n = 0; game.decision?.kind !== 'auctionPayment' && n < 160; n++) {
@@ -323,20 +324,25 @@ export function createClassicNexusSkillsPaymentsFixture(options: ClassicNexusSki
if (player.elites) { player.elites.reserves -= elite; player.elites.tanks += elite; }
staging.push(`Conserved ${actor}: ${tanks} reserve counters to Tanks, including ${elite} original elites; no earned-revival receipt staged.`);
const view = viewGame(game, actor);
paymentAction = { type: kind === 'emperor-revival' ? 'nexusEmperorRevive' : 'nexusFremenRevive',
event: kind === 'emperor-revival' ? view.nexusEmperorSecretAlly!.event : view.nexusFremenRevival!.event,
elite: elite ? 1 : 0 };
if (kind === 'emperor-revival') paymentAction = { type: 'nexusEmperorRevive',
event: view.nexusEmperorSecretAlly!.event, elite: elite ? 1 : 0 };
else {
paymentAction = { type: 'nexusFremenRevival', event: view.nexusFremenRevival!.event };
revivalAction = { type: 'revive', amount: 3, elite: elite ? 1 : 0 };
}
}
classicNexusSkillsPaymentsInventory(game);
return { initial, afterSetup, beforeFirstMentat, firstMentatStep, afterFirstMentat, beforeClosingDraw,
closingDraw, afterClosingDraw, beforePayment: structuredClone(game), game, actor, opponent, kind,
paymentAction, actions, planActions, staging, declaredIdentity };
paymentAction, revivalAction, actions, planActions, staging, declaredIdentity };
});
}
/** Stops at the real private Cunning return instead of silently choosing it. */
export function payClassicNexusSkillsPaymentsFixture(fixture: ClassicNexusSkillsPaymentsFixture, state = fixture.game): Game {
return applyAction(state, fixture.actor, fixture.paymentAction);
let game = applyAction(state, fixture.actor, fixture.paymentAction);
if (fixture.revivalAction) game = applyAction(game, fixture.actor, fixture.revivalAction);
return game;
}
export function finishClassicNexusSkillsPaymentsFixture(fixture: ClassicNexusSkillsPaymentsFixture,
state: Game, returned?: string[]): Game {
@@ -39,6 +39,7 @@ export type DiscoveryClassicNexusEffectsFixture = {
opponent: string;
kind: DiscoveryClassicNexusEffectKind;
paymentAction: Action;
revivalAction?: Action;
token: string;
source: string;
tokenOwner: string | null;
@@ -259,7 +260,7 @@ export function createDiscoveryClassicNexusEffectsFixture(options: DiscoveryClas
assert.equal(game.nexusCards!.cards!.hands[actor], selected);
game = advanceDiscoveryClassicNexusEffects(game, game => game.nexusCards?.phase?.stage !== 'drawing', actions);
game = advanceDiscoveryClassicNexusEffectsToPhase(game, kind === 'guild-shipment' ? 5 : 4, actions);
let paymentAction: Action;
let paymentAction: Action, revivalAction: Action | undefined;
if (kind === 'guild-shipment') {
game = advanceDiscoveryClassicNexusEffects(game, game => game.active === actor && clean(game), actions);
paymentAction = { type: 'ship', territory: 'shrine', sector: 0, amount: 3, elite: 0,
@@ -272,18 +273,23 @@ export function createDiscoveryClassicNexusEffectsFixture(options: DiscoveryClas
if (player.elites) { player.elites.reserves -= elite; player.elites.tanks += elite; }
staging.push(`Conserved ${tanks} original ${actor} reserve counters to Tanks, including ${elite} elites; no return or quota receipt assigned.`);
const view = viewGame(game, actor);
paymentAction = { type: kind === 'emperor-revival' ? 'nexusEmperorRevive' : 'nexusFremenRevive',
event: kind === 'emperor-revival' ? view.nexusEmperorSecretAlly!.event : view.nexusFremenRevival!.event, elite: elite ? 1 : 0 };
if (kind === 'emperor-revival') {
paymentAction = { type: 'nexusEmperorRevive', event: view.nexusEmperorSecretAlly!.event, elite: elite ? 1 : 0 };
} else {
paymentAction = { type: 'nexusFremenRevival', event: view.nexusFremenRevival!.event };
revivalAction = { type: 'revive', amount: 3, elite: elite ? 1 : 0 };
}
}
discoveryClassicNexusEffectsInventory(game);
const industry: TechId = kind === 'guild-shipment' ? 'heighliners' : 'axlotl', tokenOwner = game.techTokens?.[industry].owner ?? null;
return { initial, afterSetup, beforeReveal, afterReveal, beforeEntry, afterEntry, beforeClosingDraw, closingDraw,
afterClosingDraw, game, actor, opponent, kind, paymentAction, token: shrine.id, source, industry, tokenOwner,
afterClosingDraw, game, actor, opponent, kind, paymentAction, revivalAction, token: shrine.id, source, industry, tokenOwner,
tokenCount: tokenOwner ? ownedTech(game.techTokens, tokenOwner).length : 0, actions, staging };
});
}
export function payDiscoveryClassicNexusEffectsFixture(fixture: DiscoveryClassicNexusEffectsFixture, game = fixture.game): Game {
return applyAction(game, fixture.actor, fixture.paymentAction);
const activated = applyAction(game, fixture.actor, fixture.paymentAction);
return fixture.revivalAction ? applyAction(activated, fixture.actor, fixture.revivalAction) : activated;
}
export function finishDiscoveryClassicNexusEffectsInteraction(state: Game, actions?: DiscoveryClassicNexusEffectStep[]): Game {
return advanceDiscoveryClassicNexusEffects(state, game => clean(game) && !game.pendingTreacheryDiscard && !game.pendingKarama, actions);
+199
View File
@@ -0,0 +1,199 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeNexusGameForAudit, joinGame, newPlayer, viewGame,
type Action, type Game,
} from '../game/engine';
import { location, territory } from '../game/board';
import { treacheryDeck } from '../game/cards';
import { validateNexusCards } from '../game/nexus-cards';
import { shipmentAvailable } from '../game/shipment-opportunity';
import { traitorDeck } from '../game/traitors';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
import { nexusTleilaxuSecretAllyPlayer as player } from './fixture-nexus-tleilaxu-secret-ally';
export type NexusFremenFreeQuotaStep = { actor: string; action: Action };
export type NexusFremenFreeQuotaFixtureOptions = { advanced?: boolean; tleilaxu?: boolean };
export type NexusFremenFreeQuotaFixture = {
game: Game;
owner: string;
initial: Game;
scenario: {
advanced: boolean;
tleilaxu: boolean;
opponent: string;
partner: string;
event: string;
staging: string[];
};
actions: NexusFremenFreeQuotaStep[];
physicalfacts: {
shipmentCost: number;
deadLeader: string;
battleLosses: number;
beforeDraw: Game;
draw: NexusFremenFreeQuotaStep;
afterDraw: Game;
};
};
export const nexusFremenFreeQuotaQuiet = (game: Game): boolean =>
!game.phaseOpening && !game.response && !game.decision && !game.pendingRevival;
/** Continue only original public choices; private battle plans and closing draws
* remain explicit actions of the chronology rather than fabricated state. */
export function nextNexusFremenFreeQuotaNativeStep(game: Game): NexusFremenFreeQuotaStep | null {
if (game.nexusCards?.phase?.stage === 'drawing' && nexusFremenFreeQuotaQuiet(game)) return null;
if (game.decision?.kind === 'faceDance')
return { actor: game.decision.player, action: { type: 'decision', reveal: false } };
return nextStrongholdFactionsNativeStep(game);
}
export function advanceNexusFremenFreeQuota(
game: Game, until: (state: Game) => boolean, actions?: NexusFremenFreeQuotaStep[],
): Game {
for (let count = 0; count < 2000; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const next = nextNexusFremenFreeQuotaNativeStep(game);
assert.ok(next, `Native Fremen chronology stopped at ${game.status}/${game.turn}/${game.phase}`);
actions?.push(structuredClone(next));
game = applyAction(game, next.actor, next.action);
}
throw Error('Native Fremen chronology did not reach its boundary');
}
/** Scalar producer entropy, scoped to the real initializer/actions. Each shuffle
* retains its original printed order; no candidate histories are retried. */
function identityRandom<T>(run: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
assert.ok(array);
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255);
return array;
};
try { return run(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
export function assertNexusFremenFreeQuotaInventory(game: Game): void {
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? [])];
assert.deepEqual(cards.map(card => card.id).sort(), treacheryDeck(game.expansions).map(card => card.id).sort());
assert.equal(new Set(cards.map(card => card.id)).size, cards.length);
for (const p of game.players) {
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, n) => sum + n, 0), 20);
if (p.elites) {
assert.equal(p.elites.reserves + p.elites.tanks +
Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0), p.faction === 'emperor' ? 5 : 3);
assert.ok(p.elites.reserves <= p.reserves && p.elites.tanks <= p.tanks);
}
}
assert.deepEqual([...(game.traitorReserve ?? []), ...game.players.flatMap(p =>
[...p.traitors, ...p.traitorChoices, ...(p.faceDancers ?? []).map(card => card.leader)])].sort(),
traitorDeck(game.players, game.expansions.includes('ix')).sort());
assert.ok(game.nexusCards?.cards);
validateNexusCards(game.nexusCards.cards, game.players);
}
/** Genuine original Basic/Advanced setup, shipment, weapon death, eight-counter
* exchange, Shai-Hulud, reciprocal alliance, and physical Fremen closing draw.
* Only original undealt deck order and producer entropy are staged. */
export function createNexusFremenFreeQuotaFixture(
options: NexusFremenFreeQuotaFixtureOptions = {},
): NexusFremenFreeQuotaFixture {
return identityRandom(() => {
const advanced = options.advanced ?? false, tleilaxu = options.tleilaxu ?? false;
const owner = 'atreides', opponent = 'emperor', partner = tleilaxu ? 'tleilaxu' : 'guild';
let lobby = createGame('NEXUSFREMENFREEQUOTA', newPlayer(owner, 'Atreides', 'atreides'), advanced, tleilaxu ? ['ix'] : []);
joinGame(lobby, newPlayer(partner, tleilaxu ? 'Tleilaxu' : 'Guild', tleilaxu ? 'tleilaxu' : 'guild'));
joinGame(lobby, newPlayer(opponent, 'Emperor', 'emperor'));
lobby.nexusCards = { cards: null, phase: null };
for (const p of lobby.players) lobby = applyAction(lobby, p.id, { type: 'ready' });
const initial = initializeNexusGameForAudit(lobby);
const actions: NexusFremenFreeQuotaStep[] = [], staging: string[] = [];
const act = (game: Game, actor: string, action: Action): Game => {
actions.push({ actor, action: structuredClone(action) });
return applyAction(game, actor, action);
};
let game = advanceNexusFremenFreeQuota(initial, state => state.status === 'playing', actions);
assert.ok(!game.players.some(p => p.faction === 'fremen'));
assert.ok(game.players.every(p => p.hand.length > 0));
if (tleilaxu) assert.equal(player(game, partner).faceDancers?.length, 3);
const weapon = player(game, opponent).hand.find(card => card.kind === 'projectile' || card.kind === 'poison');
assert.ok(weapon, 'The original third-seat deal supplies the real leader-killing weapon');
assertNexusFremenFreeQuotaInventory(game);
game = advanceNexusFremenFreeQuota(game,
state => state.turn === 1 && state.phase === 5 && state.active === opponent && nexusFremenFreeQuotaQuiet(state), actions);
const sector = territory('arrakeen').sectors[0], target = location('arrakeen', sector);
assert.ok(shipmentAvailable(game, player(game, opponent)));
const shipmentSpice = player(game, opponent).spice;
game = act(game, opponent, { type: 'ship', territory: 'arrakeen', sector, amount: 1, elite: 0, allyPayment: 0 });
game = advanceNexusFremenFreeQuota(game, nexusFremenFreeQuotaQuiet, actions);
const shipmentCost = shipmentSpice - player(game, opponent).spice;
assert.ok(shipmentCost > 0);
assert.equal(player(game, opponent).forces[target], 1);
assert.equal(player(game, owner).forces[target], 10);
game = advanceNexusFremenFreeQuota(game,
state => state.turn === 1 && state.phase === 6 && nexusFremenFreeQuotaQuiet(state), actions);
if (!game.battle) {
assert.ok(game.active === owner || game.active === opponent);
game = act(game, game.active, { type: 'chooseBattle', territory: 'arrakeen',
target: game.active === owner ? opponent : owner });
}
game = advanceNexusFremenFreeQuota(game,
state => nexusFremenFreeQuotaQuiet(state) && !nextNexusFremenFreeQuotaNativeStep(state), actions);
assert.ok(game.battle && !game.battle.revealed);
const ownLeader = player(game, owner).leaders.filter(leader => !leader.dead).sort((a, b) => a.strength - b.strength)[0];
const otherLeader = player(game, opponent).leaders.filter(leader => !leader.dead).sort((a, b) => a.strength - b.strength)[0];
assert.ok(ownLeader && otherLeader);
game = act(game, owner, { type: 'battlePlan', dial: advanced ? 4 : 8, support: 0,
leader: ownLeader.id, weapon: null, defense: null });
game = act(game, opponent, { type: 'battlePlan', dial: 0, support: 0,
leader: otherLeader.id, weapon: weapon.id, defense: null });
game = advanceNexusFremenFreeQuota(game, state => state.turn === 2 && state.phase === 1 &&
nexusFremenFreeQuotaQuiet(state) && !state.spiceSequence && !state.spiceResolution &&
!state.spiceWindow && !state.nexus && state.ready.length === 0, actions);
assert.equal(player(game, owner).leaders.find(leader => leader.id === ownLeader.id)?.dead, true);
assert.equal(player(game, owner).tanks, 8);
const wormIndex = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper);
assert.ok(wormIndex >= 0);
game.spiceDeck.unshift(game.spiceDeck.splice(wormIndex, 1)[0]);
staging.push('Ordered one original undealt Shai-Hulud singleton before the real turn-two Spice Blow.');
game = advanceNexusFremenFreeQuota(game,
state => state.nexus === true && !state.spiceWindow && nexusFremenFreeQuotaQuiet(state), actions);
game = act(game, opponent, { type: 'alliance', target: partner });
game = act(game, partner, { type: 'alliance', target: opponent });
assert.equal(player(game, owner).ally, null);
game = advanceNexusFremenFreeQuota(game, state => state.nexusCards?.phase?.stage === 'drawing', actions);
assert.deepEqual(game.nexusCards!.phase!.eligible, [owner]);
const cards = game.nexusCards!.cards!;
assert.equal(cards.hands[owner], null);
const fremenIndex = cards.deck.indexOf('fremen');
assert.ok(fremenIndex >= 0);
cards.deck.unshift(cards.deck.splice(fremenIndex, 1)[0]);
staging.push('Ordered the original undealt Fremen Nexus singleton before the real closing draw; no hand was replaced.');
const beforeDraw = structuredClone(game);
const draw: NexusFremenFreeQuotaStep = { actor: owner, action: { type: 'nexusCardChoice',
turn: game.turn, card: cards.hands[owner], choice: 'draw', ownRedraws: 0 } };
game = act(game, draw.actor, draw.action);
assert.equal(game.nexusCards!.cards!.hands[owner], 'fremen');
const afterDraw = structuredClone(game);
game = advanceNexusFremenFreeQuota(game,
state => state.turn === 2 && state.phase === 4 && nexusFremenFreeQuotaQuiet(state), actions);
assert.equal(player(game, owner).tanks, 8);
assert.equal(player(game, owner).freeForcesRevived, 0);
assert.equal(player(game, owner).ally, null);
const offer = viewGame(game, owner).nexusFremenRevival;
assert.ok(offer);
assert.equal(offer.blocked, null);
assertNexusFremenFreeQuotaInventory(game);
return { initial, game, owner, actions,
scenario: { advanced, tleilaxu, opponent, partner, event: offer.event, staging },
physicalfacts: { shipmentCost, deadLeader: ownLeader.id, battleLosses: 8, beforeDraw, draw, afterDraw } };
});
}
+9 -4
View File
@@ -35,6 +35,7 @@ export type NexusModulePaymentsFixture = {
actor: string;
kind: NexusModulePaymentKind;
paymentAction: Action;
revivalAction?: Action;
amount: number;
cost: number;
token: TechId;
@@ -204,7 +205,7 @@ export function createNexusModulePaymentsFixture(options: NexusModulePaymentsOpt
const shipment = kind.endsWith('shipment');
const purchase = kind === 'emperor-purchase';
game = advanceNexusModulePaymentsToPhase(game, shipment ? 5 : purchase ? 3 : 4, actions);
let paymentAction: Action, cost = 0, destination: string | undefined;
let paymentAction: Action, revivalAction: Action | undefined, cost = 0, destination: string | undefined;
const amount = shipment ? 5 : purchase ? 1 : 3;
if (purchase) {
for (let n = 0; game.decision?.kind !== 'auctionPayment' && n < 100; n++) {
@@ -243,20 +244,24 @@ export function createNexusModulePaymentsFixture(options: NexusModulePaymentsOpt
staging.push(`Conserved ${actor}: ${tanks} reserves to Tanks, including ${elite} actual elites.`);
const view = viewGame(game, actor);
const event = kind === 'emperor-revival' ? view.nexusEmperorSecretAlly!.event : view.nexusFremenRevival!.event;
paymentAction = { type: kind === 'emperor-revival' ? 'nexusEmperorRevive' : 'nexusFremenRevive',
event, elite: elite ? 1 : 0 };
if (kind === 'emperor-revival') paymentAction = { type: 'nexusEmperorRevive', event, elite: elite ? 1 : 0 };
else {
paymentAction = { type: 'nexusFremenRevival', event };
revivalAction = { type: 'revive', amount: 3, elite: elite ? 1 : 0 };
}
}
nexusInventory(game);
const token: TechId = shipment ? 'heighliners' : purchase ? 'production' : 'axlotl';
const tokenOwner = game.techTokens?.[token].owner ?? null;
const tokenCount = tokenOwner ? ownedTech(game.techTokens, tokenOwner).length : 0;
return { game, beforePayment: structuredClone(game), setup, actions, staging, actor, kind,
paymentAction, amount, cost, token, tokenOwner, tokenCount, destination };
paymentAction, revivalAction, amount, cost, token, tokenOwner, tokenCount, destination };
});
}
export function payNexusModulePaymentsFixture(fixture: NexusModulePaymentsFixture): Game {
let game = applyAction(fixture.game, fixture.actor, fixture.paymentAction);
if (fixture.revivalAction) game = applyAction(game, fixture.actor, fixture.revivalAction);
for (let n = 0; (!clean(game) || game.pendingShipment || game.pendingRevival) && n < 200; n++)
game = step(game);
assert.ok(clean(game) && !game.pendingShipment && !game.pendingRevival);
+13 -6
View File
@@ -44,6 +44,7 @@ export type NexusSkillsModulesPaymentsFixture = {
opponent: string;
kind: NexusSkillsModulesPaymentKind;
paymentAction: Action;
revivalAction?: Action;
amount: number;
cost: number;
token: TechId;
@@ -249,7 +250,7 @@ export function createNexusSkillsModulesPaymentsFixture(options: NexusSkillsModu
const afterClosingDraw = structuredClone(game);
game = advanceNexusSkillsModulesPayments(game, state => state.nexusCards?.phase?.stage !== 'drawing', actions);
game = advanceNexusSkillsModulesPaymentsToPhase(game, kind === 'native-shipment' ? 5 : kind === 'emperor-purchase' ? 3 : 4, actions);
let paymentAction: Action, destination: string | undefined, cost = 0;
let paymentAction: Action, revivalAction: Action | undefined, destination: string | undefined, cost = 0;
const amount = kind === 'native-shipment' ? options.amount ?? 3 : kind === 'emperor-purchase' ? 1 : 3;
if (kind === 'emperor-purchase') {
for (let n = 0; game.decision?.kind !== 'auctionPayment' && n < 160; n++) {
@@ -282,20 +283,26 @@ export function createNexusSkillsModulesPaymentsFixture(options: NexusSkillsModu
if (player.elites) { player.elites.reserves -= elite; player.elites.tanks += elite; }
staging.push(`Conserved ${actor}: ${tanks} original reserve counters to Tanks, including ${elite} elites; no revival receipt staged.`);
const view = viewGame(game, actor);
paymentAction = { type: kind === 'emperor-revival' ? 'nexusEmperorRevive' : 'nexusFremenRevive',
event: kind === 'emperor-revival' ? view.nexusEmperorSecretAlly!.event : view.nexusFremenRevival!.event, elite: elite ? 1 : 0 };
if (kind === 'emperor-revival') paymentAction = { type: 'nexusEmperorRevive',
event: view.nexusEmperorSecretAlly!.event, elite: elite ? 1 : 0 };
else {
paymentAction = { type: 'nexusFremenRevival', event: view.nexusFremenRevival!.event };
revivalAction = { type: 'revive', amount: 3, elite: elite ? 1 : 0 };
}
}
nexusSkillsModulesPaymentsInventory(game);
const token: TechId = kind === 'native-shipment' ? 'heighliners' : kind === 'emperor-purchase' ? 'production' : 'axlotl';
const tokenOwner = game.techTokens?.[token].owner ?? null;
return { initial, afterSetup, beforeFirstMentat, firstMentatStep, afterFirstMentat, beforeClosingDraw, closingDraw,
afterClosingDraw, beforePayment: structuredClone(game), game, actor, opponent, kind, paymentAction, amount, cost,
afterClosingDraw, beforePayment: structuredClone(game), game, actor, opponent, kind, paymentAction, revivalAction, amount, cost,
token, tokenOwner, tokenCount: tokenOwner ? ownedTech(game.techTokens, tokenOwner).length : 0, destination, actions, staging };
});
}
/** One original action only: exposes any actual interception/payment response. */
/** Commits the payment and optional native return, exposing actual settlement responses. */
export function payNexusSkillsModulesPaymentsFixture(fixture: NexusSkillsModulesPaymentsFixture, state = fixture.game): Game {
return applyAction(state, fixture.actor, fixture.paymentAction);
let game = applyAction(state, fixture.actor, fixture.paymentAction);
if (fixture.revivalAction) game = applyAction(game, fixture.actor, fixture.revivalAction);
return game;
}
export function finishNexusSkillsModulesPaymentsFixture(_fixture: NexusSkillsModulesPaymentsFixture, state: Game,
actions?: NexusSkillsModulesPaymentStep[]): Game {
@@ -162,7 +162,7 @@ for (const kind of ['native-suk', 'visitor-suk'] as const) {
}
for (const advanced of [false, true]) {
void test(`${advanced ? 'Advanced typed' : 'Basic ordinary'} real zero-save Suk losses fund exact-three Fremen Nexus return at the next actual Revival`, () => {
void test(`${advanced ? 'Advanced typed' : 'Basic ordinary'} real zero-save Suk losses return through the Fremen quota and normal Homeworld revival at the next actual Revival`, () => {
const f = fixture({ advanced, discovery: advanced, tech: true, strongholds: advanced });
const pending = settle(reveal(f), 'suk'); assert.ok(pending.decision?.kind === 'sukRescue');
const choice = pending.decision.options.findIndex(option => !option.normal && !option.elite && !option.kept);
@@ -173,16 +173,22 @@ for (const advanced of [false, true]) {
assert.equal(player(done, f.owner).elites?.tanks ?? 0, advanced ? 1 : 0);
assert.equal(done.nexusCards!.cards!.hands[f.owner], 'fremen', 'Battle rescue never spends the unrelated physically held Nexus singleton.');
const window = revivalWindow(done, f.actions, f.staging), offer = viewGame(window, f.owner).nexusFremenRevival!;
assert.equal(offer.blocked, null); assert.ok(offer.eliteOptions.includes(advanced ? 1 : 0));
assert.equal(offer.blocked, null);
assert.equal(viewGame(window, f.opponent).nexusFremenRevival, null);
legalPolicies(window, f.owner);
const action: Action = { type: 'nexusFremenRevive', event: offer.event, elite: advanced ? 1 : 0 };
const action: Action = { type: 'nexusFremenRevival', event: offer.event };
reject(window, f.opponent, action);
reject(window, f.owner, { ...action, event: 'stale-free-three' });
reject(window, f.owner, { ...action, elite: 2 });
const before = structuredClone(player(window, f.owner));
const kaitain = pool(window, f.owner, 'homeworld:emperor');
const salusa = advanced ? pool(window, f.owner, 'homeworld:emperor:salusa') : null;
const paid = applyAction(JSON.parse(JSON.stringify(window)), f.owner, action), after = player(paid, f.owner);
const granted = applyAction(JSON.parse(JSON.stringify(window)), f.owner, action);
assert.deepEqual(player(granted, f.owner), before, 'Quota activation does not move forces or consume the ordinary return ledger');
assert.deepEqual(granted.homeworlds, window.homeworlds);
assert.deepEqual(granted.techTokens, window.techTokens, 'Axlotl activity belongs to the native return, not card activation');
if (advanced) reject(granted, f.owner, { type: 'revive', amount: 3, elite: 2 });
const paid = settle(applyAction(granted, f.owner, { type: 'revive', amount: 3, elite: advanced ? 1 : 0 }));
const after = player(paid, f.owner);
assert.equal(after.spice, before.spice, 'Free-three is not charged as paid revival or external Suk placement.');
assert.equal(after.tanks, before.tanks - 3); assert.equal(after.reserves, before.reserves + 3);
assert.equal(after.revived, 3); assert.equal(after.freeForcesRevived, 3);
@@ -193,6 +199,8 @@ for (const advanced of [false, true]) {
assert.equal(after.elites!.reserves, before.elites!.reserves + 1);
assert.equal(after.elites!.revived, 1);
}
assert.deepEqual(paid.leaderSkills, window.leaderSkills, 'Normal force revival retains the original trained Skill custody');
if (advanced) assert.deepEqual(paid.discoveries, window.discoveries);
assert.equal(paid.nexusCards!.cards!.hands[f.owner], null);
assert.equal(paid.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1);
reject(paid, f.owner, action);
@@ -0,0 +1,242 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { forceRevivalQuote } from '../game/revival';
import { nexusTleilaxuSecretAllyPlayer as player } from './fixture-nexus-tleilaxu-secret-ally';
import {
advanceNexusFremenFreeQuota, assertNexusFremenFreeQuotaInventory,
createNexusFremenFreeQuotaFixture, nexusFremenFreeQuotaQuiet,
} from './fixture-nexus-fremen-free-quota';
function rejected(game: Game, actor: string, action: Action): void {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before, 'Refusal preserves physical custody, ledgers, banks and pending frames');
}
for (const advanced of [false, true]) {
for (const tleilaxu of [false, true]) {
const profile = `${advanced ? 'Advanced' : 'Basic'} ${tleilaxu ? 'native Tleilaxu/Ix' : 'classic'}`;
void test(`${profile}: activation grants quota, then original one-plus-two requests return three free counters`, () => {
const f = createNexusFremenFreeQuotaFixture({ advanced, tleilaxu });
const before = player(f.game, f.owner);
const offer = viewGame(f.game, f.owner).nexusFremenRevival;
assert.ok(offer);
assert.equal(offer.blocked, null);
assert.equal(offer.quota, 3);
assert.equal(offer.freeRemaining, 3);
assert.equal(offer.maxForces, 3);
assert.equal(offer.ordinaryRemaining, 3);
for (const other of f.game.players.filter(p => p.id !== f.owner))
assert.equal(viewGame(f.game, other.id).nexusFremenRevival, null);
let game = applyAction(f.game, f.owner, { type: 'nexusFremenRevival', event: offer.event });
assert.deepEqual(game.players, f.game.players, 'Enabling the quota returns no counters, leaders or spice');
assert.equal(game.turn, f.game.turn);
assert.equal(game.phase, 4);
assert.ok(nexusFremenFreeQuotaQuiet(game), 'Activation starts no substitute revival or income pipeline');
assert.equal(game.nexusCards!.cards!.hands[f.owner], null);
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1);
assert.equal(viewGame(game, f.owner).nexusFremenRevival, null);
assert.equal(viewGame(game, f.owner).revival.freeRemaining, 3);
assert.equal(viewGame(game, f.owner).revival.forcesRemaining, 3);
const receipt = game.nexusFremenRevivalHistory!.at(-1)!;
assert.equal(receipt.version, 2);
assert.equal(receipt.revivedAtUse, 0);
assert.equal(receipt.freeAtUse, 0);
assert.equal(receipt.policy, 'ordinary-free-quota-three');
assert.equal(receipt.legacyReturn, undefined, 'New activation does not manufacture an old physical transfer');
rejected(game, f.owner, { type: 'nexusFremenRevival', event: offer.event });
const incomeBefore = player(game, f.scenario.partner).spice;
game = applyAction(game, f.owner, { type: 'revive', amount: 1, elite: 0 });
game = advanceNexusFremenFreeQuota(game, state => state.turn === 2 && state.phase === 4 && nexusFremenFreeQuotaQuiet(state));
assert.equal(player(game, f.owner).tanks, before.tanks - 1);
assert.equal(player(game, f.owner).reserves, before.reserves + 1);
assert.equal(player(game, f.owner).revived, 1);
assert.equal(player(game, f.owner).freeForcesRevived, 1);
assert.equal(player(game, f.owner).spice, before.spice);
assert.equal(viewGame(game, f.owner).revival.freeRemaining, 2);
assert.equal(player(game, f.scenario.partner).spice, incomeBefore + (tleilaxu ? 1 : 0));
if (tleilaxu) assert.equal(game.revivalFreeIncome?.[f.owner], game.turn);
game = applyAction(game, f.owner, { type: 'revive', amount: 2, elite: 0 });
game = advanceNexusFremenFreeQuota(game, state => state.turn === 2 && state.phase === 4 && nexusFremenFreeQuotaQuiet(state));
const holder = player(game, f.owner);
assert.equal(holder.tanks, before.tanks - 3);
assert.equal(holder.reserves, before.reserves + 3);
assert.equal(holder.revived, 3);
assert.equal(holder.freeForcesRevived, 3);
assert.equal(holder.spice, before.spice);
assert.deepEqual(holder.leaders, before.leaders, 'No extra leader revival is granted');
assert.equal(viewGame(game, f.owner).revival.freeRemaining, 0);
assert.equal(viewGame(game, f.owner).revival.forcesRemaining, 0);
assert.equal(player(game, f.scenario.partner).spice, incomeBefore + (tleilaxu ? 1 : 0),
'The real native free-revival income occurs once, not once per request or on card activation');
rejected(game, f.owner, { type: 'revive', amount: 1, elite: 0 });
assertNexusFremenFreeQuotaInventory(game);
});
void test(`${profile}: prior ordinary free use counts and late activation permits only the ordinary remainder`, () => {
const f = createNexusFremenFreeQuotaFixture({ advanced, tleilaxu });
const before = player(f.game, f.owner);
let game = applyAction(f.game, f.owner, { type: 'revive', amount: 2, elite: 0 });
game = advanceNexusFremenFreeQuota(game, state => state.turn === 2 && state.phase === 4 && nexusFremenFreeQuotaQuiet(state));
assert.equal(player(game, f.owner).revived, 2);
assert.equal(player(game, f.owner).freeForcesRevived, 2);
const offer = viewGame(game, f.owner).nexusFremenRevival;
assert.ok(offer);
assert.equal(offer.blocked, null);
assert.equal(offer.freeRemaining, 1);
assert.equal(offer.maxForces, 1);
assert.equal(offer.ordinaryRemaining, 1);
const atUse = structuredClone(player(game, f.owner));
game = applyAction(game, f.owner, { type: 'nexusFremenRevival', event: offer.event });
assert.deepEqual(player(game, f.owner), atUse);
assert.equal(game.nexusFremenRevivalHistory!.at(-1)!.revivedAtUse, 2);
assert.equal(game.nexusFremenRevivalHistory!.at(-1)!.freeAtUse, 2);
rejected(game, f.owner, { type: 'revive', amount: 2, elite: 0 });
game = applyAction(game, f.owner, { type: 'revive', amount: 1, elite: 0 });
game = advanceNexusFremenFreeQuota(game, state => state.turn === 2 && state.phase === 4 && nexusFremenFreeQuotaQuiet(state));
assert.equal(player(game, f.owner).tanks, before.tanks - 3);
assert.equal(player(game, f.owner).reserves, before.reserves + 3);
assert.equal(player(game, f.owner).spice, before.spice);
assert.equal(player(game, f.owner).freeForcesRevived, 3);
assert.equal(viewGame(game, f.owner).revival.forcesRemaining, 0);
assertNexusFremenFreeQuotaInventory(game);
});
void test(`${profile}: wrong actor, stale event, payload keys and removed transfer action cannot spend the singleton`, () => {
const f = createNexusFremenFreeQuotaFixture({ advanced, tleilaxu });
for (const actor of [f.scenario.opponent, f.scenario.partner])
rejected(f.game, actor, { type: 'nexusFremenRevival', event: f.scenario.event });
rejected(f.game, f.owner, { type: 'nexusFremenRevival', event: 'stale' });
rejected(f.game, f.owner, { type: 'nexusFremenRevival', event: f.scenario.event, amount: 3 });
rejected(f.game, f.owner, { type: 'nexusFremenRevival', event: f.scenario.event, elite: 0 });
rejected(f.game, f.owner, { type: 'nexusFremenRevive', event: f.scenario.event, elite: 0 });
const next = advanceNexusFremenFreeQuota(f.game,
state => state.turn === 3 && state.phase === 4 && nexusFremenFreeQuotaQuiet(state));
assert.equal(next.nexusCards!.cards!.hands[f.owner], 'fremen');
assert.equal(player(next, f.owner).tanks, 8);
const offer = viewGame(next, f.owner).nexusFremenRevival;
assert.ok(offer);
assert.equal(offer.blocked, null);
assert.notEqual(offer.event, f.scenario.event);
rejected(next, f.owner, { type: 'nexusFremenRevival', event: f.scenario.event });
let returned = applyAction(next, f.owner, { type: 'nexusFremenRevival', event: offer.event });
returned = applyAction(returned, f.owner, { type: 'revive', amount: 1, elite: 0 });
returned = advanceNexusFremenFreeQuota(returned,
state => state.turn === 3 && state.phase === 4 && nexusFremenFreeQuotaQuiet(state));
assert.equal(player(returned, f.owner).tanks, 7);
assert.equal(player(returned, f.owner).freeForcesRevived, 1);
assertNexusFremenFreeQuotaInventory(returned);
});
void test(`${profile}: an unused portion expires at real phase end and does not become next-turn free quota`, () => {
const f = createNexusFremenFreeQuotaFixture({ advanced, tleilaxu });
let game = applyAction(f.game, f.owner, { type: 'nexusFremenRevival', event: f.scenario.event });
game = applyAction(game, f.owner, { type: 'revive', amount: 1, elite: 0 });
game = advanceNexusFremenFreeQuota(game, state => state.turn === 2 && state.phase === 4 && nexusFremenFreeQuotaQuiet(state));
const phaseEnd = advanceNexusFremenFreeQuota(game,
state => state.turn === 2 && state.phase === 5 && nexusFremenFreeQuotaQuiet(state));
rejected(phaseEnd, f.owner, { type: 'revive', amount: 1, elite: 0 });
rejected(phaseEnd, f.owner, { type: 'nexusFremenRevival', event: f.scenario.event });
const next = advanceNexusFremenFreeQuota(phaseEnd,
state => state.turn === 3 && state.phase === 4 && nexusFremenFreeQuotaQuiet(state));
const holder = player(next, f.owner);
assert.equal(holder.tanks, 7);
assert.equal(holder.revived, 0);
assert.equal(holder.freeForcesRevived, 0);
assert.equal(viewGame(next, f.owner).nexusFremenRevival, null);
assert.equal(viewGame(next, f.owner).revival.freeRemaining, 2, 'Only the native Atreides rate survives');
assert.equal(viewGame(next, f.owner).revival.forcesRemaining, 3);
assert.equal(forceRevivalQuote(next, holder, 3).free, 2);
const price = forceRevivalQuote(next, holder, 3).cost;
assert.ok(price > 0);
let returned = applyAction(next, f.owner, { type: 'revive', amount: 3, elite: 0 });
returned = advanceNexusFremenFreeQuota(returned,
state => state.turn === 3 && state.phase === 4 && nexusFremenFreeQuotaQuiet(state));
assert.equal(player(returned, f.owner).tanks, 4);
assert.equal(player(returned, f.owner).freeForcesRevived, 2);
assert.equal(player(returned, f.owner).spice, holder.spice - price);
assert.equal(returned.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1);
assertNexusFremenFreeQuotaInventory(returned);
});
void test(`${profile}: the original AI can enable the quota and use its ordinary revival action`, () => {
const f = createNexusFremenFreeQuotaFixture({ advanced, tleilaxu });
const view = viewGame(f.game, f.owner);
view.players.find(p => p.id === f.owner)!.bot = 'Easy';
const enable = botActions(view).find(action => action.type === 'nexusFremenRevival');
assert.ok(enable, 'The owner AI has a legal activation, not a direct-transfer action');
let game = applyAction(f.game, f.owner, enable);
const enabled = viewGame(game, f.owner);
enabled.players.find(p => p.id === f.owner)!.bot = 'Easy';
const ordinary = botActions(enabled).find(action => action.type === 'revive');
assert.ok(ordinary);
game = applyAction(game, f.owner, ordinary);
game = advanceNexusFremenFreeQuota(game,
state => state.turn === 2 && state.phase === 4 && nexusFremenFreeQuotaQuiet(state));
assert.equal(player(game, f.owner).tanks, 8 - player(game, f.owner).revived);
assert.ok(player(game, f.owner).revived > 0);
assert.equal(player(game, f.owner).freeForcesRevived, player(game, f.owner).revived);
assert.equal(player(game, f.owner).spice, player(f.game, f.owner).spice);
assertNexusFremenFreeQuotaInventory(game);
});
}
void test(`${advanced ? 'Advanced' : 'Basic'} native Tleilaxu: prior paid returns keep their price and consume the native expanded allowance`, () => {
const f = createNexusFremenFreeQuotaFixture({ advanced, tleilaxu: true });
let game = applyAction(f.game, f.scenario.partner, { type: 'tleilaxuRevivalLimit', target: f.owner });
game = advanceNexusFremenFreeQuota(game, state => state.turn === 2 && state.phase === 4 && nexusFremenFreeQuotaQuiet(state));
assert.equal(viewGame(game, f.owner).revival.forcesRemaining, 5);
const before = structuredClone(player(game, f.owner));
const priorPrice = forceRevivalQuote(game, before, 3).cost;
assert.ok(priorPrice > 0);
game = applyAction(game, f.owner, { type: 'revive', amount: 3, elite: 0 });
game = advanceNexusFremenFreeQuota(game, state => state.turn === 2 && state.phase === 4 && nexusFremenFreeQuotaQuiet(state));
assert.equal(player(game, f.owner).revived, 3);
assert.equal(player(game, f.owner).freeForcesRevived, 2);
assert.equal(player(game, f.owner).spice, before.spice - priorPrice);
const offer = viewGame(game, f.owner).nexusFremenRevival;
assert.ok(offer);
assert.equal(offer.blocked, null);
assert.equal(offer.freeRemaining, 1);
assert.equal(offer.maxForces, 1);
assert.equal(offer.ordinaryRemaining, 2);
const atUse = structuredClone(player(game, f.owner));
game = applyAction(game, f.owner, { type: 'nexusFremenRevival', event: offer.event });
assert.deepEqual(player(game, f.owner), atUse, 'No refund or paid-to-free reclassification occurs at activation');
assert.equal(game.nexusFremenRevivalHistory!.at(-1)!.revivedAtUse, 3);
assert.equal(game.nexusFremenRevivalHistory!.at(-1)!.freeAtUse, 2);
assert.equal(viewGame(game, f.owner).revival.forcesRemaining, 2, 'The native five-return permission survives without expansion');
const afterPrice = forceRevivalQuote(game, player(game, f.owner), 2).cost;
assert.ok(afterPrice > 0, 'Only one of the remaining two is free');
rejected(game, f.owner, { type: 'revive', amount: 3, elite: 0 });
game = applyAction(game, f.owner, { type: 'revive', amount: 2, elite: 0 });
game = advanceNexusFremenFreeQuota(game, state => state.turn === 2 && state.phase === 4 && nexusFremenFreeQuotaQuiet(state));
assert.equal(player(game, f.owner).revived, 5);
assert.equal(player(game, f.owner).freeForcesRevived, 3, 'Actual free use is three, not five reclassified returns');
assert.equal(player(game, f.owner).tanks, before.tanks - 5);
assert.equal(player(game, f.owner).reserves, before.reserves + 5);
assert.equal(player(game, f.owner).spice, before.spice - priorPrice - afterPrice);
rejected(game, f.owner, { type: 'revive', amount: 1, elite: 0 });
assertNexusFremenFreeQuotaInventory(game);
});
void test(`${advanced ? 'Advanced' : 'Basic'} classic: a fully consumed ordinary allowance cannot discard the card uselessly`, () => {
const f = createNexusFremenFreeQuotaFixture({ advanced });
let game = applyAction(f.game, f.owner, { type: 'revive', amount: 3, elite: 0 });
game = advanceNexusFremenFreeQuota(game, state => state.turn === 2 && state.phase === 4 && nexusFremenFreeQuotaQuiet(state));
const offer = viewGame(game, f.owner).nexusFremenRevival;
assert.ok(offer);
assert.ok(offer.blocked);
assert.equal(offer.maxForces, 0);
rejected(game, f.owner, { type: 'nexusFremenRevival', event: offer.event });
assert.equal(game.nexusCards!.cards!.hands[f.owner], 'fremen');
assert.equal(player(game, f.owner).freeForcesRevived, 2, 'The prior paid counter remains paid');
assertNexusFremenFreeQuotaInventory(game);
});
}
+297
View File
@@ -0,0 +1,297 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createGame, newPlayer, type Game, type Player } from '../game/engine';
import { createNexusCards, discardNexusCard, drawNexusCard, validateNexusCards } from '../game/nexus-cards';
import {
createFremenNexusRevival,
fremenNexusRevivalEvent,
fremenNexusRevivalOffer,
fremenNexusRevivalSignature,
upgradeFremenNexusRevival,
validateFremenNexusRevival,
type FremenNexusRevival,
} from '../game/nexus-fremen-revival';
import { forceRevivalQuote, forceRevivalRemaining, newRevivalRules, normalRevivalAllowance } from '../game/revival';
/** Resolved-facts unit context, not a native gameplay chronology. Custody still
* comes from the original inventory/draw API, never an edited private hand. */
function fixture(faction: Player['faction'] = 'guild', advanced = true): Game {
const g = createGame('FQUOTA', newPlayer('q', 'Quota owner', faction), advanced);
g.players.push(newPlayer('a', 'Atreides', 'atreides'), newPlayer('h', 'Harkonnen', 'harkonnen'));
if (faction === 'atreides') g.players[1] = newPlayer('a', 'Guild', 'guild');
g.status = 'playing';
g.phase = 4;
g.turn = 2;
g.revivalRules = newRevivalRules();
const p = g.players[0];
p.reserves = 14;
p.tanks = 6;
p.spice = 17;
p.revived = 0;
p.freeForcesRevived = 0;
if (p.elites) {
p.elites.tanks = 0;
p.elites.revived = 0;
}
const cards = createNexusCards(g.players, () => 0.5);
cards.deck = ['fremen', ...cards.deck.filter(card => card !== 'fremen')];
g.nexusCards = { cards: drawNexusCard(cards, 'q', g.players, () => 0.5), phase: null };
return g;
}
/** Install the engine-owned receipt/permission contract on a copy so these
* tests consume the shared ordinary arithmetic without introducing a new API. */
function granted(g: Game) {
const receipt = createFremenNexusRevival(g, 'q');
const result = structuredClone(g);
result.revivalRules!.nexusFremen = { turn: result.turn, owners: ['q'] };
result.nexusFremenRevivalHistory = [receipt];
result.nexusFremenRevivalEvents = [receipt.event];
result.nexusCards!.cards = discardNexusCard(result.nexusCards!.cards!, 'q', result.players);
return result;
}
void test('a quota receipt changes no physical pieces, ledgers, wallet or custody', () => {
const g = fixture();
const before = structuredClone(g);
const record = createFremenNexusRevival(g, 'q');
assert.equal(record.version, 2);
assert.equal(record.revivedAtUse, 0);
assert.equal(record.freeAtUse, 0);
assert.equal(Object.hasOwn(record, 'legacyReturn'), false);
assert.equal(Object.hasOwn(record, 'before'), false);
assert.equal(Object.hasOwn(record, 'after'), false);
validateFremenNexusRevival(g, record);
assert.deepEqual(g, before);
const result = granted(g);
assert.deepEqual(result.players, before.players);
validateNexusCards(result.nexusCards!.cards!, result.players);
assert.equal(result.nexusCards!.cards!.hands.q, null);
assert.equal(result.nexusCards!.cards!.discard.includes('fremen'), true);
assert.equal(fremenNexusRevivalOffer(result, 'q'), null);
assert.equal(fremenNexusRevivalOffer(g, 'a'), null);
});
void test('partial Tanks and already-used native free returns leave legal partial quota', () => {
const g = fixture();
const p = g.players[0];
p.revived = 1;
p.freeForcesRevived = 1;
p.tanks = 2;
p.reserves = 18;
const offer = fremenNexusRevivalOffer(g, 'q')!;
assert.equal(offer.blocked, null);
assert.equal(offer.freeRemaining, 2);
assert.equal(offer.maxForces, 2);
const result = granted(g);
assert.equal(forceRevivalRemaining(result, result.players[0]), 2);
assert.deepEqual(forceRevivalQuote(result, result.players[0], 1), { free: 1, normalCost: 0, cost: 0 });
assert.deepEqual(forceRevivalQuote(result, result.players[0], 2), { free: 2, normalCost: 0, cost: 0 });
// Actual ordinary free usage is shared with later partial requests.
result.players[0].revived++;
result.players[0].freeForcesRevived!++;
result.players[0].tanks--;
result.players[0].reserves++;
assert.equal(normalRevivalAllowance(result, result.players[0]).freeRemaining, 1);
assert.equal(forceRevivalQuote(result, result.players[0], 1).cost, 0);
});
void test('native higher quotas survive and never justify a useless Fremen discard', () => {
const g = fixture('atreides');
g.recruits = { turn: g.turn, player: 'q', card: 'ecaz-recruits' };
assert.equal(normalRevivalAllowance(g, g.players[0]).freeRate, 4);
assert.match(fremenNexusRevivalOffer(g, 'q')!.blocked!, /already at least three/);
assert.throws(() => createFremenNexusRevival(g, 'q'));
const result = structuredClone(g);
result.revivalRules!.nexusFremen = { turn: g.turn, owners: ['q'] };
assert.equal(normalRevivalAllowance(result, result.players[0]).freeRate, 4);
assert.equal(normalRevivalAllowance(result, result.players[0]).limit, 7);
const single = fixture();
single.players[0].tanks = 1;
assert.match(fremenNexusRevivalOffer(single, 'q')!.blocked!, /cannot improve/);
});
void test('Recruits doubles the native rate, not the fixed quota, and retains its seven-force limit', () => {
const g = fixture('emperor');
g.recruits = { turn: g.turn, player: 'q', card: 'ecaz-recruits' };
assert.equal(normalRevivalAllowance(g, g.players[0]).freeRate, 2);
assert.equal(fremenNexusRevivalOffer(g, 'q')!.blocked, null);
const result = granted(g);
const allowance = normalRevivalAllowance(result, result.players[0]);
assert.equal(allowance.limit, 7);
assert.equal(allowance.freeRate, 3);
assert.equal(allowance.freeRemaining, 3);
assert.equal(forceRevivalQuote(result, result.players[0], 4).cost, 2);
result.players[0].revived = 3;
result.players[0].freeForcesRevived = 3;
assert.equal(forceRevivalQuote(result, result.players[0], 1).cost, 2);
});
void test('Cyborgs retain unlimited typed eligibility while Advanced Sardaukar retain their one-return cap', () => {
const ix = fixture('ixians');
ix.expansions = ['ix'];
ix.players[0].tanks = 4;
ix.players[0].elites!.tanks = 3;
ix.players[0].elites!.revived = 1;
assert.equal(fremenNexusRevivalOffer(ix, 'q')!.maxForces, 3);
assert.equal(fremenNexusRevivalOffer(ix, 'q')!.blocked, null);
const result = granted(ix);
assert.deepEqual(forceRevivalQuote(result, result.players[0], 3, 3, 3), { free: 3, normalCost: 0, cost: 0 });
result.recruits = { turn: result.turn, player: 'q', card: 'ecaz-recruits' };
assert.equal(forceRevivalQuote(result, result.players[0], 4, 3, 2).cost, 3);
const emperor = fixture('emperor');
emperor.players[0].tanks = 3;
emperor.players[0].elites = { reserves: 2, tanks: 3, forces: {}, revived: 0 };
emperor.players[0].elites!.tanks = 3;
assert.equal(fremenNexusRevivalOffer(emperor, 'q')!.maxForces, 1);
emperor.players[0].elites!.revived = 1;
assert.ok(fremenNexusRevivalOffer(emperor, 'q')!.blocked);
emperor.advanced = false;
assert.equal(fremenNexusRevivalOffer(emperor, 'q')!.maxForces, 3);
assert.equal(fremenNexusRevivalOffer(emperor, 'q')!.blocked, null);
});
void test('ordinary free blocking and Tleilaxu prevention apply both before and after the grant', () => {
for (const denial of ['free', 'prevention'] as const) {
const g = fixture();
if (denial === 'free') g.revivalRules!.freeBlocked = ['q'];
else g.revivalPrevention = { turn: g.turn, player: 'q' };
const before = structuredClone(g);
assert.ok(fremenNexusRevivalOffer(g, 'q')!.blocked);
assert.throws(() => createFremenNexusRevival(g, 'q'));
assert.deepEqual(g, before);
}
const result = granted(fixture());
result.revivalRules!.freeBlocked = ['q'];
assert.equal(normalRevivalAllowance(result, result.players[0]).freeRemaining, 0);
assert.equal(forceRevivalQuote(result, result.players[0], 1).cost, 2);
});
void test('ally, pending interaction, wrong phase and stale use cannot spend another grant', () => {
const changes: Array<(g: Game) => void> = [
g => { g.players[0].ally = 'a'; g.players[1].ally = 'q'; },
g => { g.phase = 5; },
g => { g.pendingRevival = { player: 'q' } as NonNullable<Game['pendingRevival']>; },
g => { g.nexusFremenRevivalEvents = [fremenNexusRevivalEvent(g.turn, 'q')]; },
g => { g.revivalRules!.nexusFremen = { turn: g.turn, owners: ['q'] }; },
];
for (const change of changes) {
const g = fixture();
change(g);
const before = structuredClone(g);
assert.ok(fremenNexusRevivalOffer(g, 'q')!.blocked);
assert.throws(() => createFremenNexusRevival(g, 'q'));
assert.deepEqual(g, before);
}
const present = fixture();
present.players.push(newPlayer('f', 'Fremen', 'fremen'));
assert.equal(fremenNexusRevivalOffer(present, 'q'), null);
});
void test('unknown legacy free usage fails closed, while untouched zero use is unambiguous', () => {
const g = fixture();
delete g.players[0].freeForcesRevived;
g.players[0].revived = 1;
const before = structuredClone(g);
assert.match(fremenNexusRevivalOffer(g, 'q')!.blocked!, /usage is unknown/);
assert.throws(() => createFremenNexusRevival(g, 'q'), /usage is unknown/);
assert.deepEqual(g, before);
g.players[0].revived = 0;
assert.equal(createFremenNexusRevival(g, 'q').freeAtUse, 0);
assert.equal(g.players[0].freeForcesRevived, undefined);
});
void test('the quota expires with its turn and grants neither force-limit nor leader allowance', () => {
const result = granted(fixture('choam'));
assert.equal(normalRevivalAllowance(result, result.players[0]).limit, 20);
assert.equal(normalRevivalAllowance(result, result.players[0]).freeRate, 3);
assert.equal(result.players[0].leaderRevived, false);
result.turn++;
assert.equal(normalRevivalAllowance(result, result.players[0]).freeRate, 0);
assert.equal(normalRevivalAllowance(result, result.players[0]).limit, 20);
const ordinary = granted(fixture());
assert.equal(normalRevivalAllowance(ordinary, ordinary.players[0]).limit, 3);
});
type LegacyReceipt = Omit<FremenNexusRevival,
'version' | 'revivedAtUse' | 'freeAtUse' | 'policy' | 'legacyReturn'> &
NonNullable<FremenNexusRevival['legacyReturn']>;
function legacy(g: Game, elite = 0): LegacyReceipt {
const before = { reserves: 14, tanks: 6, eliteReserves: 0, eliteTanks: elite,
eliteRevived: 0, revived: 0, freeForcesRevived: 0, spice: 17 };
const record: LegacyReceipt = {
event: fremenNexusRevivalEvent(g.turn, 'q'), owner: 'q', turn: g.turn,
phase: 4, faction: g.players[0].faction, advanced: g.advanced, elite, before,
after: { ...before, reserves: 17, tanks: 3, eliteReserves: elite, eliteTanks: 0,
eliteRevived: elite, revived: 3, freeForcesRevived: 3 }, signature: '',
};
record.signature = JSON.stringify({ ...record, signature: undefined });
return record;
}
void test('immutable legacy adoption retains the completed physical facts and exhausted quota', () => {
const g = fixture('emperor');
const old = legacy(g, 1);
g.players[0].revived = 3;
g.players[0].freeForcesRevived = 3;
const before = structuredClone(g), oldBefore = structuredClone(old);
const record = upgradeFremenNexusRevival(g, old);
assert.deepEqual(record.legacyReturn, { elite: old.elite, before: old.before, after: old.after });
assert.notEqual(record.legacyReturn!.before, old.before);
assert.notEqual(record.legacyReturn!.after, old.after);
assert.equal(record.revivedAtUse, 0);
assert.equal(record.freeAtUse, 0);
assert.equal(upgradeFremenNexusRevival(g, record), record);
assert.deepEqual(old, oldBefore);
assert.deepEqual(g, before);
const adopted = structuredClone(g);
adopted.revivalRules!.nexusFremen = { turn: g.turn, owners: ['q'] };
assert.equal(normalRevivalAllowance(adopted, adopted.players[0]).freeRemaining, 0);
assert.equal(forceRevivalRemaining(adopted, adopted.players[0]), 0);
});
void test('legacy signatures, exact shape, conservation and original Emperor cap must validate before adoption', () => {
for (const change of [
(old: LegacyReceipt) => { old.after.spice++; },
(old: LegacyReceipt) => { old.after.tanks++; },
(old: LegacyReceipt) => { old.before.revived = 1; },
(old: LegacyReceipt) => { old.before.reserves = 20; },
(old: LegacyReceipt) => { old.elite = 2; old.before.eliteTanks = 2; old.after.eliteReserves = 2; old.after.eliteRevived = 2; },
]) {
const g = fixture('emperor');
const old = legacy(g, 1);
change(old);
old.signature = JSON.stringify({ ...old, signature: undefined });
const before = structuredClone(g), oldBefore = structuredClone(old);
assert.throws(() => upgradeFremenNexusRevival(g, old), /original physical return/);
assert.deepEqual(g, before);
assert.deepEqual(old, oldBefore);
}
const g = fixture();
const old = legacy(g);
assert.throws(() => upgradeFremenNexusRevival(g, { ...old, extra: true }));
assert.throws(() => upgradeFremenNexusRevival(g, { ...old, signature: 'tampered' }));
assert.throws(() => upgradeFremenNexusRevival(fixture('ixians'), legacy(fixture('ixians'), 1)));
});
void test('malformed version-two source receipts reject without normalization or mutation', () => {
const g = fixture();
const receipt = createFremenNexusRevival(g, 'q');
const malformed: unknown[] = [
{ ...receipt, version: 1 }, { ...receipt, signature: 'tampered' },
{ ...receipt, extra: true }, { ...receipt, freeAtUse: 1 },
{ ...receipt, revivedAtUse: -1 }, { ...receipt, turn: g.turn + 1 },
{ ...receipt, phase: 5 }, { ...receipt, policy: 'direct-return' },
{ ...receipt, owner: 'missing' }, { ...receipt, legacyReturn: undefined },
];
for (const input of malformed) {
const record = input as FremenNexusRevival;
if (record.signature !== 'tampered') record.signature = fremenNexusRevivalSignature(record);
const before = structuredClone(g), recordBefore = structuredClone(record);
assert.throws(() => upgradeFremenNexusRevival(g, record));
assert.deepEqual(g, before);
assert.deepEqual(record, recordBefore);
}
});
+53 -114
View File
@@ -1,121 +1,60 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { applyAction, viewGame, type Game } from '../game/engine';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import { botActions } from '../game/bots';
import { nexusFremenRevivalAction } from '../game/nexus-fremen-revival-options';
import { NexusFremenRevival } from '../components/nexus-fremen-revival';
import { nexusInventory, nexusReady, nexusReload, nexusTurnTwo, orderNexusSpice, finishNexusSpice } from './fixture-nexus-cards';
import test from 'node:test';
import { applyAction, normalizeAutomaticGame, viewGame, type Game, type Player } from '../game/engine';
import { createNexusFremenFreeQuotaFixture } from './fixture-nexus-fremen-free-quota';
function fixture(advanced = false, elite = 0) {
let g = nexusTurnTwo({ advanced, hostFaction: advanced ? 'emperor' : 'guild', seatIds: ['q', 'p', 'r'] });
orderNexusSpice(g, ['worm', 'land', 'land']);
g = nexusReady(nexusReady(g));
g = applyAction(g, 'p', { type: 'alliance', target: 'r' });
g = applyAction(g, 'r', { type: 'alliance', target: 'p' });
g = finishNexusSpice(g);
const cards = g.nexusCards!.cards!;
cards.deck = ['fremen', ...cards.deck.filter(card => card !== 'fremen')];
g = applyAction(g, 'q', { type: 'nexusCardChoice', turn: g.turn, card: null, choice: 'draw', ownRedraws: 0 });
for (const id of g.nexusCards!.phase!.eligible)
if (!g.nexusCards!.phase!.done.includes(id))
g = applyAction(g, id, { type: 'nexusCardChoice', turn: g.turn, card: null, choice: 'keep', ownRedraws: 0 });
g = nexusReady(g);
for (let i = 0; g.phase === 3 && i < 15; i++) {
if (g.phaseOpening)
g = applyAction(g, g.players.find(p => !g.phaseOpening!.passed.includes(p.id))!.id, { type: 'ready' });
else if (g.auction) g = applyAction(g, g.auction.active, { type: 'passBid' });
else g = nexusReady(g);
}
assert.equal(g.phase, 4);
assert.equal(g.decision, null);
const p = g.players.find(p => p.id === 'q')!;
assert.ok(p.reserves >= 4 && (p.elites?.reserves ?? 0) >= elite);
p.reserves -= 4;
p.tanks += 4;
if (p.elites) { p.elites.reserves -= elite; p.elites.tanks += elite; }
nexusInventory(g);
const view = viewGame(g, 'q');
const action = nexusFremenRevivalAction(view, elite)!;
assert.ok(action);
return { g, action };
function pools(player: Player) {
return { reserves: player.reserves, tanks: player.tanks,
eliteReserves: player.elites?.reserves ?? 0, eliteTanks: player.elites?.tanks ?? 0,
eliteRevived: player.elites?.revived ?? 0, revived: player.revived,
freeForcesRevived: player.freeForcesRevived ?? 0, spice: player.spice };
}
for (const advanced of [false, true]) {
void test(`Fremen Secret Ally returns an exact free ordinary group in ${advanced ? 'Advanced' : 'Basic'} play`, () => {
const { g, action } = fixture(advanced);
const before = g.players.find(p => p.id === 'q')!;
const spice = before.spice, reserves = before.reserves, tanks = before.tanks;
const observer = viewGame(g, 'r');
assert.equal(observer.nexusFremenRevival, null);
assert.equal(observer.nexusCards?.card, null);
const result = applyAction(nexusReload(g), 'q', action);
const p = result.players.find(p => p.id === 'q')!;
assert.equal(p.reserves, reserves + 3);
assert.equal(p.tanks, tanks - 3);
assert.equal(p.spice, spice);
assert.equal(p.revived, 3);
assert.equal(p.freeForcesRevived, 3);
assert.equal(result.nexusCards!.cards!.hands.q, null);
assert.ok(result.nexusCards!.cards!.discard.includes('fremen'));
assert.equal(viewGame(result, 'q').nexusFremenRevival, null);
assert.equal(viewGame(result, 'r').nexusFremenRevival, null);
nexusInventory(result);
assert.deepEqual(nexusReload(result).nexusFremenRevivalHistory, result.nexusFremenRevivalHistory);
assert.throws(() => applyAction(nexusReload(result), 'q', action));
void test(`${advanced ? 'Advanced' : 'Basic'} saved exact-three physical receipt adopts its exhausted quota without replaying the actual return`, () => {
const fixture = createNexusFremenFreeQuotaFixture({ advanced });
const before = fixture.game.players.find(player => player.id === fixture.owner)!;
const offer = viewGame(fixture.game, fixture.owner).nexusFremenRevival!;
const granted = applyAction(fixture.game, fixture.owner,
{ type: 'nexusFremenRevival', event: offer.event });
const returned = applyAction(granted, fixture.owner, { type: 'revive', amount: 3, elite: 0 });
const after = returned.players.find(player => player.id === fixture.owner)!;
assert.equal(after.tanks, before.tanks - 3);
assert.equal(after.freeForcesRevived, 3);
// Only historical receipt encoding is changed; both physical snapshots came
// from the genuine original producer and the actual native return above.
const legacyReceipt = { event: offer.event, owner: fixture.owner, turn: returned.turn,
phase: 4, faction: after.faction, advanced, elite: 0,
before: pools(before), after: pools(after), signature: '' };
legacyReceipt.signature = JSON.stringify({ ...legacyReceipt, signature: undefined });
const legacy = JSON.parse(JSON.stringify({ ...returned,
nexusFremenRevivalHistory: [legacyReceipt] })) as Game;
delete legacy.revivalRules!.nexusFremen;
const original = structuredClone(legacy);
const projected = viewGame(legacy, fixture.owner);
assert.equal(projected.revival.freeRemaining, 0);
assert.deepEqual(legacy, original, 'Reading an old encoding cannot rewrite its physical state');
const adopted = normalizeAutomaticGame(legacy);
assert.deepEqual(adopted.players, returned.players);
assert.deepEqual(adopted.nexusCards, returned.nexusCards);
assert.deepEqual(adopted.ready, returned.ready);
assert.equal(adopted.phase, 4);
assert.equal(adopted.nexusFremenRevivalHistory![0].version, 2);
assert.deepEqual(adopted.nexusFremenRevivalHistory![0].legacyReturn,
{ elite: 0, before: pools(before), after: pools(after) });
assert.deepEqual(adopted.revivalRules!.nexusFremen,
{ turn: adopted.turn, owners: [fixture.owner] });
assert.deepEqual(normalizeAutomaticGame(adopted), adopted,
'A second adoption cannot replay the return or reopen the free quota');
assert.throws(() => applyAction(adopted, fixture.owner,
{ type: 'nexusFremenRevival', event: offer.event }));
assert.throws(() => applyAction(adopted, fixture.owner, { type: 'revive', amount: 1, elite: 0 }));
assert.deepEqual(legacy, original);
const broken = structuredClone(legacy);
broken.nexusFremenRevivalHistory![0].signature = 'broken';
const damaged = structuredClone(broken);
assert.throws(() => viewGame(broken, fixture.owner));
assert.throws(() => normalizeAutomaticGame(broken));
assert.deepEqual(broken, damaged, 'Invalid historical facts fail without repairing or returning any force');
});
}
void test('Advanced elite cap, legal bot participation and accessible control', () => {
const { g } = fixture(true, 1);
const view = viewGame(g, 'q');
assert.deepEqual(view.nexusFremenRevival?.eliteOptions, [0, 1]);
for (const level of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const botView = structuredClone(view);
botView.players.find(p => p.id === 'q')!.bot = level;
const action = botActions(botView)[0];
assert.equal(action?.type, 'nexusFremenRevive');
const result = applyAction(nexusReload(g), 'q', action);
const p = result.players.find(p => p.id === 'q')!;
assert.equal(p.elites?.revived, level === 'Easy' ? 0 : 1);
assert.equal(p.revived, 3);
}
const markup = renderToStaticMarkup(createElement(NexusFremenRevival, { game: view, act: () => {}, busy: false }));
assert.match(markup, /Fremen Secret Ally revival/);
assert.match(markup, /ordinary three-force Revival allowance/);
assert.match(markup, /Elite forces to revive/);
});
void test('rejected stale, illegal elite and unaffordable groups preserve original input', () => {
const { g, action } = fixture(true, 1);
for (const change of [
(copy: Game) => { copy.players.find(p => p.id === 'q')!.revived = 1; },
(copy: Game) => { copy.players.find(p => p.id === 'q')!.tanks = 2; },
(copy: Game) => { copy.players.find(p => p.id === 'q')!.elites!.revived = 1; },
]) {
const copy = nexusReload(g);
change(copy);
const before = JSON.stringify(copy);
assert.throws(() => applyAction(copy, 'q', action));
assert.equal(JSON.stringify(copy), before);
}
for (const invalid of [{ ...action, elite: 2 }, { ...action, event: 'old' }, { ...action, bonus: true }]) {
const copy = nexusReload(g);
const before = JSON.stringify(copy);
assert.throws(() => applyAction(copy, 'q', invalid));
assert.equal(JSON.stringify(copy), before);
}
});
void test('independent Ecaz Treachery inventory cannot borrow the bounded revival path', () => {
const { g, action } = fixture();
g.ecazTreachery = true;
g.deck.push(...ecazTreacheryCards());
const offer = viewGame(g, 'q').nexusFremenRevival;
assert.match(offer?.blocked ?? '', /Combined expansion and module/);
const before = JSON.stringify(g);
assert.throws(() => applyAction(g, 'q', action));
assert.equal(JSON.stringify(g), before);
});
+31 -13
View File
@@ -131,10 +131,10 @@ void test('Fremen paid borrowed Guild tariff does not relocate southern reserves
collectOnce(f, paid, 0);
});
// Printed Nexus Emperor: three additional free beyond limits. Printed Fremen:
// total free three, recorded ordinary-quota interpretation (NEXUS_FREMEN_RULES).
// E1 p5 classifies both actual phase-4 free batches as Axlotl activity, without
// conflating their different ordinary quota ledgers or granting native identity.
// Emperor returns an additional free group directly; Fremen spends its card
// before a separate ordinary return that uses the fixed free quota of three.
// Both actual free groups accrue Axlotl through their original return source,
// without granting native faction identity.
for (const kind of ['emperor-revival', 'fremen-revival'] as const) {
for (const strongholds of [false, true]) {
void test(`${kind} consumes the physical card and exact original ledgers${strongholds ? ' without a Stronghold revival bonus' : ''}, then collects Axlotl once`, () => {
@@ -161,6 +161,7 @@ for (const kind of ['emperor-revival', 'fremen-revival'] as const) {
assert.equal(quote.cost, 2, 'Atreides keeps its ordinary two-free/one-paid invoice after the additional grant.');
const wallet = actor.spice;
paid = applyAction(paid, f.actor, { type: 'revive', amount: 3 });
paid = advanceNexusModulePaymentsToPhase(paid, 4);
actor = paid.players.find(p => p.id === f.actor)!;
assert.equal(actor.reserves, before.reserves + 6);
assert.equal(actor.tanks, before.tanks - 6);
@@ -176,17 +177,24 @@ for (const kind of ['emperor-revival', 'fremen-revival'] as const) {
}
for (const kind of ['emperor-revival', 'fremen-revival'] as const) {
void test(`${kind} preserves actual Advanced elite identity and one-per-turn cap, including bot participation`, () => {
void test(`${kind} preserves actual Advanced elite identity and one-per-turn cap through bot activation and native selection`, () => {
const f = createNexusModulePaymentsFixture({ kind, advanced: true, strongholds: true,
ownerFaction: kind === 'emperor-revival' ? 'fremen' : 'emperor', eliteTanks: 2 });
const view = viewGame(f.game, f.actor);
const offer = kind === 'emperor-revival' ? view.nexusEmperorSecretAlly!.revival : view.nexusFremenRevival!;
assert.deepEqual(offer.eliteOptions, [0, 1]);
rejected(f.game, f.actor, { ...f.paymentAction, elite: 2 });
view.players.find(p => p.id === f.actor)!.bot = 'Hard';
const bot = botActions(view).find(action => action.type === f.paymentAction.type);
assert.ok(bot, 'The actual bot must participate in the newly admitted module option.');
const paid = applyAction(f.game, f.actor, bot);
let paid = applyAction(f.game, f.actor, bot);
if (f.revivalAction) {
rejected(paid, f.actor, { type: 'revive', amount: 3, elite: 2 });
paid = applyAction(paid, f.actor, f.revivalAction);
}
for (let n = 0; (paid.response || paid.decision || paid.pendingRevival) && n < 200; n++) {
const next = nextNexusModulePaymentsNativeStep(paid);
assert.ok(next);
paid = applyAction(paid, next.actor, next.action);
}
const actor = paid.players.find(p => p.id === f.actor)!;
const before = f.beforePayment.players.find(p => p.id === f.actor)!;
assert.equal(actor.elites!.reserves, before.elites!.reserves + 1);
@@ -201,14 +209,24 @@ for (const kind of ['emperor-revival', 'fremen-revival'] as const) {
});
}
void test('Fremen late return and exact-three shortage remain unavailable; a real Truthtrance blocks both Nexus returns', () => {
void test('Fremen prior free returns count toward the quota, exhausted improvement is unavailable, and real Truthtrance blocks both grants', () => {
const f = createNexusModulePaymentsFixture({ kind: 'fremen-revival' });
const late = applyAction(f.game, f.actor, { type: 'revive', amount: 1 });
assert.match(viewGame(late, f.actor).nexusFremenRevival!.blocked!, /before ordinary force revivals/);
rejected(late, f.actor, f.paymentAction);
const late = advanceNexusModulePaymentsToPhase(applyAction(f.game, f.actor, { type: 'revive', amount: 1 }), 4);
const granted = applyAction(late, f.actor, f.paymentAction);
assert.equal(granted.players.find(p => p.id === f.actor)!.tanks, late.players.find(p => p.id === f.actor)!.tanks);
const returned = advanceNexusModulePaymentsToPhase(applyAction(granted, f.actor, { type: 'revive', amount: 2 }), 4);
assert.equal(returned.players.find(p => p.id === f.actor)!.freeForcesRevived, 3);
assert.equal(returned.players.find(p => p.id === f.actor)!.revived, 3);
for (const kind of ['fremen-revival', 'emperor-revival'] as const) {
const short = createNexusModulePaymentsFixture({ kind, tanks: 2 });
rejected(short.game, short.actor, short.paymentAction);
if (kind === 'emperor-revival') rejected(short.game, short.actor, short.paymentAction);
else {
const enabled = applyAction(short.game, short.actor, short.paymentAction);
rejected(enabled, short.actor, { type: 'revive', amount: 3 });
const partial = advanceNexusModulePaymentsToPhase(applyAction(enabled, short.actor, { type: 'revive', amount: 2 }), 4);
assert.equal(partial.players.find(p => p.id === short.actor)!.tanks, 0);
assert.equal(partial.players.find(p => p.id === short.actor)!.freeForcesRevived, 2);
}
const quiet = createNexusModulePaymentsFixture({ kind });
const questioner = quiet.game.players.find(p => p.id !== quiet.actor)!;
const card = holdNexusRicheseCard(quiet.game, questioner.id, 'truthtrance');
@@ -55,8 +55,8 @@ const configurations = [
{ name: 'Advanced Tech/Strongholds', advanced: true, tech: true, strongholds: true },
] as const;
// Original Nexus Emperor extra-three and the recorded original Fremen free-three
// interpretation remain distinct. E1 printed p5: free revivals trigger Axlotl,
// Emperor's additional direct group and Fremen's fixed ordinary free quota
// retain separate sources. E1 printed p5: actual free revivals trigger Axlotl,
// payable only at Revival end (http://boardgame.bg/dune%20ixians%20and%20tleilaxu%20rulebook.pdf).
for (const config of configurations) {
for (const kind of ['emperor-revival', 'fremen-revival'] as const) {
@@ -77,7 +77,7 @@ for (const config of configurations) {
const before = nexusSkillsModulesPaymentsPlayer(f.game, f.actor);
rejected(f.game, f.opponent, f.paymentAction);
rejected(f.game, f.actor, { ...f.paymentAction, event: 'stale' });
let paid = payNexusSkillsModulesPaymentsFixture(f);
let paid = finishNexusSkillsModulesPaymentsFixture(f, payNexusSkillsModulesPaymentsFixture(f));
let actor = nexusSkillsModulesPaymentsPlayer(paid, f.actor);
assert.equal(actor.reserves, before.reserves + 3); assert.equal(actor.tanks, before.tanks - 3);
assert.deepEqual(wallets(paid), wallets(f.game), 'A free return does not collect the accrued token or mint personal income');
@@ -85,10 +85,17 @@ for (const config of configurations) {
assert.equal(actor.freeForcesRevived ?? 0, kind === 'fremen-revival' ? 3 : (before.freeForcesRevived ?? 0));
assert.equal(paid.nexusCards!.cards!.hands[f.actor], null);
assert.equal(paid.nexusCards!.cards!.discard.filter(card => card === (kind === 'emperor-revival' ? 'emperor' : 'fremen')).length, 1);
const receipt = kind === 'emperor-revival' ? paid.nexusEmperorSecretHistory![0] : paid.nexusFremenRevivalHistory![0];
assert.equal(receipt.owner, f.actor); assert.equal(receipt.turn, 2); assert.equal(receipt.phase, 4);
assert.equal(receipt.before.tanks - receipt.after.tanks, 3);
assert.equal(receipt.before.spice, receipt.after.spice);
if (kind === 'emperor-revival') {
const receipt = paid.nexusEmperorSecretHistory![0];
assert.equal(receipt.owner, f.actor); assert.equal(receipt.turn, 2); assert.equal(receipt.phase, 4);
assert.equal(receipt.before.tanks - receipt.after.tanks, 3);
assert.equal(receipt.before.spice, receipt.after.spice);
} else {
const receipt = paid.nexusFremenRevivalHistory![0];
assert.equal(receipt.owner, f.actor); assert.equal(receipt.turn, 2); assert.equal(receipt.phase, 4);
assert.equal(receipt.revivedAtUse, before.revived);
assert.equal(receipt.freeAtUse, before.freeForcesRevived ?? 0);
}
if (config.tech) {
assert.equal(paid.techTokens!.axlotl.spice, f.tokenCount);
assert.equal(paid.techTokens!.axlotl.triggeredTurn, paid.turn);
@@ -131,7 +138,7 @@ for (const config of configurations) {
rejected(f.game, f.opponent, f.paymentAction);
const view = viewGame(f.game, f.actor); view.players.find(p => p.id === f.actor)!.bot = 'Hard';
const bot = botActions(view).find(action => action.type === 'nexusEmperorPurchase');
assert.deepEqual(bot, f.paymentAction);
assert.ok(bot);
const paid = applyAction(f.game, f.actor, bot!);
assert.deepEqual(wallets(paid), wallets(f.game));
assert.equal(nexusSkillsModulesPaymentsPlayer(paid, f.actor).hand.filter(card => card.id === lot.id).length, 1);
@@ -170,16 +177,19 @@ for (const config of configurations) {
}
for (const kind of ['emperor-revival', 'fremen-revival'] as const) {
void test(`Advanced combined ${kind} real bot returns one original elite and respects the shared per-turn cap`, () => {
void test(`Advanced combined ${kind} bot activation and native return preserve an original elite and the shared per-turn cap`, () => {
const f = createNexusSkillsModulesPaymentsFixture({ kind, advanced: true, tech: true, strongholds: true,
ownerFaction: kind === 'emperor-revival' ? 'fremen' : 'emperor', eliteTanks: 2 });
const view = viewGame(f.game, f.actor);
const offer = kind === 'emperor-revival' ? view.nexusEmperorSecretAlly!.revival : view.nexusFremenRevival!;
assert.deepEqual(offer.eliteOptions, [0, 1]);
rejected(f.game, f.actor, { ...f.paymentAction, elite: 2 });
view.players.find(p => p.id === f.actor)!.bot = 'Hard';
const bot = botActions(view).find(action => action.type === f.paymentAction.type); assert.ok(bot);
const paid = applyAction(f.game, f.actor, bot);
let paid = applyAction(f.game, f.actor, bot);
if (f.revivalAction) {
rejected(paid, f.actor, { type: 'revive', amount: 3, elite: 2 });
paid = applyAction(paid, f.actor, f.revivalAction);
}
paid = finishNexusSkillsModulesPaymentsFixture(f, paid);
const before = nexusSkillsModulesPaymentsPlayer(f.game, f.actor), after = nexusSkillsModulesPaymentsPlayer(paid, f.actor);
assert.equal(after.elites!.reserves, before.elites!.reserves + 1);
assert.equal(after.elites!.tanks, before.elites!.tanks - 1); assert.equal(after.elites!.revived, 1);
@@ -190,18 +200,25 @@ for (const kind of ['emperor-revival', 'fremen-revival'] as const) {
});
}
void test('combined ordinary-first Emperor extra-three and early-only Fremen preserve their different ledgers and exact-three boundary', () => {
void test('combined Emperor remains additional while Fremen quota counts prior free returns and requires available improvement', () => {
const extra = createNexusSkillsModulesPaymentsFixture({ advanced: true, strongholds: true });
const ordinary = applyAction(extra.game, extra.actor, { type: 'revive', amount: 3 });
const ordinary = finishNexusSkillsModulesPaymentsFixture(extra,
applyAction(extra.game, extra.actor, { type: 'revive', amount: 3 }));
const paid = payNexusSkillsModulesPaymentsFixture(extra, ordinary);
const before = nexusSkillsModulesPaymentsPlayer(ordinary, extra.actor), after = nexusSkillsModulesPaymentsPlayer(paid, extra.actor);
assert.equal(after.reserves, before.reserves + 3); assert.equal(after.tanks, before.tanks - 3);
assert.equal(after.spice, before.spice); assert.equal(after.revived, 3); assert.equal(after.freeForcesRevived, 2);
assert.equal(paid.techTokens!.axlotl.spice, ordinary.techTokens!.axlotl.spice);
const fremen = createNexusSkillsModulesPaymentsFixture({ kind: 'fremen-revival', advanced: true, strongholds: true });
const late = applyAction(fremen.game, fremen.actor, { type: 'revive', amount: 1 });
assert.ok(viewGame(late, fremen.actor).nexusFremenRevival!.blocked);
rejected(late, fremen.actor, fremen.paymentAction);
const late = finishNexusSkillsModulesPaymentsFixture(fremen,
applyAction(fremen.game, fremen.actor, { type: 'revive', amount: 1 }));
const granted = applyAction(late, fremen.actor, fremen.paymentAction);
assert.equal(nexusSkillsModulesPaymentsPlayer(granted, fremen.actor).tanks, nexusSkillsModulesPaymentsPlayer(late, fremen.actor).tanks);
const returned = finishNexusSkillsModulesPaymentsFixture(fremen,
applyAction(granted, fremen.actor, { type: 'revive', amount: 2 }));
assert.equal(nexusSkillsModulesPaymentsPlayer(returned, fremen.actor).revived, 3);
assert.equal(nexusSkillsModulesPaymentsPlayer(returned, fremen.actor).freeForcesRevived, 3);
custody(fremen.game, returned);
for (const kind of ['emperor-revival', 'fremen-revival'] as const) {
const short = createNexusSkillsModulesPaymentsFixture({ kind, advanced: true, strongholds: true, tanks: 2 });
rejected(short.game, short.actor, short.paymentAction);