Compose standalone E3 Nexus without Leader Skills

This commit is contained in:
Quintine committed 2026-10-07 10:18:19 +11:00
1 parent ec3d26f663
commit 185f3b37be
18 files changed
+1675 -38

No files matched your search

+8 -3
View File
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, createGame, initializeDiscoveryGameForAudit, joinGame, newPlayer, viewGame } from '../game/engine';
import { applyAction, createGame, initializeDiscoveryGameForAudit, joinGame, newPlayer } from '../game/engine';
import { createTechTokens } from '../game/tech-tokens';
import { nativeTypedClearBoard, nativeTypedPhase, nativeTypedPlace, nativeTypedPlayer, nativeTypedReload, nativeTypedStep } from './fixture-discovery-native-typed';
import { putDiscovery } from './fixture-discovery';
@@ -14,6 +14,13 @@ void test('native Moritani assassination retains the actual revealed Discovery b
game.discoveryEnabled = true; game.techTokens = createTechTokens();
game = initializeDiscoveryGameForAudit(game);
for (let n = 0; game.status === 'setup' && n < 100; n++) game = nativeTypedStep(game);
// Keep the conserved token supply available: the first real Spice Blow
// draws ordinary land, rather than randomly placing this later fixture token.
for (const position of [0, 1]) {
const index = game.spiceDeck.findIndex((card, i) => i >= position && 'territory' in card && !card.discovery);
assert.ok(index >= position);
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
}
game = nativeTypedPhase(game, 5);
nativeTypedClearBoard(game);
// Conserved original supply token and reserve positions; inspect/reveal,
@@ -40,7 +47,6 @@ void test('native Moritani assassination retains the actual revealed Discovery b
const next = nextSkillsTechBattleStep(game); game = applyAction(game, next.actor, next.action);
}
assert.ok(game.decision?.kind === 'moritaniAssassinate');
assert.equal(viewGame(game, 'm').moritaniAssassinate!.pending!.territory, 'cistern');
const spice = nativeTypedPlayer(game, 'm').spice;
const event = game.decision.event;
game = applyAction(nativeTypedReload(game), 'm', { type: 'decision', event, card: 'guild-1' });
@@ -49,5 +55,4 @@ void test('native Moritani assassination retains the actual revealed Discovery b
assert.equal(game.moritaniAssassinate!.opportunities[0].territory, 'cistern');
assert.equal(game.moritaniAssassinate!.opportunities[0].card, 'guild-1');
assert.equal(game.moritaniAssassinate!.opportunities[0].stage, 'revealed');
assert.equal(viewGame(game, 'm').moritaniAssassinate!.history[0].territory, 'cistern');
});
+335
View File
@@ -0,0 +1,335 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { DIFFICULTIES } from '../game/bot-profiles';
import { treacheryDeck } from '../game/cards';
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
import { NEXUS_FACTIONS } from '../game/nexus-cards';
import { ownedTech } from '../game/tech-tokens';
import { strongholdControllers } from '../game/stronghold-cards';
import { quoteBattleResolution, type BattleResolutionQuote, type ResolutionCombatant } from '../game/battle-resolution-quote';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import { homeworldContext } from '../game/homeworld-game';
import { homeworldForceGroups } from '../game/homeworld-custody';
import { homeworldCombatLocation } from '../game/homeworld-combat';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { nativeShipmentSources } from '../game/homeworld-options';
import { ecazOccupancyRelation } from '../game/ecaz-occupy';
import { nexusEcazDukeAction } from '../game/nexus-ecaz-duke-options';
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
import {
advanceE3NexusEcaz as advance, createE3NexusEcazClosing as closing,
createE3NexusEcazCoalition as coalition, createE3NexusEcazCunning as cunning,
createE3NexusEcazDiscovery as discovery, e3NexusEcazClean as clean,
e3NexusEcazPlayer as player, e3NexusEcazPolicy as policy, e3NexusEcazPool as pool,
e3NexusEcazReload as reload, e3NexusEcazStep as step, revealE3NexusEcaz as reveal,
shipE3NexusEcaz as ship,
type E3NexusEcazStep,
} from './fixture-e3-nexus-ecaz';
/** The last battle can enter ordinary Collection before this owned suffix returns. */
function ordinaryCollectionSpice(g: Game, actor: string): number {
return g.phase === 7 ? quoteSpiceCollection(g).receipts.find(r => r.player === actor)?.strongholds ?? 0 : 0;
}
function reject(g: Game, actor: string, action: Action, reason?: RegExp): void {
const before = structuredClone(g);
if (reason) assert.throws(() => applyAction(g, actor, action), reason);
else assert.throws(() => applyAction(g, actor, action));
assert.deepEqual(g, before, 'Rejected ownership, stale event and unsupported FORCE choices cannot consume original pieces.');
}
/** Count only real zones. The separate Duke never becomes a sixth native disc. */
function physical(g: Game): void {
const nx = g.nexusCards!.cards!;
assert.deepEqual([...nx.deck, ...nx.discard, ...Object.values(nx.hands).filter(c => c !== null)].sort(), [...NEXUS_FACTIONS].sort());
const auction = g.auction?.cards.slice(g.auction.index + (g.currentAuctionSale ? 1 : 0)) ?? [];
assert.deepEqual([...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand), ...auction].map(c => c.id).sort(),
treacheryDeck(['ecaz']).map(c => c.id).sort());
const homes = g.homeworlds?.custody ? homeworldForceGroups(homeworldContext(g), g.homeworlds.custody) : [];
for (const p of g.players) {
const visiting = homes.filter(h => h.native !== p.id).map(h => h.forces[p.id] ?? { normal: 0, elite: 0 });
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((a, n) => a + n, 0) +
visiting.reduce((a, f) => a + f.normal + f.elite, 0), 20);
if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((a, n) => a + n, 0) +
visiting.reduce((a, f) => a + f.elite, 0), p.faction === 'fremen' ? 3 : 5);
}
assert.equal(player(g, g.players.find(p => p.faction === 'ecaz')!.id).leaders.length, 5);
assert.ok(g.players.every(p => p.leaders.every(l => l.id !== DUKE_VIDAL_ID)));
}
/** Four minimal saved policies at genuine windows; assert their consumer effect. */
function policies(g: Game, actor: string, effect: (after: Game, action: Action) => void): void {
for (const difficulty of DIFFICULTIES) {
const action = policy(g, actor, difficulty)[0]; assert.ok(action, `${difficulty} needs the actual owned action.`);
const after = step(g, { actor, action }); effect(after, action); physical(after);
}
}
function battleQuote(g: Game): BattleResolutionQuote {
const b = g.battle!, publicBattle = viewGame(g, g.host).battle!;
assert.ok(b?.revealed);
const side = (actor: string): ResolutionCombatant => {
const p = player(g, actor), view = viewGame(g, actor).battle!;
assert.ok(view.ownForces);
return { id: actor, faction: p.faction, ally: p.ally, spice: p.spice, hand: p.hand,
plan: b.plans[actor], leader: p.leaders.find(l => l.id === b.plans[actor].leader) ??
(g.dukeVidal?.controller === actor && b.plans[actor].leader === DUKE_VIDAL_ID ? g.dukeVidal.leader : undefined),
forces: view.ownForces, stronghold: view.strongholdEffects[actor] };
};
const context = homeworldContext(g);
const home = b.territory.startsWith('homeworld:') ? homeworldCombatLocation({ ...context,
players: context.players.map(p => ({ ...p, ally: player(g, p.id).ally })), order: g.order }, g.homeworlds!.custody!, b.territory) : null;
return quoteBattleResolution({ advanced: g.advanced, typedCasualties: !!g.homeworlds,
turn: g.turn, territory: b.territory, aggressor: publicBattle.aggressor,
...(publicBattle.ecazOccupy?.profile ? { ecazOccupy: publicBattle.ecazOccupy.profile } : {}),
...(home ? { homeworld: { native: home.native, card: home.card, side: home.side, nativeForces: home.forces[home.native] } } : {}),
attacker: side(b.attacker), defender: side(b.defender),
voters: publicBattle.traitorVoters.map(actor => ({ id: actor, called: false, traitors: player(g, actor).traitors,
beneficiary: [b.attacker, b.defender].includes(actor) ? actor : player(g, actor).ally! })),
participants: g.players, physicalCards: [...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand)],
pendingAuditorPresent: false, pendingRetentionPresent: false });
}
function settle(g: Game, actions?: E3NexusEcazStep[], exercisePolicies = true): Game {
for (let n = 0; n < 180; n++) {
g = advance(g, s => !s.response && !s.phaseOpening &&
(!!s.decision && ['battleLosses', 'battleCards', 'techToken'].includes(s.decision.kind) || !s.battle && clean(s)), actions);
if (!g.decision && !g.battle && clean(g)) return g;
const d = g.decision; assert.ok(d);
let action: Action;
if (d.kind === 'battleLosses') {
action = { type: 'decision', choice: 0 };
if (exercisePolicies) policies(g, d.player, (after, selected) => {
const option = d.options[Number(selected.choice)], owner = d.forceOwner ?? d.player;
assert.ok(option);
assert.equal(player(after, owner).tanks, player(g, owner).tanks + option.normal + option.elite);
});
} else if (d.kind === 'battleCards') {
action = { type: 'decision', discard: [] };
if (exercisePolicies) policies(g, d.player, (after, selected) => {
for (const id of selected.discard as string[]) {
assert.ok(!player(after, d.player).hand.some(c => c.id === id));
assert.equal(after.discard.filter(c => c.id === id).length, 1);
}
assert.notEqual(after.decision?.kind, 'battleCards');
});
} else if (d.kind === 'techToken') {
assert.equal(g.battle, null, 'Physical battle cleanup precedes mandatory winner Tech custody.');
reject(g, d.loser, { type: 'decision', token: d.choices[0] });
reject(g, d.player, { type: 'decision', decline: true });
action = { type: 'decision', token: d.choices[0] };
if (exercisePolicies) policies(g, d.player, (after, selected) => {
const token = selected.token as keyof NonNullable<Game['techTokens']>;
assert.ok(d.choices.includes(token)); assert.equal(g.techTokens![token].owner, d.loser);
assert.equal(after.techTokens![token].owner, d.player);
assert.equal(player(after, d.player).spice, player(g, d.player).spice, 'Taking a token is not another income payment.');
});
} else throw Error(`Unexpected original Ecaz settlement ${d.kind}`);
g = step(g, { actor: d.player, action }, actions);
}
throw Error('Original Ecaz physical settlement did not finish.');
}
function assertClosing(f: { owner: string; others: string[]; alliance: Game; draw: { before: Game; step: E3NexusEcazStep; after: Game } }): void {
assert.equal(player(f.alliance, f.owner).ally, null);
assert.equal(player(f.alliance, f.others[0]).ally, f.others[1]);
assert.equal(f.draw.before.nexusCards!.cards!.hands[f.owner], null);
policies(f.draw.before, f.owner, after => {
assert.equal(after.nexusCards!.cards!.hands[f.owner], 'ecaz');
assert.ok(!after.nexusCards!.cards!.deck.includes('ecaz'));
});
reject(f.draw.before, f.others[0], f.draw.step.action);
reject(f.draw.after, f.owner, f.draw.step.action);
}
void test('original no-Skills Ecaz closing draw → paid Wallach visitor → living unclaimed Duke → native support settlement → physical end-turn expiry', () => {
const f = cunning({ discovery: true }); assertClosing(f);
const sources = nativeShipmentSources(viewGame(f.arrival.before, f.owner), 1, 0); assert.ok(sources);
const quoted = homeworldShipmentChoice(viewGame(f.arrival.before, f.owner), f.location,
Object.fromEntries(Object.entries(sources).filter(([, group]) => group.normal + group.elite > 0)));
assert.equal(player(f.arrival.settled, f.owner).spice, player(f.arrival.before, f.owner).spice - quoted.cost);
assert.equal(player(f.arrival.settled, f.owner).reserves, player(f.arrival.before, f.owner).reserves - 1);
assert.equal(pool(f.arrival.settled, f.owner, f.location).normal, 1);
assert.deepEqual(pool(f.arrival.settled, f.opponent, f.location), pool(f.arrival.before, f.opponent, f.location));
assert.ok(!f.arrival.settled.pendingAmbassador && !f.arrival.settled.pendingTerrorEntry);
assert.notEqual(f.arrival.settled.decision?.kind, 'intrusion');
assert.ok(!Object.keys(player(f.arrival.settled, f.owner).forces).some(key => key.startsWith('homeworld:')));
assert.ok(!Object.keys(player(f.arrival.settled, f.opponent).advisors ?? {}).some(key => key.startsWith('homeworld:')));
assert.ok(!f.game.battle!.ecazOccupy, 'A real native-home battle never becomes Arrakis Occupy.');
const action = nexusEcazDukeAction(viewGame(f.cunning.before, f.owner)); assert.ok(action);
assert.equal(f.cunning.before.dukeVidal!.controller, null);
assert.equal(f.cunning.before.dukeVidal!.leader.dead, false);
reject(f.cunning.before, f.opponent, action);
reject(f.cunning.before, f.owner, { ...action, event: 'foreign-window' });
policies(f.cunning.before, f.owner, after => {
assert.equal(after.dukeVidal!.controller, f.owner); assert.equal(after.dukeVidal!.source, 'ecazNexus');
assert.equal(after.nexusCards!.cards!.hands[f.owner], null);
assert.equal(after.nexusCards!.cards!.discard.filter(c => c === 'ecaz').length, 1);
});
reject(f.cunning.after, f.owner, action);
reject(f.game, f.owner, action);
const originalDisc = structuredClone(f.cunning.after.dukeVidal!.leader);
const revealed = reveal(f.game, f.plans, f.actions), quote = battleQuote(revealed);
const nativeSide = revealed.battle!.attacker === f.opponent ? 'attacker' : 'defender';
assert.ok(quote.scores![nativeSide] > quote.leaderStrengths[nativeSide] + 3,
'The actual native Homeworld supplies additional free strength, not additional paid support.');
assert.equal(quote.winner, f.opponent);
assert.equal(quote.payments.find(p => p.player === f.opponent)!.ownPayment, 3,
'Native free strength does not fund three dialed support.');
const lostToken = ownedTech(revealed.techTokens, f.owner)[0]; assert.ok(lostToken);
const done = settle(revealed, f.actions);
assert.equal(player(done, f.opponent).spice, player(revealed, f.opponent).spice - 3);
assert.equal(player(done, f.opponent).tanks, player(revealed, f.opponent).tanks + 3);
assert.equal(pool(done, f.opponent, f.location).normal, pool(revealed, f.opponent, f.location).normal - 3);
assert.equal(pool(done, f.owner, f.location).normal, 0);
assert.equal(player(done, f.owner).tanks, player(revealed, f.owner).tanks + 1);
assert.equal(done.techTokens![lostToken].owner, f.opponent);
assert.equal(done.dukeVidal!.leader.dead, originalDisc.dead);
assert.equal(done.dukeVidal!.leader.deaths, originalDisc.deaths);
assert.deepEqual(done.strongholdCards!.owners, revealed.strongholdCards!.owners, 'A Homeworld win cannot acquire an Arrakis Stronghold Card.');
physical(done);
const later = advance(done, s => s.turn === 3 && s.phase === 1 && clean(s), f.actions);
assert.equal(later.dukeVidal!.controller, null); assert.equal(later.dukeVidal!.source, null);
assert.equal(later.dukeVidal!.leader.dead, originalDisc.dead);
assert.equal(later.dukeVidal!.leader.deaths, originalDisc.deaths);
assert.equal(later.nexusCards!.cards!.discard.filter(c => c === 'ecaz').length, 1);
physical(later);
});
for (const advanced of [true, false]) void test(`${advanced ? 'Advanced held Tabr+Tech+Homeworlds' : 'Basic even-FORCE Tech'} original Ecaz-selected Arrakis coalition keeps source-local payments, owner casualties and winner rewards`, () => {
const f = coalition({ advanced, tech: true, strongholds: advanced, homeworlds: advanced }); assertClosing(f);
assert.ok(f.claim);
if (advanced) {
assert.deepEqual(f.firstMentat.after.strongholdCards!.owners, strongholdControllers(f.firstMentat.before.players, false));
assert.equal(f.firstMentat.after.strongholdCards!.owners.sietch_tabr, f.owner);
}
for (const arrival of f.arrivals) {
const actor = arrival.step.actor, amount = Number(arrival.step.action.amount);
assert.equal(player(arrival.settled, actor).reserves, player(arrival.before, actor).reserves - amount);
assert.equal(player(arrival.settled, actor).forces[f.location], (player(arrival.before, actor).forces[f.location] ?? 0) + amount);
assert.ok(player(arrival.settled, actor).spice < player(arrival.before, actor).spice);
}
const lastArrival = f.arrivals.at(-1)!.settled, accrued = lastArrival.techTokens!.heighliners;
assert.ok(accrued.owner); assert.equal(accrued.spice, ownedTech(lastArrival.techTokens, accrued.owner).length);
assert.equal(f.beforeBattle.techTokens!.heighliners.spice, 0);
assert.equal(player(f.beforeBattle, accrued.owner).spice, player(lastArrival, accrued.owner).spice + accrued.spice,
'Only the ORIGINAL Heighliners holder receives the phase-end shipment benefit.');
assert.equal(ecazOccupancyRelation(f.beforeBattle.players, f.owner, f.ally, { kind: 'territory', id: 'sietch_tabr' }), 'ecazAlliance');
assert.equal(ecazOccupancyRelation(f.beforeBattle.players, f.owner, f.ally, { kind: 'homeworld', id: 'homeworld:emperor' }), 'different');
const d = f.game.decision; assert.ok(d?.kind === 'ecazBattleLead');
reject(f.game, f.ally, { type: 'decision', event: d.event, lead: f.owner });
reject(f.game, f.owner, { type: 'decision', event: 'stale', lead: f.owner });
reject(f.game, f.owner, { type: 'decision', event: d.event, lead: f.opponent });
const chooseAndReveal = (state: Game, lead: string) => {
let g = advance(state, s => !!s.battle && clean(s) && !s.battle.preparation && s.battle.preLeader?.closed !== false);
const profile = viewGame(g, lead).battle!.ecazOccupy!.profile!;
const own = player(g, lead).leaders.filter(l => !l.dead).sort((a, b) => b.strength - a.strength)[0];
const enemy = player(g, f.opponent).leaders.filter(l => !l.dead).sort((a, b) => a.strength - b.strength)[0];
const plans: E3NexusEcazStep[] = [
{ actor: lead, action: { type: 'battlePlan', leader: own.id, dial: profile.fixedEcazDial + 3, support: advanced ? 3 : 0 } },
{ actor: f.opponent, action: { type: 'battlePlan', leader: enemy.id, dial: 1, support: advanced ? 1 : 0 } },
];
const before = reload(g);
policies(g, lead, (offered) => {
assert.equal(player(offered, lead).spice, player(g, lead).spice, 'Sealing is not a second debit.');
const branch = reveal(offered, [plans[1]]), quoted = battleQuote(branch), finished = settle(branch, undefined, false);
assert.equal(finished.lastBattleContext!.winner, quoted.winner);
for (const payment of quoted.payments) {
const income = quoted.strongholdIncome.filter(i => i.player === payment.player).reduce((a, i) => a + i.amount, 0);
assert.equal(player(finished, payment.player).spice, player(branch, payment.player).spice - payment.ownPayment + income +
(quoted.bounty?.player === payment.player ? quoted.bounty.amount : 0) + ordinaryCollectionSpice(finished, payment.player));
}
physical(finished);
});
g = reveal(g, plans);
return { before, revealed: g, quote: battleQuote(g), done: settle(g) };
};
policies(f.game, f.owner, (after, chosen) => {
const lead = String(chosen.lead);
assert.ok([f.owner, f.ally].includes(lead));
const result = chooseAndReveal(after, lead);
assert.equal(result.done.lastBattleContext!.winner, lead);
const enemyToken = ownedTech(result.before.techTokens, f.opponent)[0]; assert.ok(enemyToken);
assert.equal(result.done.techTokens![enemyToken].owner, lead);
assert.equal(ownedTech(result.done.techTokens, lead).length, 2);
assert.equal(ownedTech(result.done.techTokens, lead === f.owner ? f.ally : f.owner).length, 1);
});
const selected = step(f.game, { actor: f.owner, action: { type: 'decision', event: d.event, lead: f.owner } });
const result = chooseAndReveal(selected, f.owner), fixed = advanced ? 3 : 2;
assert.deepEqual(result.quote.fixedLosses, [{ owner: f.owner, normal: fixed, elite: 0 }]);
assert.equal(result.quote.casualties!.owner, f.ally);
assert.equal(player(result.done, f.owner).tanks - player(result.before, f.owner).tanks, fixed);
assert.equal(player(result.done, f.ally).tanks - player(result.before, f.ally).tanks, 3);
assert.equal(player(result.done, f.opponent).tanks - player(result.before, f.opponent).tanks, 8);
assert.equal(player(result.done, f.owner).forces[f.location], 2);
assert.equal(player(result.done, f.ally).forces[f.location], 1);
assert.equal(player(result.done, f.owner).spice, player(result.before, f.owner).spice - (advanced ? 3 : 0) + (advanced ? 1 : 0));
assert.equal(player(result.done, f.ally).spice, player(result.before, f.ally).spice, 'The selected Ecaz payer never debits its variable army owner.');
assert.deepEqual(result.quote.strongholdIncome, advanced ? [{ player: f.owner, amount: 1 }] : []);
const alliedLead = chooseAndReveal(step(f.game, { actor: f.owner,
action: { type: 'decision', event: d.event, lead: f.ally } }), f.ally);
assert.equal(alliedLead.done.lastBattleContext!.winner, f.ally);
assert.deepEqual(alliedLead.quote.strongholdIncome, [], 'The allied lead cannot borrow Ecaz’s held Tabr battle income.');
assert.equal(player(alliedLead.done, f.owner).spice, player(alliedLead.before, f.owner).spice);
assert.equal(player(alliedLead.done, f.ally).spice, player(alliedLead.before, f.ally).spice - (advanced ? 3 : 0));
assert.equal(alliedLead.done.techTokens![ownedTech(alliedLead.before.techTokens, f.opponent)[0]].owner, f.ally);
const completed = advance(result.done, s => s.turn === 4 && s.phase === 0);
assert.equal(completed.status, 'playing'); assert.equal(completed.lastBattleContext!.winner, f.owner);
assert.equal(completed.techTokens![ownedTech(result.before.techTokens, f.opponent)[0]].owner, f.owner);
physical(completed);
});
void test('the real Basic odd-FORCE coalition remains guarded before any selected lead, loss or reward can be consumed', () => {
const f = coalition({ advanced: false, tech: true }, 3);
reject(f.beforeBattle, f.choice.actor, f.choice.action, /Odd-force Basic Occupy/);
assert.equal(player(f.beforeBattle, f.owner).forces[f.location], 3);
assert.equal(f.beforeBattle.battle, null); physical(f.beforeBattle);
});
void test('original Discovery reveal and free parent entry precede, rather than predeal, the later qualifying Ecaz closing draw', () => {
const f = discovery({ advanced: false, roster: ['ecaz', 'guild', 'emperor', 'beneGesserit'], tech: true, homeworlds: true });
assert.equal(player(f.arrival.settled, f.owner).reserves, player(f.arrival.before, f.owner).reserves - 2);
assert.ok(player(f.arrival.settled, f.owner).spice < player(f.arrival.before, f.owner).spice);
assert.equal(f.revealed.discoveries!.tokens.find(t => t.id === f.token)!.revealedTurn, 1);
assert.ok(Object.values(f.entry.before.nexusCards!.cards!.hands).every(c => c === null));
const offer = viewGame(f.entry.before, f.owner), action = discoveryEntryMoveAction(offer, offer.discoveryEntry!.sources); assert.ok(action);
reject(f.entry.before, f.others[0], action);
reject(f.entry.before, f.owner, { ...action, event: 'stale' });
reject(f.entry.after, f.owner, action);
policies(f.entry.before, f.owner, (after, offered) => {
assert.equal(player(after, f.owner).spice, player(f.entry.before, f.owner).spice);
assert.equal(player(after, f.owner).reserves, player(f.entry.before, f.owner).reserves);
assert.deepEqual(after.homeworlds!.custody, f.entry.before.homeworlds!.custody);
if (offered.groups) {
assert.equal(player(after, f.owner).forces['cistern:0'], 2);
assert.equal(player(after, f.owner).forces['gara_kulon:8'] ?? 0, 0);
} else assert.equal(player(after, f.owner).forces['gara_kulon:8'], 2);
assert.notEqual(after.decision?.kind, 'discoveryEntry');
});
assert.equal(player(f.entry.after, f.owner).forces['cistern:0'], 2);
assert.equal(f.draw.before.turn, 2); assertClosing(f);
const later = advance(f.game, s => s.turn === 3 && s.phase === 1 && clean(s));
assert.equal(player(later, f.owner).forces['cistern:0'], 2);
assert.equal(later.nexusCards!.cards!.hands[f.owner], 'ecaz'); physical(later);
});
void test('the existing Basic Harkonnen roster boundary still permits source-clear Cunning without Skills or an invented seat parity guard', () => {
const f = closing({ advanced: false, roster: ['ecaz', 'guild', 'harkonnen'], tech: true, homeworlds: true }); assertClosing(f);
const arrival = ship(f.game, f.owner, 'homeworld:guild', 1);
const g = advance(arrival.settled, s => s.phase === 6 && clean(s) && !s.battle), action = nexusEcazDukeAction(viewGame(g, f.owner)); assert.ok(action);
policies(g, f.owner, after => {
assert.equal(after.dukeVidal!.controller, f.owner);
assert.equal(after.nexusCards!.cards!.hands[f.owner], null);
assert.equal(after.nexusCards!.cards!.discard.filter(c => c === 'ecaz').length, 1);
});
});
void test('Advanced Harkonnen is still an eligible no-Skills roster opponent, not authority to bypass the canonical Duke Cunning exclusion', () => {
const f = closing({ advanced: true, roster: ['ecaz', 'guild', 'harkonnen'], tech: true, homeworlds: true }); assertClosing(f);
const arrival = ship(f.game, f.owner, 'homeworld:guild', 1);
const g = advance(arrival.settled, s => s.phase === 6 && clean(s) && !s.battle);
const offer = viewGame(g, f.owner).nexusEcazDuke; assert.ok(offer);
assert.match(offer.blocked!, /Advanced Harkonnen/);
assert.equal(nexusEcazDukeAction(viewGame(g, f.owner)), null);
reject(g, f.owner, { type: 'nexusEcazDuke', event: offer.event }, /Advanced Harkonnen/);
assert.equal(g.nexusCards!.cards!.hands[f.owner], 'ecaz');
assert.equal(g.dukeVidal!.controller, null); assert.equal(g.dukeVidal!.leader.dead, false);
physical(g);
});
+342
View File
@@ -0,0 +1,342 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, initializeNexusGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { DIFFICULTIES } from '../game/bot-profiles';
import { MOBILE_STRONGHOLD, territory } from '../game/board';
import { ownedTech } from '../game/tech-tokens';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import { nativeShipmentSources } from '../game/homeworld-options';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { nativeE3HomeworldPool as homePool } from './fixture-homeworld-native-e3-modules';
import { nexusMoritaniAction } from '../game/nexus-moritani-options';
import { nexusTraitorDrawAction, nexusTraitorReturnAction } from '../game/nexus-traitor-options';
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
import {
advanceMoritaniNexus as advance, assertMoritaniNexusPhysical as physical,
closeMoritaniNexus as close, createMoritaniNexusClosing as closing,
createMoritaniNexusDiscovery as discovery, createMoritaniNexusLobby as lobby,
moritaniNexusAct as act, moritaniNexusClean as clean, moritaniNexusPlayer as player,
moritaniNexusPolicy as policy, moritaniNexusReload as reload,
openMoritaniNexusBattle as openBattle, openMoritaniNexusLoss as loss,
orderMoritaniNexusSpice as orderSpice, revealMoritaniNexus as reveal,
settleMoritaniNexus as settle, shipMoritaniNexus as ship,
type MoritaniNexusStep,
} from './fixture-e3-nexus-moritani';
function ordinaryCollectionSpice(g: Game, actor: string): number {
return g.phase === 7 ? quoteSpiceCollection(g).receipts.find(r => r.player === actor)?.strongholds ?? 0 : 0;
}
function reject(g: Game, actor: string, action: Action): void {
const before = structuredClone(g); assert.throws(() => applyAction(g, actor, action)); assert.deepEqual(g, before);
}
/** Every policy consumes a real private owned window from a JSON continuation. */
function policies(g: Game, actor: string, consume: (after: Game, action: Action) => void): void {
for (const difficulty of DIFFICULTIES) {
const saved = reload(g); player(saved, actor).bot = difficulty;
const selected = policy(saved, actor, difficulty)[0]; assert.ok(selected, `${difficulty} needs an actual owned choice.`);
const after = applyAction(saved, actor, selected); consume(after, selected); physical(after);
}
}
function finish(g: Game, actions?: MoritaniNexusStep[]): Game {
for (let n = 0; n < 12; n++) {
g = settle(g, actions);
const d = g.decision;
if (!d) return g;
if (d.kind === 'moritaniAssassinate') {
g = act(g, { actor: d.player, action: { type: 'decision', event: d.event, decline: true } }, actions);
continue;
}
assert.equal(d.kind, 'techToken');
if (d.kind !== 'techToken') throw Error('Expected actual winner Tech choice.');
assert.equal(g.battle, null, 'The battle and physical card cleanup precede its reward.');
for (const entry of g.lastBattleContext!.mandatoryDiscard?.entries ?? [])
assert.ok(g.discard.some(c => c.id === entry.card));
reject(g, d.loser, { type: 'decision', token: d.choices[0] });
reject(g, d.player, { type: 'decision', decline: true });
policies(g, d.player, (after, action) => {
const token = action.token as keyof NonNullable<Game['techTokens']>;
assert.ok(d.choices.includes(token)); assert.equal(after.techTokens![token].owner, d.player);
});
g = act(g, { actor: d.player, action: { type: 'decision', token: d.choices[0] } }, actions);
}
throw Error('Original native aftermath reopened.');
}
void test('no-Skills native closing draw, paid Guild-home loss, printed bounty and private Mentat replacement obey the original physical order', () => {
const f = closing({ homeworlds: true, tech: true, strongholds: true, discovery: true });
assert.equal(f.firstMentat.after.strongholdCards!.owners.sietch_tabr, f.owner);
assert.equal(f.firstMentat.after.strongholdCards!.owners.tueks_sietch, f.opponent);
assert.equal(f.alliance.players.find(p => p.id === f.owner)!.ally, null);
policies(f.draw.before, f.owner, after => {
assert.equal(after.nexusCards!.cards!.hands[f.owner], 'moritani');
assert.ok(!after.nexusCards!.cards!.deck.includes('moritani'));
});
reject(f.draw.before, f.opponent, f.draw.step.action);
reject(f.draw.after, f.owner, f.draw.step.action);
const b = loss(f, f.game);
const sources = nativeShipmentSources(viewGame(b.arrival.before, f.owner), 1, 0); assert.ok(sources);
const quote = homeworldShipmentChoice(viewGame(b.arrival.before, f.owner), b.location,
Object.fromEntries(Object.entries(sources).filter(([, group]) => group.normal + group.elite > 0)));
assert.equal(player(b.settledArrival, f.owner).spice, player(b.arrival.before, f.owner).spice - quote.cost);
assert.equal(player(b.settledArrival, f.owner).reserves, player(b.arrival.before, f.owner).reserves - 1);
assert.equal(homePool(b.settledArrival, f.owner, b.location).normal, 1);
assert.ok(!b.settledArrival.pendingTerrorEntry && !b.settledArrival.pendingAmbassador);
const revealed = reveal(b.game, b.plans, f.actions);
assert.equal(player(revealed, f.opponent).spice, player(b.game, f.opponent).spice, 'Sealed support is not prepaid.');
let g = settle(revealed, f.actions);
const d = g.decision; assert.ok(d?.kind === 'moritaniAssassinate');
assert.equal(player(g, f.owner).leaders.find(l => l.id === b.ownLeader)!.dead, true);
assert.equal(player(g, f.owner).tanks, player(revealed, f.owner).tanks + 1);
assert.equal(homePool(g, f.owner, b.location).normal, 0);
assert.equal(player(g, f.opponent).tanks, player(revealed, f.opponent).tanks);
assert.equal(homePool(g, f.opponent, b.location).normal, homePool(revealed, f.opponent, b.location).normal);
const killed = player(revealed, f.owner).leaders.find(l => l.id === b.ownLeader)!;
assert.equal(player(g, f.opponent).spice, player(revealed, f.opponent).spice,
'Native assassination pauses the original winner casualties, support, bounty and cleanup continuation.');
assert.equal(player(g, f.owner).spice, player(revealed, f.owner).spice);
const token = ownedTech(g.techTokens, f.owner)[0]; assert.ok(token);
const target = player(g, f.opponent).leaders.find(l => l.id === f.target)!;
assert.equal(target.dead, false); assert.notEqual(b.enemyLeader, f.target);
const before = reload(g), action: Action = { type: 'decision', event: d.event, card: f.target };
reject(g, f.opponent, action); reject(g, f.owner, { ...action, event: 'stale' });
reject(g, f.owner, { ...action, card: b.enemyLeader });
policies(g, f.owner, after => {
assert.equal(player(after, f.opponent).leaders.find(l => l.id === f.target)!.dead, true);
assert.equal(player(after, f.owner).spice, player(before, f.owner).spice + target.strength);
assert.equal(after.techTokens![token].owner, f.owner, 'Winner Tech cannot bypass private assassination and cleanup.');
});
g = act(g, { actor: f.owner, action }, f.actions);
assert.equal(player(g, f.opponent).leaders.find(l => l.id === f.target)!.deaths, target.deaths + 1);
assert.ok(player(g, f.owner).traitors.includes(f.target), 'The revealed physical card remains held until Mentat.');
reject(g, f.owner, action);
g = finish(g, f.actions);
assert.equal(g.techTokens![token].owner, f.opponent);
assert.equal(player(g, f.opponent).tanks, player(revealed, f.opponent).tanks + 1);
assert.equal(homePool(g, f.opponent, b.location).normal, homePool(revealed, f.opponent, b.location).normal - 1);
assert.equal(player(g, f.opponent).spice, player(revealed, f.opponent).spice - 1 + killed.strength +
ordinaryCollectionSpice(g, f.opponent), 'The original winner support/bounty settles once after assassination.');
const reserve = [...g.traitorReserve!], held = [...player(g, f.owner).traitors];
g = advance(g, s => s.phase === 8 && s.decision?.kind === 'moritaniPlacement', f.actions);
const receipt = g.moritaniAssassinate!.opportunities[0];
assert.equal(receipt.stage, 'replaced'); assert.equal(receipt.replacement, reserve[0]);
assert.deepEqual(g.traitorReserve, reserve.slice(1));
assert.deepEqual(player(g, f.owner).traitors, [...held.filter(id => id !== f.target), reserve[0]]);
const balance = player(g, f.owner).spice;
g = act(g, { actor: f.owner, action: { type: 'decision', decline: true } }, f.actions);
assert.equal(player(g, f.owner).spice, balance, 'Replacement never pays the bounty a second time.');
physical(g);
});
void test('native Moritani Cunning spends one physical Nexus for an original supply placement and real assassination Terror entry, never relocation or home placement', () => {
const f = closing({ roster: ['moritani', 'guild', 'atreides'], homeworlds: true, tech: true, strongholds: true });
let g = advance(f.game, s => s.phase === 8 && s.decision?.kind === 'moritaniPlacement', f.actions);
const view = viewGame(g, f.owner), token = g.moritaniTerror!.tokens.find(t => t.kind === 'assassination' && t.status === 'available')!;
const action = nexusMoritaniAction(view, view.nexusMoritani!.event, token.id, 'polar_sink'); assert.ok(action);
reject(g, f.opponent, action); reject(g, f.owner, { ...action, nexus: 'stale' });
reject(g, f.owner, { ...action, territory: 'homeworld:guild' });
reject(g, f.owner, { ...action, territory: 'homeworld:moritani' });
reject(g, f.owner, { ...action, territory: MOBILE_STRONGHOLD });
const placed = g.moritaniTerror!.tokens.find(t => t.status === 'placed')!;
reject(g, f.owner, { ...action, token: placed.id });
policies(g, f.owner, (after, chosen) => {
assert.equal(after.nexusCards!.cards!.hands[f.owner], null);
assert.equal(after.nexusCards!.cards!.discard.filter(c => c === 'moritani').length, 1);
const committed = advance(after, s => clean(s) && !s.pendingMoritaniPlacement);
assert.equal(committed.moritaniTerror!.tokens.find(t => t.id === chosen.token)!.location, chosen.territory);
assert.equal(committed.moritaniTerror!.tokens.filter(t => t.status === 'available').length,
g.moritaniTerror!.tokens.filter(t => t.status === 'available').length - 1);
});
g = act(g, { actor: f.owner, action }, f.actions);
g = advance(g, s => clean(s) && !s.pendingMoritaniPlacement, f.actions);
assert.equal(g.moritaniTerror!.tokens.find(t => t.id === token.id)!.location, 'polar_sink');
assert.equal(g.nexusMoritaniHistory!.at(-1)!.stage, 'complete');
reject(g, f.owner, action);
g = advance(g, s => s.turn === 3 && s.phase === 5 && s.active === f.opponent && clean(s), f.actions);
const beforeShipment = reload(g), sources = nativeShipmentSources(viewGame(g, f.opponent), 1, 0); assert.ok(sources);
g = act(g, { actor: f.opponent, action: { type: 'ship', territory: 'polar_sink', sector: 0,
amount: 1, elite: 0, allyPayment: 0, homeworldSources: sources } }, f.actions);
g = advance(g, s => !s.response && !s.phaseOpening && s.decision?.kind === 'moritaniTerror', f.actions);
if (g.pendingTerrorEntry!.stage === 'select') {
g = act(g, { actor: f.owner, action: { type: 'decision', token: token.id } }, f.actions);
}
const before = reload(g), revealAction: Action = { type: 'decision', reveal: true };
reject(g, f.opponent, revealAction);
g = act(g, { actor: f.owner, action: revealAction }, f.actions);
g = advance(g, s => !s.pendingShipment && !s.pendingTerrorEntry && clean(s), f.actions);
const dead = player(g, f.opponent).leaders.filter(l => l.dead && !player(before, f.opponent).leaders.find(old => old.id === l.id)!.dead);
assert.equal(dead.length, 1);
assert.equal(dead[0].deaths, player(before, f.opponent).leaders.find(l => l.id === dead[0].id)!.deaths + 1);
assert.equal(player(g, f.owner).spice, player(before, f.owner).spice + dead[0].strength);
assert.equal(player(g, f.opponent).reserves, player(beforeShipment, f.opponent).reserves - 1);
assert.equal(player(g, f.opponent).forces['polar_sink:0'], 1);
assert.ok(player(g, f.opponent).spice < player(beforeShipment, f.opponent).spice);
assert.equal(g.moritaniTerror!.tokens.find(t => t.id === token.id)!.status, 'removed');
assert.equal(g.moritaniAssassinate!.opportunities.length, 0, 'Terror death is not a fabricated post-loss Traitor assassination.');
reject(g, f.owner, revealAction); physical(g);
});
void test('actual Harkonnen Nexus Secret Ally draws and returns physical extra Traitors before a no-Skills native loss can assassinate the retained identity', () => {
const f = closing({ nexus: 'harkonnen', homeworlds: true, tech: true });
let g = advance(f.game, s => s.phase === 8 && clean(s), f.actions);
assert.equal(g.players.some(p => p.faction === 'harkonnen'), false);
assert.equal(player(g, f.owner).traitors.includes(f.target), false);
// Select only the still-undealt original reserve. Both other draw identities
// stay physical, and the old held identity is returned by the owner below.
const i = g.traitorReserve!.indexOf(f.target); assert.ok(i >= 0);
g.traitorReserve!.unshift(g.traitorReserve!.splice(i, 1)[0]);
const before = reload(g), view = viewGame(g, f.owner), offer = view.nexusTraitors!.offer!;
const action = nexusTraitorDrawAction(view, offer.event, 'secretAlly'); assert.ok(action);
reject(g, f.opponent, action); reject(g, f.owner, { ...action, event: 'stale' });
policies(g, f.owner, after => {
assert.equal(after.nexusCards!.cards!.hands[f.owner], null);
assert.deepEqual(after.traitorReserve, before.traitorReserve!.slice(2));
});
g = act(g, { actor: f.owner, action }, f.actions);
const pending = viewGame(g, f.owner).nexusTraitors!.pending!;
const choices = pending.choices.map(c => c.id), returned = choices.filter(id => id !== f.target); assert.equal(returned.length, 2);
const returnAction = nexusTraitorReturnAction(viewGame(g, f.owner), pending.event, returned); assert.ok(returnAction);
reject(g, f.opponent, returnAction); reject(g, f.owner, { ...returnAction, cards: [returned[0], returned[0]] });
policies(g, f.owner, after => {
assert.equal(player(after, f.owner).traitors.length, player(before, f.owner).traitors.length);
assert.equal(after.traitorReserve!.length, before.traitorReserve!.length);
});
g = act(g, { actor: f.owner, action: returnAction }, f.actions);
assert.deepEqual(player(g, f.owner).traitors, [f.target]);
reject(g, f.owner, returnAction);
const b = loss(f, g), revealed = reveal(b.game, b.plans, f.actions);
g = settle(revealed, f.actions);
const d = g.decision; assert.ok(d?.kind === 'moritaniAssassinate');
const bounty = player(g, f.opponent).leaders.find(l => l.id === f.target)!.strength, balance = player(g, f.owner).spice;
g = act(g, { actor: f.owner, action: { type: 'decision', event: d.event, card: f.target } }, f.actions);
assert.equal(player(g, f.owner).spice, balance + bounty);
assert.equal(player(g, f.opponent).leaders.find(l => l.id === f.target)!.dead, true);
g = finish(g, f.actions); physical(g);
});
void test('Advanced normal Traitor call forfeits assassination, while Basic keeps the printed ordinary call and battle outcome', () => {
for (const advanced of [true, false]) {
const f = closing({ advanced, normalCall: true, homeworlds: true, tech: true });
const b = loss(f, f.game, true); assert.equal(b.enemyLeader, f.target);
let g = settle(reveal(b.game, b.plans, f.actions), f.actions, true);
assert.notEqual(g.decision?.kind, 'moritaniAssassinate');
g = finish(g, f.actions);
assert.equal(g.lastBattleContext!.winner, f.owner);
assert.equal(player(g, f.opponent).leaders.find(l => l.id === f.target)!.dead, true);
assert.equal(player(g, f.opponent).forces[b.location] ?? 0, 0);
assert.equal(player(g, f.owner).forces[b.location], 1);
assert.equal(player(g, f.owner).leaders.find(l => l.id === b.ownLeader)!.dead, false);
if (advanced) {
assert.equal(g.moritaniAssassinate!.normalTraitorCall, true);
assert.equal(g.moritaniAssassinate!.opportunities.length, 0);
const blocked = viewGame(g, f.owner).moritaniAssassinate; assert.ok(blocked?.blocked);
const emperor = g.players.find(p => p.faction === 'emperor')!.id;
const arrival = ship(g, f.owner, 'homeworld:emperor', 1, f.actions);
const battle = openBattle(arrival.settled, 'homeworld:emperor', f.owner, emperor, f.actions);
const own = player(battle, f.owner).leaders.filter(l => !l.dead && !l.usedAt).sort((a, b) => a.strength - b.strength)[0];
const enemy = player(battle, emperor).leaders.filter(l => !l.dead && !l.usedAt).sort((a, b) => b.strength - a.strength)[0];
g = finish(reveal(battle, [
{ actor: f.owner, action: { type: 'battlePlan', leader: own.id, dial: 0, support: 0 } },
{ actor: emperor, action: { type: 'battlePlan', leader: enemy.id, dial: 1, support: 1 } },
], f.actions), f.actions);
assert.equal(g.lastBattleContext!.winner, emperor);
assert.equal(g.moritaniAssassinate!.opportunities.length, 0, 'A later real native-home loss cannot recover the forfeited power.');
assert.equal(player(g, emperor).leaders.find(l => l.id === enemy.id)!.dead, false);
} else assert.ok(!g.moritaniAssassinate, 'Basic must not receive the Advanced private assassination power.');
reject(g, f.owner, { type: 'decision', event: b.game.battle!.event, card: f.target });
physical(g);
}
});
void test('Basic no-Skills ordinary Homeworld loss uses printed battle casualties without an Advanced assassination suffix', () => {
const f = closing({ advanced: false, homeworlds: true, tech: true });
const b = loss(f, f.game), revealed = reveal(b.game, b.plans, f.actions);
const done = finish(revealed, f.actions);
assert.equal(done.lastBattleContext!.winner, f.opponent);
assert.ok(!done.moritaniAssassinate);
assert.equal(player(done, f.owner).leaders.find(l => l.id === b.ownLeader)!.dead, true);
assert.equal(player(done, f.opponent).leaders.find(l => l.id === f.target)!.dead, false);
assert.equal(player(done, f.owner).tanks, player(revealed, f.owner).tanks + 1);
assert.equal(homePool(done, f.owner, b.location).normal, 0);
assert.equal(player(done, f.opponent).spice, player(revealed, f.opponent).spice +
player(revealed, f.owner).leaders.find(l => l.id === b.ownLeader)!.strength);
physical(done);
});
void test('original held Tabr rewards only its local normal win after real paid support, before loser Tech is transferred', () => {
const f = closing({ tech: true, strongholds: true });
const location = `sietch_tabr:${territory('sietch_tabr').sectors[0]}`;
const arrival = ship(f.game, f.opponent, location, 1, f.actions);
assert.equal(player(arrival.settled, f.opponent).reserves, player(arrival.before, f.opponent).reserves - 1);
assert.ok(player(arrival.settled, f.opponent).spice < player(arrival.before, f.opponent).spice);
const g = openBattle(arrival.settled, location, f.owner, f.opponent, f.actions);
const own = player(g, f.owner).leaders.filter(l => !l.dead && !l.usedAt).sort((a, b) => b.strength - a.strength)[0];
const enemy = player(g, f.opponent).leaders.filter(l => !l.dead && !l.usedAt).sort((a, b) => a.strength - b.strength)[0];
const revealed = reveal(g, [
{ actor: f.owner, action: { type: 'battlePlan', leader: own.id, dial: 3, support: 3 } },
{ actor: f.opponent, action: { type: 'battlePlan', leader: enemy.id, dial: 1, support: 1 } },
], f.actions);
const pending = settle(revealed, f.actions);
assert.equal(pending.lastBattleContext!.winner, f.owner);
assert.equal(player(pending, f.owner).spice, player(revealed, f.owner).spice - 3 + 1 + ordinaryCollectionSpice(pending, f.owner));
assert.equal(player(pending, f.owner).forces[location], player(revealed, f.owner).forces[location] - 3);
assert.equal(player(pending, f.owner).tanks, player(revealed, f.owner).tanks + 3);
assert.equal(player(pending, f.opponent).forces[location] ?? 0, 0);
const token = ownedTech(revealed.techTokens, f.opponent)[0]; assert.ok(token);
assert.equal(pending.techTokens![token].owner, f.owner, 'A sole loser token transfers automatically after physical cleanup.');
const done = finish(pending, f.actions);
assert.equal(done.techTokens![token].owner, f.owner);
assert.equal(player(done, f.owner).spice, player(pending, f.owner).spice, 'Tech choice cannot replay held-card income.');
physical(done);
});
void test('original Discovery supply, reveal and free nested entry survive the late closing draw and source-local physical battle loss without Skills', () => {
const f = discovery({ homeworlds: true, tech: true, strongholds: true });
assert.equal(player(f.shipment.settled, f.owner).reserves, player(f.shipment.before, f.owner).reserves - 2);
assert.ok(player(f.shipment.settled, f.owner).spice < player(f.shipment.before, f.owner).spice);
const entryView = viewGame(f.entry.before, f.owner), entry = discoveryEntryMoveAction(entryView, entryView.discoveryEntry!.sources); assert.ok(entry);
reject(f.entry.before, f.opponent, entry); reject(f.entry.before, f.owner, { ...entry, event: 'stale' });
policies(f.entry.before, f.owner, (after, action) => {
const moved = Array.isArray(action.groups)
? action.groups.reduce((sum, group) => sum + Number(group.normal) + Number(group.elite), 0) : 0;
assert.equal(player(after, f.owner).forces['cistern:0'] ?? 0, moved);
assert.equal(player(after, f.owner).forces['gara_kulon:8'] ?? 0, 2 - moved);
assert.equal(player(after, f.owner).spice, player(f.entry.before, f.owner).spice);
});
assert.equal(player(f.entry.after, f.owner).forces['cistern:0'], 2);
assert.equal(player(f.entry.after, f.owner).reserves, player(f.entry.before, f.owner).reserves);
assert.equal(player(f.entry.after, f.owner).spice, player(f.entry.before, f.owner).spice);
assert.equal(player(f.entry.after, f.owner).forces[f.shipment.step.action.territory + ':' + f.shipment.step.action.sector] ?? 0, 0);
let g = advance(f.game, s => s.phase === 1 && clean(s), f.actions); orderSpice(g, true);
const closed = close(f, g); g = closed.game;
assert.equal(closed.draw.after.nexusCards!.cards!.hands[f.owner], 'moritani');
const arrival = ship(g, f.opponent, 'cistern:0', 1, f.actions);
assert.equal(player(arrival.settled, f.opponent).forces['cistern:0'], 1);
assert.ok(player(arrival.settled, f.opponent).spice < player(arrival.before, f.opponent).spice);
g = openBattle(arrival.settled, 'cistern:0', f.owner, f.opponent, f.actions);
const own = player(g, f.owner).leaders.filter(l => !l.dead && !l.usedAt).sort((a, b) => a.strength - b.strength)[0];
const enemy = player(g, f.opponent).leaders.filter(l => !l.dead && !l.usedAt && l.id !== f.target).sort((a, b) => b.strength - a.strength)[0];
const revealed = reveal(g, [
{ actor: f.owner, action: { type: 'battlePlan', leader: own.id, dial: 0, support: 0 } },
{ actor: f.opponent, action: { type: 'battlePlan', leader: enemy.id, dial: 1, support: 1 } },
], f.actions);
const done = finish(revealed, f.actions);
assert.equal(done.lastBattleContext!.winner, f.opponent);
assert.equal(player(done, f.owner).forces['cistern:0'] ?? 0, 0);
assert.equal(player(done, f.owner).tanks, player(revealed, f.owner).tanks + 2);
assert.equal(player(done, f.opponent).tanks, player(revealed, f.opponent).tanks + 1);
assert.equal(player(done, f.opponent).spice, player(revealed, f.opponent).spice - 1 + ordinaryCollectionSpice(done, f.opponent),
'Ordinary Collection is separate; held Tuek income and native-home strength do not migrate into a nested battle.');
assert.equal(homePool(done, f.owner, 'homeworld:moritani').normal, homePool(revealed, f.owner, 'homeworld:moritani').normal);
physical(done);
});
void test('Advanced Harkonnen and mixed-E3 original lobbies remain inadmissible without mutating the declared source pieces', () => {
const native = lobby(); joinGame(native, newPlayer('harkonnen', 'Harkonnen', 'harkonnen'));
const mixed = lobby(); joinGame(mixed, newPlayer('ecaz', 'Ecaz', 'ecaz'));
for (const g of [native, mixed]) {
let ready = reload(g);
for (const p of ready.players) ready = act(ready, { actor: p.id, action: { type: 'ready' } });
const before = structuredClone(ready); assert.throws(() => initializeNexusGameForAudit(ready)); assert.deepEqual(ready, before);
}
});
+322
View File
@@ -0,0 +1,322 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeNexusGameForAudit, joinGame, newPlayer, viewGame,
type Action, type Game,
} from '../game/engine';
import type { FactionId } from '../game/catalog';
import { botActions } from '../game/bots';
import { treacheryDeck } from '../game/cards';
import type { Difficulty } from '../game/bot-profiles';
import { territory } from '../game/board';
import { NEXUS_FACTIONS } from '../game/nexus-cards';
import { nativeShipmentSources } from '../game/homeworld-options';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { nexusEcazDukeAction } from '../game/nexus-ecaz-duke-options';
import { nexusCardAction } from '../game/nexus-card-options';
import { DISCOVERY_CARD_PLACEMENTS } from '../game/discoveries';
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
import { nextNativeE3HomeworldStep, nativeE3HomeworldPlayer, nativeE3HomeworldPool } from './fixture-homeworld-native-e3-modules';
import { withClassicDiscoveryNexusLottery, withClassicDiscoveryNexusToken } from './fixture-discovery-classic-nexus';
export { nativeE3HomeworldPlayer as e3NexusEcazPlayer, nativeE3HomeworldPool as e3NexusEcazPool };
export type E3NexusEcazStep = { actor: string; action: Action };
export type E3NexusEcazTransition = { before: Game; step: E3NexusEcazStep; after: Game };
export type E3NexusEcazOptions = {
/** An original undealt lobby or its native setup; never convert a played save. */
initial?: Game;
advanced?: boolean;
roster?: readonly FactionId[];
tech?: boolean;
strongholds?: boolean;
homeworlds?: boolean;
discovery?: boolean;
};
export type E3NexusEcazSetup = {
initial: Game; offered: Game; afterSetup: Game; owner: string; others: string[];
actions: E3NexusEcazStep[];
};
export type E3NexusEcazClosing = E3NexusEcazSetup & {
firstMentat: E3NexusEcazTransition; alliance: Game; draw: E3NexusEcazTransition; game: Game;
claim?: E3NexusEcazTransition;
};
export type E3NexusEcazCoalition = E3NexusEcazClosing & {
ally: string; opponent: string; location: string; ecazForces: number;
arrivals: (E3NexusEcazTransition & { settled: Game })[]; beforeBattle: Game; choice: E3NexusEcazStep; game: Game;
};
export const e3NexusEcazReload = (g: Game): Game => JSON.parse(JSON.stringify(g)) as Game;
export const e3NexusEcazClean = (g: Game): boolean =>
!g.response && !g.phaseOpening && !g.decision && !g.pendingTreacheryDiscard && !g.truthtrance;
/** Minimal persisted bot profiles, projected only after genuine owner custody exists. */
export function e3NexusEcazPolicy(g: Game, actor: string, difficulty: Difficulty): Action[] {
const saved = e3NexusEcazReload(g);
nativeE3HomeworldPlayer(saved, actor).bot = difficulty;
return botActions(viewGame(e3NexusEcazReload(saved), actor));
}
export function e3NexusEcazStep(g: Game, step: E3NexusEcazStep, actions?: E3NexusEcazStep[]): Game {
actions?.push(structuredClone(step));
return withClassicDiscoveryNexusLottery(() => applyAction(e3NexusEcazReload(g), step.actor, step.action));
}
export function nextE3NexusEcazStep(g: Game): E3NexusEcazStep | null {
if (!g.response && !g.phaseOpening && !g.pendingTreacheryDiscard) {
if (g.nexusCards?.phase?.stage === 'drawing' && !g.decision) {
const actor = g.nexusCards.phase.eligible.find(id => !g.nexusCards!.phase!.done.includes(id));
if (actor) {
const view = viewGame(g, actor), choices = view.nexusCards!.choices;
const action = nexusCardAction(view, choices.includes('keep') ? 'keep' : 'draw'); assert.ok(action);
return { actor, action };
}
}
if (g.decision?.kind === 'ecazBattleLead' || g.decision?.kind === 'techToken') return null;
if (g.decision?.kind === 'homeworldShipmentGuild') return { actor: g.decision.player,
action: { type: 'decision', event: g.decision.event, allow: true } };
if (g.decision && ['advisor', 'intrusion', 'advisorBattle'].includes(g.decision.kind))
return { actor: g.decision.player, action: { type: 'decision', accept: false } };
}
return nextNativeE3HomeworldStep(g);
}
export function advanceE3NexusEcaz(g: Game, until: (g: Game) => boolean, actions?: E3NexusEcazStep[]): Game {
for (let n = 0; n < 3000; n++) {
if (until(g)) return g;
assert.notEqual(g.status, 'finished');
const next = nextE3NexusEcazStep(g);
assert.ok(next, 'The actual owner must choose coalition lead, seal a plan or select the winner Tech token.');
g = e3NexusEcazStep(g, next, actions);
}
throw Error('Original no-Skills Ecaz chronology did not reach its owned boundary.');
}
function permutation(source: readonly string[], desired: readonly string[]): number[] {
const current = [...source], rolls: number[] = [];
for (let i = current.length - 1; i > 0; i--) {
const j = current.indexOf(desired[i]); assert.ok(j >= 0 && j <= i);
rolls.push(Math.floor((j + 0.5) * 0x100000000 / (i + 1)));
[current[i], current[j]] = [current[j], current[i]];
}
assert.deepEqual(current, desired); return rolls;
}
/** Select only ORIGINAL undealt Nexus/Treachery permutations. No hand is assigned. */
function initialize(g: Game): Game {
const cards = treacheryDeck(['ecaz']), remaining = [...cards], front: string[] = [];
for (const p of g.players) for (let n = 0; n < (p.faction === 'harkonnen' ? 2 : 1); n++) {
const index = remaining.findIndex(c => c.kind === 'worthless' || c.kind === 'shield');
assert.ok(index >= 0); front.push(remaining.splice(index, 1)[0].id);
}
const rolls = [...(g.discoveryEnabled ? Array<number>(7).fill(0xffffffff) : []),
...permutation(NEXUS_FACTIONS, ['ecaz', ...NEXUS_FACTIONS.filter(c => c !== 'ecaz')]),
...permutation(cards.map(c => c.id), [...front, ...remaining.map(c => c.id)])];
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const native = crypto.getRandomValues.bind(crypto); let index = 0;
crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
if (array instanceof Uint32Array && array.length === 1) array[0] = rolls[index++] ?? 0xffffffff;
else Reflect.apply(native, crypto, [array]);
return array;
};
try { return initializeNexusGameForAudit(g); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
export function createE3NexusEcazSetup(options: E3NexusEcazOptions = {}): E3NexusEcazSetup {
const roster = options.roster ?? ['ecaz', 'guild', 'emperor'];
let g = options.initial ? e3NexusEcazReload(options.initial)
: createGame('E3NEXUSECAZ', newPlayer(roster[0], roster[0], roster[0]), options.advanced ?? true, ['ecaz']);
if (!options.initial) for (const faction of roster.slice(1)) joinGame(g, newPlayer(faction, faction, faction));
assert.ok(g.status === 'lobby' || g.status === 'setup' && g.turn === 1 && g.phase === 0);
assert.equal(g.advanced, options.advanced ?? g.advanced); assert.deepEqual(g.expansions, ['ecaz']);
assert.equal(g.players.filter(p => p.faction === 'ecaz').length, 1);
assert.ok(g.players.every(p => ['ecaz', 'guild', 'emperor', 'atreides', 'fremen', 'beneGesserit', 'harkonnen'].includes(p.faction)));
assert.ok(!g.leaderSkills && !g.ecazTreachery);
const modules = { techTokens: options.tech ?? !!g.techTokens,
strongholdCards: options.strongholds ?? !!g.strongholdCards, homeworlds: options.homeworlds ?? !!g.homeworlds };
const actions: E3NexusEcazStep[] = [];
if (g.status === 'lobby') {
for (const [type, enabled] of Object.entries(modules)) if (!!g[type as keyof typeof modules] !== enabled)
g = e3NexusEcazStep(g, { actor: g.host, action: { type, enabled } }, actions);
g.discoveryEnabled = options.discovery ?? !!g.discoveryEnabled;
g.nexusCards ??= { cards: null, phase: null };
} else {
for (const [type, enabled] of Object.entries(modules)) assert.equal(!!g[type as keyof typeof modules], enabled);
assert.equal(!!g.discoveryEnabled, options.discovery ?? !!g.discoveryEnabled);
assert.ok(g.nexusCards?.cards, 'Never graft Nexus onto a dealt setup.');
}
const initial = e3NexusEcazReload(g);
if (g.status === 'lobby') {
for (const p of g.players) if (!p.ready) g = e3NexusEcazStep(g, { actor: p.id, action: { type: 'ready' } }, actions);
g = initialize(g);
}
const offered = e3NexusEcazReload(g), owner = g.players.find(p => p.faction === 'ecaz')!.id;
for (let n = 0; g.status === 'setup' && n < 200; n++) {
const next = nextE3NexusEcazStep(g); assert.ok(next);
g = e3NexusEcazStep(g, next, actions);
}
assert.equal(g.status, 'playing');
assert.equal(nativeE3HomeworldPlayer(g, owner).reserves, 14);
assert.equal(Object.values(nativeE3HomeworldPlayer(g, owner).forces).reduce((n, f) => n + f, 0), 6);
return { initial, offered, afterSetup: g, owner, others: g.players.filter(p => p.id !== owner).map(p => p.id), actions };
}
/** Only unplayed Spice Cards move within their original deck. */
export function orderE3NexusEcazSpice(g: Game, worm = false): void {
const kinds = [...(worm ? ['worm'] : []), ...Array<string>(g.advanced ? 2 : 1).fill('land')];
for (const [position, kind] of kinds.entries()) {
const index = g.spiceDeck.findIndex((c, i) => i >= position && (kind === 'worm'
? 'worm' in c && !c.greatMaker && !c.suppressed : 'territory' in c && !c.discovery));
assert.ok(index >= position); g.spiceDeck.splice(position, 0, g.spiceDeck.splice(index, 1)[0]);
}
}
export function shipE3NexusEcaz(g: Game, actor: string, destination: string, amount: number,
actions?: E3NexusEcazStep[]): E3NexusEcazTransition & { settled: Game } {
g = advanceE3NexusEcaz(g, s => s.phase === 5 && s.active === actor && e3NexusEcazClean(s), actions);
const before = e3NexusEcazReload(g), sources = g.homeworlds ? nativeShipmentSources(viewGame(g, actor), amount, 0) : undefined;
if (g.homeworlds) assert.ok(sources);
let action: Action;
if (destination.startsWith('homeworld:')) {
assert.ok(sources);
const choice = homeworldShipmentChoice(viewGame(g, actor), destination,
Object.fromEntries(Object.entries(sources).filter(([, f]) => f.normal + f.elite > 0)));
assert.ok(choice.action, choice.blocked ?? 'Original native invasion is unavailable.'); action = choice.action;
} else {
const [kind, sector] = destination.split(':');
action = { type: 'ship', territory: kind, sector: Number(sector), amount, elite: 0, allyPayment: 0,
...(sources ? { homeworldSources: sources } : {}) };
}
const step = { actor, action }; g = e3NexusEcazStep(g, step, actions);
const after = e3NexusEcazReload(g);
g = advanceE3NexusEcaz(g, s => !s.pendingShipment && !s.pendingHomeworldShipment && e3NexusEcazClean(s), actions);
return { before, step, after, settled: g };
}
function finishMentat(g: Game, actions: E3NexusEcazStep[]): E3NexusEcazTransition {
const turn = g.turn;
for (let n = 0; n < 200; n++) {
const before = e3NexusEcazReload(g), step = nextE3NexusEcazStep(g); assert.ok(step);
g = e3NexusEcazStep(g, step, actions);
if (g.turn !== turn) return { before, step, after: g };
}
throw Error('Original Mentat did not finish.');
}
/** Two other real seats ally; only then can unallied Ecaz draw at Nexus closing. */
export function closeE3NexusEcaz(f: E3NexusEcazSetup, state: Game, firstMentat: E3NexusEcazTransition): E3NexusEcazClosing {
let g = advanceE3NexusEcaz(state, s => s.turn === 2 && s.phase === 1 && e3NexusEcazClean(s), f.actions);
orderE3NexusEcazSpice(g, true);
g = advanceE3NexusEcaz(g, s => s.nexus && !s.spiceWindow && !s.spiceResolution && e3NexusEcazClean(s), f.actions);
for (const [actor, target] of [[f.others[0], f.others[1]], [f.others[1], f.others[0]]])
g = e3NexusEcazStep(g, { actor, action: { type: 'alliance', target } }, f.actions);
const alliance = e3NexusEcazReload(g);
g = advanceE3NexusEcaz(g, s => s.nexusCards?.phase?.stage === 'drawing' && e3NexusEcazClean(s), f.actions);
assert.ok(g.nexusCards!.phase!.eligible.includes(f.owner));
const before = e3NexusEcazReload(g), step: E3NexusEcazStep = { actor: f.owner,
action: { type: 'nexusCardChoice', turn: g.turn, card: g.nexusCards!.cards!.hands[f.owner], choice: 'draw', ownRedraws: 0 } };
g = e3NexusEcazStep(g, step, f.actions);
return { ...f, firstMentat, alliance, draw: { before, step, after: g }, game: g };
}
export function createE3NexusEcazClosing(options: E3NexusEcazOptions = {}, claimTabr = false): E3NexusEcazClosing {
const f = createE3NexusEcazSetup(options); assert.ok(f.others.length >= 2);
let g = e3NexusEcazReload(f.afterSetup); orderE3NexusEcazSpice(g);
let claim: E3NexusEcazTransition | undefined;
if (claimTabr) {
const arrival = shipE3NexusEcaz(g, f.owner, 'sietch_tabr:14', 1, f.actions);
g = arrival.settled; claim = arrival;
}
g = advanceE3NexusEcaz(g, s => s.phase === 8 && e3NexusEcazClean(s), f.actions);
const mentat = finishMentat(g, f.actions);
return { ...closeE3NexusEcaz(f, mentat.after, mentat), claim };
}
export function openE3NexusEcazBattle(g: Game, destination: string, first: string, second: string,
actions?: E3NexusEcazStep[]): Game {
g = advanceE3NexusEcaz(g, s => s.phase === 6 && e3NexusEcazClean(s) && !!s.active, actions);
const choice = viewGame(g, g.active!).battleChoices.find(b => b.territory === destination &&
[first, second].includes(b.attacker) && [first, second].includes(b.defender)); assert.ok(choice);
g = e3NexusEcazStep(g, { actor: choice.chooser, action: { type: 'chooseBattle', territory: destination,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } }, actions);
return advanceE3NexusEcaz(g, s => !!s.battle && e3NexusEcazClean(s) && !s.battle.preparation && s.battle.preLeader?.closed !== false, actions);
}
export function createE3NexusEcazCunning(options: E3NexusEcazOptions = {}) {
const f = createE3NexusEcazClosing({ ...options, advanced: true, roster: ['ecaz', 'guild', 'beneGesserit'],
homeworlds: true, tech: true, strongholds: true });
const opponent = f.afterSetup.players.find(p => p.faction === 'beneGesserit')!.id, location = 'homeworld:beneGesserit';
const arrival = shipE3NexusEcaz(f.game, f.owner, location, 1, f.actions);
let g = advanceE3NexusEcaz(arrival.settled, s => s.phase === 6 && e3NexusEcazClean(s) && !s.battle, f.actions);
const before = e3NexusEcazReload(g), action = nexusEcazDukeAction(viewGame(g, f.owner)); assert.ok(action);
const step = { actor: f.owner, action }; g = e3NexusEcazStep(g, step, f.actions);
const cunning: E3NexusEcazTransition = { before, step, after: g };
g = openE3NexusEcazBattle(g, location, f.owner, opponent, f.actions);
const own = nativeE3HomeworldPlayer(g, f.owner).leaders.filter(l => !l.dead).sort((a, b) => a.strength - b.strength)[0];
const enemy = nativeE3HomeworldPlayer(g, opponent).leaders.filter(l => !l.dead).sort((a, b) => b.strength - a.strength)[0];
const plans: E3NexusEcazStep[] = [
{ actor: f.owner, action: { type: 'battlePlan', leader: own.id, dial: 0, support: 0 } },
{ actor: opponent, action: { type: 'battlePlan', leader: enemy.id, dial: 3, support: 3 } },
];
return { ...f, opponent, location, arrival, cunning, game: g, plans };
}
/** Every army reaches Tabr by paid native shipment; no board/wallet/card staging. */
export function createE3NexusEcazCoalition(options: E3NexusEcazOptions = {}, ecazForces?: number): E3NexusEcazCoalition {
const f = createE3NexusEcazClosing({ ...options, roster: ['ecaz', 'guild', 'emperor'] }, true);
let g = advanceE3NexusEcaz(f.game, s => s.turn === 3 && s.phase === 1 && e3NexusEcazClean(s), f.actions);
orderE3NexusEcazSpice(g, true);
g = advanceE3NexusEcaz(g, s => s.nexus && !s.spiceWindow && !s.spiceResolution && e3NexusEcazClean(s), f.actions);
const ally = g.players.find(p => p.faction === 'emperor')!.id, opponent = g.players.find(p => p.faction === 'guild')!.id;
g = e3NexusEcazStep(g, { actor: ally, action: { type: 'alliance', target: null } }, f.actions);
for (const [actor, target] of [[f.owner, ally], [ally, f.owner]])
g = e3NexusEcazStep(g, { actor, action: { type: 'alliance', target } }, f.actions);
const location = `sietch_tabr:${territory('sietch_tabr').sectors[0]}`;
const amount = ecazForces ?? (g.advanced ? 5 : 4);
const requested: Record<string, number> = { [f.owner]: amount - 1, [ally]: 4, [opponent]: 8 };
const arrivals: (E3NexusEcazTransition & { settled: Game })[] = [];
const shipped = new Set<string>();
for (let n = 0; n < 300; n++) {
if (g.phase === 6 && e3NexusEcazClean(g)) break;
if (g.phase === 5 && e3NexusEcazClean(g) && g.active && !shipped.has(g.active)) {
const actor = g.active, arrival = shipE3NexusEcaz(g, actor, location, requested[actor], f.actions);
arrivals.push(arrival); shipped.add(actor); g = arrival.settled;
} else {
const next = nextE3NexusEcazStep(g); assert.ok(next); g = e3NexusEcazStep(g, next, f.actions);
}
}
assert.equal(shipped.size, 3);
const beforeBattle = e3NexusEcazReload(g), choice = viewGame(g, g.active!).battleChoices.find(b => b.territory === 'sietch_tabr'); assert.ok(choice);
const owned: E3NexusEcazStep = { actor: choice.chooser, action: { type: 'chooseBattle', territory: 'sietch_tabr',
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } };
if (g.advanced || amount % 2 === 0) g = e3NexusEcazStep(g, owned, f.actions);
return { ...f, ally, opponent, location, ecazForces: amount, arrivals, beforeBattle, choice: owned, game: g };
}
export function revealE3NexusEcaz(g: Game, plans: readonly E3NexusEcazStep[], actions?: E3NexusEcazStep[]): Game {
for (const plan of plans) {
g = e3NexusEcazStep(g, plan, actions);
if (!g.battle?.revealed) g = advanceE3NexusEcaz(g, s => !!s.battle && e3NexusEcazClean(s) && !s.battle.preparation && s.battle.preLeader?.closed !== false, actions);
}
assert.ok(g.battle?.revealed); return g;
}
/** Token placement uses its original supply lottery, not a preplaced token. */
export function createE3NexusEcazDiscovery(options: E3NexusEcazOptions = {}) {
const f = createE3NexusEcazSetup({ ...options, discovery: true });
let g = e3NexusEcazReload(f.afterSetup);
const index = g.spiceDeck.findIndex(c => 'territory' in c && c.discovery === 'discovery-hagga-basin'); assert.ok(index >= 0);
g.spiceDeck.unshift(g.spiceDeck.splice(index, 1)[0]);
if (g.advanced) {
const second = g.spiceDeck.findIndex((c, i) => i > 0 && 'territory' in c && !c.discovery && c.territory !== 'hagga_basin'); assert.ok(second > 0);
g.spiceDeck.splice(1, 0, g.spiceDeck.splice(second, 1)[0]);
}
g = advanceE3NexusEcaz(g, s => s.phase === 1 && e3NexusEcazClean(s), f.actions);
for (let n = 0; !g.discoveries!.tokens.some(t => t.face === 'cistern' && t.status === 'placed') && n < 160; n++) {
const step = nextE3NexusEcazStep(g); assert.ok(step); f.actions.push(structuredClone(step));
g = withClassicDiscoveryNexusToken(g, 'cistern', () => applyAction(e3NexusEcazReload(g), step.actor, step.action));
}
const destination = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'];
const arrival = shipE3NexusEcaz(g, f.owner, `${destination.territory}:${destination.sector}`, 2, f.actions);
g = advanceE3NexusEcaz(arrival.settled, s => s.phase === 7 && e3NexusEcazClean(s), f.actions);
const token = g.discoveries!.tokens.find(t => t.face === 'cistern' && t.status === 'placed')!.id;
if (viewGame(g, f.owner).discoveries!.canInspect.includes(token))
g = e3NexusEcazStep(g, { actor: f.owner, action: { type: 'discovery', token, reveal: false } }, f.actions);
const beforeReveal = e3NexusEcazReload(g);
g = e3NexusEcazStep(g, { actor: f.owner, action: { type: 'discovery', token, reveal: true } }, f.actions);
const revealed = e3NexusEcazReload(g);
g = advanceE3NexusEcaz(g, s => s.phase === 8 && e3NexusEcazClean(s), f.actions);
const firstMentat = finishMentat(g, f.actions);
g = advanceE3NexusEcaz(firstMentat.after, s => !s.response && !s.phaseOpening &&
s.decision?.kind === 'discoveryEntry' && s.decision.player === f.owner, f.actions);
const before = e3NexusEcazReload(g), view = viewGame(g, f.owner), action = discoveryEntryMoveAction(view, view.discoveryEntry!.sources); assert.ok(action);
const step = { actor: f.owner, action }; g = e3NexusEcazStep(g, step, f.actions);
const entry: E3NexusEcazTransition = { before, step, after: e3NexusEcazReload(g) };
const closing = closeE3NexusEcaz(f, g, firstMentat);
return { ...closing, arrival, token, beforeReveal, revealed, entry };
}
+308
View File
@@ -0,0 +1,308 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeNexusGameForAudit, joinGame, newPlayer, viewGame,
type Action, type Game,
} from '../game/engine';
import type { FactionId } from '../game/catalog';
import { territory } from '../game/board';
import { leaders, spiceDeck, treacheryDeck } from '../game/cards';
import { traitorDeck } from '../game/traitors';
import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards';
import { DISCOVERY_CARD_PLACEMENTS, DISCOVERY_SPICE_CARDS, validateDiscoveryState } from '../game/discoveries';
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
import { nativeShipmentSources } from '../game/homeworld-options';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
import { homeworldForceGroups } from '../game/homeworld-custody';
import { nextDiscoveryStandaloneNexusE3SkillsStep } from './fixture-discovery-standalone-nexus-e3-skills';
import { moritaniStrongholdsPolicy } from './fixture-moritani-strongholds';
import { withClassicDiscoveryNexusLottery, withClassicDiscoveryNexusToken } from './fixture-discovery-classic-nexus';
export type MoritaniNexusStep = { actor: string; action: Action };
export type MoritaniNexusTransition = { before: Game; step: MoritaniNexusStep; after: Game };
export type MoritaniNexusOptions = {
initial?: Game; advanced?: boolean; roster?: readonly FactionId[];
homeworlds?: boolean; discovery?: boolean; tech?: boolean; strongholds?: boolean;
nexus?: 'moritani' | 'harkonnen'; normalCall?: boolean;
};
export type MoritaniNexusSetup = {
initial: Game; offered: Game; afterSetup: Game; owner: string; opponent: string;
target: string; actions: MoritaniNexusStep[];
};
export type MoritaniNexusClosing = MoritaniNexusSetup & {
firstStorm: Game; firstMentat: MoritaniNexusTransition; alliance: Game;
draw: MoritaniNexusTransition; game: Game;
};
export type MoritaniNexusBattle = {
arrival: MoritaniNexusTransition; settledArrival: Game; game: Game;
location: string; plans: MoritaniNexusStep[]; ownLeader: string; enemyLeader: string;
};
export const moritaniNexusReload = (g: Game): Game => JSON.parse(JSON.stringify(g)) as Game;
export const moritaniNexusClean = (g: Game) =>
!g.response && !g.phaseOpening && !g.decision && !g.pendingTreacheryDiscard && !g.truthtrance;
export const moritaniNexusPlayer = (g: Game, actor: string) => {
const p = g.players.find(p => p.id === actor); assert.ok(p); return p;
};
export const moritaniNexusPolicy = moritaniStrongholdsPolicy;
export function moritaniNexusAct(g: Game, step: MoritaniNexusStep, actions?: MoritaniNexusStep[]): Game {
actions?.push(structuredClone(step));
return withClassicDiscoveryNexusLottery(() => applyAction(moritaniNexusReload(g), step.actor, step.action));
}
export function nextMoritaniNexusStep(g: Game): MoritaniNexusStep | null {
if (!g.response && !g.phaseOpening && !g.pendingTreacheryDiscard) {
const d = g.decision;
if (d && ['moritaniAssassinate', 'techToken'].includes(d.kind)) return null;
if (d?.kind === 'homeworldShipmentGuild') return { actor: d.player, action: { type: 'decision', event: d.event, allow: true } };
if (d?.kind === 'homeworldDefense') return { actor: d.player, action: { type: 'decision', event: d.event, use: false } };
if (d?.kind === 'homeworldRevivalDeployment') return { actor: d.player, action: { type: 'decision', event: d.event, decline: true } };
if (d?.kind === 'grummanCollection') return { actor: d.player,
action: { type: 'decision', event: d.event, decline: true } };
}
const next = nextDiscoveryStandaloneNexusE3SkillsStep(g);
if (next?.action.type === 'stormDial') next.action.amount = g.turn === 1 ? 0 : 1;
return next;
}
export function advanceMoritaniNexus(g: Game, until: (g: Game) => boolean, actions?: MoritaniNexusStep[]): Game {
for (let n = 0; n < 3000; n++) {
if (until(g)) return g;
assert.notEqual(g.status, 'finished');
const next = nextMoritaniNexusStep(g); assert.ok(next, 'Original sealed plans or native aftermath require their actual owner.');
g = moritaniNexusAct(g, next, actions);
}
throw Error('Original no-Skills Moritani programme did not reach its owned boundary.');
}
/** Scope entropy to the original undealt physical supply. UUID generation stays native. */
function permutation(source: readonly string[], desired: readonly string[]): number[] {
assert.deepEqual([...source].sort(), [...desired].sort());
const working = [...source], rolls: number[] = [];
for (let i = working.length - 1; i > 0; i--) {
const j = working.indexOf(desired[i]); assert.ok(j >= 0 && j <= i);
rolls.push(Math.floor((j + 0.5) * 0x100000000 / (i + 1)));
[working[i], working[j]] = [working[j], working[i]];
}
return rolls;
}
function lottery<T>(rolls: readonly number[], operation: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const native = crypto.getRandomValues.bind(crypto); let cursor = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1 && cursor < rolls.length) array[0] = rolls[cursor++];
else Reflect.apply(native, crypto, [array]);
return array;
};
try { return operation(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
export function createMoritaniNexusLobby(options: MoritaniNexusOptions = {}): Game {
const roster: readonly FactionId[] = options.roster ?? ['moritani', 'guild', 'emperor'];
const advanced = options.advanced ?? options.initial?.advanced ?? true;
let g = options.initial ? moritaniNexusReload(options.initial)
: createGame('E3NEXUSMORITANI', newPlayer(roster[0], roster[0], roster[0]), advanced, ['ecaz']);
if (!options.initial) for (const faction of roster.slice(1)) joinGame(g, newPlayer(faction, faction, faction));
assert.equal(g.status, 'lobby'); assert.equal(g.advanced, advanced); assert.deepEqual(g.expansions, ['ecaz']);
assert.equal(g.players.filter(p => ['ecaz', 'moritani'].includes(p.faction)).length, 1);
assert.ok(g.players.some(p => p.faction === 'moritani'));
assert.ok(g.players.every(p => ['moritani', 'guild', 'emperor', 'atreides', 'fremen', 'beneGesserit',
...(!advanced ? ['harkonnen'] : [])].includes(p.faction)));
assert.equal(new Set(g.players.map(p => p.faction)).size, g.players.length);
assert.ok(g.players.length >= (options.tech ? 3 : 2) && g.players.length <= 6);
assert.ok(!options.strongholds || advanced); assert.ok(!g.leaderSkills && !g.ecazTreachery);
for (const [type, enabled, present] of [
['homeworlds', options.homeworlds ?? false, !!g.homeworlds],
['techTokens', options.tech ?? false, !!g.techTokens],
['strongholdCards', options.strongholds ?? false, !!g.strongholdCards],
] as const) if (enabled !== present) g = applyAction(g, g.host, { type, enabled });
g.discoveryEnabled = options.discovery ?? false;
g.nexusCards ??= { cards: null, phase: null };
assert.ok(g.nexusCards.cards === null && g.nexusCards.phase === null);
return g;
}
export function createMoritaniNexusSetup(options: MoritaniNexusOptions = {}): MoritaniNexusSetup {
let g = createMoritaniNexusLobby(options);
const initial = moritaniNexusReload(g), actions: MoritaniNexusStep[] = [];
const owner = g.players.find(p => p.faction === 'moritani')!.id;
const opponent = g.players.find(p => p.faction === 'guild')!.id; assert.ok(opponent);
const target = leaders('guild').find(l => l.name === (options.normalCall ? 'Staban Tuek' : 'Master Bewt'))!.id;
for (const p of g.players) if (!p.ready) g = moritaniNexusAct(g, { actor: p.id, action: { type: 'ready' } }, actions);
if (options.initial) g = initializeNexusGameForAudit(g);
else {
const nx = options.nexus ?? 'moritani', cards = treacheryDeck(['ecaz']), remaining = [...cards];
const front = g.players.flatMap(p => {
const kinds = [p.id === opponent ? 'projectile' : 'worthless', ...(p.faction === 'harkonnen' ? ['shield'] : [])];
return kinds.map(kind => { const i = remaining.findIndex(c => c.kind === kind); assert.ok(i >= 0); return remaining.splice(i, 1)[0].id; });
});
const traitors = traitorDeck(g.players), desired = [target, ...traitors.filter(c => c !== target)];
const index = nx === 'harkonnen' ? g.players.length * 4 : g.players.findIndex(p => p.id === owner) * 4;
[desired[0], desired[index]] = [desired[index], desired[0]];
const spiceCount = spiceDeck().length + (g.discoveryEnabled ? DISCOVERY_SPICE_CARDS.length + 1 : 0);
g = lottery([...(g.discoveryEnabled ? Array<number>(7).fill(0xffffffff) : []),
...permutation(NEXUS_FACTIONS, [nx, ...NEXUS_FACTIONS.filter(c => c !== nx)]),
...permutation(cards.map(c => c.id), [...front, ...remaining.map(c => c.id)]),
...Array<number>(spiceCount - 1).fill(0xffffffff), ...permutation(traitors, desired)], () => initializeNexusGameForAudit(g));
}
const offered = moritaniNexusReload(g);
// A real Bene Gesserit prediction can postpone the same original Traitor deal.
// Supplied human lobbies keep their native lottery.
while (g.status === 'setup') {
let next = nextMoritaniNexusStep(g); assert.ok(next);
if (!options.initial && g.setupStage === 'prediction') {
const source = traitorDeck(g.players), desired = [target, ...source.filter(c => c !== target)];
const index = options.nexus === 'harkonnen' ? g.players.length * 4 : g.players.findIndex(p => p.id === owner) * 4;
[desired[0], desired[index]] = [desired[index], desired[0]];
const step = next; actions.push(structuredClone(step));
g = lottery(permutation(source, desired), () => applyAction(moritaniNexusReload(g), step.actor, step.action));
continue;
}
if (!options.initial && options.nexus !== 'harkonnen' && g.setupStage === 'traitors' && moritaniNexusPlayer(g, owner).traitorChoices.length)
next = { actor: owner, action: { type: 'traitor', leader: target } };
if (g.decision?.kind === 'moritaniSetup' &&
g.players.every(p => Object.entries(p.forces).every(([key, amount]) => !key.startsWith('sietch_tabr:') || amount === 0)))
next = { actor: owner, action: { type: 'decision', territory: 'sietch_tabr', sector: territory('sietch_tabr').sectors[0] } };
g = moritaniNexusAct(g, next, actions);
}
assert.equal(g.status, 'playing');
return { initial, offered, afterSetup: g, owner, opponent, target, actions };
}
/** Only unplayed original Spice Cards are positioned. */
export function orderMoritaniNexusSpice(g: Game, worm = false): void {
const kinds = [...(worm ? ['worm'] : []), ...Array<string>(g.advanced ? 2 : 1).fill('land')];
for (const [position, kind] of kinds.entries()) {
const index = g.spiceDeck.findIndex((c, i) => i >= position && (kind === 'worm'
? 'worm' in c && !c.greatMaker : 'territory' in c && !c.discovery));
assert.ok(index >= position); g.spiceDeck.splice(position, 0, g.spiceDeck.splice(index, 1)[0]);
}
}
export function closeMoritaniNexus(f: MoritaniNexusSetup, state: Game): { alliance: Game; draw: MoritaniNexusTransition; game: Game } {
let g = advanceMoritaniNexus(state, s => !!s.nexus && !s.spiceWindow && !s.spiceResolution && moritaniNexusClean(s), f.actions);
const others = g.players.filter(p => p.id !== f.owner); assert.ok(others.length >= 2);
for (const [actor, target] of [[others[0].id, others[1].id], [others[1].id, others[0].id]])
g = moritaniNexusAct(g, { actor, action: { type: 'alliance', target } }, f.actions);
const alliance = moritaniNexusReload(g);
g = advanceMoritaniNexus(g, s => s.nexusCards?.phase?.stage === 'drawing' && moritaniNexusClean(s), f.actions);
assert.ok(g.nexusCards!.phase!.eligible.includes(f.owner));
const before = moritaniNexusReload(g), step: MoritaniNexusStep = { actor: f.owner,
action: { type: 'nexusCardChoice', turn: g.turn, card: g.nexusCards!.cards!.hands[f.owner], choice: 'draw', ownRedraws: 0 } };
g = moritaniNexusAct(g, step, f.actions);
return { alliance, draw: { before, step, after: moritaniNexusReload(g) }, game: g };
}
export function createMoritaniNexusClosing(options: MoritaniNexusOptions = {}): MoritaniNexusClosing {
const f = createMoritaniNexusSetup(options); let g = moritaniNexusReload(f.afterSetup);
orderMoritaniNexusSpice(g);
g = advanceMoritaniNexus(g, s => s.phase === 1 && moritaniNexusClean(s), f.actions);
const firstStorm = moritaniNexusReload(g);
g = advanceMoritaniNexus(g, s => s.phase === 8 && s.decision?.kind === 'moritaniPlacement', f.actions);
const token = g.moritaniTerror!.tokens.find(t => t.kind === 'extortion' && t.status === 'available'); assert.ok(token);
g = moritaniNexusAct(g, { actor: f.owner, action: { type: 'decision', token: token.id, territory: 'arrakeen' } }, f.actions);
let firstMentat: MoritaniNexusTransition | undefined;
while (g.turn === 1) {
const before = moritaniNexusReload(g), step = nextMoritaniNexusStep(g); assert.ok(step);
g = moritaniNexusAct(g, step, f.actions);
if (g.turn === 2) firstMentat = { before, step, after: moritaniNexusReload(g) };
}
assert.ok(firstMentat);
g = advanceMoritaniNexus(g, s => s.phase === 1 && moritaniNexusClean(s), f.actions);
orderMoritaniNexusSpice(g, true);
return { ...f, firstStorm, firstMentat, ...closeMoritaniNexus(f, g) };
}
export function shipMoritaniNexus(g: Game, actor: string, destination: string, amount: number,
actions?: MoritaniNexusStep[]): MoritaniNexusTransition & { settled: Game } {
g = advanceMoritaniNexus(g, s => s.phase === 5 && s.active === actor && moritaniNexusClean(s), actions);
const before = moritaniNexusReload(g), view = viewGame(g, actor);
let action: Action;
const sources = g.homeworlds ? nativeShipmentSources(view, amount, 0) : undefined;
if (destination.startsWith('homeworld:')) {
assert.ok(sources); const quote = homeworldShipmentChoice(view, destination,
Object.fromEntries(Object.entries(sources).filter(([, f]) => f.normal + f.elite > 0)));
assert.ok(quote.action, quote.blocked ?? 'Original paid native entry unavailable.'); action = quote.action;
} else {
const [territory, sector] = destination.split(':');
action = { type: 'ship', territory, sector: Number(sector), amount, elite: 0, allyPayment: 0,
...(sources ? { homeworldSources: sources } : {}) };
}
const step = { actor, action }; g = moritaniNexusAct(g, step, actions);
const after = moritaniNexusReload(g);
g = advanceMoritaniNexus(g, s => !s.pendingHomeworldShipment && !s.pendingShipment && moritaniNexusClean(s), actions);
return { before, step, after, settled: g };
}
export function openMoritaniNexusBattle(g: Game, location: string, owner: string, opponent: string,
actions?: MoritaniNexusStep[]): Game {
g = advanceMoritaniNexus(g, s => s.phase === 6 && moritaniNexusClean(s) && !!s.active, actions);
assert.ok(g.active === owner || g.active === opponent);
const territory = location.startsWith('homeworld:') ? location : location.split(':')[0];
g = moritaniNexusAct(g, { actor: g.active!, action: { type: 'chooseBattle', territory,
target: g.active === owner ? opponent : owner } }, actions);
return advanceMoritaniNexus(g, s => !!s.battle && moritaniNexusClean(s) && !s.battle.preparation && s.battle.preLeader?.closed !== false, actions);
}
export function openMoritaniNexusLoss(f: MoritaniNexusSetup, state: Game, normalCall = false): MoritaniNexusBattle {
const location = state.homeworlds && !normalCall ? 'homeworld:guild' : 'tueks_sietch:5';
const arrival = shipMoritaniNexus(state, f.owner, location, 1, f.actions);
const g = openMoritaniNexusBattle(arrival.settled, location, f.owner, f.opponent, f.actions);
const own = moritaniNexusPlayer(g, f.owner).leaders.filter(l => !l.dead && !l.usedAt).sort((a, b) => a.strength - b.strength)[0];
const enemy = moritaniNexusPlayer(g, f.opponent).leaders.filter(l => !l.dead && !l.usedAt && (normalCall || l.id !== f.target))
.sort((a, b) => b.strength - a.strength)[0];
const weapon = moritaniNexusPlayer(g, f.opponent).hand.find(c => c.kind === 'projectile');
const worthless = moritaniNexusPlayer(g, f.owner).hand.find(c => c.kind === 'worthless');
assert.ok(own && enemy && weapon && worthless);
return { arrival, settledArrival: arrival.settled, game: g, location, ownLeader: own.id, enemyLeader: enemy.id, plans: [
{ actor: f.owner, action: { type: 'battlePlan', leader: own.id, dial: 0, support: 0, weapon: worthless.id } },
{ actor: f.opponent, action: { type: 'battlePlan', leader: enemy.id, dial: 1, support: g.advanced ? 1 : 0, weapon: weapon.id } },
] };
}
export function revealMoritaniNexus(g: Game, plans: MoritaniNexusStep[], actions?: MoritaniNexusStep[]): Game {
for (const step of plans) {
g = moritaniNexusAct(g, step, actions);
if (!g.battle?.revealed) g = advanceMoritaniNexus(g, s => !!s.battle && moritaniNexusClean(s) && !s.battle.preparation && s.battle.preLeader?.closed !== false, actions);
}
assert.ok(g.battle?.revealed); return g;
}
export function settleMoritaniNexus(g: Game, actions?: MoritaniNexusStep[], normalCall = false): Game {
for (let n = 0; n < 160; n++) {
if (!g.response && !g.phaseOpening && (g.decision?.kind === 'moritaniAssassinate' || g.decision?.kind === 'techToken' || !g.battle && moritaniNexusClean(g))) return g;
const next = nextMoritaniNexusStep(g); assert.ok(next);
if (next.action.type === 'traitorCall') next.action.call = normalCall && moritaniNexusPlayer(g, next.actor).faction === 'moritani';
g = moritaniNexusAct(g, next, actions);
}
throw Error('Original native battle suffix did not settle.');
}
export function createMoritaniNexusDiscovery(options: MoritaniNexusOptions = {}) {
const f = createMoritaniNexusSetup({ ...options, discovery: true }); let g = moritaniNexusReload(f.afterSetup);
const index = g.spiceDeck.findIndex(c => 'territory' in c && c.discovery === 'discovery-hagga-basin'); assert.ok(index >= 0);
g.spiceDeck.unshift(g.spiceDeck.splice(index, 1)[0]);
const second = g.spiceDeck.findIndex((c, i) => i > 0 && 'territory' in c && !c.discovery && c.territory !== 'hagga_basin');
assert.ok(second > 0); g.spiceDeck.splice(1, 0, g.spiceDeck.splice(second, 1)[0]);
g = advanceMoritaniNexus(g, s => s.phase === 1 && moritaniNexusClean(s), f.actions);
for (let n = 0; !g.discoveries!.tokens.some(t => t.face === 'cistern' && t.status === 'placed') && n < 160; n++) {
const step = nextMoritaniNexusStep(g); assert.ok(step); f.actions.push(structuredClone(step));
g = withClassicDiscoveryNexusToken(g, 'cistern', () => applyAction(moritaniNexusReload(g), step.actor, step.action));
}
const placement = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'];
const shipment = shipMoritaniNexus(g, f.owner, `${placement.territory}:${placement.sector}`, 2, f.actions);
g = advanceMoritaniNexus(shipment.settled, s => s.phase === 7 && moritaniNexusClean(s), f.actions);
const token = g.discoveries!.tokens.find(t => t.face === 'cistern' && t.status === 'placed')!.id;
if (viewGame(g, f.owner).discoveries!.canInspect.includes(token))
g = moritaniNexusAct(g, { actor: f.owner, action: { type: 'discovery', token, reveal: false } }, f.actions);
g = moritaniNexusAct(g, { actor: f.owner, action: { type: 'discovery', token, reveal: true } }, f.actions);
const revealed = moritaniNexusReload(g);
g = advanceMoritaniNexus(g, s => s.turn === 2 && !s.response && !s.phaseOpening && s.decision?.kind === 'discoveryEntry' && s.decision.player === f.owner, f.actions);
const before = moritaniNexusReload(g), view = viewGame(g, f.owner);
const action = discoveryEntryMoveAction(view, view.discoveryEntry!.sources); assert.ok(action);
const step = { actor: f.owner, action }; g = moritaniNexusAct(g, step, f.actions);
return { ...f, shipment, revealed, token, entry: { before, step, after: moritaniNexusReload(g) }, game: g };
}
export function assertMoritaniNexusPhysical(g: Game): void {
validateNexusCards(g.nexusCards!.cards!, g.players);
if (g.discoveries) validateDiscoveryState(g.discoveries);
if (g.homeworlds) homeworldGameIntegrity(g);
const homes = g.homeworlds?.custody ? homeworldForceGroups(homeworldContext(g), g.homeworlds.custody) : [];
for (const p of g.players) {
const visitors = homes.filter(h => h.native !== p.id).reduce((n, h) => n + (h.forces[p.id]?.normal ?? 0), 0);
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((n, f) => n + f, 0) + visitors, 20);
}
const retired = g.moritaniAssassinate?.opportunities.filter(o => o.stage === 'replaced').map(o => o.card!) ?? [];
assert.deepEqual([...g.traitorReserve!, ...g.players.flatMap(p => [...p.traitors, ...p.traitorChoices]), ...retired].sort(), traitorDeck(g.players).sort());
const auction = g.auction?.cards.slice(g.auction.index + (g.currentAuctionSale ? 1 : 0)) ?? [];
assert.deepEqual([...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand), ...auction].map(c => c.id).sort(), treacheryDeck(['ecaz']).map(c => c.id).sort());
}