Compose classic Nexus with original Discovery modules
This commit is contained in:
1 parent
4672b97417
commit
a2a92bb408
18 files changed
+1458
-28
No files matched your search
@@ -0,0 +1,292 @@
|
||||
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 { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { emperorNexusModeSupported } from '../game/nexus-emperor-secret-ally';
|
||||
import { nexusEmperorRevivalAction } from '../game/nexus-emperor-secret-ally-options';
|
||||
import { nexusFremenRevivalAction } from '../game/nexus-fremen-revival-options';
|
||||
import { forceRevivalQuote, forceRevivalRemaining } from '../game/revival';
|
||||
import { quoteNexusGuildSecretShipment } from '../game/nexus-guild-secret-ally';
|
||||
import { ownedTech } from '../game/tech-tokens';
|
||||
import { territory } from '../game/board';
|
||||
import { quoteSpiceCollection } from '../game/board-resolution-quote';
|
||||
import { holdNexusSkillsModulesPaymentsCard } from './fixture-nexus-skills-modules-payments';
|
||||
import {
|
||||
advanceDiscoveryClassicNexusEffects, advanceDiscoveryClassicNexusEffectsToPhase,
|
||||
createDiscoveryClassicNexusEffectsFixture, discoveryClassicNexusEffectsInventory,
|
||||
discoveryClassicNexusEffectsPlayer, finishDiscoveryClassicNexusEffectsInteraction,
|
||||
finishDiscoveryClassicNexusNestedBattle, initializeDiscoveryClassicNexusEffectsSetup,
|
||||
openDiscoveryClassicNexusNestedBattle, payDiscoveryClassicNexusEffectsFixture,
|
||||
type DiscoveryClassicNexusEffectsFixture,
|
||||
} from './fixture-discovery-classic-nexus-effects';
|
||||
|
||||
const wallets = (game: Game) => Object.fromEntries(game.players.map(player => [player.id, player.spice]));
|
||||
function rejected(game: Game, actor: string, action: Action): void {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before, 'Rejected declarations retain original physical cards, counters and payment ledgers');
|
||||
}
|
||||
function legalPolicies(game: Game, actor: string): void {
|
||||
assert.equal(DIFFICULTIES.length, 4);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game, actor);
|
||||
view.players.find(player => player.id === actor)!.bot = difficulty;
|
||||
const actions = botActions(view);
|
||||
assert.ok(actions.length > 0, `${difficulty} must expose an original legal continuation`);
|
||||
for (const action of actions) discoveryClassicNexusEffectsInventory(applyAction(structuredClone(game), actor, action));
|
||||
}
|
||||
}
|
||||
function sourceTransitions(fixture: DiscoveryClassicNexusEffectsFixture): void {
|
||||
const revealer = fixture.kind === 'guild-shipment' ? fixture.opponent : fixture.actor;
|
||||
const beforeToken = fixture.beforeReveal.discoveries!.tokens.find(token => token.id === fixture.token)!;
|
||||
const afterToken = fixture.afterReveal.discoveries!.tokens.find(token => token.id === fixture.token)!;
|
||||
assert.equal(beforeToken.face, 'shrine'); assert.equal(beforeToken.revealedTurn, null);
|
||||
assert.equal(afterToken.revealedTurn, 1);
|
||||
assert.equal(fixture.beforeEntry.turn, 2); assert.equal(fixture.beforeEntry.phase, 0);
|
||||
const before = discoveryClassicNexusEffectsPlayer(fixture.beforeEntry, revealer);
|
||||
const after = discoveryClassicNexusEffectsPlayer(fixture.afterEntry, revealer);
|
||||
assert.equal(before.forces[fixture.source], 2); assert.equal(after.forces[fixture.source] ?? 0, 0);
|
||||
assert.equal(after.forces['shrine:0'], 2);
|
||||
assert.equal(after.reserves, before.reserves); assert.equal(after.tanks, before.tanks);
|
||||
assert.equal(after.spice, before.spice); assert.equal(after.shipped, before.shipped); assert.equal(after.moved, before.moved);
|
||||
assert.deepEqual(fixture.afterEntry.techTokens, fixture.beforeEntry.techTokens, 'Free next-turn entry does not trigger paid shipping industry');
|
||||
assert.equal(fixture.beforeClosingDraw.nexusCards!.phase!.stage, 'drawing');
|
||||
assert.equal(fixture.beforeClosingDraw.players.find(player => player.id === fixture.actor)!.ally, null);
|
||||
assert.equal(fixture.afterClosingDraw.nexusCards!.cards!.hands[fixture.actor],
|
||||
fixture.kind === 'guild-shipment' ? 'guild' : fixture.kind === 'fremen-revival' ? 'fremen' : 'emperor');
|
||||
discoveryClassicNexusEffectsInventory(fixture.game);
|
||||
}
|
||||
function unchangedCustody(before: Game, after: Game): void {
|
||||
assert.deepEqual(after.discoveries, before.discoveries);
|
||||
assert.deepEqual(after.strongholdCards, before.strongholdCards);
|
||||
for (const player of before.players) assert.deepEqual(viewGame(after, player.id).discoveries, viewGame(before, player.id).discoveries);
|
||||
discoveryClassicNexusEffectsInventory(after);
|
||||
}
|
||||
const configurations = [
|
||||
{ name: 'Basic', advanced: false, tech: false, strongholds: false },
|
||||
{ name: 'Basic Tech', advanced: false, tech: true, strongholds: false },
|
||||
{ name: 'Advanced', advanced: true, tech: false, strongholds: false },
|
||||
{ name: 'Advanced Tech', advanced: true, tech: true, strongholds: false },
|
||||
{ name: 'Advanced Strongholds', advanced: true, tech: false, strongholds: true },
|
||||
{ name: 'Advanced Tech/Strongholds', advanced: true, tech: true, strongholds: true },
|
||||
] as const;
|
||||
|
||||
for (const configuration of configurations) for (const kind of ['emperor-revival', 'fremen-revival'] as const) {
|
||||
void test(`${configuration.name} classic Discovery/Nexus ${kind} uses the real source, separate quota and once-only phase-end industry`, () => {
|
||||
const fixture = createDiscoveryClassicNexusEffectsFixture({ ...configuration, kind });
|
||||
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');
|
||||
assert.equal(kind === 'emperor-revival' ? viewGame(fixture.game, fixture.opponent).nexusEmperorSecretAlly
|
||||
: viewGame(fixture.game, fixture.opponent).nexusFremenRevival, null);
|
||||
legalPolicies(fixture.game, fixture.actor);
|
||||
rejected(fixture.game, fixture.opponent, fixture.paymentAction);
|
||||
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 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));
|
||||
assert.equal(after.revived, kind === 'fremen-revival' ? 3 : before.revived);
|
||||
assert.equal(after.freeForcesRevived ?? 0, kind === 'fremen-revival' ? 3 : before.freeForcesRevived ?? 0);
|
||||
assert.equal(forceRevivalRemaining(paid, after), kind === 'fremen-revival' ? 0 : 3);
|
||||
assert.equal(paid.nexusCards!.cards!.hands[fixture.actor], null);
|
||||
const card = kind === 'emperor-revival' ? 'emperor' : 'fremen';
|
||||
assert.equal(paid.nexusCards!.cards!.discard.filter(value => value === card).length, 1);
|
||||
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);
|
||||
unchangedCustody(fixture.game, paid); rejected(paid, fixture.actor, fixture.paymentAction);
|
||||
if (kind === 'emperor-revival') {
|
||||
const quote = forceRevivalQuote(paid, after, 3);
|
||||
assert.equal(quote.cost, 2);
|
||||
paid = finishDiscoveryClassicNexusEffectsInteraction(applyAction(paid, fixture.actor, { type: 'revive', amount: 3 }));
|
||||
const ordinary = discoveryClassicNexusEffectsPlayer(paid, fixture.actor);
|
||||
assert.equal(ordinary.reserves, before.reserves + 6); assert.equal(ordinary.tanks, before.tanks - 6);
|
||||
assert.equal(ordinary.revived, 3); assert.equal(ordinary.freeForcesRevived, 2);
|
||||
assert.equal(ordinary.spice, before.spice - 2);
|
||||
} else rejected(paid, fixture.actor, { type: 'revive', amount: 1 });
|
||||
if (configuration.tech) {
|
||||
assert.equal(paid.techTokens!.axlotl.spice, fixture.tokenCount);
|
||||
assert.equal(paid.techTokens!.axlotl.triggeredTurn, 2);
|
||||
}
|
||||
const expected = wallets(paid);
|
||||
if (fixture.tokenOwner) expected[fixture.tokenOwner] += fixture.tokenCount;
|
||||
const movement = advanceDiscoveryClassicNexusEffectsToPhase(paid, 5);
|
||||
assert.deepEqual(wallets(movement), expected, 'Only real Revival-end Axlotl credit becomes spendable');
|
||||
if (configuration.tech) assert.equal(movement.techTokens!.axlotl.spice, 0);
|
||||
const completedMovement = advanceDiscoveryClassicNexusEffectsToPhase(movement, 6);
|
||||
const afterMovement = { ...expected };
|
||||
if (completedMovement.phase >= 7)
|
||||
for (const receipt of quoteSpiceCollection(movement).receipts) afterMovement[receipt.player] += receipt.strongholds + receipt.collected;
|
||||
assert.deepEqual(wallets(completedMovement), afterMovement, 'Only independently quoted Collection can accompany an auto-skipped empty Battle; Axlotl cannot pay twice');
|
||||
const nextTurn = advanceDiscoveryClassicNexusEffects(completedMovement, game => game.turn === 3);
|
||||
const nextRevival = advanceDiscoveryClassicNexusEffectsToPhase(nextTurn, 4);
|
||||
assert.equal(discoveryClassicNexusEffectsPlayer(nextRevival, fixture.actor).revived, 0);
|
||||
assert.equal(discoveryClassicNexusEffectsPlayer(nextRevival, fixture.actor).freeForcesRevived ?? 0, 0);
|
||||
assert.deepEqual(kind === 'emperor-revival' ? nextRevival.nexusEmperorSecretHistory : nextRevival.nexusFremenRevivalHistory, history);
|
||||
rejected(nextRevival, fixture.actor, fixture.paymentAction);
|
||||
discoveryClassicNexusEffectsInventory(nextRevival);
|
||||
});
|
||||
}
|
||||
|
||||
void test('Advanced Discovery Emperor extra-three and Fremen free-three retain actual native elite subsets and the shared one-elite cap', () => {
|
||||
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 });
|
||||
legalPolicies(fixture.game, fixture.actor);
|
||||
const before = discoveryClassicNexusEffectsPlayer(fixture.game, fixture.actor);
|
||||
const paid = 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);
|
||||
assert.equal(after.reserves, before.reserves + 3); assert.equal(after.tanks, before.tanks - 3);
|
||||
assert.deepEqual(wallets(paid), wallets(fixture.game));
|
||||
rejected(paid, fixture.actor, { type: 'revive', amount: 1, elite: 1 });
|
||||
unchangedCustody(fixture.game, paid);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Discovery ordinary-first Emperor return is extra; ordinary-first Fremen is genuinely late and still rejected', () => {
|
||||
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);
|
||||
const before = discoveryClassicNexusEffectsPlayer(ordinary, emperor.actor), after = discoveryClassicNexusEffectsPlayer(paid, emperor.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.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);
|
||||
}
|
||||
});
|
||||
|
||||
void test('an earned Discovery/Nexus Axlotl pile cannot settle through an unsettled real printed Truthtrance', () => {
|
||||
const fixture = createDiscoveryClassicNexusEffectsFixture({ advanced: true, tech: true, strongholds: true });
|
||||
let game = payDiscoveryClassicNexusEffectsFixture(fixture);
|
||||
const card = holdNexusSkillsModulesPaymentsCard(game, fixture.opponent, 'truthtrance', fixture.staging);
|
||||
const before = structuredClone(game);
|
||||
game = applyAction(game, fixture.opponent, { type: 'card', card: card.id });
|
||||
assert.ok(game.truthtrance); assert.equal(game.phase, 4);
|
||||
assert.deepEqual(wallets(game), wallets(before)); assert.equal(game.techTokens!.axlotl.spice, fixture.tokenCount);
|
||||
for (const player of game.players) rejected(game, player.id, { type: 'ready' });
|
||||
legalPolicies(game, game.players.find(player => !game.truthtrance!.passed.includes(player.id))!.id);
|
||||
const settled = finishDiscoveryClassicNexusEffectsInteraction(game);
|
||||
assert.equal(settled.truthtrance ?? null, null); assert.equal(settled.phase, 4);
|
||||
assert.deepEqual(wallets(settled), wallets(before)); assert.equal(settled.techTokens!.axlotl.spice, fixture.tokenCount);
|
||||
assert.ok(settled.discard.some(value => value.id === card.id && value.effect === 'truthtrance'));
|
||||
discoveryClassicNexusEffectsInventory(settled);
|
||||
});
|
||||
|
||||
for (const configuration of [configurations[0], configurations[1], configurations[4], configurations[5]]) {
|
||||
void test(`${configuration.name} paid Guild Secret Ally nested Shrine arrival retains invoice/source identity and original battle reward once`, () => {
|
||||
const fixture = createDiscoveryClassicNexusEffectsFixture({ ...configuration, kind: 'guild-shipment' });
|
||||
sourceTransitions(fixture); legalPolicies(fixture.game, fixture.actor);
|
||||
const before = discoveryClassicNexusEffectsPlayer(fixture.game, fixture.actor);
|
||||
const quote = quoteNexusGuildSecretShipment(territory('shrine').type, 3);
|
||||
assert.equal(quote.cost, 2, 'A nested revealed location uses the printed stronghold shipping tariff, not a desert or native-free tariff');
|
||||
rejected(fixture.game, fixture.opponent, fixture.paymentAction);
|
||||
rejected(fixture.game, fixture.actor, { ...fixture.paymentAction, nexus: 'stale' });
|
||||
rejected(fixture.game, fixture.actor, { ...fixture.paymentAction, smuggler: true });
|
||||
const paid = finishDiscoveryClassicNexusEffectsInteraction(payDiscoveryClassicNexusEffectsFixture(fixture));
|
||||
const arrived = discoveryClassicNexusEffectsPlayer(paid, fixture.actor);
|
||||
assert.equal(arrived.spice, before.spice - quote.cost);
|
||||
assert.equal(arrived.reserves, before.reserves - 3); assert.equal(arrived.forces['shrine:0'], 3);
|
||||
assert.equal(arrived.shipped, true); assert.equal(arrived.moved, before.moved);
|
||||
for (const player of paid.players) if (player.id !== fixture.actor) assert.equal(player.spice, discoveryClassicNexusEffectsPlayer(fixture.game, player.id).spice);
|
||||
assert.equal(paid.nexusCards!.cards!.hands[fixture.actor], null);
|
||||
assert.equal(paid.nexusCards!.cards!.discard.filter(card => card === 'guild').length, 1);
|
||||
assert.equal(paid.nexusGuildSecretHistory!.length, 1);
|
||||
const record = paid.nexusGuildSecretHistory![0], frame = JSON.parse(record.frame);
|
||||
assert.equal(record.receipt.owner, fixture.actor); assert.equal(record.receipt.turn, 2); assert.equal(record.kind, 'reserve');
|
||||
assert.equal(frame.player, fixture.actor); assert.equal(frame.territory, 'shrine'); assert.equal(frame.sector, 0);
|
||||
assert.equal(frame.amount, 3); assert.equal(frame.elite, 0); assert.equal(frame.cost, quote.cost); assert.equal(frame.allyPayment, 0);
|
||||
assert.equal(frame.guildSecretEvent, fixture.paymentAction.nexus);
|
||||
assert.equal(frame.nexusEvent, undefined); assert.equal(frame.guildNexusEvent, undefined); assert.equal(frame.source, undefined);
|
||||
unchangedCustody(fixture.game, paid); rejected(paid, fixture.actor, fixture.paymentAction);
|
||||
if (configuration.tech) {
|
||||
assert.equal(paid.techTokens!.heighliners.spice, fixture.tokenCount);
|
||||
assert.equal(paid.techTokens!.heighliners.triggeredTurn, 2);
|
||||
}
|
||||
const expected = wallets(paid);
|
||||
if (fixture.tokenOwner) expected[fixture.tokenOwner] += fixture.tokenCount;
|
||||
const battle = openDiscoveryClassicNexusNestedBattle(fixture, paid);
|
||||
assert.deepEqual(wallets(battle.beforeBattle), expected, 'The original Movement-end industry pays once before the actual nested battle');
|
||||
if (configuration.tech) assert.equal(battle.beforeBattle.techTokens!.heighliners.spice, 0);
|
||||
assert.equal(battle.game.battle!.territory, 'shrine');
|
||||
const loser = discoveryClassicNexusEffectsPlayer(battle.game, fixture.opponent);
|
||||
const bounty = loser.leaders.find(leader => leader.id === battle.losingLeader)!.strength;
|
||||
const priorTokenOwners = battle.game.techTokens && Object.fromEntries(Object.entries(battle.game.techTokens).map(([id, token]) => [id, token.owner]));
|
||||
const collection = quoteSpiceCollection(battle.beforeBattle);
|
||||
const done = finishDiscoveryClassicNexusNestedBattle(battle);
|
||||
assert.equal(done.lastBattleContext!.winner, fixture.actor);
|
||||
assert.equal(discoveryClassicNexusEffectsPlayer(done, fixture.actor).forces['shrine:0'], 3);
|
||||
assert.equal(discoveryClassicNexusEffectsPlayer(done, fixture.opponent).forces['shrine:0'] ?? 0, 0);
|
||||
assert.equal(discoveryClassicNexusEffectsPlayer(done, fixture.opponent).tanks, loser.tanks + 2);
|
||||
const battleWallets = { ...expected };
|
||||
battleWallets[fixture.actor] += bounty;
|
||||
if (done.phase >= 7)
|
||||
for (const receipt of collection.receipts) battleWallets[receipt.player] += receipt.strongholds + receipt.collected;
|
||||
assert.deepEqual(wallets(done), battleWallets, 'Only the original leader bounty and independently quoted native Collection may accompany battle completion');
|
||||
assert.equal(discoveryClassicNexusEffectsPlayer(done, fixture.opponent).leaders.find(leader => leader.id === battle.losingLeader)!.dead, true);
|
||||
assert.ok(discoveryClassicNexusEffectsPlayer(done, fixture.actor).hand.some(card => card.id === battle.weapon.id));
|
||||
assert.ok(!done.discard.some(card => card.id === battle.weapon.id));
|
||||
assert.deepEqual(done.nexusGuildSecretHistory, paid.nexusGuildSecretHistory);
|
||||
assert.deepEqual(done.discoveries, paid.discoveries); assert.deepEqual(done.strongholdCards, paid.strongholdCards);
|
||||
if (configuration.tech) {
|
||||
assert.equal(ownedTech(done.techTokens, fixture.opponent).length, 0);
|
||||
for (const [id, owner] of Object.entries(priorTokenOwners!))
|
||||
assert.equal(done.techTokens![id as keyof NonNullable<Game['techTokens']>].owner, owner === fixture.opponent ? fixture.actor : owner);
|
||||
}
|
||||
discoveryClassicNexusEffectsInventory(done);
|
||||
const atMentat = wallets(done);
|
||||
if (done.phase < 7)
|
||||
for (const receipt of collection.receipts) atMentat[receipt.player] += receipt.strongholds + receipt.collected;
|
||||
const mentat = advanceDiscoveryClassicNexusEffectsToPhase(done, 8);
|
||||
assert.deepEqual(wallets(mentat), atMentat, 'Only original city Collection pays; Shrine cannot recollect shipment industry or leader bounty');
|
||||
assert.deepEqual(mentat.nexusGuildSecretHistory, paid.nexusGuildSecretHistory);
|
||||
discoveryClassicNexusEffectsInventory(mentat);
|
||||
});
|
||||
}
|
||||
|
||||
void test('authenticated native Nexus/Discovery setup preserves original seat IDs/dealt custody and excludes unrelated Emperor module profiles', () => {
|
||||
const initial = initializeDiscoveryClassicNexusEffectsSetup({ advanced: true, tech: true, strongholds: true,
|
||||
seatIds: ['authenticated-owner', 'authenticated-opponent', 'authenticated-third'] });
|
||||
const saved = structuredClone(initial);
|
||||
const accepted = initializeDiscoveryClassicNexusEffectsSetup({ initial });
|
||||
assert.deepEqual(accepted, saved);
|
||||
const fixture = createDiscoveryClassicNexusEffectsFixture({ initial });
|
||||
assert.equal(fixture.actor, 'authenticated-owner'); assert.deepEqual(fixture.initial, saved);
|
||||
assert.deepEqual(fixture.game.players.map(player => player.id), initial.players.map(player => player.id));
|
||||
assert.deepEqual(initial, saved);
|
||||
assert.equal(emperorNexusModeSupported(fixture.game), true);
|
||||
// Explicit adversarial compositions, not histories claimed to be natural play.
|
||||
for (const change of [
|
||||
(game: Game) => { game.expansions = ['ix']; },
|
||||
(game: Game) => { game.players.find(player => player.id === fixture.opponent)!.faction = 'emperor'; },
|
||||
(game: Game) => { game.advanced = false; },
|
||||
(game: Game) => { game.semutaPreview = true; },
|
||||
]) {
|
||||
const invalid = structuredClone(fixture.game); change(invalid);
|
||||
assert.equal(emperorNexusModeSupported(invalid), false);
|
||||
rejected(invalid, fixture.actor, fixture.paymentAction);
|
||||
}
|
||||
const paid = payDiscoveryClassicNexusEffectsFixture(fixture);
|
||||
const tampered = structuredClone(paid); tampered.nexusEmperorSecretHistory![0].after.revived++;
|
||||
rejected(tampered, fixture.actor, { type: 'ready' });
|
||||
discoveryClassicNexusEffectsInventory(paid);
|
||||
});
|
||||
@@ -0,0 +1,373 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { territory } from '../game/board';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { botActions } from '../game/bots';
|
||||
import { validateNexusCards } from '../game/nexus-cards';
|
||||
import { validateDiscoveryState } from '../game/discoveries';
|
||||
import { greatMakerRideAction } from '../game/great-maker-options';
|
||||
import { quoteEcazInquiry } from '../game/nexus-ecaz-inquiry';
|
||||
import { nexusCleanPlayBlocked } from '../game/nexus-play-boundary';
|
||||
import { strongholdControllers } from '../game/stronghold-cards';
|
||||
import {
|
||||
advanceClassicDiscoveryNexus, applyClassicDiscoveryNexusStep, classicDiscoveryNexusClean,
|
||||
classicDiscoveryNexusPlayer, createClassicDiscoveryNexusFixture, enterClassicDiscoveryNexus,
|
||||
finishClassicDiscoveryNexusMentat, formClassicDiscoveryNexusAlliance, nextClassicDiscoveryNexusStep,
|
||||
openClassicDiscoveryNexusAlliance, openClassicDiscoveryNexusEncounter, revealClassicDiscoveryNexus,
|
||||
voteClassicDiscoveryNexus, type ClassicDiscoveryNexusFixture,
|
||||
} from './fixture-discovery-classic-nexus';
|
||||
|
||||
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
|
||||
function custody(game: Game): void {
|
||||
validateNexusCards(game.nexusCards!.cards!, game.players);
|
||||
validateDiscoveryState(game.discoveries!);
|
||||
assert.deepEqual([...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand)]
|
||||
.map(card => card.id).sort(), baseDeck().map(card => card.id).sort());
|
||||
for (const player of game.players) {
|
||||
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, amount) => sum + amount, 0), 20);
|
||||
if (player.elites) assert.equal(player.elites.reserves + player.elites.tanks +
|
||||
Object.values(player.elites.forces).reduce((sum, amount) => sum + amount, 0), player.faction === 'emperor' ? 5 : 3);
|
||||
}
|
||||
assert.ok(!(game.discoveryEntry && game.nexusCards!.phase?.stage === 'drawing'));
|
||||
assert.ok(!(game.greatMaker?.stage === 'vote' && game.nexusCards!.phase?.stage === 'drawing'));
|
||||
}
|
||||
function rejectUnchanged(game: Game, actor: string, action: Action): void {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
|
||||
/** Real majority, optional reserve-ride decline, real negotiated alliance, then
|
||||
* the single source-closing deal. Only the remaining physical Nexus deck order
|
||||
* is positioned before that deal; no card is transferred into a hand. */
|
||||
function closingDraw(fixture: ClassicDiscoveryNexusFixture): Game {
|
||||
let game = openClassicDiscoveryNexusEncounter(fixture);
|
||||
game = voteClassicDiscoveryNexus(game, game.players.map(() => true));
|
||||
game = openClassicDiscoveryNexusAlliance(game);
|
||||
assert.ok(fixture.fremen);
|
||||
game = formClassicDiscoveryNexusAlliance(game, fixture.opponent, fixture.fremen);
|
||||
const deck = game.nexusCards!.cards!.deck;
|
||||
const index = deck.indexOf('ecaz'); assert.ok(index >= 0);
|
||||
deck.unshift(deck.splice(index, 1)[0]);
|
||||
game = advanceClassicDiscoveryNexus(game, state => state.nexusCards!.phase?.stage === 'drawing');
|
||||
assert.equal(game.phase, 1);
|
||||
assert.equal(game.nexus, false);
|
||||
assert.equal(game.discoveryEntry, undefined);
|
||||
assert.equal(game.greatMaker?.stage, 'complete');
|
||||
assert.deepEqual(game.nexusCards!.phase!.eligible, [fixture.collector]);
|
||||
assert.deepEqual(viewGame(game, fixture.opponent).nexusCards!.choices, []);
|
||||
const draw: Action = { type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'draw', ownRedraws: 0 };
|
||||
const before = structuredClone(game), owner = classicDiscoveryNexusPlayer(game, fixture.collector);
|
||||
game = applyClassicDiscoveryNexusStep(game, { actor: fixture.collector, action: draw });
|
||||
assert.equal(game.phase, 2, 'Completing the original closing draw must release the next phase.');
|
||||
assert.equal(game.nexusCards!.phase!.stage, 'complete');
|
||||
assert.equal(game.nexusCards!.cards!.hands[fixture.collector], 'ecaz');
|
||||
assert.equal(classicDiscoveryNexusPlayer(game, fixture.collector).spice, owner.spice);
|
||||
assert.equal(game.nexusCards!.cards!.deck.length, before.nexusCards!.cards!.deck.length - 1);
|
||||
assert.deepEqual(game.wormRides, []);
|
||||
rejectUnchanged(game, fixture.collector, draw);
|
||||
custody(game);
|
||||
return game;
|
||||
}
|
||||
function secondCollection(fixture: ClassicDiscoveryNexusFixture, game: Game): Game {
|
||||
const shrine = game.discoveries!.tokens.find(token => token.face === 'shrine' && token.status === 'placed');
|
||||
assert.ok(shrine, 'The explicit original second Hiereg supply lottery must place Shrine.');
|
||||
game = advanceClassicDiscoveryNexus(game, state => state.phase === 5 && state.active === fixture.collector && classicDiscoveryNexusClean(state));
|
||||
const sector = territory(shrine.territory!).sectors.find(value => value !== game.storm);
|
||||
assert.notEqual(sector, undefined);
|
||||
game = applyClassicDiscoveryNexusStep(game, { actor: fixture.collector,
|
||||
action: { type: 'ship', territory: shrine.territory!, sector: sector!, amount: 1, elite: 0, allyPayment: 0 } });
|
||||
game = advanceClassicDiscoveryNexus(game, state => state.phase === 7 && classicDiscoveryNexusClean(state));
|
||||
return revealClassicDiscoveryNexus(game, fixture.collector, shrine.id);
|
||||
}
|
||||
|
||||
void test('original staged Nexus/Discovery is playable from Basic and Advanced two-through-six seats through Mentat and free entry', () => {
|
||||
for (const advanced of [false, true]) for (const seats of [2, 3, 4, 5, 6] as const) {
|
||||
const fixture = createClassicDiscoveryNexusFixture({ advanced, seats, tech: seats >= 3, strongholds: advanced && seats >= 3 });
|
||||
assert.equal(fixture.setup.status, 'setup');
|
||||
assert.equal(fixture.setup.players.length, seats);
|
||||
assert.equal(fixture.setup.spiceDeck.length, 28);
|
||||
assert.equal(fixture.setup.discoveries!.tokens.length, 8);
|
||||
custody(fixture.setup);
|
||||
assert.equal(fixture.collection.turn, 1);
|
||||
assert.equal(classicDiscoveryNexusPlayer(fixture.collection, fixture.collector).forces[`${fixture.parent}:${fixture.parentSector}`], 3);
|
||||
assert.equal(fixture.firstMentat.before.phase, 8);
|
||||
assert.equal(fixture.firstMentat.after.turn, 2);
|
||||
assert.equal(fixture.game.phase, 0);
|
||||
assert.equal(fixture.game.discoveryEntry?.stage, 'choose');
|
||||
assert.equal(fixture.game.storm, fixture.firstMentat.before.storm, 'Free entry precedes original Storm movement.');
|
||||
assert.equal(fixture.game.nexusCards!.phase?.stage, 'complete');
|
||||
if (fixture.game.strongholdCards) {
|
||||
assert.equal(fixture.firstMentat.before.strongholdCards!.claimedTurn, 0);
|
||||
assert.equal(fixture.game.strongholdCards.claimedTurn, 1);
|
||||
assert.deepEqual(fixture.game.strongholdCards.owners, strongholdControllers(fixture.game.players, false));
|
||||
assert.equal(fixture.game.strongholdCards.owners.tueks_sietch, fixture.collector);
|
||||
assert.equal(fixture.game.strongholdCards.owners.sietch_tabr, fixture.fremen);
|
||||
}
|
||||
const before = classicDiscoveryNexusPlayer(fixture.game, fixture.collector);
|
||||
const entered = enterClassicDiscoveryNexus(reload(fixture.game), fixture.collector);
|
||||
const after = classicDiscoveryNexusPlayer(entered, fixture.collector);
|
||||
assert.equal(after.forces['cistern:0'], 1);
|
||||
assert.equal(after.forces[`${fixture.parent}:${fixture.parentSector}`], 2);
|
||||
assert.equal(after.spice, before.spice);
|
||||
assert.equal(after.shipped, before.shipped);
|
||||
assert.equal(after.moved, before.moved);
|
||||
assert.equal(entered.discoveryEntry, undefined);
|
||||
const encounter = advanceClassicDiscoveryNexus(entered, game => game.greatMaker?.stage === 'vote');
|
||||
assert.equal(encounter.turn, 2);
|
||||
assert.equal(classicDiscoveryNexusPlayer(encounter, fixture.opponent).forces[fixture.wormSource], undefined);
|
||||
assert.equal(classicDiscoveryNexusPlayer(encounter, fixture.opponent).tanks, 3);
|
||||
assert.equal(encounter.spice[fixture.wormSource], undefined);
|
||||
assert.equal(classicDiscoveryNexusPlayer(encounter, fixture.collector).forces['cistern:0'], 1);
|
||||
assert.deepEqual(encounter.wormRides, []);
|
||||
custody(encounter);
|
||||
const refused = voteClassicDiscoveryNexus(encounter, encounter.players.map(() => false));
|
||||
const mentat = finishClassicDiscoveryNexusMentat(refused);
|
||||
assert.equal(mentat.after.turn, 3);
|
||||
assert.equal(mentat.after.nexusCards!.phase?.stage, 'complete');
|
||||
assert.equal(mentat.after.discoveryEntry, undefined, 'An exhausted one-time entry cannot reopen without a newly revealed location.');
|
||||
custody(mentat.after);
|
||||
}
|
||||
});
|
||||
|
||||
void test('fresh authenticated Game and seat IDs survive original setup, private cards and first free entry without mutating the caller', () => {
|
||||
const fixture = createClassicDiscoveryNexusFixture({ gameId: 'AUTHENTICATEDNEXUS', seatIds: ['auth-guild', 'auth-emperor', 'auth-fremen'], advanced: true });
|
||||
for (const initial of [fixture.initial, fixture.setup]) {
|
||||
const before = structuredClone(initial);
|
||||
const continued = createClassicDiscoveryNexusFixture({ initial });
|
||||
assert.deepEqual(initial, before);
|
||||
assert.equal(continued.game.code, initial.code);
|
||||
assert.deepEqual(continued.game.players.map(player => player.id), initial.players.map(player => player.id));
|
||||
assert.equal(continued.collector, 'auth-guild');
|
||||
assert.equal(continued.opponent, 'auth-emperor');
|
||||
assert.equal(continued.game.decision?.kind, 'discoveryEntry');
|
||||
custody(continued.game);
|
||||
}
|
||||
rejectUnchanged(fixture.initial, fixture.initial.host, { type: 'start' });
|
||||
assert.throws(() => createClassicDiscoveryNexusFixture({ initial: fixture.game }));
|
||||
});
|
||||
|
||||
void test('Great Maker ordinary worm prefix precedes every vote; strict majority waits for all owners, and a tie adds no Nexus', () => {
|
||||
const fixture = createClassicDiscoveryNexusFixture({ seats: 4 });
|
||||
const original = openClassicDiscoveryNexusEncounter(fixture);
|
||||
assert.equal(original.greatMaker?.territory, 'hagga_basin');
|
||||
assert.equal(original.greatMaker?.stage, 'vote');
|
||||
assert.equal(classicDiscoveryNexusPlayer(original, fixture.opponent).tanks, 3);
|
||||
assert.equal(original.nexus, false);
|
||||
assert.ok(nexusCleanPlayBlocked(original));
|
||||
rejectUnchanged(original, original.order[1], { type: 'decision', event: original.greatMaker!.event, yes: true });
|
||||
let majority = voteClassicDiscoveryNexus(reload(original), [true, true, true]);
|
||||
assert.equal(majority.nexus, false);
|
||||
assert.equal(majority.decision?.player, majority.order[3]);
|
||||
majority = voteClassicDiscoveryNexus(majority, [false]);
|
||||
assert.equal(majority.nexus, true);
|
||||
assert.equal(majority.nexusCards!.phase!.occurred, true);
|
||||
assert.equal(majority.decision?.kind, 'greatMakerRide');
|
||||
const tied = voteClassicDiscoveryNexus(reload(original), [true, false, true, false]);
|
||||
assert.equal(tied.nexus, false);
|
||||
assert.equal(tied.nexusCards!.phase!.occurred, false);
|
||||
const afterTie = finishClassicDiscoveryNexusMentat(tied).after;
|
||||
assert.equal(afterTie.turn, 3);
|
||||
assert.equal(afterTie.nexusCards!.phase!.eligible.length, 0);
|
||||
custody(afterTie);
|
||||
});
|
||||
|
||||
void test('a preceding original Shai-Hulud and tied Great Maker produce one negotiated Nexus and one closing deal, not duplicated rides or payments', () => {
|
||||
const fixture = createClassicDiscoveryNexusFixture({ advanced: true, seats: 4, ordinaryWormFirst: true });
|
||||
let game = openClassicDiscoveryNexusEncounter(fixture);
|
||||
assert.equal(game.nexus, true);
|
||||
assert.equal(classicDiscoveryNexusPlayer(game, fixture.opponent).tanks, 3);
|
||||
const wallets = game.players.map(player => player.spice);
|
||||
game = voteClassicDiscoveryNexus(game, [true, false, true, false]);
|
||||
assert.equal(game.nexus, true);
|
||||
game = openClassicDiscoveryNexusAlliance(game);
|
||||
game = formClassicDiscoveryNexusAlliance(game, fixture.opponent, fixture.fremen!);
|
||||
game = advanceClassicDiscoveryNexus(game, state => state.nexusCards!.phase?.stage === 'drawing');
|
||||
const action: Action = { type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'draw', ownRedraws: 0 };
|
||||
game = applyClassicDiscoveryNexusStep(game, { actor: fixture.collector, action });
|
||||
rejectUnchanged(game, fixture.collector, action);
|
||||
game = advanceClassicDiscoveryNexus(game, state => state.phase === 2 && classicDiscoveryNexusClean(state));
|
||||
assert.equal(game.phase, 2);
|
||||
assert.deepEqual(game.players.map(player => player.spice), wallets);
|
||||
assert.equal(classicDiscoveryNexusPlayer(game, fixture.opponent).tanks, 3);
|
||||
assert.deepEqual(game.wormRides, []);
|
||||
rejectUnchanged(game, fixture.collector, action);
|
||||
custody(game);
|
||||
});
|
||||
|
||||
void test('Fremen Great Maker reserve ride transfers real normal/starred counters into a separately revealed nested destination without spending shipment, movement or spice', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const fixture = createClassicDiscoveryNexusFixture({ advanced });
|
||||
let game = openClassicDiscoveryNexusEncounter(fixture, false);
|
||||
game = voteClassicDiscoveryNexus(game, game.players.map(() => false));
|
||||
assert.equal(game.decision?.kind, 'greatMakerRide');
|
||||
const fremen = classicDiscoveryNexusPlayer(game, fixture.fremen!);
|
||||
const elite = advanced ? 1 : 0;
|
||||
assert.ok((fremen.elites?.reserves ?? 0) >= elite);
|
||||
const ride = greatMakerRideAction(viewGame(game, fremen.id), 'cistern', 0, 2, elite);
|
||||
assert.ok(ride, 'A revealed separate location is a real Great Maker reserve destination.');
|
||||
const event = game.greatMaker!.event;
|
||||
game = applyClassicDiscoveryNexusStep(game, { actor: fremen.id, action: ride });
|
||||
const after = classicDiscoveryNexusPlayer(game, fremen.id);
|
||||
assert.equal(after.reserves, fremen.reserves - 2);
|
||||
assert.equal(after.forces['cistern:0'], 2);
|
||||
assert.equal(after.elites?.forces['cistern:0'] ?? 0, elite);
|
||||
assert.equal(after.elites?.reserves ?? 0, (fremen.elites?.reserves ?? 0) - elite);
|
||||
assert.equal(after.spice, fremen.spice);
|
||||
assert.equal(after.shipped, fremen.shipped);
|
||||
assert.equal(after.moved, fremen.moved);
|
||||
assert.deepEqual(Object.fromEntries(Object.entries(after.forces).filter(([key]) => key !== 'cistern:0')), fremen.forces);
|
||||
assert.equal(game.greatMaker?.stage, 'complete');
|
||||
assert.deepEqual(game.wormRides, []);
|
||||
assert.notEqual(game.response?.kind, 'wormSurvival');
|
||||
rejectUnchanged(game, fremen.id, { ...ride, event });
|
||||
const moved = advanceClassicDiscoveryNexus(game, state => state.phase === 5 && state.active === fremen.id && classicDiscoveryNexusClean(state));
|
||||
assert.equal(classicDiscoveryNexusPlayer(moved, fremen.id).shipped, false);
|
||||
custody(moved);
|
||||
}
|
||||
});
|
||||
|
||||
void test('actual closing Nexus draw, end-Mentat settlement and next free-entry owner are sequential; held no-phase Nexus cannot interrupt that owner', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const fixture = createClassicDiscoveryNexusFixture({ advanced, strongholds: advanced });
|
||||
let game = secondCollection(fixture, closingDraw(fixture));
|
||||
const mentat = finishClassicDiscoveryNexusMentat(game);
|
||||
game = mentat.after;
|
||||
assert.equal(game.turn, 3);
|
||||
assert.equal(game.decision?.kind, 'discoveryEntry');
|
||||
assert.equal(game.nexusCards!.phase!.stage, 'complete');
|
||||
assert.equal(game.nexusCards!.cards!.hands[fixture.collector], 'ecaz');
|
||||
if (advanced) assert.equal(game.strongholdCards!.claimedTurn, 2);
|
||||
const blocked = quoteEcazInquiry(game, fixture.collector); assert.ok(blocked?.blocked);
|
||||
rejectUnchanged(game, fixture.collector, { type: 'nexusEcazInquiry', event: blocked.event, target: fixture.opponent });
|
||||
const before = classicDiscoveryNexusPlayer(game, fixture.collector);
|
||||
game = enterClassicDiscoveryNexus(game, fixture.collector);
|
||||
assert.equal(classicDiscoveryNexusPlayer(game, fixture.collector).forces['shrine:0'], 1);
|
||||
assert.equal(classicDiscoveryNexusPlayer(game, fixture.collector).spice, before.spice);
|
||||
assert.equal(game.discoveryEntry, undefined);
|
||||
const offer = quoteEcazInquiry(game, fixture.collector); assert.ok(offer && !offer.blocked);
|
||||
game = applyClassicDiscoveryNexusStep(game, { actor: fixture.collector,
|
||||
action: { type: 'nexusEcazInquiry', event: offer.event, target: fixture.opponent } });
|
||||
assert.equal(game.nexusCards!.cards!.hands[fixture.collector], null);
|
||||
assert.equal(game.nexusEcazInquiries!.length, 1);
|
||||
const storm = advanceClassicDiscoveryNexus(game, state => state.phase === 1 && classicDiscoveryNexusClean(state));
|
||||
assert.equal(classicDiscoveryNexusPlayer(storm, fixture.collector).forces['shrine:0'], 1);
|
||||
custody(storm);
|
||||
}
|
||||
});
|
||||
|
||||
void test('additional original Advanced worm destroys the real parent army but not its nested Cistern, while owned placement blocks no-phase interruption', () => {
|
||||
const fixture = createClassicDiscoveryNexusFixture({ advanced: true });
|
||||
let game = secondCollection(fixture, closingDraw(fixture));
|
||||
const front: Game['spiceDeck'] = [];
|
||||
for (let index = 0; index < 2; index++) {
|
||||
const worm = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker);
|
||||
assert.ok(worm >= 0); front.push(game.spiceDeck.splice(worm, 1)[0]);
|
||||
}
|
||||
for (let index = 0; index < 2; index++) {
|
||||
const land = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery);
|
||||
assert.ok(land >= 0); front.push(game.spiceDeck.splice(land, 1)[0]);
|
||||
}
|
||||
// Explicit turn-three unplayed positions: two original worms, two original
|
||||
// land replacements. Native Fremen placement chooses the actual parent.
|
||||
game.spiceDeck.unshift(...front);
|
||||
game = finishClassicDiscoveryNexusMentat(game).after;
|
||||
game = enterClassicDiscoveryNexus(game, fixture.collector);
|
||||
game = advanceClassicDiscoveryNexus(game, state => state.decision?.kind === 'wormPlacement');
|
||||
assert.ok(nexusCleanPlayBlocked(game));
|
||||
const blocked = quoteEcazInquiry(game, fixture.collector); assert.ok(blocked?.blocked);
|
||||
rejectUnchanged(game, fixture.collector, { type: 'nexusEcazInquiry', event: blocked.event, target: fixture.opponent });
|
||||
const before = classicDiscoveryNexusPlayer(game, fixture.collector);
|
||||
assert.equal(before.forces[`${fixture.parent}:${fixture.parentSector}`], 2);
|
||||
assert.equal(before.forces['cistern:0'], 1);
|
||||
game = applyClassicDiscoveryNexusStep(game, { actor: fixture.fremen!,
|
||||
action: { type: 'decision', accept: true, territory: fixture.parent } });
|
||||
game = advanceClassicDiscoveryNexus(game, state => !state.response && !state.decision);
|
||||
const after = classicDiscoveryNexusPlayer(game, fixture.collector);
|
||||
assert.equal(after.forces[`${fixture.parent}:${fixture.parentSector}`], undefined);
|
||||
assert.equal(after.tanks, before.tanks + 2);
|
||||
assert.equal(after.forces['cistern:0'], 1);
|
||||
assert.equal(after.forces['shrine:0'], 1);
|
||||
custody(game);
|
||||
});
|
||||
|
||||
void test('joining a later original alliance returns the real held Nexus card and removes draw eligibility before original closing controls continue', () => {
|
||||
const fixture = createClassicDiscoveryNexusFixture();
|
||||
let game = secondCollection(fixture, closingDraw(fixture));
|
||||
const worm = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker); assert.ok(worm >= 0);
|
||||
const original = game.spiceDeck.splice(worm, 1)[0];
|
||||
const land = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(land >= 0);
|
||||
game.spiceDeck.unshift(original, game.spiceDeck.splice(land, 1)[0]);
|
||||
game = finishClassicDiscoveryNexusMentat(game).after;
|
||||
game = enterClassicDiscoveryNexus(game, fixture.collector);
|
||||
game = openClassicDiscoveryNexusAlliance(game);
|
||||
game = applyClassicDiscoveryNexusStep(game, { actor: fixture.opponent, action: { type: 'alliance', target: null } });
|
||||
game = formClassicDiscoveryNexusAlliance(game, fixture.collector, fixture.opponent);
|
||||
assert.equal(game.nexusCards!.cards!.hands[fixture.collector], null);
|
||||
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'ecaz').length, 1);
|
||||
game = advanceClassicDiscoveryNexus(game, state => state.nexusCards!.phase?.stage === 'drawing');
|
||||
assert.deepEqual(game.nexusCards!.phase!.eligible, [fixture.fremen]);
|
||||
assert.deepEqual(viewGame(game, fixture.collector).nexusCards!.choices, []);
|
||||
rejectUnchanged(game, fixture.collector, { type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'draw', ownRedraws: 0 });
|
||||
const next = nextClassicDiscoveryNexusStep(game); assert.ok(next);
|
||||
game = applyClassicDiscoveryNexusStep(game, next);
|
||||
assert.equal(game.phase, 2);
|
||||
custody(game);
|
||||
});
|
||||
|
||||
void test('optional original Tech accrues after real next-turn charity and pays exactly once at native phase end', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const fixture = createClassicDiscoveryNexusFixture({ advanced, tech: true, techCharity: true });
|
||||
let game = openClassicDiscoveryNexusEncounter(fixture);
|
||||
game = voteClassicDiscoveryNexus(game, game.players.map(() => false));
|
||||
game = advanceClassicDiscoveryNexus(game, state => state.phase === 2 && classicDiscoveryNexusClean(state));
|
||||
const before = classicDiscoveryNexusPlayer(game, fixture.fremen!).spice;
|
||||
game = applyClassicDiscoveryNexusStep(game, { actor: fixture.opponent, action: { type: 'charity' } });
|
||||
game = advanceClassicDiscoveryNexus(game, state => classicDiscoveryNexusClean(state));
|
||||
assert.equal(classicDiscoveryNexusPlayer(game, fixture.opponent).spice, 2);
|
||||
assert.equal(game.techTokens!.production.spice, 1);
|
||||
assert.equal(game.techTokens!.production.triggeredTurn, 2);
|
||||
assert.equal(classicDiscoveryNexusPlayer(game, fixture.fremen!).spice, before);
|
||||
game = advanceClassicDiscoveryNexus(game, state => state.phase === 3 && classicDiscoveryNexusClean(state));
|
||||
assert.equal(game.techTokens!.production.spice, 0);
|
||||
assert.equal(classicDiscoveryNexusPlayer(game, fixture.fremen!).spice, before + 1);
|
||||
rejectUnchanged(game, fixture.opponent, { type: 'charity' });
|
||||
const revival = advanceClassicDiscoveryNexus(game, state => state.phase === 4 && classicDiscoveryNexusClean(state));
|
||||
assert.equal(classicDiscoveryNexusPlayer(revival, fixture.fremen!).spice, before + 1);
|
||||
custody(revival);
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal policies have actual legal owned continuations at free entry, Great Maker vote/ride and original closing Nexus deal', () => {
|
||||
const fixture = createClassicDiscoveryNexusFixture({ advanced: true });
|
||||
const vote = openClassicDiscoveryNexusEncounter(fixture);
|
||||
const ride = voteClassicDiscoveryNexus(reload(vote), vote.players.map(() => true));
|
||||
let drawing = openClassicDiscoveryNexusAlliance(reload(ride));
|
||||
drawing = formClassicDiscoveryNexusAlliance(drawing, fixture.opponent, fixture.fremen!);
|
||||
drawing = advanceClassicDiscoveryNexus(drawing, game => game.nexusCards!.phase?.stage === 'drawing');
|
||||
const cases = [
|
||||
{ game: fixture.game, owner: fixture.collector, type: 'decision' },
|
||||
{ game: vote, owner: vote.decision!.player, type: 'decision' },
|
||||
{ game: ride, owner: ride.decision!.player, type: 'decision' },
|
||||
{ game: drawing, owner: fixture.collector, type: 'nexusCardChoice' },
|
||||
];
|
||||
for (const difficulty of DIFFICULTIES) for (const current of cases) {
|
||||
const view = viewGame(current.game, current.owner);
|
||||
view.players.find(player => player.id === current.owner)!.bot = difficulty;
|
||||
const before = structuredClone(view);
|
||||
const action = botActions(view).find(action => action.type === current.type);
|
||||
assert.ok(action, `${difficulty} must answer the actual owned ${current.game.decision?.kind ?? 'closing Nexus draw'}.`);
|
||||
const after = applyClassicDiscoveryNexusStep(reload(current.game), { actor: current.owner, action });
|
||||
assert.deepEqual(view, before);
|
||||
if (current.game.decision?.kind === 'greatMakerVote')
|
||||
assert.equal(after.greatMaker!.votes.length, current.game.greatMaker!.votes.length + 1);
|
||||
else if (current.game.decision?.kind === 'greatMakerRide')
|
||||
assert.equal(after.greatMaker!.stage, 'complete');
|
||||
else if (current.game.decision?.kind === 'discoveryEntry')
|
||||
assert.equal(after.discoveryEntry, undefined);
|
||||
else assert.equal(after.nexusCards!.phase!.stage, 'complete');
|
||||
custody(after);
|
||||
}
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, initializeDiscoveryGameForAudit, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, RuleError, type Game } from '../game/engine';
|
||||
import { createGame, initializeDiscoveryGameForAudit, initializeLeaderSkillsGameForAudit, initializeNexusGameForAudit, joinGame, newPlayer, RuleError, type Game } from '../game/engine';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import { createTechTokens } from '../game/tech-tokens';
|
||||
import { createStrongholdCards } from '../game/stronghold-cards';
|
||||
@@ -70,3 +70,22 @@ void test('Discovery Skills cannot replace already initialized Discovery compone
|
||||
const started = initializeDiscoveryGameForAudit(lobby(['guild', 'emperor', 'harkonnen'], []));
|
||||
assert.throws(() => initializeLeaderSkillsGameForAudit(started), RuleError);
|
||||
});
|
||||
|
||||
void test('Discovery Nexus rejects native families, used components and incompatible prerequisites before redealing', () => {
|
||||
const native = lobby(['ixians', 'guild', 'fremen'], ['ix']);
|
||||
native.nexusCards = { cards: null, phase: null };
|
||||
assert.throws(() => initializeNexusGameForAudit(native), RuleError);
|
||||
const two = lobby(['guild', 'emperor'], []);
|
||||
two.nexusCards = { cards: null, phase: null }; two.techTokens = createTechTokens();
|
||||
assert.throws(() => initializeNexusGameForAudit(two), RuleError);
|
||||
const basic = lobby(['guild', 'emperor', 'fremen'], [], false);
|
||||
basic.nexusCards = { cards: null, phase: null }; basic.strongholdCards = createStrongholdCards();
|
||||
assert.throws(() => initializeNexusGameForAudit(basic), RuleError);
|
||||
const used = lobby(['guild', 'emperor', 'fremen'], []);
|
||||
used.nexusCards = { cards: null, phase: null }; used.techTokens = createTechTokens();
|
||||
used.techTokens.production.owner = used.host;
|
||||
assert.throws(() => initializeNexusGameForAudit(used), RuleError);
|
||||
const started = initializeDiscoveryGameForAudit(lobby(['guild', 'emperor', 'fremen'], []));
|
||||
started.nexusCards = { cards: null, phase: null };
|
||||
assert.throws(() => initializeNexusGameForAudit(started), RuleError);
|
||||
});
|
||||
@@ -0,0 +1,318 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, initializeNexusGameForAudit, joinGame, newPlayer, viewGame,
|
||||
type Action, type Game, type Player } from '../game/engine';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import type { Card } from '../game/cards';
|
||||
import { validateDiscoveryState } from '../game/discoveries';
|
||||
import { classicDiscoveryNexusProfile } from '../game/discovery-module-profile';
|
||||
import { traitorDeck } from '../game/traitors';
|
||||
import { ownedTech, type TechId } from '../game/tech-tokens';
|
||||
import { nexusInventory } from './fixture-nexus-cards';
|
||||
import { holdNexusSkillsModulesPaymentsCard, nextNexusSkillsModulesPaymentsNativeStep } from './fixture-nexus-skills-modules-payments';
|
||||
|
||||
export type DiscoveryClassicNexusEffectKind = 'emperor-revival' | 'fremen-revival' | 'guild-shipment';
|
||||
export type DiscoveryClassicNexusEffectStep = { actor: string; action: Action };
|
||||
export type DiscoveryClassicNexusEffectsOptions = {
|
||||
/** Authenticated original lobby or turn-one uncompleted native setup; never redealt. */
|
||||
initial?: Game;
|
||||
seatIds?: string[];
|
||||
advanced?: boolean;
|
||||
tech?: boolean;
|
||||
strongholds?: boolean;
|
||||
kind?: DiscoveryClassicNexusEffectKind;
|
||||
ownerFaction?: FactionId;
|
||||
tanks?: number;
|
||||
eliteTanks?: number;
|
||||
};
|
||||
export type DiscoveryClassicNexusEffectsFixture = {
|
||||
initial: Game;
|
||||
afterSetup: Game;
|
||||
beforeReveal: Game;
|
||||
afterReveal: Game;
|
||||
beforeEntry: Game;
|
||||
afterEntry: Game;
|
||||
beforeClosingDraw: Game;
|
||||
closingDraw: DiscoveryClassicNexusEffectStep;
|
||||
afterClosingDraw: Game;
|
||||
game: Game;
|
||||
actor: string;
|
||||
opponent: string;
|
||||
kind: DiscoveryClassicNexusEffectKind;
|
||||
paymentAction: Action;
|
||||
token: string;
|
||||
source: string;
|
||||
tokenOwner: string | null;
|
||||
tokenCount: number;
|
||||
industry: TechId;
|
||||
actions: DiscoveryClassicNexusEffectStep[];
|
||||
staging: string[];
|
||||
};
|
||||
export type DiscoveryClassicNexusNestedBattle = {
|
||||
beforeBattle: Game;
|
||||
game: Game;
|
||||
actor: string;
|
||||
opponent: string;
|
||||
weapon: Card;
|
||||
losingLeader: string;
|
||||
plans: DiscoveryClassicNexusEffectStep[];
|
||||
};
|
||||
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision && !game.truthtrance;
|
||||
export function discoveryClassicNexusEffectsPlayer(game: Game, id: string): Player {
|
||||
const player = game.players.find(player => player.id === id);
|
||||
assert.ok(player, `Missing original authenticated seat ${id}`);
|
||||
return player;
|
||||
}
|
||||
/** Labelled scalar lottery: native UUID byte-array generation is not replaced. */
|
||||
function originalLottery<T>(run: () => T, scalar = 0xffffffff): T {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const native = crypto.getRandomValues.bind(crypto);
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (array instanceof Uint32Array && array.length === 1) array[0] = scalar;
|
||||
else Reflect.apply(native, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try { return run(); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
export function nextDiscoveryClassicNexusEffectsStep(game: Game): DiscoveryClassicNexusEffectStep | null {
|
||||
if (!game.phaseOpening && !game.response) {
|
||||
const decision = game.decision;
|
||||
if (decision?.kind === 'discoveryEntry' || decision?.kind === 'greatMakerRide' || decision?.kind === 'nexusFremenCunningOffer' || decision?.kind === 'nexusFremenCunningRide')
|
||||
return { actor: decision.player, action: { type: 'decision', event: decision.event, accept: false } };
|
||||
if (decision?.kind === 'greatMakerVote')
|
||||
return { actor: decision.player, action: { type: 'decision', event: decision.event, yes: false } };
|
||||
if (decision?.kind === 'wormRide') return { actor: decision.player, action: { type: 'decision', accept: false } };
|
||||
if (decision?.kind === 'wormProtection') return { actor: decision.player, action: { type: 'decision', accept: true } };
|
||||
}
|
||||
return nextNexusSkillsModulesPaymentsNativeStep(game);
|
||||
}
|
||||
export function advanceDiscoveryClassicNexusEffectsStep(game: Game, actions?: DiscoveryClassicNexusEffectStep[]): Game {
|
||||
const next = nextDiscoveryClassicNexusEffectsStep(game);
|
||||
assert.ok(next, 'Original continuation stopped for a human private battle plan');
|
||||
actions?.push(structuredClone(next));
|
||||
return applyAction(game, next.actor, next.action);
|
||||
}
|
||||
export function advanceDiscoveryClassicNexusEffects(state: Game, predicate: (game: Game) => boolean,
|
||||
actions?: DiscoveryClassicNexusEffectStep[]): Game {
|
||||
let game = state;
|
||||
for (let n = 0; n < 1800; n++) {
|
||||
if (predicate(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
game = advanceDiscoveryClassicNexusEffectsStep(game, actions);
|
||||
}
|
||||
throw Error('Original Discovery/Nexus continuation did not reach its boundary');
|
||||
}
|
||||
export function advanceDiscoveryClassicNexusEffectsToPhase(state: Game, phase: number,
|
||||
actions?: DiscoveryClassicNexusEffectStep[]): Game {
|
||||
const turn = state.turn;
|
||||
return advanceDiscoveryClassicNexusEffects(state, game => {
|
||||
assert.equal(game.turn, turn);
|
||||
return game.phase >= phase && clean(game);
|
||||
}, actions);
|
||||
}
|
||||
export function discoveryClassicNexusEffectsInventory(game: Game): void {
|
||||
nexusInventory(game);
|
||||
validateDiscoveryState(game.discoveries!);
|
||||
assert.deepEqual([...(game.traitorReserve ?? []), ...game.players.flatMap(player =>
|
||||
[...player.traitors, ...player.traitorChoices])].sort(), traitorDeck(game.players, false).sort());
|
||||
for (const player of game.players) if (player.elites) {
|
||||
assert.equal(player.elites.reserves + player.elites.tanks + Object.values(player.elites.forces).reduce((a, b) => a + b, 0),
|
||||
player.faction === 'fremen' ? 3 : 5);
|
||||
assert.ok(player.elites.reserves <= player.reserves && player.elites.tanks <= player.tanks);
|
||||
for (const [key, amount] of Object.entries(player.elites.forces)) assert.ok(amount <= (player.forces[key] ?? 0));
|
||||
}
|
||||
}
|
||||
export function initializeDiscoveryClassicNexusEffectsSetup(options: DiscoveryClassicNexusEffectsOptions = {}): Game {
|
||||
const kind = options.kind ?? 'emperor-revival', faction = options.ownerFaction ?? 'atreides';
|
||||
const forbidden = kind === 'guild-shipment' ? 'guild' : kind === 'fremen-revival' ? 'fremen' : 'emperor';
|
||||
let game = options.initial ? structuredClone(options.initial)
|
||||
: createGame('DISCOVERYCLASSICNEXUSEFFECTS', newPlayer(options.seatIds?.[0] ?? 'owner', faction, faction),
|
||||
options.advanced ?? !!options.strongholds);
|
||||
if (!options.initial) {
|
||||
const count = options.seatIds?.length ?? 3;
|
||||
assert.ok(count >= 3 && count <= 5);
|
||||
for (const candidate of ['harkonnen', 'guild', 'fremen', 'atreides', 'beneGesserit', 'emperor'] as const)
|
||||
if (game.players.length < count && candidate !== forbidden && candidate !== faction)
|
||||
joinGame(game, newPlayer(options.seatIds?.[game.players.length] ?? candidate, candidate, candidate));
|
||||
assert.equal(game.players.length, count);
|
||||
}
|
||||
assert.ok(game.players.some(player => player.faction === faction));
|
||||
assert.ok(!game.players.some(player => player.faction === forbidden));
|
||||
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
|
||||
const tech = options.tech ?? !!game.techTokens, strongholds = options.strongholds ?? !!game.strongholdCards;
|
||||
if (game.status === 'setup') {
|
||||
assert.equal(game.turn, 1); assert.equal(game.phase, 0);
|
||||
assert.equal(!!game.techTokens, tech); assert.equal(!!game.strongholdCards, strongholds);
|
||||
assert.ok(classicDiscoveryNexusProfile(game));
|
||||
discoveryClassicNexusEffectsInventory(game);
|
||||
return game;
|
||||
}
|
||||
assert.equal(game.status, 'lobby');
|
||||
if (tech && !game.techTokens) game = applyAction(game, game.host, { type: 'techTokens', enabled: true });
|
||||
if (strongholds && !game.strongholdCards) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: true });
|
||||
assert.equal(!!game.techTokens, tech); assert.equal(!!game.strongholdCards, strongholds);
|
||||
game.discoveryEnabled = true;
|
||||
game.nexusCards ??= { cards: null, phase: null };
|
||||
assert.ok(classicDiscoveryNexusProfile(game));
|
||||
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
|
||||
return originalLottery(() => initializeNexusGameForAudit(game));
|
||||
}
|
||||
function reserveBoard(game: Game, staging: string[]): void {
|
||||
for (const player of game.players) {
|
||||
player.reserves += Object.values(player.forces).reduce((a, b) => a + b, 0); player.forces = {};
|
||||
if (player.elites) {
|
||||
player.elites.reserves += Object.values(player.elites.forces).reduce((a, b) => a + b, 0); player.elites.forces = {};
|
||||
}
|
||||
if (player.advisors) player.advisors = {};
|
||||
}
|
||||
staging.push('Conserved all original on-board counters into their own reserves; no clock, wallet, receipt or module ownership assigned.');
|
||||
}
|
||||
function placeOrdinary(game: Game, actor: string, key: string, amount: number, staging: string[]): void {
|
||||
const player = discoveryClassicNexusEffectsPlayer(game, actor);
|
||||
assert.ok(player.reserves - (player.elites?.reserves ?? 0) >= amount);
|
||||
player.reserves -= amount; player.forces[key] = (player.forces[key] ?? 0) + amount;
|
||||
staging.push(`Conserved ${amount} original ordinary ${actor} reserve counters to ${key}.`);
|
||||
}
|
||||
function orderSpice(game: Game, selectors: ('discovery' | 'land' | 'worm')[], staging: string[]): void {
|
||||
const front: Game['spiceDeck'] = [];
|
||||
for (const selector of selectors) {
|
||||
const index = game.spiceDeck.findIndex(card => selector === 'worm' ? 'worm' in card
|
||||
: 'territory' in card && (selector === 'discovery' ? card.discovery === 'discovery-hagga-basin'
|
||||
: !card.discovery && card.territory !== 'gara_kulon'));
|
||||
assert.ok(index >= 0, 'Only original undealt physical Spice singletons may be selected');
|
||||
front.push(game.spiceDeck.splice(index, 1)[0]);
|
||||
}
|
||||
game.spiceDeck.unshift(...front);
|
||||
staging.push(`Ordered conserved original undealt Spice Cards: ${selectors.join(', ')}; original discard untouched.`);
|
||||
}
|
||||
/** Original setup, actual printed Discovery blow/lottery, Collection reveal,
|
||||
* next-turn entry and end-Spice-Blow Nexus/alliance draw all precede the effect. */
|
||||
export function createDiscoveryClassicNexusEffectsFixture(options: DiscoveryClassicNexusEffectsOptions = {}): DiscoveryClassicNexusEffectsFixture {
|
||||
const initial = initializeDiscoveryClassicNexusEffectsSetup(options);
|
||||
return originalLottery(() => {
|
||||
const kind = options.kind ?? 'emperor-revival', actor = initial.players.find(player => player.faction === (options.ownerFaction ?? 'atreides'))!.id;
|
||||
const opponent = initial.players.find(player => player.id !== actor && player.faction !== 'beneGesserit')!.id;
|
||||
const actions: DiscoveryClassicNexusEffectStep[] = [], staging = ['Scalar lottery fixed at the last legal index; UUID bytes remain native. Physical Hiereg lottery selects the original Shrine token; not an untouched random sample.'];
|
||||
let game = advanceDiscoveryClassicNexusEffects(initial, game => game.status === 'playing', actions);
|
||||
const afterSetup = structuredClone(game);
|
||||
reserveBoard(game, staging);
|
||||
// The actual first Storm producers assign original Tech owners.
|
||||
game = advanceDiscoveryClassicNexusEffectsToPhase(game, 1, actions);
|
||||
orderSpice(game, ['discovery', 'land'], staging);
|
||||
const pool = game.discoveries!.tokens.filter(token => token.type === 'hiereg' && token.status === 'supply');
|
||||
const lotteryIndex = pool.findIndex(token => token.face === 'shrine'); assert.ok(lotteryIndex >= 0);
|
||||
staging.push(`Actual printed Discovery blow lottery selects index ${lotteryIndex} of ${pool.length} original supply Hiereg tokens; original token IDs/order remain unchanged.`);
|
||||
game = originalLottery(() => advanceDiscoveryClassicNexusEffects(game,
|
||||
state => state.discoveries!.tokens.some(token => token.face === 'shrine' && token.status === 'placed'), actions),
|
||||
Math.floor((lotteryIndex + 0.5) * 0x100000000 / pool.length));
|
||||
game = advanceDiscoveryClassicNexusEffectsToPhase(game, 5, actions);
|
||||
const shrine = game.discoveries!.tokens.find(token => token.face === 'shrine')!;
|
||||
assert.equal(shrine.status, 'placed');
|
||||
assert.equal(shrine.revealedTurn, null);
|
||||
const source = `${shrine.territory}:${shrine.sector}`, revealer = kind === 'guild-shipment' ? opponent : actor;
|
||||
assert.notEqual(shrine.sector, game.storm);
|
||||
placeOrdinary(game, revealer, source, 2, staging);
|
||||
if (game.strongholdCards) placeOrdinary(game, actor, 'arrakeen:10', 1, staging);
|
||||
game = advanceDiscoveryClassicNexusEffectsToPhase(game, 7, actions);
|
||||
if (viewGame(game, revealer).discoveries!.canInspect.includes(shrine.id)) {
|
||||
const action: Action = { type: 'discovery', token: shrine.id, reveal: false };
|
||||
actions.push({ actor: revealer, action }); game = applyAction(game, revealer, action);
|
||||
}
|
||||
const beforeReveal = structuredClone(game), reveal: Action = { type: 'discovery', token: shrine.id, reveal: true };
|
||||
assert.ok(viewGame(game, revealer).discoveries!.canReveal.includes(shrine.id));
|
||||
actions.push({ actor: revealer, action: reveal }); game = applyAction(game, revealer, reveal);
|
||||
const afterReveal = structuredClone(game);
|
||||
game = advanceDiscoveryClassicNexusEffects(game, game => game.decision?.kind === 'discoveryEntry', actions);
|
||||
assert.ok(game.decision?.kind === 'discoveryEntry');
|
||||
assert.equal(game.turn, 2); assert.equal(game.decision!.player, revealer);
|
||||
const beforeEntry = structuredClone(game), entry: Action = { type: 'decision', event: game.decision!.event,
|
||||
accept: true, groups: viewGame(game, revealer).discoveryEntry!.sources };
|
||||
actions.push({ actor: revealer, action: entry }); game = applyAction(game, revealer, entry);
|
||||
const afterEntry = structuredClone(game);
|
||||
assert.equal(discoveryClassicNexusEffectsPlayer(game, revealer).forces['shrine:0'], 2);
|
||||
game = advanceDiscoveryClassicNexusEffectsToPhase(game, 1, actions);
|
||||
orderSpice(game, ['worm', 'land', 'land'], staging);
|
||||
let allied = false;
|
||||
for (let n = 0; game.nexusCards?.phase?.stage !== 'drawing' && n < 240; n++) {
|
||||
if (game.nexus && !game.spiceWindow && !game.spiceResolution && clean(game) && !allied) {
|
||||
const others = game.players.filter(player => player.id !== actor);
|
||||
for (const [from, to] of [[others[0].id, others[1].id], [others[1].id, others[0].id]]) {
|
||||
const action: Action = { type: 'alliance', target: to };
|
||||
actions.push({ actor: from, action }); game = applyAction(game, from, action);
|
||||
}
|
||||
allied = true;
|
||||
}
|
||||
game = advanceDiscoveryClassicNexusEffectsStep(game, actions);
|
||||
}
|
||||
assert.ok(allied); assert.equal(game.nexusCards?.phase?.stage, 'drawing');
|
||||
const selected = kind === 'guild-shipment' ? 'guild' : kind === 'fremen-revival' ? 'fremen' : 'emperor';
|
||||
const cards = game.nexusCards!.cards!, index = cards.deck.indexOf(selected); assert.ok(index >= 0);
|
||||
cards.deck.unshift(cards.deck.splice(index, 1)[0]);
|
||||
staging.push(`Ordered the original undealt ${selected} Nexus singleton before its actual end-Spice-Blow draw.`);
|
||||
const beforeClosingDraw = structuredClone(game), closingDraw: DiscoveryClassicNexusEffectStep = { actor,
|
||||
action: { type: 'nexusCardChoice', turn: game.turn, card: cards.hands[actor], choice: 'draw', ownRedraws: 0 } };
|
||||
actions.push(closingDraw); game = applyAction(game, actor, closingDraw.action);
|
||||
const afterClosingDraw = structuredClone(game);
|
||||
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;
|
||||
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,
|
||||
nexus: viewGame(game, actor).nexusGuildSecretAlly!.event };
|
||||
} else {
|
||||
const player = discoveryClassicNexusEffectsPlayer(game, actor), tanks = options.tanks ?? 6, elite = options.eliteTanks ?? 0;
|
||||
assert.ok(Number.isInteger(tanks) && tanks >= 0 && Number.isInteger(elite) && elite >= 0 && elite <= tanks);
|
||||
assert.ok(tanks - elite <= player.reserves - (player.elites?.reserves ?? 0) && elite <= (player.elites?.reserves ?? 0));
|
||||
player.reserves -= tanks; player.tanks += tanks;
|
||||
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 };
|
||||
}
|
||||
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,
|
||||
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);
|
||||
}
|
||||
export function finishDiscoveryClassicNexusEffectsInteraction(state: Game, actions?: DiscoveryClassicNexusEffectStep[]): Game {
|
||||
return advanceDiscoveryClassicNexusEffects(state, game => clean(game) && !game.pendingTreacheryDiscard && !game.pendingKarama, actions);
|
||||
}
|
||||
/** Paid nested arrival is retained, then the ordinary battle producer seals real
|
||||
* physical cards/plans. No battle result, ownership or reward is fabricated. */
|
||||
export function openDiscoveryClassicNexusNestedBattle(fixture: DiscoveryClassicNexusEffectsFixture, state: Game): DiscoveryClassicNexusNestedBattle {
|
||||
assert.equal(fixture.kind, 'guild-shipment');
|
||||
let game = finishDiscoveryClassicNexusEffectsInteraction(state, fixture.actions);
|
||||
for (const player of game.players) { game.deck.push(...player.hand); player.hand = []; }
|
||||
fixture.staging.push('Conserved all original held Treachery Cards into the undealt deck before selecting the nested battle cards.');
|
||||
const weapon = holdNexusSkillsModulesPaymentsCard(game, fixture.actor, 'projectile', fixture.staging);
|
||||
const owner = discoveryClassicNexusEffectsPlayer(game, fixture.actor), opponent = discoveryClassicNexusEffectsPlayer(game, fixture.opponent);
|
||||
const leader = owner.leaders.filter(leader => !leader.dead && !leader.usedAt).sort((a, b) => b.strength - a.strength)[0];
|
||||
const losingLeader = opponent.leaders.filter(leader => !leader.dead && !leader.usedAt).sort((a, b) => a.strength - b.strength)[0];
|
||||
assert.ok(leader && losingLeader);
|
||||
game = advanceDiscoveryClassicNexusEffectsToPhase(game, 6, fixture.actions);
|
||||
assert.ok(game.active === owner.id || game.active === opponent.id);
|
||||
const beforeBattle = structuredClone(game), choose: Action = { type: 'chooseBattle', territory: 'shrine', target: game.active === owner.id ? opponent.id : owner.id };
|
||||
fixture.actions.push({ actor: game.active!, action: choose }); game = applyAction(game, game.active!, choose);
|
||||
game = advanceDiscoveryClassicNexusEffects(game, game => !!game.battle && clean(game) && !game.battle.preparation && game.battle.preLeader?.closed !== false, fixture.actions);
|
||||
const plans: DiscoveryClassicNexusEffectStep[] = [
|
||||
{ actor: owner.id, action: { type: 'battlePlan', leader: leader.id, dial: 0, support: 0, weapon: weapon.id } },
|
||||
{ actor: opponent.id, action: { type: 'battlePlan', leader: losingLeader.id, dial: 0, support: 0 } },
|
||||
];
|
||||
return { beforeBattle, game, actor: owner.id, opponent: opponent.id, weapon, losingLeader: losingLeader.id, plans };
|
||||
}
|
||||
export function finishDiscoveryClassicNexusNestedBattle(battle: DiscoveryClassicNexusNestedBattle): Game {
|
||||
let game = battle.game;
|
||||
for (const step of battle.plans) game = applyAction(game, step.actor, step.action);
|
||||
return advanceDiscoveryClassicNexusEffects(game, game => !game.battle && clean(game) && !game.pendingTreacheryDiscard);
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeNexusGameForAudit, joinGame, newPlayer,
|
||||
viewGame, type Action, type Game, type Player,
|
||||
} from '../game/engine';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import { DISCOVERY_CARD_PLACEMENTS, DISCOVERY_TOKEN_BY_ID, type DiscoveryTokenFace } from '../game/discoveries';
|
||||
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
|
||||
import { classicDiscoveryNexusProfile } from '../game/discovery-module-profile';
|
||||
import { nextNexusSkillsModulesPaymentsNativeStep } from './fixture-nexus-skills-modules-payments';
|
||||
|
||||
export type ClassicDiscoveryNexusStep = { actor: string; action: Action };
|
||||
export type ClassicDiscoveryNexusOptions = {
|
||||
/** Authenticated fresh lobby or its undealt original staged setup; never a played save. */
|
||||
initial?: Game;
|
||||
gameId?: string;
|
||||
seatIds?: string[];
|
||||
seats?: 2 | 3 | 4 | 5 | 6;
|
||||
advanced?: boolean;
|
||||
tech?: boolean;
|
||||
strongholds?: boolean;
|
||||
/** Actual ordinary worm immediately before the Great Maker, not a Nexus flag assignment. */
|
||||
ordinaryWormFirst?: boolean;
|
||||
/** Actual ten-spice Emperor shipment to an empty desert makes next-turn charity relevant. */
|
||||
techCharity?: boolean;
|
||||
};
|
||||
export type ClassicDiscoveryNexusMentat = {
|
||||
before: Game; step: ClassicDiscoveryNexusStep; after: Game;
|
||||
};
|
||||
export type ClassicDiscoveryNexusFixture = {
|
||||
initial: Game; setup: Game; collection: Game; game: Game;
|
||||
firstMentat: ClassicDiscoveryNexusMentat;
|
||||
collector: string; opponent: string; fremen: string | null;
|
||||
token: string; parent: string; parentSector: number; wormSource: string;
|
||||
actions: ClassicDiscoveryNexusStep[]; staging: string[];
|
||||
};
|
||||
export const classicDiscoveryNexusClean = (game: Game): boolean =>
|
||||
!game.phaseOpening && !game.response && !game.decision && !game.truthtrance && !game.pendingTreacheryDiscard;
|
||||
export function classicDiscoveryNexusPlayer(game: Game, id: string): Player {
|
||||
const player = game.players.find(candidate => candidate.id === id);
|
||||
assert.ok(player, `Missing original classic seat ${id}`);
|
||||
return player;
|
||||
}
|
||||
|
||||
/** Explicit scalar lottery position: preserve the original shuffled arrays.
|
||||
* This also selects original Storm Card 1 first, rather than assigning a clock
|
||||
* or storm distance. UUID/byte-array entropy remains native. */
|
||||
export function withClassicDiscoveryNexusLottery<T>(operation: () => T): T {
|
||||
const native = crypto.getRandomValues.bind(crypto);
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (array instanceof Uint32Array && array.length === 1) array[0] = 0xffffffff;
|
||||
else Reflect.apply(native, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try { return operation(); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
|
||||
/** No optional spending, shipment, movement, card play, alliance or sealed
|
||||
* Battle Plan is invented by this driver. Every step belongs to a live owner.
|
||||
* Shared original controls retain priority over the optional Discovery choices. */
|
||||
export function nextClassicDiscoveryNexusStep(game: Game): ClassicDiscoveryNexusStep | null {
|
||||
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
|
||||
const decision = game.decision;
|
||||
if (decision?.kind === 'discoveryEntry' || decision?.kind === 'greatMakerRide')
|
||||
return { actor: decision.player, action: { type: 'decision', event: decision.event, accept: false } };
|
||||
if (decision?.kind === 'greatMakerVote')
|
||||
return { actor: decision.player, action: { type: 'decision', event: decision.event, yes: false } };
|
||||
if (decision?.kind === 'wormRide' || decision?.kind === 'wormPlacement')
|
||||
return { actor: decision.player, action: { type: 'decision', accept: false } };
|
||||
if (decision?.kind === 'wormProtection')
|
||||
return { actor: decision.player, action: { type: 'decision', accept: true } };
|
||||
if (decision?.kind === 'advisor' || decision?.kind === 'intrusion')
|
||||
return { actor: decision.player, action: { type: 'decision', accept: false } };
|
||||
if (decision?.kind === 'discoveryDiscard') {
|
||||
const card = classicDiscoveryNexusPlayer(game, decision.player).hand[0];
|
||||
assert.ok(card);
|
||||
return { actor: decision.player, action: { type: 'decision', event: decision.event, card: card.id } };
|
||||
}
|
||||
}
|
||||
return nextNexusSkillsModulesPaymentsNativeStep(game);
|
||||
}
|
||||
export function applyClassicDiscoveryNexusStep(game: Game, step: ClassicDiscoveryNexusStep,
|
||||
actions?: ClassicDiscoveryNexusStep[]): Game {
|
||||
actions?.push(structuredClone(step));
|
||||
return withClassicDiscoveryNexusLottery(() => applyAction(game, step.actor, step.action));
|
||||
}
|
||||
export function advanceClassicDiscoveryNexus(game: Game, until: (game: Game) => boolean,
|
||||
actions?: ClassicDiscoveryNexusStep[]): Game {
|
||||
for (let count = 0; count < 1800; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original lifecycle ended before its requested continuation.');
|
||||
const step = nextClassicDiscoveryNexusStep(game);
|
||||
assert.ok(step, 'The original continuation needs a human sealed Battle Plan.');
|
||||
game = applyClassicDiscoveryNexusStep(game, step, actions);
|
||||
}
|
||||
throw new Error('The original Nexus/Discovery lifecycle did not reach its owned continuation.');
|
||||
}
|
||||
|
||||
/** Physical supply-token lottery only; never writes a token location or face. */
|
||||
export function withClassicDiscoveryNexusToken<T>(game: Game, face: DiscoveryTokenFace, operation: () => T): T {
|
||||
const eligible = game.discoveries!.tokens.filter(token => token.status === 'supply' && token.type === DISCOVERY_TOKEN_BY_ID[face].type);
|
||||
const index = eligible.findIndex(token => token.face === face);
|
||||
assert.ok(index >= 0, 'The requested physical token must remain in its original supply.');
|
||||
const native = crypto.getRandomValues.bind(crypto);
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (array instanceof Uint32Array && array.length === 1)
|
||||
array[0] = Math.floor((index + 0.5) / eligible.length * 0x100000000);
|
||||
else Reflect.apply(native, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try { return operation(); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
function orderSpice(game: Game, options: ClassicDiscoveryNexusOptions): void {
|
||||
const take = (matches: (card: Game['spiceDeck'][number]) => boolean) => {
|
||||
const index = game.spiceDeck.findIndex(matches);
|
||||
assert.ok(index >= 0, 'Each selected physical Spice Card must still be undealt.');
|
||||
return game.spiceDeck.splice(index, 1)[0];
|
||||
};
|
||||
const front = [take(card => 'territory' in card && card.discovery === 'discovery-hagga-basin')];
|
||||
if (game.advanced) front.push(take(card => 'territory' in card && !card.discovery && card.territory === 'broken_land'));
|
||||
if (options.ordinaryWormFirst) front.push(take(card => 'worm' in card && !card.greatMaker));
|
||||
front.push(take(card => 'worm' in card && !!card.greatMaker));
|
||||
front.push(take(card => 'territory' in card && card.discovery === 'discovery-sihaya-ridge'));
|
||||
if (game.advanced) front.push(take(card => 'territory' in card && !card.discovery && card.territory === 'rock_outcroppings'));
|
||||
game.spiceDeck.unshift(...front);
|
||||
}
|
||||
export function revealClassicDiscoveryNexus(game: Game, owner: string, token: string,
|
||||
actions?: ClassicDiscoveryNexusStep[]): Game {
|
||||
if (viewGame(game, owner).discoveries!.canInspect.some(id => id === token))
|
||||
game = applyClassicDiscoveryNexusStep(game, { actor: owner, action: { type: 'discovery', token, reveal: false } }, actions);
|
||||
return applyClassicDiscoveryNexusStep(game, { actor: owner, action: { type: 'discovery', token, reveal: true } }, actions);
|
||||
}
|
||||
export function finishClassicDiscoveryNexusMentat(game: Game, actions?: ClassicDiscoveryNexusStep[]): ClassicDiscoveryNexusMentat {
|
||||
const turn = game.turn;
|
||||
game = advanceClassicDiscoveryNexus(game, state => state.phase === 8 && classicDiscoveryNexusClean(state), actions);
|
||||
for (let count = 0; count < 100; count++) {
|
||||
const before = structuredClone(game), step = nextClassicDiscoveryNexusStep(game);
|
||||
assert.ok(step);
|
||||
game = applyClassicDiscoveryNexusStep(game, step, actions);
|
||||
if (game.turn !== turn || game.status === 'finished') return { before, step, after: game };
|
||||
}
|
||||
throw new Error('The original Mentat controls failed to close the turn.');
|
||||
}
|
||||
export function enterClassicDiscoveryNexus(game: Game, owner: string, amount = 1,
|
||||
actions?: ClassicDiscoveryNexusStep[]): Game {
|
||||
assert.ok(game.decision?.kind === 'discoveryEntry');
|
||||
assert.equal(game.decision.player, owner);
|
||||
const view = viewGame(game, owner), source = view.discoveryEntry!.sources.find(source => source.normal >= amount);
|
||||
assert.ok(source, 'Select physically available ordinary source counters.');
|
||||
const action = discoveryEntryMoveAction(view, [{ source: source.source, normal: amount, elite: 0 }]);
|
||||
assert.ok(action);
|
||||
return applyClassicDiscoveryNexusStep(game, { actor: owner, action }, actions);
|
||||
}
|
||||
|
||||
/** Original base33/all12 Nexus + seven Spice Cards/eight Discovery tokens.
|
||||
* All clocks, wallets, board counters and pending frames arise from native
|
||||
* staged setup and actions. Only undealt cards and supply/Storm lotteries are
|
||||
* positioned explicitly. The returned human window is before turn-two Storm. */
|
||||
export function createClassicDiscoveryNexusFixture(options: ClassicDiscoveryNexusOptions = {}): ClassicDiscoveryNexusFixture {
|
||||
let game: Game;
|
||||
if (options.initial) game = structuredClone(options.initial);
|
||||
else {
|
||||
const factions: FactionId[] = ['guild', 'emperor', 'fremen', 'atreides', 'harkonnen', 'beneGesserit'];
|
||||
const count = options.seats ?? options.seatIds?.length ?? 3;
|
||||
assert.ok(count >= 2 && count <= 6);
|
||||
if (options.seatIds) assert.equal(options.seatIds.length, count);
|
||||
const ids = options.seatIds ?? factions;
|
||||
game = createGame(options.gameId ?? 'CLASSICDISCOVERYNEXUS', newPlayer(ids[0], 'Guild', 'guild'), options.advanced ?? false);
|
||||
for (let seat = 1; seat < count; seat++) joinGame(game, newPlayer(ids[seat], factions[seat], factions[seat]));
|
||||
}
|
||||
assert.deepEqual(game.expansions, []);
|
||||
assert.ok(!game.homeworlds && !game.leaderSkills && !game.ecazTreachery && !game.spiceBankerIncomePreview);
|
||||
assert.ok(game.status === 'lobby' || (game.status === 'setup' && game.turn === 1 && game.phase === 0 &&
|
||||
['prediction', 'traitors'].includes(game.setupStage!) && game.players.every(player => !player.hand.length && !player.traitors.length)));
|
||||
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
|
||||
const collector = game.players.find(player => player.faction === 'guild')?.id;
|
||||
const opponent = game.players.find(player => player.faction === 'emperor')?.id;
|
||||
assert.ok(collector && opponent, 'This original human recipe needs its Guild collector and Emperor source army.');
|
||||
const actions: ClassicDiscoveryNexusStep[] = [];
|
||||
if (game.status === 'lobby') {
|
||||
if (options.tech && !game.techTokens) game = applyAction(game, game.host, { type: 'techTokens', enabled: true });
|
||||
if (options.strongholds && !game.strongholdCards) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: true });
|
||||
game.discoveryEnabled = true;
|
||||
game.nexusCards ??= { cards: null, phase: null };
|
||||
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
|
||||
}
|
||||
const initial = structuredClone(game);
|
||||
if (game.status === 'lobby') game = withClassicDiscoveryNexusLottery(() => initializeNexusGameForAudit(game));
|
||||
assert.ok(game.discoveryEnabled && game.discoveries && game.nexusCards?.cards);
|
||||
assert.ok(classicDiscoveryNexusProfile(game));
|
||||
const setup = structuredClone(game);
|
||||
game = advanceClassicDiscoveryNexus(game, state => state.status === 'playing', actions);
|
||||
orderSpice(game, options);
|
||||
game = advanceClassicDiscoveryNexus(game, state => state.phase === 1 && classicDiscoveryNexusClean(state), actions);
|
||||
// Token selection must surround the real Spice Blow action, not the generic
|
||||
// scalar-lottery wrapper that would otherwise own that same random draw.
|
||||
for (let count = 0; !game.discoveries!.tokens.some(token => token.face === 'cistern' && token.status === 'placed') && count < 100; count++) {
|
||||
const step = nextClassicDiscoveryNexusStep(game); assert.ok(step);
|
||||
actions.push(structuredClone(step));
|
||||
game = withClassicDiscoveryNexusToken(game, 'cistern', () => applyAction(game, step.actor, step.action));
|
||||
}
|
||||
assert.ok(game.discoveries!.tokens.some(token => token.face === 'cistern' && token.status === 'placed'));
|
||||
const placement = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'];
|
||||
const shipped = new Set<string>();
|
||||
game = advanceClassicDiscoveryNexus(game, state => state.phase === 5 && classicDiscoveryNexusClean(state), actions);
|
||||
for (let count = 0; game.phase === 5 && count < 200; count++) {
|
||||
if (classicDiscoveryNexusClean(game) && game.active && !shipped.has(game.active) && [collector, opponent].includes(game.active)) {
|
||||
const actor = game.active;
|
||||
const action: Action = actor === collector
|
||||
? { type: 'ship', territory: placement.territory, sector: placement.sector, amount: 3, elite: 0, allyPayment: 0 }
|
||||
: { type: 'ship', territory: options.techCharity ? 'rock_outcroppings' : 'hagga_basin', sector: options.techCharity ? 13 : 12,
|
||||
amount: options.techCharity ? 5 : 3, elite: 0, allyPayment: 0 };
|
||||
game = applyClassicDiscoveryNexusStep(game, { actor, action }, actions);
|
||||
shipped.add(actor);
|
||||
} else {
|
||||
const step = nextClassicDiscoveryNexusStep(game); assert.ok(step);
|
||||
game = applyClassicDiscoveryNexusStep(game, step, actions);
|
||||
}
|
||||
}
|
||||
assert.equal(shipped.size, 2);
|
||||
game = advanceClassicDiscoveryNexus(game, state => state.phase === 7 && classicDiscoveryNexusClean(state), actions);
|
||||
const token = game.discoveries!.tokens.find(token => token.face === 'cistern')!;
|
||||
const collection = structuredClone(game);
|
||||
game = revealClassicDiscoveryNexus(game, collector, token.id, actions);
|
||||
const firstMentat = finishClassicDiscoveryNexusMentat(game, actions);
|
||||
game = firstMentat.after;
|
||||
assert.equal(game.turn, 2);
|
||||
assert.ok(game.decision?.kind === 'discoveryEntry');
|
||||
assert.equal(game.decision.player, collector);
|
||||
return { initial, setup, collection, firstMentat, game, collector, opponent,
|
||||
fremen: game.players.find(player => player.faction === 'fremen')?.id ?? null,
|
||||
token: token.id, parent: placement.territory, parentSector: placement.sector, wormSource: 'hagga_basin:12', actions,
|
||||
staging: ['Conserved undealt Spice positions: turn-one Hagga Basin Discovery, optional Advanced Broken Land; turn-two optional ordinary Shai-Hulud, Great Maker, Sihaya Ridge Discovery, optional Advanced Rock Outcroppings.',
|
||||
'Selected original Cistern supply-token lottery; original scalar shuffle/Storm lottery positions. No phases, wallets, armies or pending frames assigned.',
|
||||
options.techCharity ? 'Native Emperor five-force/ten-spice Rock Outcroppings shipment supplies actual later charity.' : 'Native Emperor three-force Hagga Basin shipment supplies actual Great Maker worm casualties.'] };
|
||||
}
|
||||
|
||||
/** Continue into the real encounter without silently voting, riding or entering. */
|
||||
export function openClassicDiscoveryNexusEncounter(fixture: ClassicDiscoveryNexusFixture, enter = true): Game {
|
||||
assert.ok(fixture.game.decision?.kind === 'discoveryEntry');
|
||||
const game = enter ? enterClassicDiscoveryNexus(fixture.game, fixture.collector) : applyClassicDiscoveryNexusStep(fixture.game,
|
||||
{ actor: fixture.collector, action: { type: 'decision', event: fixture.game.decision!.event, accept: false } });
|
||||
return advanceClassicDiscoveryNexus(game, state => state.greatMaker?.stage === 'vote');
|
||||
}
|
||||
export function voteClassicDiscoveryNexus(game: Game, votes: readonly boolean[], actions?: ClassicDiscoveryNexusStep[]): Game {
|
||||
for (const yes of votes) {
|
||||
assert.ok(game.decision?.kind === 'greatMakerVote');
|
||||
game = applyClassicDiscoveryNexusStep(game, { actor: game.decision.player,
|
||||
action: { type: 'decision', event: game.decision.event, yes } }, actions);
|
||||
}
|
||||
return game;
|
||||
}
|
||||
export function openClassicDiscoveryNexusAlliance(game: Game): Game {
|
||||
return advanceClassicDiscoveryNexus(game, state => state.nexus && !state.spiceWindow && !state.spiceResolution && classicDiscoveryNexusClean(state));
|
||||
}
|
||||
export function formClassicDiscoveryNexusAlliance(game: Game, first: string, second: string): Game {
|
||||
game = applyClassicDiscoveryNexusStep(game, { actor: first, action: { type: 'alliance', target: second } });
|
||||
return applyClassicDiscoveryNexusStep(game, { actor: second, action: { type: 'alliance', target: first } });
|
||||
}
|
||||
Reference in new issue
Block a user