Connect Fremen Nexus fixed quota to native partial revival
This commit is contained in:
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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 } };
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user