Compose original classic and paired Nexus Discovery modules
This commit is contained in:
1 parent
9e8c718739
commit
c8a6d0bb3c
20 files changed
+1603
-44
No files matched your search
@@ -0,0 +1,303 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
|
||||
import { validateLeaderSkills } from '../game/leader-skills';
|
||||
import { validateDiscoveryState } from '../game/discoveries';
|
||||
import { validateNexusCards } from '../game/nexus-cards';
|
||||
import { forceRevivalRemaining } from '../game/revival';
|
||||
import { ownedTech } from '../game/tech-tokens';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { botActions } from '../game/bots';
|
||||
import { quoteClassicSkillsStrongholdBattle } from './fixture-classic-skills-stronghold';
|
||||
import {
|
||||
advanceDiscoveryClassicNexusSkills, closeDiscoveryClassicNexusSkills,
|
||||
createDiscoveryClassicNexusSkillsFixture, discoveryClassicNexusSkillsClean,
|
||||
discoveryClassicNexusSkillsPlayer as player, enterDiscoveryClassicNexusSkills,
|
||||
finishDiscoveryClassicNexusSkillsBattle, initializeDiscoveryClassicNexusSkillsSetup,
|
||||
openDiscoveryClassicNexusSkillsBattle, revealDiscoveryClassicNexusSkillsBattle,
|
||||
stepDiscoveryClassicNexusSkills,
|
||||
} from './fixture-discovery-classic-nexus-skills';
|
||||
import {
|
||||
finishClassicDiscoveryNexusMentat, formClassicDiscoveryNexusAlliance, openClassicDiscoveryNexusAlliance,
|
||||
} from './fixture-discovery-classic-nexus';
|
||||
|
||||
function inventory(game: Game): void {
|
||||
validateLeaderSkills(game.leaderSkills!, game.players);
|
||||
validateDiscoveryState(game.discoveries!);
|
||||
validateNexusCards(game.nexusCards!.cards!, game.players);
|
||||
assert.deepEqual([...game.leaderSkills!.deck, ...Object.values(game.leaderSkills!.offers).flatMap(offer => offer.cards),
|
||||
...game.leaderSkills!.assignments.map(assignment => assignment.skill)].sort(), LEADER_SKILL_CARDS.map(card => card.id).sort());
|
||||
assert.deepEqual([...game.deck, ...game.discard, ...game.players.flatMap(seat => seat.hand)].map(card => card.id).sort(),
|
||||
baseDeck().map(card => card.id).sort());
|
||||
for (const seat of game.players) {
|
||||
assert.equal(seat.reserves + seat.tanks + Object.values(seat.forces).reduce((sum, amount) => sum + amount, 0), 20);
|
||||
if (seat.elites) assert.equal(seat.elites.reserves + seat.elites.tanks + Object.values(seat.elites.forces).reduce((sum, amount) => sum + amount, 0),
|
||||
seat.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 reject(game: Game, actor: string, action: Action): void {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
|
||||
void test('original classic all14/all12/Discovery setup trains before traitors and reaches physical next-turn entry at Basic/Advanced two through six seats', () => {
|
||||
for (const advanced of [false, true]) for (const seats of [2, 3, 4, 5, 6] as const) {
|
||||
const fixture = createDiscoveryClassicNexusSkillsFixture({ advanced, seats, tech: seats >= 3, strongholds: advanced });
|
||||
assert.equal(fixture.setup.status, 'setup'); assert.equal(fixture.setup.leaderSkills!.assignments.length, 0);
|
||||
assert.equal(fixture.setup.spiceDeck.length, 28); assert.equal(fixture.setup.discoveries!.tokens.length, 8);
|
||||
assert.ok(fixture.setup.players.every(seat => !seat.traitors.length && !seat.traitorChoices.length));
|
||||
assert.equal(fixture.trained.leaderSkills!.assignments.length, seats);
|
||||
const trainingSteps = fixture.actions.filter(step => step.action.type === 'leaderSkill');
|
||||
assert.equal(trainingSteps.length, seats);
|
||||
const lastTraining = fixture.actions.findLastIndex(step => step.action.type === 'leaderSkill');
|
||||
const firstTraitor = fixture.actions.findIndex(step => step.action.type === 'traitor');
|
||||
assert.ok(firstTraitor > lastTraining, 'Actual training precedes the original Traitor selection.');
|
||||
assert.equal(fixture.firstMentat.before.phase, 8); assert.equal(fixture.firstMentat.after.turn, 2);
|
||||
assert.equal(fixture.game.phase, 0); assert.equal(fixture.game.storm, fixture.firstMentat.before.storm);
|
||||
const before = player(fixture.game, fixture.owner), entered = enterDiscoveryClassicNexusSkills(fixture), after = player(entered, fixture.owner);
|
||||
assert.equal(after.forces['cistern:0'], 3); assert.equal(after.forces[fixture.source] ?? 0, 0);
|
||||
assert.equal(after.reserves, before.reserves); assert.equal(after.spice, before.spice);
|
||||
assert.equal(after.shipped, before.shipped); assert.equal(after.moved, before.moved);
|
||||
assert.deepEqual(entered.leaderSkills, fixture.game.leaderSkills);
|
||||
if (advanced) {
|
||||
assert.equal(entered.strongholdCards!.owners.arrakeen, fixture.owner);
|
||||
assert.equal(entered.strongholdCards!.claimedTurn, 1);
|
||||
}
|
||||
inventory(fixture.setup); inventory(entered);
|
||||
}
|
||||
});
|
||||
|
||||
void test('authenticated original lobby and undealt human setup retain actual offered training, original hands and actors without demanding a specific skill', () => {
|
||||
const lobby = createGame('HUMANCLASSICNXSKILLDISCOVERY', newPlayer('human-collector', 'Guild', 'guild'), true);
|
||||
joinGame(lobby, newPlayer('human-emperor', 'Emperor', 'emperor'));
|
||||
joinGame(lobby, newPlayer('human-fremen', 'Fremen', 'fremen'));
|
||||
const before = structuredClone(lobby);
|
||||
const setup = initializeDiscoveryClassicNexusSkillsSetup({ initial: lobby, tech: true, strongholds: true });
|
||||
assert.deepEqual(lobby, before);
|
||||
const offered = structuredClone(setup);
|
||||
const fixture = createDiscoveryClassicNexusSkillsFixture({ initial: setup });
|
||||
assert.deepEqual(setup, offered); assert.deepEqual(fixture.setup, offered);
|
||||
assert.deepEqual(fixture.game.players.map(seat => seat.id), ['human-collector', 'human-emperor', 'human-fremen']);
|
||||
for (const step of fixture.actions.filter(step => step.action.type === 'leaderSkill')) {
|
||||
assert.ok(offered.leaderSkills!.offers[step.actor].cards.some(skill => skill === step.action.skill));
|
||||
assert.ok(offered.players.find(seat => seat.id === step.actor)!.leaders.some(leader => leader.id === step.action.leader));
|
||||
}
|
||||
assert.deepEqual(fixture.setup.players.map(seat => seat.hand), offered.players.map(seat => seat.hand));
|
||||
assert.equal(fixture.game.decision?.player, 'human-collector');
|
||||
const closing = closeDiscoveryClassicNexusSkills(fixture);
|
||||
const originalTop = closing.beforeDraw.nexusCards!.cards!.deck[0];
|
||||
assert.equal(closing.game.nexusCards!.cards!.hands[fixture.owner], originalTop,
|
||||
'Human closing draw consumes the actual original top card, not a demanded Nexus identity.');
|
||||
assert.equal(closing.game.nexusCards!.cards!.deck.length, closing.beforeDraw.nexusCards!.cards!.deck.length - 1);
|
||||
inventory(closing.game);
|
||||
assert.throws(() => createDiscoveryClassicNexusSkillsFixture({ initial: fixture.game }));
|
||||
});
|
||||
|
||||
void test('actual Great Maker losses and all votes precede both Advanced piles, settled alliance and the qualified closing draw', () => {
|
||||
const fixture = createDiscoveryClassicNexusSkillsFixture({ advanced: true, tech: true, strongholds: true });
|
||||
const closing = closeDiscoveryClassicNexusSkills(fixture, 'emperor');
|
||||
assert.equal(player(closing.vote, fixture.owner).tanks, 6);
|
||||
assert.equal(player(closing.vote, fixture.owner).forces[fixture.wormSource], undefined);
|
||||
assert.equal(player(closing.vote, fixture.owner).forces['cistern:0'], 3);
|
||||
assert.equal(closing.vote.nexusCards!.cards!.hands[fixture.owner], null);
|
||||
assert.notEqual(closing.vote.nexusCards!.phase?.stage, 'drawing');
|
||||
assert.equal(closing.alliance.greatMaker?.stage, 'complete');
|
||||
assert.equal(closing.beforeDraw.phase, 1); assert.equal(closing.beforeDraw.nexus, false);
|
||||
assert.deepEqual(closing.beforeDraw.nexusCards!.phase!.eligible, [fixture.owner]);
|
||||
assert.equal(closing.beforeDraw.spiceWindow, null); assert.equal(closing.beforeDraw.spiceResolution, null);
|
||||
assert.ok(closing.beforeDraw.spiceDiscard.flat().some(card => 'territory' in card && card.territory === 'broken_land'));
|
||||
assert.ok(closing.beforeDraw.spiceDiscard.flat().filter(card => 'territory' in card && card.territory === 'rock_outcroppings').length >= 1);
|
||||
assert.equal(closing.game.nexusCards!.cards!.hands[fixture.owner], 'emperor');
|
||||
assert.equal(closing.game.phase, 2); assert.equal(closing.game.nexusCards!.phase!.stage, 'complete');
|
||||
assert.deepEqual(closing.game.leaderSkills, fixture.game.leaderSkills);
|
||||
reject(closing.game, fixture.owner, closing.drawStep.action);
|
||||
inventory(closing.vote); inventory(closing.game);
|
||||
});
|
||||
|
||||
void test('own-faction redraw preference and later allied forfeiture use cards supplied by genuine closing draws only', () => {
|
||||
const fixture = createDiscoveryClassicNexusSkillsFixture();
|
||||
const closing = closeDiscoveryClassicNexusSkills(fixture, 'atreides');
|
||||
const before = structuredClone(closing.beforeDraw), cards = before.nexusCards!.cards!;
|
||||
const index = cards.deck.indexOf('emperor'); assert.ok(index > 0);
|
||||
cards.deck.splice(1, 0, cards.deck.splice(index, 1)[0]);
|
||||
const redraw = stepDiscoveryClassicNexusSkills(before, { actor: fixture.owner,
|
||||
action: { ...closing.drawStep.action, ownRedraws: 1 } });
|
||||
assert.equal(redraw.nexusCards!.cards!.hands[fixture.owner], 'emperor');
|
||||
assert.equal(redraw.nexusCards!.cards!.discard.filter(card => card === 'atreides').length, 1);
|
||||
assert.equal(redraw.nexusCards!.cards!.deck.length, before.nexusCards!.cards!.deck.length - 2);
|
||||
inventory(redraw);
|
||||
let game = finishClassicDiscoveryNexusMentat(closing.game).after;
|
||||
game = advanceDiscoveryClassicNexusSkills(game, game => game.phase === 1 && discoveryClassicNexusSkillsClean(game));
|
||||
const wormIndex = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker); assert.ok(wormIndex >= 0);
|
||||
const worm = game.spiceDeck.splice(wormIndex, 1)[0];
|
||||
const landIndex = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(landIndex >= 0);
|
||||
game.spiceDeck.unshift(worm, game.spiceDeck.splice(landIndex, 1)[0]);
|
||||
game = openClassicDiscoveryNexusAlliance(game);
|
||||
const heldWindow = structuredClone(game);
|
||||
const keep = advanceDiscoveryClassicNexusSkills(heldWindow, game => game.nexusCards?.phase?.stage === 'drawing');
|
||||
const kept = stepDiscoveryClassicNexusSkills(keep, { actor: fixture.owner, action: {
|
||||
type: 'nexusCardChoice', turn: keep.turn, card: 'atreides', choice: 'keep', ownRedraws: 0 } });
|
||||
assert.equal(kept.nexusCards!.cards!.hands[fixture.owner], 'atreides');
|
||||
assert.deepEqual(kept.nexusCards!.cards!.deck, keep.nexusCards!.cards!.deck);
|
||||
game = stepDiscoveryClassicNexusSkills(game, { actor: fixture.opponent, action: { type: 'alliance', target: null } });
|
||||
game = formClassicDiscoveryNexusAlliance(game, fixture.owner, fixture.opponent);
|
||||
assert.equal(game.nexusCards!.cards!.hands[fixture.owner], null);
|
||||
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'atreides').length, 1);
|
||||
game = advanceDiscoveryClassicNexusSkills(game, game => game.nexusCards?.phase?.stage === 'drawing');
|
||||
assert.ok(!game.nexusCards!.phase!.eligible.includes(fixture.owner));
|
||||
assert.deepEqual(viewGame(game, fixture.owner).nexusCards!.choices, []);
|
||||
inventory(kept); inventory(game);
|
||||
});
|
||||
|
||||
for (const advanced of [false, true]) for (const kind of ['emperor', 'fremen'] as const) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} trained Discovery ${kind} returns native Great Maker casualties with distinct ordinary ledgers and actual Axlotl settlement`, () => {
|
||||
const fixture = createDiscoveryClassicNexusSkillsFixture({ advanced, tech: true, strongholds: advanced });
|
||||
const closing = closeDiscoveryClassicNexusSkills(fixture, kind);
|
||||
const game = advanceDiscoveryClassicNexusSkills(closing.game, game => game.phase === 4 && discoveryClassicNexusSkillsClean(game));
|
||||
assert.equal(player(game, fixture.owner).tanks, 6, 'Tanks arise from the original worm action, never an injected death.');
|
||||
const view = viewGame(game, fixture.owner), action: Action = { type: kind === 'emperor' ? 'nexusEmperorRevive' : 'nexusFremenRevive',
|
||||
event: kind === 'emperor' ? view.nexusEmperorSecretAlly!.event : view.nexusFremenRevival!.event, elite: 0 };
|
||||
reject(game, fixture.opponent, action);
|
||||
const before = player(game, fixture.owner);
|
||||
let paid = stepDiscoveryClassicNexusSkills(structuredClone(game), { actor: fixture.owner, action });
|
||||
const after = player(paid, fixture.owner);
|
||||
assert.equal(after.reserves, before.reserves + 3); assert.equal(after.tanks, 3); assert.equal(after.spice, before.spice);
|
||||
assert.equal(after.revived, kind === 'fremen' ? 3 : 0);
|
||||
assert.equal(after.freeForcesRevived ?? 0, kind === 'fremen' ? 3 : 0);
|
||||
assert.equal(forceRevivalRemaining(paid, after), kind === 'fremen' ? 0 : 3);
|
||||
assert.equal(paid.nexusCards!.cards!.hands[fixture.owner], null);
|
||||
assert.equal(paid.nexusCards!.cards!.discard.filter(card => card === kind).length, 1);
|
||||
assert.deepEqual(paid.leaderSkills, game.leaderSkills);
|
||||
const history = kind === 'emperor' ? paid.nexusEmperorSecretHistory! : paid.nexusFremenRevivalHistory!;
|
||||
assert.equal(history.length, 1); assert.equal(history[0].owner, fixture.owner);
|
||||
assert.equal(history[0].before.tanks - history[0].after.tanks, 3);
|
||||
reject(paid, fixture.owner, action);
|
||||
if (kind === 'emperor') {
|
||||
paid = stepDiscoveryClassicNexusSkills(paid, { actor: fixture.owner, action: { type: 'revive', amount: 3 } });
|
||||
paid = advanceDiscoveryClassicNexusSkills(paid, discoveryClassicNexusSkillsClean);
|
||||
assert.equal(player(paid, fixture.owner).tanks, 0); assert.equal(player(paid, fixture.owner).revived, 3);
|
||||
assert.equal(player(paid, fixture.owner).freeForcesRevived, 2); assert.equal(player(paid, fixture.owner).spice, before.spice - 2);
|
||||
} else reject(paid, fixture.owner, { type: 'revive', amount: 1 });
|
||||
const owner = paid.techTokens!.axlotl.owner!;
|
||||
const credit = ownedTech(paid.techTokens, owner).length;
|
||||
assert.equal(paid.techTokens!.axlotl.triggeredTurn, 2); assert.equal(paid.techTokens!.axlotl.spice, credit);
|
||||
const wallet = player(paid, owner).spice;
|
||||
const movement = advanceDiscoveryClassicNexusSkills(paid, game => game.phase === 5 && discoveryClassicNexusSkillsClean(game));
|
||||
assert.equal(player(movement, owner).spice, wallet + credit); assert.equal(movement.techTokens!.axlotl.spice, 0);
|
||||
assert.deepEqual(movement.leaderSkills, fixture.game.leaderSkills); inventory(movement);
|
||||
let ordinaryFirst = stepDiscoveryClassicNexusSkills(structuredClone(game), { actor: fixture.owner,
|
||||
action: { type: 'revive', amount: kind === 'emperor' ? 3 : 1 } });
|
||||
ordinaryFirst = advanceDiscoveryClassicNexusSkills(ordinaryFirst, discoveryClassicNexusSkillsClean);
|
||||
if (kind === 'emperor') {
|
||||
const extra = stepDiscoveryClassicNexusSkills(ordinaryFirst, { actor: fixture.owner, action });
|
||||
assert.equal(player(extra, fixture.owner).tanks, 0);
|
||||
assert.equal(player(extra, fixture.owner).revived, 3);
|
||||
assert.equal(player(extra, fixture.owner).freeForcesRevived, 2);
|
||||
assert.equal(player(extra, fixture.owner).spice, player(ordinaryFirst, fixture.owner).spice);
|
||||
assert.deepEqual(extra.techTokens, ordinaryFirst.techTokens, 'Extra three cannot retrigger the already earned original Axlotl activity.');
|
||||
inventory(extra);
|
||||
} else {
|
||||
reject(ordinaryFirst, fixture.owner, action);
|
||||
assert.equal(player(ordinaryFirst, fixture.owner).tanks, 5);
|
||||
assert.equal(player(ordinaryFirst, fixture.owner).revived, 1);
|
||||
assert.equal(ordinaryFirst.nexusCards!.cards!.hands[fixture.owner], 'fremen');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void test('borrowed Guild nested shipment retains physical training and held Arrakeen, then actual skilled Warmaster cleanup and mandatory Tech transfer occur in order', () => {
|
||||
const fixture = createDiscoveryClassicNexusSkillsFixture({ advanced: true, tech: true, strongholds: true, skill: 'warmaster' });
|
||||
const closing = closeDiscoveryClassicNexusSkills(fixture, 'guild');
|
||||
const battle = openDiscoveryClassicNexusSkillsBattle(fixture, closing.game);
|
||||
const before = player(battle.shipmentWindow, fixture.owner), arrived = player(battle.arrived, fixture.owner);
|
||||
assert.equal(arrived.forces['cistern:0'], 6); assert.equal(arrived.reserves, before.reserves - 3);
|
||||
assert.equal(arrived.spice, before.spice - 2); assert.equal(arrived.shipped, true); assert.equal(arrived.moved, before.moved);
|
||||
assert.equal(battle.arrived.nexusCards!.cards!.hands[fixture.owner], null);
|
||||
assert.equal(battle.arrived.nexusGuildSecretHistory!.length, 1);
|
||||
assert.equal(battle.arrived.nexusGuildSecretHistory![0].receipt.owner, fixture.owner);
|
||||
assert.equal(battle.arrived.strongholdCards!.owners.arrakeen, fixture.owner);
|
||||
assert.deepEqual(battle.arrived.leaderSkills, battle.shipmentWindow.leaderSkills);
|
||||
reject(battle.shipmentWindow, fixture.owner, { ...battle.shipmentStep.action, smuggler: true });
|
||||
const industryOwner = battle.arrived.techTokens!.heighliners.owner!;
|
||||
const industryCredit = ownedTech(battle.arrived.techTokens, industryOwner).length;
|
||||
assert.equal(battle.arrived.techTokens!.heighliners.triggeredTurn, 2);
|
||||
assert.equal(battle.arrived.techTokens!.heighliners.spice, industryCredit);
|
||||
assert.equal(battle.game.techTokens!.heighliners.spice, 0);
|
||||
assert.equal(player(battle.game, industryOwner).spice, player(battle.arrived, industryOwner).spice + industryCredit,
|
||||
'Actual movement end settles shipping industry exactly once before the private nested plans.');
|
||||
const revealed = revealDiscoveryClassicNexusSkillsBattle(battle), quote = quoteClassicSkillsStrongholdBattle(revealed);
|
||||
assert.equal(quote.winner, fixture.owner);
|
||||
assert.equal(viewGame(revealed, fixture.owner).battle!.strongholdEffects[fixture.owner], null);
|
||||
const payment = quote.payments.find(receipt => receipt.player === fixture.owner)!;
|
||||
assert.equal(payment.bankSupport, 0); assert.equal(payment.ownPayment, 1);
|
||||
const side = revealed.battle!.attacker === fixture.owner ? 'attacker' : 'defender';
|
||||
assert.deepEqual(quote.leaderSkillBonuses[side].applied, [{ skill: 'warmaster', amount: 3, mode: 'skilled' }]);
|
||||
const loser = player(revealed, fixture.opponent), reward = ownedTech(revealed.techTokens, fixture.opponent);
|
||||
assert.ok(reward.length > 0);
|
||||
const cleanup = finishDiscoveryClassicNexusSkillsBattle(revealed, true);
|
||||
assert.equal(player(cleanup, fixture.owner).forces['cistern:0'], 5);
|
||||
assert.equal(player(cleanup, fixture.opponent).forces['cistern:0'] ?? 0, 0);
|
||||
assert.equal(player(cleanup, fixture.opponent).tanks, loser.tanks + 2);
|
||||
assert.deepEqual(cleanup.techTokens, revealed.techTokens, 'Battle cleanup still owns its priority before the winner reward.');
|
||||
const done = finishDiscoveryClassicNexusSkillsBattle(cleanup);
|
||||
assert.equal(done.lastBattleContext!.winner, fixture.owner);
|
||||
assert.ok(player(done, fixture.owner).hand.some(card => card.id === battle.weapon));
|
||||
assert.ok(!done.discard.some(card => card.id === battle.weapon));
|
||||
assert.equal(viewGame(done, fixture.owner).leaderSkills!.assignments.find(assignment => assignment.leader === fixture.leader)!.faceUp, true);
|
||||
for (const token of reward) assert.equal(done.techTokens![token].owner, fixture.owner);
|
||||
assert.deepEqual(ownedTech(done.techTokens, fixture.opponent), []);
|
||||
assert.deepEqual(done.nexusGuildSecretHistory, battle.arrived.nexusGuildSecretHistory);
|
||||
assert.equal(done.strongholdCards!.owners.arrakeen, fixture.owner);
|
||||
inventory(done);
|
||||
});
|
||||
|
||||
void test('all four minimal policies produce actual offered training, physical entry, qualified Nexus draw and source-clear return outcomes', () => {
|
||||
const fixture = createDiscoveryClassicNexusSkillsFixture({ advanced: true, tech: true });
|
||||
const closing = closeDiscoveryClassicNexusSkills(fixture, 'emperor');
|
||||
const revival = advanceDiscoveryClassicNexusSkills(closing.game, game => game.phase === 4 && discoveryClassicNexusSkillsClean(game));
|
||||
const training = advanceDiscoveryClassicNexusSkills(structuredClone(fixture.setup), game => game.setupStage === 'leaderSkills');
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const cases = [
|
||||
{ game: training, actor: Object.keys(training.leaderSkills!.offers)[0], type: 'leaderSkill' },
|
||||
{ game: fixture.game, actor: fixture.owner, type: 'decision' },
|
||||
{ game: closing.beforeDraw, actor: fixture.owner, type: 'nexusCardChoice' },
|
||||
{ game: revival, actor: fixture.owner, type: 'nexusEmperorRevive' },
|
||||
];
|
||||
for (const current of cases) {
|
||||
const view = viewGame(current.game, current.actor); view.players.find(seat => seat.id === current.actor)!.bot = difficulty;
|
||||
const untouched = structuredClone(view), action = botActions(view).find(action => action.type === current.type);
|
||||
assert.ok(action, `${difficulty} needs the actual owned ${current.type}.`);
|
||||
const after = stepDiscoveryClassicNexusSkills(structuredClone(current.game), { actor: current.actor, action });
|
||||
assert.deepEqual(view, untouched);
|
||||
if (current.type === 'leaderSkill') {
|
||||
assert.equal(after.leaderSkills!.assignments.length, 1);
|
||||
const assignment = after.leaderSkills!.assignments[0]; assert.equal(assignment.owner, current.actor);
|
||||
assert.ok(current.game.leaderSkills!.offers[current.actor].cards.includes(assignment.skill));
|
||||
assert.equal(after.leaderSkills!.offers[current.actor], undefined);
|
||||
assert.deepEqual(after.players.map(seat => seat.hand), current.game.players.map(seat => seat.hand));
|
||||
} else if (current.type === 'decision') {
|
||||
assert.equal(after.discoveryEntry, undefined);
|
||||
const entered = player(after, current.actor).forces['cistern:0'] ?? 0;
|
||||
assert.equal((player(after, current.actor).forces[fixture.source] ?? 0) + entered, 3);
|
||||
assert.equal(player(after, current.actor).spice, player(current.game, current.actor).spice);
|
||||
assert.deepEqual(after.leaderSkills, current.game.leaderSkills);
|
||||
} else if (current.type === 'nexusCardChoice') {
|
||||
assert.equal(after.nexusCards!.phase!.stage, 'complete');
|
||||
assert.ok(after.nexusCards!.cards!.hands[current.actor]);
|
||||
assert.equal(after.phase, 2);
|
||||
} else {
|
||||
assert.equal(player(after, current.actor).tanks, 3);
|
||||
assert.equal(player(after, current.actor).reserves, player(current.game, current.actor).reserves + 3);
|
||||
assert.equal(player(after, current.actor).revived, 0);
|
||||
assert.equal(after.nexusCards!.cards!.hands[current.actor], null);
|
||||
assert.equal(after.techTokens!.axlotl.triggeredTurn, 2);
|
||||
assert.deepEqual(after.leaderSkills, current.game.leaderSkills);
|
||||
}
|
||||
inventory(after);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -57,6 +57,7 @@ void test('Discovery Skills cannot silently drop unsupported native families or
|
||||
assert.throws(() => initializeLeaderSkillsGameForAudit(unsupported), RuleError);
|
||||
const overlays = Array.from({ length: 5 }, () => lobby(['guild', 'emperor', 'harkonnen'], []));
|
||||
overlays[0].homeworlds = { custody: null };
|
||||
overlays[1] = lobby(['ixians', 'tleilaxu', 'guild'], ['ix']);
|
||||
overlays[1].nexusCards = { cards: null, phase: null };
|
||||
overlays[2].advanced = false; overlays[2].strongholdCards = createStrongholdCards();
|
||||
overlays[3].mentatQuestionPreview = true;
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
applyAction, createGame, joinGame, newPlayer,
|
||||
viewGame, type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { MOBILE_LOCATION, validGameLocation } from '../game/board';
|
||||
import { treacheryDeck } from '../game/cards';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { validateRicheseNoField } from '../game/richese-no-field';
|
||||
import { DISCOVERY_SPICE_CARDS, validateDiscoveryState } from '../game/discoveries';
|
||||
import { validateNexusCards } from '../game/nexus-cards';
|
||||
import { nexusFaceDancersAction } from '../game/nexus-tleilaxu-options';
|
||||
import { ownedTech } from '../game/tech-tokens';
|
||||
import { traitorDeck } from '../game/traitors';
|
||||
import { quoteStrongholdFactionsBattle } from './fixture-stronghold-factions';
|
||||
import {
|
||||
advancePairedDiscoveryNexus, applyPairedDiscoveryNexusStep, closePairedDiscoveryNexus,
|
||||
createPairedDiscoveryNexusFixture, holdPairedDiscoveryNexusCard,
|
||||
initializePairedDiscoveryNexusForFixture, nextPairedDiscoveryNexusStep,
|
||||
pairedDiscoveryNexusClean, pairedDiscoveryNexusPlayer as player, placePairedDiscoveryNexus,
|
||||
pairedDiscoveryRicheseRequest, preparePairedDiscoveryNexusBattle,
|
||||
reservePairedDiscoveryNexusBoard, revealPairedDiscoveryNexusBattle, settlePairedDiscoveryNexusShipment,
|
||||
type PairedDiscoveryNexusFixture,
|
||||
} from './fixture-discovery-paired-nexus';
|
||||
|
||||
function reject(game: Game, actor: string, action: Action): void {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before, 'Rejected native choices leave original cards, wallets, subtype counters and receipts unchanged.');
|
||||
}
|
||||
function custody(game: Game): void {
|
||||
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
|
||||
...(game.ixSetupCards ?? []), ...(game.ixAuction?.cards ?? []),
|
||||
...(game.auction?.cards.slice(game.auction.index) ?? []),
|
||||
...(game.richeseCache ?? []), ...(game.richeseRemoved ?? [])];
|
||||
const expected = [...treacheryDeck(game.expansions), ...(game.expansions[0] === 'choam' ? richeseCards() : [])];
|
||||
assert.deepEqual(cards.map(c => c.id).sort(), expected.map(c => c.id).sort());
|
||||
assert.equal(new Set(cards.map(c => c.id)).size, cards.length);
|
||||
const identities = [...(game.traitorReserve ?? []), ...game.players.flatMap(p => [
|
||||
...p.traitors, ...p.traitorChoices, ...(p.faceDancers ?? []).map(c => c.leader),
|
||||
])];
|
||||
assert.deepEqual(identities.sort(), traitorDeck(game.players, game.expansions[0] === 'ix').sort());
|
||||
assert.equal(new Set(identities).size, identities.length);
|
||||
for (const p of game.players) {
|
||||
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, n) => sum + n, 0), 20, p.faction);
|
||||
if (p.elites) {
|
||||
assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0),
|
||||
p.faction === 'ixians' ? 7 : p.faction === 'fremen' ? 3 : 5);
|
||||
assert.ok(p.elites.reserves <= p.reserves && p.elites.tanks <= p.tanks);
|
||||
for (const [key, count] of Object.entries(p.elites.forces)) assert.ok(count >= 0 && count <= (p.forces[key] ?? 0));
|
||||
}
|
||||
if (p.noField) validateRicheseNoField(p.noField);
|
||||
}
|
||||
validateDiscoveryState(game.discoveries!);
|
||||
validateNexusCards(game.nexusCards!.cards!, game.players);
|
||||
assert.ok(!game.leaderSkills && !game.homeworlds);
|
||||
}
|
||||
function richeseWindow(f: PairedDiscoveryNexusFixture, state: Game): Game {
|
||||
return advancePairedDiscoveryNexus(state, g => g.phase === 5 && g.active === f.owner && pairedDiscoveryNexusClean(g));
|
||||
}
|
||||
function richeseReveal(game: Game, owner: string): Game {
|
||||
const p = player(game, owner); assert.ok(p.noField!.deployed);
|
||||
return applyAction(game, owner, { type: 'revealNoField', token: p.noField!.deployed.tokenId, event: p.noFieldEvent });
|
||||
}
|
||||
|
||||
for (const family of ['ix', 'choam'] as const) for (const mode of [
|
||||
{ advanced: false, tech: false, strongholds: false },
|
||||
{ advanced: false, tech: true, strongholds: false },
|
||||
{ advanced: true, tech: false, strongholds: false },
|
||||
{ advanced: true, tech: true, strongholds: false },
|
||||
{ advanced: true, tech: false, strongholds: true },
|
||||
{ advanced: true, tech: true, strongholds: true },
|
||||
]) void test(`paired ${family} ${JSON.stringify(mode)}: actual printed reveal, END Mentat, typed entry/Maker ride and alliance-qualified closing native deals`, () => {
|
||||
const f = createPairedDiscoveryNexusFixture({ family, ...mode });
|
||||
assert.deepEqual(f.setup.spiceDeck.flatMap(c => 'territory' in c && c.discovery ? [c.discovery] : []).sort(),
|
||||
DISCOVERY_SPICE_CARDS.map(c => c.discovery).sort());
|
||||
assert.equal(f.setup.spiceDeck.filter(c => 'worm' in c && c.greatMaker).length, 1);
|
||||
assert.equal(f.setup.spiceDeck.filter(c => 'sandtrout' in c && c.sandtrout).length, family === 'ix' ? 1 : 0);
|
||||
assert.equal(f.setup.discoveries!.tokens.length, 8);
|
||||
assert.equal(f.afterSetup.players.reduce((sum, p) => sum + p.hand.length, 0) + f.afterSetup.deck.length,
|
||||
family === 'ix' ? 47 : 35);
|
||||
assert.equal(f.game.decision?.kind, 'discoveryEntry'); assert.equal(f.game.phase, 0); assert.equal(f.game.turn, 2);
|
||||
assert.ok(f.actions.some(s => s.action.type === 'ship') && f.actions.some(s => s.action.type === 'discovery'));
|
||||
assert.ok(Object.values(f.game.nexusCards!.cards!.hands).every(c => c === null), 'END Mentat/free entry do not manufacture a Nexus hand.');
|
||||
if (mode.strongholds) assert.equal(f.firstMentat.after.strongholdCards!.owners[f.claim], f.owner);
|
||||
const c = closePairedDiscoveryNexus(f), old = player(c.entry.before, f.collector), entered = player(c.entry.after, f.collector);
|
||||
const total = family === 'ix' ? 2 : 1, elite = family === 'ix' ? 1 : 0;
|
||||
assert.equal(entered.forces['shrine:0'], total);
|
||||
assert.equal(entered.elites?.forces['shrine:0'] ?? 0, elite);
|
||||
assert.equal(entered.forces[f.source], 3 - total);
|
||||
assert.equal(entered.reserves, old.reserves); assert.equal(entered.spice, old.spice);
|
||||
assert.equal(entered.moved, old.moved); assert.equal(entered.shipped, old.shipped);
|
||||
assert.equal(player(c.vote, f.native).forces['hagga_basin:12'] ?? 0, 0);
|
||||
assert.equal(player(c.vote, f.native).tanks, player(f.game, f.native).tanks + 2, 'Actual Great Maker ordinary worm losses precede its vote.');
|
||||
assert.ok(Object.values(c.vote.nexusCards!.cards!.hands).every(c => c === null));
|
||||
const rider = player(c.ride.before, f.fremen), arrived = player(c.ride.after, f.fremen);
|
||||
assert.equal(arrived.forces['polar_sink:0'], 2);
|
||||
assert.equal(arrived.elites?.forces['polar_sink:0'] ?? 0, mode.advanced ? 1 : 0);
|
||||
assert.equal(arrived.reserves, rider.reserves - 2);
|
||||
assert.equal(arrived.elites?.reserves ?? 0, (rider.elites?.reserves ?? 0) - (mode.advanced ? 1 : 0));
|
||||
assert.equal(arrived.spice, rider.spice); assert.equal(arrived.shipped, rider.shipped); assert.equal(arrived.moved, rider.moved);
|
||||
assert.notEqual(c.ride.after.nexusCards!.phase?.stage, 'drawing', 'Maker reserve ride is not the closing phase draw.');
|
||||
assert.equal(c.drawing.greatMaker!.stage, 'complete');
|
||||
assert.ok(!c.drawing.spiceWindow && !c.drawing.spiceResolution);
|
||||
assert.equal(player(c.drawing, f.guild).ally, f.fremen); assert.equal(player(c.drawing, f.fremen).ally, f.guild);
|
||||
assert.deepEqual([...c.drawing.nexusCards!.phase!.eligible].sort(), [f.owner, f.native].sort());
|
||||
if (mode.advanced) assert.ok(c.drawing.spiceDiscard[1].some(card => 'territory' in card && card.territory === 'rock_outcroppings'),
|
||||
'The actual second Advanced pile closes before any physical Nexus draw.');
|
||||
const faces = family === 'ix' ? ['ixians', 'tleilaxu'] : ['richese', 'choam'];
|
||||
assert.equal(c.afterDraw.nexusCards!.cards!.hands[f.owner], faces[0]);
|
||||
assert.equal(c.afterDraw.nexusCards!.cards!.hands[f.native], faces[1]);
|
||||
assert.equal(c.afterDraw.nexusCards!.cards!.hands[f.guild], null); assert.equal(c.afterDraw.nexusCards!.cards!.hands[f.fremen], null);
|
||||
if (mode.strongholds) assert.deepEqual(c.afterDraw.strongholdCards, f.firstMentat.after.strongholdCards);
|
||||
for (const game of [f.afterSetup, f.game, c.entry.after, c.vote, c.ride.after, c.drawing, c.afterDraw]) custody(game);
|
||||
});
|
||||
|
||||
void test('paired native Great Maker vote and both Spice piles without a settled alliance deal no Nexus cards', () => {
|
||||
const f = createPairedDiscoveryNexusFixture({ family: 'ix' }), c = closePairedDiscoveryNexus(f);
|
||||
const game = advancePairedDiscoveryNexus(c.ride.after, g => g.phase === 2 && pairedDiscoveryNexusClean(g));
|
||||
assert.ok(game.players.every(p => p.ally === null));
|
||||
assert.ok(Object.values(game.nexusCards!.cards!.hands).every(card => card === null));
|
||||
assert.equal(game.nexusCards!.cards!.deck.length, c.ride.after.nexusCards!.cards!.deck.length);
|
||||
assert.notEqual(game.nexusCards!.phase?.stage, 'drawing');
|
||||
custody(game);
|
||||
});
|
||||
|
||||
void test('paired E2 immediate and concealed groups are capped by the original reserve pool without a second tariff', () => {
|
||||
const f = createPairedDiscoveryNexusFixture({ family: 'choam', advanced: false, tech: true, strongholds: false });
|
||||
const c = closePairedDiscoveryNexus(f), window = richeseWindow(f, c.afterDraw);
|
||||
const count = player(window, f.owner).reserves - 2;
|
||||
placePairedDiscoveryNexus(window, f.owner, 'polar_sink:0', count);
|
||||
f.staging.push(`Conserved ${count} original Richese reserves → Polar Sink; exactly two physical reserves remain before declaration.`);
|
||||
const request = pairedDiscoveryRicheseRequest(f, window), before = player(window, f.owner);
|
||||
const shipped = settlePairedDiscoveryNexusShipment(applyAction(window, f.owner, request));
|
||||
assert.equal(player(shipped, f.owner).forces['shrine:0'], 2); assert.equal(player(shipped, f.owner).reserves, 0);
|
||||
const materialized = richeseReveal(shipped, f.owner);
|
||||
assert.equal(player(materialized, f.owner).forces['shrine:0'], 2);
|
||||
assert.equal(player(materialized, f.owner).reserves, 0);
|
||||
assert.equal(player(materialized, f.owner).spice, before.spice - 1);
|
||||
assert.equal(player(materialized, f.guild).spice, player(window, f.guild).spice + 1);
|
||||
assert.equal(player(materialized, f.owner).noField!.lastShipped, request.noField);
|
||||
assert.equal(materialized.nexusCards!.cards!.discard.filter(card => card === 'richese').length, 1);
|
||||
custody(materialized);
|
||||
});
|
||||
|
||||
for (const advanced of [false, true]) void test(`paired E1 ${advanced ? 'Advanced' : 'Basic'}: original Cunning typed casualties, original Tech before nested Face Dance and draw-before-retire Tleilaxu Cunning`, () => {
|
||||
const f = createPairedDiscoveryNexusFixture({ family: 'ix', advanced, tech: true, strongholds: advanced });
|
||||
const closing = closePairedDiscoveryNexus(f), b = preparePairedDiscoveryNexusBattle(f, closing.afterDraw);
|
||||
const offer = viewGame(b.game, f.owner).nexusSuboids!.offer!; assert.ok(offer && !offer.blocked);
|
||||
const action: Action = { type: 'nexusSuboids', event: offer.event };
|
||||
reject(b.game, f.native, action);
|
||||
const used = applyAction(b.game, f.owner, action);
|
||||
assert.equal(used.nexusCards!.cards!.hands[f.owner], null);
|
||||
assert.equal(used.nexusCards!.cards!.discard.filter(c => c === 'ixians').length, 1);
|
||||
assert.deepEqual(player(used, f.owner).forces, player(b.game, f.owner).forces);
|
||||
assert.deepEqual(player(used, f.owner).elites, player(b.game, f.owner).elites);
|
||||
reject(used, f.owner, action);
|
||||
const revealed = revealPairedDiscoveryNexusBattle(b, used), quote = quoteStrongholdFactionsBattle(revealed);
|
||||
assert.equal(quote.winner, f.owner);
|
||||
assert.equal(quote.casualties!.forces.normalFixedHalf, false);
|
||||
assert.equal(quote.casualties!.forces.normalFreeSupport, true);
|
||||
assert.equal(viewGame(revealed, f.owner).battle!.strongholdEffects[f.owner], null, 'Retained Arrakeen does not subsidize a different nested stronghold-price site.');
|
||||
if (advanced) assert.equal(quote.payments.find(p => p.player === f.owner)!.bankSupport, 0);
|
||||
let losses = advancePairedDiscoveryNexus(revealed, g => g.decision?.kind === 'battleLosses' || g.decision?.kind === 'faceDance');
|
||||
if (losses.decision?.kind === 'battleLosses') {
|
||||
const choice = losses.decision.options.findIndex(o => o.normal === 2 && o.elite === 0); assert.ok(choice >= 0);
|
||||
losses = applyAction(losses, f.owner, { type: 'decision', choice });
|
||||
}
|
||||
const pending = advancePairedDiscoveryNexus(losses, g => g.decision?.kind === 'faceDance');
|
||||
assert.equal(pending.lastBattleContext!.winner, f.owner);
|
||||
assert.equal(player(pending, f.owner).forces['shrine:0'], 4);
|
||||
assert.equal(player(pending, f.owner).elites!.forces['shrine:0'], 3);
|
||||
assert.equal(player(pending, f.owner).tanks, player(revealed, f.owner).tanks + 2);
|
||||
assert.equal(player(pending, f.owner).elites!.tanks, player(revealed, f.owner).elites!.tanks);
|
||||
assert.equal(player(pending, f.native).tanks, player(revealed, f.native).tanks + 6);
|
||||
assert.ok(player(pending, f.owner).hand.some(c => c.id === b.weapon) && player(pending, f.owner).hand.some(c => c.id === b.defense));
|
||||
const defeatedTech = ownedTech(revealed.techTokens, f.native); assert.equal(defeatedTech.length, 1);
|
||||
assert.equal(pending.techTokens![defeatedTech[0]].owner, f.owner, 'Original winner already owns the actual loser Tech before replacement.');
|
||||
const winner = player(pending, f.owner), dancer = player(pending, f.native), stock = structuredClone(dancer.faceDancers!);
|
||||
const dance: Action = { type: 'decision', reveal: true, sector: 0, sources: { reserves: 2, [f.source]: 2 } };
|
||||
reject(pending, f.native, { ...dance, sector: 1 }); reject(pending, f.native, { ...dance, sources: { [MOBILE_LOCATION]: 4 } });
|
||||
reject(pending, f.native, { ...dance, sources: { reserves: 5 } });
|
||||
const after = applyAction(pending, f.native, dance), returned = player(after, f.owner), replaced = player(after, f.native);
|
||||
assert.equal(returned.forces['shrine:0'] ?? 0, 0); assert.equal(returned.elites!.forces['shrine:0'] ?? 0, 0);
|
||||
assert.equal(returned.reserves, winner.reserves + 4); assert.equal(returned.elites!.reserves, winner.elites!.reserves + 3);
|
||||
assert.equal(returned.tanks, winner.tanks); assert.equal(returned.spice, winner.spice); assert.deepEqual(returned.hand, winner.hand);
|
||||
assert.equal(returned.leaders.find(l => l.id === b.leader)!.dead, true);
|
||||
assert.equal(replaced.forces['shrine:0'], 4); assert.equal(replaced.forces[f.source], 1); assert.equal(replaced.reserves, dancer.reserves - 2);
|
||||
assert.deepEqual(after.techTokens, pending.techTokens); assert.deepEqual(after.strongholdCards, pending.strongholdCards);
|
||||
const revealedIdentity = replaced.faceDancers!.find(c => c.revealed)!; assert.ok(revealedIdentity);
|
||||
const untouched = stock.filter(c => c.leader !== revealedIdentity.leader), reserve = [...after.traitorReserve!];
|
||||
const view = viewGame(after, f.native), replacement = nexusFaceDancersAction(view, view.nexusTleilaxu!.cunning!.event); assert.ok(replacement);
|
||||
reject(after, f.native, { ...replacement, leaders: [revealedIdentity.leader] });
|
||||
const renewed = applyAction(after, f.native, replacement), nextStock = player(renewed, f.native).faceDancers!;
|
||||
assert.deepEqual(nextStock.filter(c => untouched.some(old => old.leader === c.leader)), untouched);
|
||||
assert.deepEqual(nextStock.filter(c => !untouched.some(old => old.leader === c.leader)), [{ leader: reserve[0], revealed: false }]);
|
||||
assert.deepEqual([...renewed.traitorReserve!].sort(), [...reserve.slice(1), revealedIdentity.leader].sort());
|
||||
assert.equal(player(renewed, f.native).faceDancerReplacedTurn, replaced.faceDancerReplacedTurn);
|
||||
assert.equal(renewed.nexusCards!.cards!.discard.filter(c => c === 'tleilaxu').length, 1);
|
||||
reject(renewed, f.native, replacement); custody(renewed);
|
||||
reservePairedDiscoveryNexusBoard(renewed, ['shrine:0', f.source, MOBILE_LOCATION]);
|
||||
f.staging.push('After genuine Face Dance/replacement: conserve only the unrelated second-conflict groups back to their original reserves; original native Mentat closes the turn and expires Cunning.');
|
||||
const nextTurn = advancePairedDiscoveryNexus(renewed, g => g.turn > renewed.turn);
|
||||
assert.equal(viewGame(nextTurn, f.owner).nexusSuboids!.active, false);
|
||||
assert.equal(nextTurn.nexusCards!.cards!.discard.filter(card => card === 'ixians').length, 1);
|
||||
if (advanced) assert.equal(nextTurn.strongholdCards!.owners.arrakeen, null, 'Only the actual next END Mentat updates the held card to the conserved current board.');
|
||||
custody(nextTurn);
|
||||
});
|
||||
|
||||
for (const advanced of [false, true]) void test(`paired E2 ${advanced ? 'Advanced' : 'Basic'}: signed nested pair charges one physical tariff, native reveal and CHOAM support invoice`, () => {
|
||||
const f = createPairedDiscoveryNexusFixture({ family: 'choam', advanced, tech: true, strongholds: advanced });
|
||||
const closing = closePairedDiscoveryNexus(f), window = richeseWindow(f, closing.afterDraw), request = pairedDiscoveryRicheseRequest(f, window);
|
||||
const before = player(window, f.owner), guild = player(window, f.guild);
|
||||
assert.equal(validGameLocation(window, 'shrine', 0), true); assert.equal(validGameLocation(window, 'cistern', 0), false);
|
||||
reject(window, f.owner, { ...request, territory: 'cistern' }); reject(window, f.owner, { ...request, territory: 'invented-nested' });
|
||||
reject(window, f.owner, { ...request, sector: 1 }); reject(window, f.owner, { ...request, revealedToken: request.noField });
|
||||
holdPairedDiscoveryNexusCard(window, f.guild, c => c.effect === 'karama', f.staging);
|
||||
const declared = applyAction(window, f.owner, request);
|
||||
assert.equal(declared.response?.kind, 'richeseNoField'); assert.equal(declared.pendingShipment!.cost, 1);
|
||||
assert.equal(declared.pendingShipment!.amount, 1); assert.equal(declared.nexusCards!.cards!.hands[f.owner], null);
|
||||
assert.equal(player(declared, f.owner).spice, before.spice); assert.equal(player(declared, f.owner).reserves, before.reserves);
|
||||
for (const target of ['cistern', 'invented-nested']) {
|
||||
const corrupt = structuredClone(declared); corrupt.pendingShipment!.territory = target;
|
||||
const next = nextPairedDiscoveryNexusStep(corrupt); assert.ok(next);
|
||||
reject(corrupt, next.actor, next.action);
|
||||
}
|
||||
const shipped = settlePairedDiscoveryNexusShipment(declared), owner = player(shipped, f.owner);
|
||||
assert.equal(owner.spice, before.spice - 1); assert.equal(player(shipped, f.guild).spice, guild.spice + 1);
|
||||
assert.equal(owner.reserves, before.reserves - 5); assert.equal(owner.forces['shrine:0'], 5);
|
||||
assert.equal(owner.noField!.deployed!.tokenId, request.noField); assert.equal(owner.noField!.lastShipped, request.noField);
|
||||
assert.deepEqual(owner.noField!.tokens, before.noField!.tokens);
|
||||
assert.equal(shipped.nexusRicheseCunningLast!.stage, 'shipped'); assert.equal(shipped.nexusCards!.cards!.discard.filter(c => c === 'richese').length, 1);
|
||||
const publicView = viewGame(shipped, f.guild); assert.equal(publicView.richeseNoField!.private, null);
|
||||
for (const token of owner.noField!.tokens) assert.equal(JSON.stringify(publicView).includes(token.id), false);
|
||||
reject(shipped, f.owner, request);
|
||||
for (const target of ['cistern', 'invented-nested']) {
|
||||
const corrupt = structuredClone(shipped); corrupt.players.find(p => p.id === f.owner)!.noField!.deployed!.location = { territory: target, sector: 0 };
|
||||
reject(corrupt, f.owner, { type: 'revealNoField', token: String(request.noField), event: owner.noFieldEvent });
|
||||
}
|
||||
const revealedMarker = richeseReveal(shipped, f.owner), materialized = player(revealedMarker, f.owner);
|
||||
assert.equal(materialized.forces['shrine:0'], 8); assert.equal(materialized.reserves, before.reserves - 8);
|
||||
assert.equal(materialized.spice, before.spice - 1); assert.equal(materialized.noField!.deployed, null);
|
||||
assert.equal(materialized.noField!.lastShipped, request.noField); custody(revealedMarker);
|
||||
const b = preparePairedDiscoveryNexusBattle(f, revealedMarker), revealed = revealPairedDiscoveryNexusBattle(b);
|
||||
const quote = quoteStrongholdFactionsBattle(revealed); assert.equal(quote.winner, f.owner);
|
||||
assert.equal(viewGame(revealed, f.owner).battle!.strongholdEffects[f.owner], null, 'Held Carthag is not inherited inside Shrine.');
|
||||
if (advanced) {
|
||||
const invoice = quote.payments.find(p => p.player === f.owner)!;
|
||||
assert.equal(invoice.ownPayment, 3); assert.equal(invoice.bankSupport, 0);
|
||||
assert.deepEqual(quote.choamIncome, { owner: f.native, amount: 1 });
|
||||
} else { assert.deepEqual(quote.payments, []); assert.equal(quote.choamIncome, null); }
|
||||
const done = advancePairedDiscoveryNexus(revealed, g => !g.battle && pairedDiscoveryNexusClean(g));
|
||||
assert.equal(done.phase, 6, 'Second physical conflict keeps unrelated Collection out of this invoice.');
|
||||
assert.equal(done.lastBattleContext!.winner, f.owner); assert.equal(player(done, f.owner).forces['shrine:0'], 5);
|
||||
assert.equal(player(done, f.owner).tanks, player(revealed, f.owner).tanks + 3);
|
||||
assert.equal(player(done, f.guild).tanks, player(revealed, f.guild).tanks + 6);
|
||||
assert.equal(player(done, f.native).spice, player(revealed, f.native).spice + (advanced ? 1 : 0));
|
||||
const bounty = player(revealed, f.guild).leaders.find(l => l.id === b.enemy)!.strength;
|
||||
assert.equal(player(done, f.owner).spice, player(revealed, f.owner).spice - (advanced ? 3 : 0) + bounty);
|
||||
assert.deepEqual(done.strongholdCards, revealed.strongholdCards); custody(done);
|
||||
});
|
||||
|
||||
void test('paired E2 CHOAM own Cunning uses original physical fuel and native response after the closing deal', () => {
|
||||
const f = createPairedDiscoveryNexusFixture({ family: 'choam' }), closing = closePairedDiscoveryNexus(f);
|
||||
let game = advancePairedDiscoveryNexus(closing.afterDraw, g => g.phase === 5 && g.active === f.native && pairedDiscoveryNexusClean(g));
|
||||
const fuel = holdPairedDiscoveryNexusCard(game, f.native, c => c.kind === 'projectile', f.staging);
|
||||
holdPairedDiscoveryNexusCard(game, f.guild, c => c.effect === 'karama', f.staging);
|
||||
const offer = viewGame(game, f.native).choamWorthless!.plays.find(p => p.source === 'nexus' && p.card.id === fuel.id && p.effect === 'kulon');
|
||||
assert.ok(offer && offer.source === 'nexus' && !offer.blocked);
|
||||
const request: Action = { type: 'card', mode: 'choam', card: fuel.id, effect: 'kulon', nexus: offer.event };
|
||||
const before = structuredClone(game); game = applyAction(game, f.native, request);
|
||||
assert.equal(game.response?.kind, 'choamWorthless'); assert.equal(game.nexusCards!.cards!.hands[f.native], null);
|
||||
assert.ok(player(game, f.native).hand.some(c => c.id === fuel.id));
|
||||
game = settlePairedDiscoveryNexusShipment(game);
|
||||
assert.equal(game.choamMovement!.bonus, 1); assert.equal(game.nexusChoamLast!.stage, 'complete');
|
||||
assert.equal(game.discard.filter(c => c.id === fuel.id).length, 1);
|
||||
assert.equal(game.nexusCards!.cards!.discard.filter(c => c === 'choam').length, 1);
|
||||
for (const p of before.players) { assert.equal(player(game, p.id).spice, p.spice); assert.deepEqual(player(game, p.id).forces, p.forces); }
|
||||
reject(game, f.native, request); custody(game);
|
||||
});
|
||||
|
||||
void test('all four minimal policies consume actual paired entry/vote/typed ride/closing draw and native Face Dance outcomes', () => {
|
||||
const f = createPairedDiscoveryNexusFixture({ family: 'ix' }), c = closePairedDiscoveryNexus(f);
|
||||
const b = preparePairedDiscoveryNexusBattle(f, c.afterDraw), revealed = revealPairedDiscoveryNexusBattle(b);
|
||||
const dance = advancePairedDiscoveryNexus(revealed, g => g.decision?.kind === 'faceDance');
|
||||
for (const difficulty of DIFFICULTIES) for (const current of [
|
||||
{ game: f.game, actor: f.collector, type: 'decision' },
|
||||
{ game: c.vote, actor: c.vote.decision!.player, type: 'decision' },
|
||||
{ game: c.ride.before, actor: f.fremen, type: 'decision' },
|
||||
{ game: c.drawing, actor: f.owner, type: 'nexusCardChoice' },
|
||||
{ game: dance, actor: f.native, type: 'decision' },
|
||||
]) {
|
||||
const view = viewGame(current.game, current.actor); view.players.find(p => p.id === current.actor)!.bot = difficulty;
|
||||
const action = botActions(view).find(a => a.type === current.type); assert.ok(action);
|
||||
const after = applyPairedDiscoveryNexusStep(structuredClone(current.game), { actor: current.actor, action });
|
||||
if (current.game.decision?.kind === 'discoveryEntry') {
|
||||
assert.equal(after.discoveryEntry, undefined);
|
||||
assert.equal(player(after, current.actor).spice, player(current.game, current.actor).spice);
|
||||
assert.equal(player(after, current.actor).reserves, player(current.game, current.actor).reserves);
|
||||
} else 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');
|
||||
assert.equal(player(after, f.fremen).spice, player(current.game, f.fremen).spice);
|
||||
assert.equal(player(after, f.fremen).shipped, player(current.game, f.fremen).shipped);
|
||||
} else if (current.game.decision?.kind === 'faceDance') {
|
||||
assert.notEqual(after.decision?.kind, 'faceDance');
|
||||
assert.deepEqual(after.techTokens, current.game.techTokens);
|
||||
if (action.reveal) {
|
||||
assert.equal(player(after, f.owner).forces['shrine:0'] ?? 0, 0);
|
||||
assert.equal(player(after, f.owner).leaders.find(l => l.id === b.leader)!.dead, true);
|
||||
assert.ok((player(after, f.native).forces['shrine:0'] ?? 0) > 0);
|
||||
} else assert.deepEqual(player(after, f.owner).forces, player(current.game, f.owner).forces);
|
||||
} else {
|
||||
assert.ok(after.nexusCards!.phase!.done.includes(current.actor));
|
||||
assert.ok(after.nexusCards!.cards!.hands[current.actor]);
|
||||
assert.equal(after.nexusCards!.cards!.deck.length, current.game.nexusCards!.cards!.deck.length - 1);
|
||||
}
|
||||
custody(after);
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal policies settle the original signed E2 pair and materialize only physical reserves', () => {
|
||||
const f = createPairedDiscoveryNexusFixture({ family: 'choam' }), c = closePairedDiscoveryNexus(f);
|
||||
const window = richeseWindow(f, c.afterDraw), request = pairedDiscoveryRicheseRequest(f, window);
|
||||
holdPairedDiscoveryNexusCard(window, f.guild, card => card.effect === 'karama', f.staging);
|
||||
const pending = applyAction(window, f.owner, request);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(pending, f.guild);
|
||||
view.players.find(p => p.id === f.guild)!.bot = difficulty;
|
||||
const action = botActions(view).find(a => a.type === 'passResponse' || a.type === 'card');
|
||||
assert.ok(action, 'The actual eligible Karama holder must resolve the signed pair response.');
|
||||
let game = applyAction(pending, f.guild, action);
|
||||
game = settlePairedDiscoveryNexusShipment(game);
|
||||
assert.equal(game.nexusCards!.cards!.discard.filter(c => c === 'richese').length, 1);
|
||||
if (game.nexusRicheseCunningLast!.stage === 'stopped') {
|
||||
assert.equal(player(game, f.owner).spice, player(window, f.owner).spice);
|
||||
assert.equal(player(game, f.owner).reserves, player(window, f.owner).reserves);
|
||||
assert.deepEqual(player(game, f.owner).forces, player(window, f.owner).forces);
|
||||
assert.deepEqual(player(game, f.owner).noField, player(window, f.owner).noField);
|
||||
assert.equal(player(game, f.owner).shipped, false);
|
||||
custody(game);
|
||||
} else {
|
||||
assert.equal(game.nexusRicheseCunningLast!.stage, 'shipped');
|
||||
assert.equal(player(game, f.owner).forces['shrine:0'], 5);
|
||||
assert.equal(player(game, f.owner).reserves, player(window, f.owner).reserves - 5);
|
||||
assert.equal(player(game, f.owner).spice, player(window, f.owner).spice - 1);
|
||||
const materialized = richeseReveal(game, f.owner);
|
||||
assert.equal(player(materialized, f.owner).forces['shrine:0'], 8);
|
||||
assert.equal(player(materialized, f.owner).reserves, player(window, f.owner).reserves - 8);
|
||||
custody(materialized);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (const family of ['ix', 'choam'] as const) void test(`paired ${family} authenticated original setup retains native actors, starting hands/offers and exposes a genuine human entry window`, () => {
|
||||
const factions = family === 'ix' ? ['ixians', 'tleilaxu', 'guild', 'fremen'] as const : ['richese', 'choam', 'guild', 'fremen'] as const;
|
||||
const lobby = createGame('PAIREDAUTHDISCOVERY', newPlayer(`auth-${factions[0]}`, factions[0], factions[0]), true, [family]);
|
||||
for (const faction of factions.slice(1)) joinGame(lobby, newPlayer(`auth-${faction}`, faction, faction));
|
||||
const setup = initializePairedDiscoveryNexusForFixture({ initial: lobby, family, tech: true, strongholds: true });
|
||||
const original = structuredClone(setup), f = createPairedDiscoveryNexusFixture({ initial: setup });
|
||||
assert.deepEqual(setup, original); assert.deepEqual(f.setup, original);
|
||||
assert.equal(f.owner, `auth-${factions[0]}`); assert.equal(f.native, `auth-${factions[1]}`);
|
||||
for (const p of setup.players) for (const card of p.hand)
|
||||
assert.ok(player(f.afterSetup, p.id).hand.some(c => c.id === card.id), 'Continue the native starting hand, not a cleared/redealt approximation.');
|
||||
assert.equal(f.game.decision!.player, family === 'ix' ? 'auth-ixians' : 'auth-guild');
|
||||
const offer = viewGame(f.game, f.collector).discoveryEntry!;
|
||||
const accepted = applyAction(f.game, f.collector, { type: 'decision', event: offer.event, accept: true,
|
||||
groups: [{ source: f.source, normal: 1, elite: family === 'ix' ? 1 : 0 }] });
|
||||
assert.equal(player(accepted, f.collector).forces['shrine:0'], family === 'ix' ? 2 : 1); custody(accepted);
|
||||
});
|
||||
@@ -0,0 +1,318 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer,
|
||||
viewGame, type Action, type Game, type Player,
|
||||
} from '../game/engine';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
|
||||
import { classicNexusLeaderSkillsProfile } from '../game/leader-skill-profile';
|
||||
import { DISCOVERY_CARD_PLACEMENTS } from '../game/discoveries';
|
||||
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
|
||||
import { createStrongholdCards } from '../game/stronghold-cards';
|
||||
import {
|
||||
advanceClassicDiscoveryNexus, applyClassicDiscoveryNexusStep, classicDiscoveryNexusClean,
|
||||
finishClassicDiscoveryNexusMentat, formClassicDiscoveryNexusAlliance,
|
||||
nextClassicDiscoveryNexusStep, openClassicDiscoveryNexusAlliance, revealClassicDiscoveryNexus,
|
||||
voteClassicDiscoveryNexus, withClassicDiscoveryNexusLottery, withClassicDiscoveryNexusToken,
|
||||
} from './fixture-discovery-classic-nexus';
|
||||
import { holdNexusSkillsModulesPaymentsCard } from './fixture-nexus-skills-modules-payments';
|
||||
|
||||
export type DiscoveryClassicNexusSkillsStep = { actor: string; action: Action };
|
||||
export type DiscoveryClassicNexusSkillsOptions = {
|
||||
/** Authenticated ready lobby or ORIGINAL unassigned turn-one setup, never a played save. */
|
||||
initial?: Game;
|
||||
gameId?: string;
|
||||
seatIds?: string[];
|
||||
seats?: 2 | 3 | 4 | 5 | 6;
|
||||
factions?: FactionId[];
|
||||
advanced?: boolean;
|
||||
tech?: boolean;
|
||||
strongholds?: boolean;
|
||||
/** Only a new, locally generated setup may position its physical offer lottery. */
|
||||
skill?: LeaderSkillId;
|
||||
};
|
||||
export type DiscoveryClassicNexusSkillsFixture = {
|
||||
initial: Game;
|
||||
setup: Game;
|
||||
trained: Game;
|
||||
collection: Game;
|
||||
firstMentat: { before: Game; step: DiscoveryClassicNexusSkillsStep; after: Game };
|
||||
game: Game;
|
||||
owner: string;
|
||||
opponent: string;
|
||||
leader: string;
|
||||
skill: LeaderSkillId;
|
||||
token: string;
|
||||
source: string;
|
||||
wormSource: string;
|
||||
actions: DiscoveryClassicNexusSkillsStep[];
|
||||
staging: string[];
|
||||
};
|
||||
export type DiscoveryClassicNexusSkillsClosing = {
|
||||
vote: Game;
|
||||
alliance: Game;
|
||||
beforeDraw: Game;
|
||||
drawStep: DiscoveryClassicNexusSkillsStep;
|
||||
game: Game;
|
||||
};
|
||||
export type DiscoveryClassicNexusSkillsBattle = {
|
||||
shipmentWindow: Game;
|
||||
shipmentStep: DiscoveryClassicNexusSkillsStep;
|
||||
arrived: Game;
|
||||
game: Game;
|
||||
weapon: string;
|
||||
plans: DiscoveryClassicNexusSkillsStep[];
|
||||
};
|
||||
export const discoveryClassicNexusSkillsClean = classicDiscoveryNexusClean;
|
||||
export const advanceDiscoveryClassicNexusSkills = advanceClassicDiscoveryNexus;
|
||||
export const stepDiscoveryClassicNexusSkills = applyClassicDiscoveryNexusStep;
|
||||
export function discoveryClassicNexusSkillsPlayer(game: Game, actor: string): Player {
|
||||
const seat = game.players.find(player => player.id === actor);
|
||||
assert.ok(seat); return seat;
|
||||
}
|
||||
|
||||
/** The thirteen scalar draws belong only to the original all14 shuffle. Native
|
||||
* UUID bytes and subsequent original component production remain unchanged. */
|
||||
function offeredSkills<T>(selected: LeaderSkillId[], operation: () => T): T {
|
||||
const working = LEADER_SKILL_CARDS.map(card => card.id);
|
||||
const remainder = working.filter(skill => !selected.includes(skill));
|
||||
const desired = selected.flatMap(skill => [skill, remainder.shift()!]);
|
||||
desired.push(...remainder);
|
||||
const rolls: number[] = [];
|
||||
for (let index = working.length - 1; index > 0; index--) {
|
||||
const swap = working.indexOf(desired[index]); assert.ok(swap >= 0 && swap <= index);
|
||||
rolls.push(Math.floor((swap + 0.5) / (index + 1) * 0x100000000));
|
||||
[working[index], working[swap]] = [working[swap], working[index]];
|
||||
}
|
||||
const native = crypto.getRandomValues.bind(crypto);
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
let cursor = 0;
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (array instanceof Uint32Array && array.length === 1) array[0] = rolls[cursor++] ?? 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');
|
||||
}
|
||||
}
|
||||
|
||||
export function initializeDiscoveryClassicNexusSkillsSetup(options: DiscoveryClassicNexusSkillsOptions = {}): Game {
|
||||
let game: Game;
|
||||
if (options.initial) game = structuredClone(options.initial);
|
||||
else {
|
||||
const factions = options.factions ?? ['atreides', 'harkonnen', 'beneGesserit', 'guild', 'emperor', 'fremen'];
|
||||
const count = options.seats ?? options.seatIds?.length ?? options.factions?.length ?? 3;
|
||||
assert.ok(count >= 2 && count <= 6 && factions.length >= count);
|
||||
if (options.seatIds) assert.equal(options.seatIds.length, count);
|
||||
game = createGame(options.gameId ?? 'DISCOVERYCLASSICNEXUSSKILLS',
|
||||
newPlayer(options.seatIds?.[0] ?? factions[0], factions[0], factions[0]), options.advanced ?? false);
|
||||
for (let index = 1; index < count; index++)
|
||||
joinGame(game, newPlayer(options.seatIds?.[index] ?? factions[index], factions[index], factions[index]));
|
||||
}
|
||||
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
|
||||
assert.deepEqual(game.expansions, []);
|
||||
if (game.status === 'lobby') {
|
||||
assert.ok(!game.leaderSkills && !game.discoveries);
|
||||
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 };
|
||||
assert.deepEqual(game.nexusCards, { cards: null, phase: null });
|
||||
assert.ok(classicNexusLeaderSkillsProfile(game));
|
||||
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
|
||||
// A supplied human lobby must keep the real lottery; never demand one skill.
|
||||
if (options.initial) game = withClassicDiscoveryNexusLottery(() => initializeLeaderSkillsGameForAudit(game));
|
||||
else {
|
||||
const requested = options.skill ?? 'warmaster';
|
||||
const passive: LeaderSkillId[] = ['sandmaster', 'prana-bindu-adept', 'killer-medic', 'master-of-assassins', 'swordmaster-of-ginaz', 'planetologist']
|
||||
.filter(skill => skill !== requested) as LeaderSkillId[];
|
||||
const selected = game.players.map((_, index) => index === 0 ? requested : passive.shift()!);
|
||||
game = offeredSkills(selected, () => initializeLeaderSkillsGameForAudit(game));
|
||||
}
|
||||
}
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.turn, 1); assert.equal(game.phase, 0);
|
||||
assert.ok(['prediction', 'leaderSkills'].includes(game.setupStage!));
|
||||
assert.ok(game.leaderSkills && game.discoveries && game.nexusCards?.cards);
|
||||
assert.ok(classicNexusLeaderSkillsProfile(game));
|
||||
assert.equal(game.leaderSkills.assignments.length, 0);
|
||||
assert.ok(game.players.every(player => !player.traitors.length && !player.traitorChoices.length));
|
||||
if (game.strongholdCards) assert.deepEqual(game.strongholdCards, createStrongholdCards());
|
||||
if (options.tech !== undefined) assert.equal(!!game.techTokens, options.tech);
|
||||
if (options.strongholds !== undefined) assert.equal(!!game.strongholdCards, options.strongholds);
|
||||
return game;
|
||||
}
|
||||
|
||||
function reserveBoard(game: Game, staging: string[]): void {
|
||||
for (const player of game.players) {
|
||||
player.reserves += Object.values(player.forces).reduce((sum, amount) => sum + amount, 0);
|
||||
player.forces = {};
|
||||
if (player.elites) {
|
||||
player.elites.reserves += Object.values(player.elites.forces).reduce((sum, amount) => sum + amount, 0);
|
||||
player.elites.forces = {};
|
||||
}
|
||||
if (player.advisors) player.advisors = {};
|
||||
}
|
||||
staging.push('Conserved original board counters into their OWN reserves before actual movement completion; no death, wallet, clock, alliance or earned receipt assigned.');
|
||||
}
|
||||
export function positionDiscoveryClassicNexusSkillsOrdinary(game: Game, actor: string, key: string, amount: number, staging: string[]): void {
|
||||
const player = discoveryClassicNexusSkillsPlayer(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 at ${key}; subsequent native actions alone determine survival.`);
|
||||
}
|
||||
export function orderDiscoveryClassicNexusSkillsSpice(game: Game, secondTurn: boolean, staging: string[]): void {
|
||||
const take = (matches: (card: Game['spiceDeck'][number]) => boolean) => {
|
||||
const index = game.spiceDeck.findIndex(matches); assert.ok(index >= 0);
|
||||
return game.spiceDeck.splice(index, 1)[0];
|
||||
};
|
||||
const front: Game['spiceDeck'] = [];
|
||||
if (secondTurn) front.push(take(card => 'worm' in card && !!card.greatMaker));
|
||||
front.push(take(card => 'territory' in card && (secondTurn ? !card.discovery && card.territory === 'broken_land'
|
||||
: card.discovery === 'discovery-hagga-basin')));
|
||||
if (game.advanced) front.push(take(card => 'territory' in card && !card.discovery &&
|
||||
(secondTurn ? card.territory !== 'broken_land' : card.territory === 'rock_outcroppings')));
|
||||
game.spiceDeck.unshift(...front);
|
||||
staging.push(secondTurn ? 'Conserved original unplayed Great Maker followed by BOTH Advanced land replacements; original discard history untouched.'
|
||||
: 'Conserved original undealt Hagga Discovery and optional Advanced Rock Outcroppings positions; original token lottery selects Cistern.');
|
||||
}
|
||||
|
||||
/** Human endpoint is actual trained next-turn free entry BEFORE Storm. Supplied
|
||||
* offers/hands/IDs survive verbatim; only source-clear legal offered training is
|
||||
* selected. Original owner clocks, reveal and first Mentat own every transition. */
|
||||
export function createDiscoveryClassicNexusSkillsFixture(options: DiscoveryClassicNexusSkillsOptions = {}): DiscoveryClassicNexusSkillsFixture {
|
||||
const setup = initializeDiscoveryClassicNexusSkillsSetup(options);
|
||||
const initial = options.initial ? structuredClone(options.initial) : structuredClone(setup);
|
||||
const owner = setup.players[0].id, opponent = setup.players[1].id;
|
||||
const actions: DiscoveryClassicNexusSkillsStep[] = [], staging: string[] = [];
|
||||
let game = structuredClone(setup);
|
||||
while (game.status === 'setup') {
|
||||
let step: DiscoveryClassicNexusSkillsStep;
|
||||
if (game.setupStage === 'leaderSkills') {
|
||||
const actor = Object.keys(game.leaderSkills!.offers)[0], view = viewGame(game, actor).leaderSkills!;
|
||||
const legal = view.offer!.cards.filter(skill => !view.unavailableSkills?.[skill]);
|
||||
const desired = !options.initial && actor === owner ? options.skill ?? 'warmaster' : undefined;
|
||||
const skill = desired && legal.includes(desired) ? desired : legal[0];
|
||||
const eligible = new Set(view.eligibleLeaders.map(leader => leader.id));
|
||||
const leader = discoveryClassicNexusSkillsPlayer(game, actor).leaders.filter(leader => eligible.has(leader.id))
|
||||
.sort((a, b) => b.strength - a.strength)[0];
|
||||
assert.ok(skill && leader, 'Human continuation requires genuinely offered legal training, never a substitute offer.');
|
||||
step = { actor, action: { type: 'leaderSkill', event: view.offer!.event, skill, leader: leader.id } };
|
||||
} else { const next = nextClassicDiscoveryNexusStep(game); assert.ok(next); step = next; }
|
||||
game = applyClassicDiscoveryNexusStep(game, step, actions);
|
||||
}
|
||||
const trained = structuredClone(game), assignment = game.leaderSkills!.assignments.find(item => item.owner === owner)!;
|
||||
game = advanceClassicDiscoveryNexus(game, state => state.phase === 1 && classicDiscoveryNexusClean(state), actions);
|
||||
orderDiscoveryClassicNexusSkillsSpice(game, false, staging);
|
||||
for (let count = 0; !game.discoveries!.tokens.some(token => token.face === 'cistern' && token.status === 'placed') && count < 100; count++) {
|
||||
const next = nextClassicDiscoveryNexusStep(game); assert.ok(next); actions.push(structuredClone(next));
|
||||
game = withClassicDiscoveryNexusToken(game, 'cistern', () => applyAction(game, next.actor, next.action));
|
||||
}
|
||||
assert.ok(game.discoveries!.tokens.some(token => token.face === 'cistern' && token.status === 'placed'));
|
||||
game = advanceClassicDiscoveryNexus(game, state => state.phase === 5 && classicDiscoveryNexusClean(state), actions);
|
||||
reserveBoard(game, staging);
|
||||
const placement = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'];
|
||||
const source = `${placement.territory}:${placement.sector}`, wormSource = 'hagga_basin:12';
|
||||
positionDiscoveryClassicNexusSkillsOrdinary(game, owner, source, 3, staging);
|
||||
positionDiscoveryClassicNexusSkillsOrdinary(game, owner, wormSource, 6, staging);
|
||||
if (game.strongholdCards) positionDiscoveryClassicNexusSkillsOrdinary(game, owner, 'arrakeen:10', 1, staging);
|
||||
game = advanceClassicDiscoveryNexus(game, state => state.phase === 7 && classicDiscoveryNexusClean(state), actions);
|
||||
const collection = structuredClone(game), token = game.discoveries!.tokens.find(token => token.face === 'cistern')!;
|
||||
game = revealClassicDiscoveryNexus(game, owner, token.id, actions);
|
||||
const firstMentat = finishClassicDiscoveryNexusMentat(game, actions); game = firstMentat.after;
|
||||
assert.equal(game.turn, 2); assert.equal(game.decision?.kind, 'discoveryEntry'); assert.equal(game.decision!.player, owner);
|
||||
return { initial, setup, trained, collection, firstMentat, game, owner, opponent,
|
||||
leader: assignment.leader, skill: assignment.skill, token: token.id, source, wormSource, actions, staging };
|
||||
}
|
||||
export function enterDiscoveryClassicNexusSkills(fixture: DiscoveryClassicNexusSkillsFixture, amount = 3): Game {
|
||||
const game = structuredClone(fixture.game), view = viewGame(game, fixture.owner);
|
||||
const source = view.discoveryEntry!.sources.find(source => source.normal >= amount); assert.ok(source);
|
||||
const action = discoveryEntryMoveAction(view, [{ source: source.source, normal: amount, elite: 0 }]); assert.ok(action);
|
||||
return applyClassicDiscoveryNexusStep(game, { actor: fixture.owner, action }, fixture.actions);
|
||||
}
|
||||
|
||||
/** Actual Great Maker death -> all votes -> reserve ride -> settled alliance ->
|
||||
* ENTIRE Spice Blow closure. Omitting target preserves the genuine original draw
|
||||
* for human continuation; targeting only reorders an unplayed deck singleton. */
|
||||
export function closeDiscoveryClassicNexusSkills(fixture: DiscoveryClassicNexusSkillsFixture, target?: FactionId): DiscoveryClassicNexusSkillsClosing {
|
||||
assert.ok(fixture.game.players.length >= 3, 'An unallied draw needs two other actual alliance seats.');
|
||||
let game = enterDiscoveryClassicNexusSkills(fixture);
|
||||
game = advanceClassicDiscoveryNexus(game, state => state.phase === 1 && classicDiscoveryNexusClean(state), fixture.actions);
|
||||
orderDiscoveryClassicNexusSkillsSpice(game, true, fixture.staging);
|
||||
game = advanceClassicDiscoveryNexus(game, state => state.greatMaker?.stage === 'vote', fixture.actions);
|
||||
const vote = structuredClone(game);
|
||||
game = voteClassicDiscoveryNexus(game, game.players.map(() => true), fixture.actions);
|
||||
game = openClassicDiscoveryNexusAlliance(game);
|
||||
const others = game.players.filter(player => player.id !== fixture.owner);
|
||||
game = formClassicDiscoveryNexusAlliance(game, others[0].id, others[1].id);
|
||||
const alliance = structuredClone(game);
|
||||
game = advanceClassicDiscoveryNexus(game, state => state.nexusCards?.phase?.stage === 'drawing', fixture.actions);
|
||||
const cards = game.nexusCards!.cards!;
|
||||
if (target) {
|
||||
const index = cards.deck.indexOf(target); assert.ok(index >= 0, 'Only an original unplayed singleton may supply the targeted draw.');
|
||||
cards.deck.unshift(cards.deck.splice(index, 1)[0]);
|
||||
fixture.staging.push(`Positioned original UNPLAYED ${target} Nexus singleton before actual closing draw; never a held-hand assignment.`);
|
||||
}
|
||||
const beforeDraw = structuredClone(game), drawStep: DiscoveryClassicNexusSkillsStep = { actor: fixture.owner,
|
||||
action: { type: 'nexusCardChoice', turn: game.turn, card: cards.hands[fixture.owner], choice: 'draw', ownRedraws: 0 } };
|
||||
game = applyClassicDiscoveryNexusStep(game, drawStep, fixture.actions);
|
||||
return { vote, alliance, beforeDraw, drawStep, game };
|
||||
}
|
||||
|
||||
/** Borrowed Guild tariff is spent through an actual nested shipment. The trained
|
||||
* Warmaster remains the physical actor; Arrakeen custody is not nested support. */
|
||||
export function openDiscoveryClassicNexusSkillsBattle(fixture: DiscoveryClassicNexusSkillsFixture, state: Game): DiscoveryClassicNexusSkillsBattle {
|
||||
assert.equal(fixture.skill, 'warmaster');
|
||||
let game = advanceClassicDiscoveryNexus(state, game => game.phase === 5 && game.active === fixture.owner && classicDiscoveryNexusClean(game), fixture.actions);
|
||||
const shipmentWindow = structuredClone(game), offer = viewGame(game, fixture.owner).nexusGuildSecretAlly;
|
||||
assert.ok(offer && !offer.blocked);
|
||||
const shipmentStep: DiscoveryClassicNexusSkillsStep = { actor: fixture.owner, action: {
|
||||
type: 'ship', territory: 'cistern', sector: 0, amount: 3, elite: 0, nexus: offer.event } };
|
||||
game = applyClassicDiscoveryNexusStep(game, shipmentStep, fixture.actions);
|
||||
game = advanceClassicDiscoveryNexus(game, game => !game.pendingShipment && classicDiscoveryNexusClean(game), fixture.actions);
|
||||
const arrived = structuredClone(game);
|
||||
positionDiscoveryClassicNexusSkillsOrdinary(game, fixture.opponent, 'cistern:0', 2, fixture.staging);
|
||||
const weapon = holdNexusSkillsModulesPaymentsCard(game, fixture.owner, 'worthless', fixture.staging).id;
|
||||
const enemy = discoveryClassicNexusSkillsPlayer(game, fixture.opponent).leaders.filter(leader => !leader.dead && !leader.usedAt &&
|
||||
!game.leaderSkills!.assignments.some(assignment => assignment.leader === leader.id)).sort((a, b) => a.strength - b.strength)[0];
|
||||
assert.ok(enemy);
|
||||
game = advanceClassicDiscoveryNexus(game, game => game.phase === 6 && classicDiscoveryNexusClean(game), fixture.actions);
|
||||
const actor = game.active!; assert.ok(actor === fixture.owner || actor === fixture.opponent);
|
||||
game = applyClassicDiscoveryNexusStep(game, { actor, action: { type: 'chooseBattle', territory: 'cistern',
|
||||
target: actor === fixture.owner ? fixture.opponent : fixture.owner } }, fixture.actions);
|
||||
for (let count = 0; count < 160; count++) {
|
||||
const next = nextClassicDiscoveryNexusStep(game);
|
||||
if (!next) break;
|
||||
if (next.action.type === 'leaderSkillVisibility') next.action.hide = next.actor === fixture.owner;
|
||||
game = applyClassicDiscoveryNexusStep(game, next, fixture.actions);
|
||||
}
|
||||
assert.ok(game.battle && !game.battle.revealed && classicDiscoveryNexusClean(game));
|
||||
return { shipmentWindow, shipmentStep, arrived, game, weapon, plans: [
|
||||
{ actor: fixture.owner, action: { type: 'battlePlan', dial: 1, support: game.advanced ? 1 : 0,
|
||||
leader: fixture.leader, weapon, defense: null } },
|
||||
{ actor: fixture.opponent, action: { type: 'battlePlan', dial: 0, support: 0, leader: enemy.id, weapon: null, defense: null } },
|
||||
] };
|
||||
}
|
||||
export function revealDiscoveryClassicNexusSkillsBattle(battle: DiscoveryClassicNexusSkillsBattle): Game {
|
||||
let game = structuredClone(battle.game);
|
||||
for (const step of battle.plans) {
|
||||
game = applyClassicDiscoveryNexusStep(game, step);
|
||||
while (game.response || game.phaseOpening || game.decision?.kind === 'fullPlanOffer') {
|
||||
const next = nextClassicDiscoveryNexusStep(game); assert.ok(next); game = applyClassicDiscoveryNexusStep(game, next);
|
||||
}
|
||||
}
|
||||
assert.ok(game.battle?.revealed); return game;
|
||||
}
|
||||
export function finishDiscoveryClassicNexusSkillsBattle(state: Game, stopAtCleanup = false): Game {
|
||||
let game = structuredClone(state);
|
||||
for (let count = 0; count < 240; count++) {
|
||||
if (stopAtCleanup && game.decision?.kind === 'battleCards') return game;
|
||||
if (!game.battle && classicDiscoveryNexusClean(game) && !game.pendingTreacheryDiscard) return game;
|
||||
const next = nextClassicDiscoveryNexusStep(game); assert.ok(next);
|
||||
if (next.action.type === 'traitorCall') next.action.call = false;
|
||||
game = applyClassicDiscoveryNexusStep(game, next);
|
||||
}
|
||||
throw Error('Original trained nested battle did not finish its cleanup.');
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, handLimit, initializePairedNexusGameForAudit, joinGame, newPlayer,
|
||||
viewGame, type Action, type Game, type Player,
|
||||
} from '../game/engine';
|
||||
import { MOBILE_LOCATION, gameTerritories, territory, validGameLocation } from '../game/board';
|
||||
import { isAuditorLeader, type Card } from '../game/cards';
|
||||
import { DISCOVERY_CARD_PLACEMENTS } from '../game/discoveries';
|
||||
import { greatMakerRideAction } from '../game/great-maker-options';
|
||||
import { nextPairedIxNexusModulesStep } from './fixture-paired-ix-nexus-modules';
|
||||
import { nextPairedChoamStep } from './fixture-paired-choam-nexus-modules';
|
||||
import {
|
||||
withClassicDiscoveryNexusLottery, withClassicDiscoveryNexusToken,
|
||||
} from './fixture-discovery-classic-nexus';
|
||||
|
||||
export type PairedDiscoveryNexusStep = { actor: string; action: Action };
|
||||
export type PairedDiscoveryNexusOptions = {
|
||||
/** Authenticated original ready lobby or original unassigned turn-one setup. */
|
||||
initial?: Game;
|
||||
family?: 'ix' | 'choam';
|
||||
advanced?: boolean;
|
||||
tech?: boolean;
|
||||
strongholds?: boolean;
|
||||
};
|
||||
export type PairedDiscoveryNexusTransition = { before: Game; step: PairedDiscoveryNexusStep; after: Game };
|
||||
export type PairedDiscoveryNexusFixture = {
|
||||
initial: Game; setup: Game; afterSetup: Game; collection: Game;
|
||||
firstMentat: PairedDiscoveryNexusTransition; game: Game;
|
||||
family: 'ix' | 'choam'; owner: string; native: string; collector: string;
|
||||
guild: string; fremen: string; token: string; source: string; claim: 'arrakeen' | 'carthag';
|
||||
actions: PairedDiscoveryNexusStep[]; staging: string[];
|
||||
};
|
||||
export type PairedDiscoveryNexusClosing = {
|
||||
entry: PairedDiscoveryNexusTransition; vote: Game; ride: PairedDiscoveryNexusTransition;
|
||||
alliance: Game; drawing: Game; afterDraw: Game;
|
||||
};
|
||||
export type PairedDiscoveryNexusBattle = {
|
||||
game: Game; beforeBattle: Game; battleStep: PairedDiscoveryNexusStep;
|
||||
winner: string; loser: string; leader: string; enemy: string;
|
||||
weapon: string; defense: string; source: string; plans: PairedDiscoveryNexusStep[];
|
||||
};
|
||||
export const pairedDiscoveryNexusClean = (game: Game): boolean =>
|
||||
!game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
|
||||
export function pairedDiscoveryNexusPlayer(game: Game, actor: string): Player {
|
||||
const p = game.players.find(p => p.id === actor); assert.ok(p); return p;
|
||||
}
|
||||
|
||||
/** Do not automate owned human entry, vote, ride, declarations or battle plans
|
||||
* in the returned programmes. These defaults only cross unrelated native windows. */
|
||||
export function nextPairedDiscoveryNexusStep(game: Game): PairedDiscoveryNexusStep | null {
|
||||
if (!game.phaseOpening && !game.response && game.decision) {
|
||||
const d = game.decision;
|
||||
if (d.kind === 'discoveryEntry' || d.kind === 'greatMakerRide')
|
||||
return { actor: d.player, action: { type: 'decision', event: d.event, accept: false } };
|
||||
if (d.kind === 'greatMakerVote')
|
||||
return { actor: d.player, action: { type: 'decision', event: d.event, yes: false } };
|
||||
if (d.kind === 'faceDance') return null;
|
||||
if (d.kind === 'discoveryDiscard') {
|
||||
const card = pairedDiscoveryNexusPlayer(game, d.player).hand[0]; assert.ok(card);
|
||||
return { actor: d.player, action: { type: 'decision', event: d.event, card: card.id } };
|
||||
}
|
||||
}
|
||||
return game.expansions[0] === 'ix' ? nextPairedIxNexusModulesStep(game) : nextPairedChoamStep(game);
|
||||
}
|
||||
export function applyPairedDiscoveryNexusStep(game: Game, step: PairedDiscoveryNexusStep,
|
||||
actions?: PairedDiscoveryNexusStep[]): Game {
|
||||
actions?.push(structuredClone(step));
|
||||
return withClassicDiscoveryNexusLottery(() => applyAction(game, step.actor, step.action));
|
||||
}
|
||||
export function advancePairedDiscoveryNexus(state: Game, until: (g: Game) => boolean,
|
||||
actions?: PairedDiscoveryNexusStep[]): Game {
|
||||
let game = structuredClone(state);
|
||||
for (let n = 0; n < 2400; n++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const step = nextPairedDiscoveryNexusStep(game);
|
||||
assert.ok(step, 'This original paired programme needs its human plan or Face Dance.');
|
||||
game = applyPairedDiscoveryNexusStep(game, step, actions);
|
||||
}
|
||||
throw Error('Paired Discovery/Nexus continuation did not reach the requested original boundary');
|
||||
}
|
||||
|
||||
/** Never restart the supplied setup, clear its original hands, replace its
|
||||
* private offers, assign actors, clocks, training, or manufacture a held card. */
|
||||
export function initializePairedDiscoveryNexusForFixture(options: PairedDiscoveryNexusOptions = {}): Game {
|
||||
const family = options.family ?? (options.initial?.expansions[0] === 'choam' ? 'choam' : 'ix');
|
||||
const factions = family === 'ix' ? ['ixians', 'tleilaxu', 'guild', 'fremen'] as const
|
||||
: ['richese', 'choam', 'guild', 'fremen'] as const;
|
||||
const advanced = options.advanced ?? options.initial?.advanced ?? true;
|
||||
const tech = options.tech ?? (options.initial ? !!options.initial.techTokens : true);
|
||||
const strongholds = options.strongholds ?? (options.initial ? !!options.initial.strongholdCards : advanced);
|
||||
let game = options.initial ? structuredClone(options.initial)
|
||||
: createGame('PAIREDDISCOVERYNEXUS', newPlayer(factions[0], factions[0], factions[0]), advanced, [family]);
|
||||
if (!options.initial) for (const faction of factions.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
|
||||
assert.deepEqual(game.players.map(p => p.faction).sort(), [...factions].sort());
|
||||
assert.deepEqual(game.expansions, [family]); assert.equal(game.advanced, advanced);
|
||||
assert.ok(!game.homeworlds && !game.leaderSkills && !game.ecazTreachery);
|
||||
assert.ok(game.status === 'lobby' || game.status === 'setup' && game.turn === 1 && game.phase === 0 &&
|
||||
game.players.every(p => !p.traitors.length && p.leaders.every(l => !l.dead && !l.usedAt)),
|
||||
'Use the original lobby or unassigned turn-one native setup, including any native starting hand.');
|
||||
if (game.status === 'lobby') {
|
||||
if (!!game.techTokens !== tech) game = applyAction(game, game.host, { type: 'techTokens', enabled: tech });
|
||||
if (!!game.strongholdCards !== strongholds) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: strongholds });
|
||||
game.discoveryEnabled = true;
|
||||
game.nexusCards ??= { cards: null, phase: null };
|
||||
for (const p of game.players) if (!p.ready) game = applyAction(game, p.id, { type: 'ready' });
|
||||
game = options.initial ? initializePairedNexusGameForAudit(game)
|
||||
: withClassicDiscoveryNexusLottery(() => initializePairedNexusGameForAudit(game));
|
||||
}
|
||||
assert.equal(game.discoveryEnabled, true); assert.ok(game.discoveries && game.nexusCards?.cards);
|
||||
assert.equal(!!game.techTokens, tech); assert.equal(!!game.strongholdCards, strongholds);
|
||||
return game;
|
||||
}
|
||||
function orderSpice(game: Game): void {
|
||||
const take = (matches: (card: Game['spiceDeck'][number]) => boolean) => {
|
||||
const at = game.spiceDeck.findIndex(matches); assert.ok(at >= 0);
|
||||
return game.spiceDeck.splice(at, 1)[0];
|
||||
};
|
||||
const cards = [take(c => 'territory' in c && c.discovery === 'discovery-hagga-basin')];
|
||||
if (game.advanced) cards.push(take(c => 'territory' in c && !c.discovery && c.territory === 'broken_land'));
|
||||
cards.push(take(c => 'worm' in c && !!c.greatMaker));
|
||||
cards.push(take(c => 'territory' in c && c.discovery === 'discovery-sihaya-ridge'));
|
||||
if (game.advanced) cards.push(take(c => 'territory' in c && !c.discovery && c.territory === 'rock_outcroppings'));
|
||||
game.spiceDeck.unshift(...cards);
|
||||
}
|
||||
export function reservePairedDiscoveryNexusBoard(game: Game, kept: string[] = []): void {
|
||||
for (const p of game.players) for (const [key, count] of Object.entries(p.forces)) {
|
||||
if (kept.includes(key)) continue;
|
||||
p.reserves += count; delete p.forces[key];
|
||||
if (p.elites) { p.elites.reserves += p.elites.forces[key] ?? 0; delete p.elites.forces[key]; }
|
||||
}
|
||||
}
|
||||
/** Explicitly conserved original subtype groups; elite is a subset of total. */
|
||||
export function placePairedDiscoveryNexus(game: Game, actor: string, key: string, total: number, elite = 0): void {
|
||||
const p = pairedDiscoveryNexusPlayer(game, actor);
|
||||
assert.ok(total >= elite && p.reserves - (p.elites?.reserves ?? 0) >= total - elite &&
|
||||
(p.elites?.reserves ?? 0) >= elite);
|
||||
p.reserves -= total; p.forces[key] = (p.forces[key] ?? 0) + total;
|
||||
if (elite) { p.elites!.reserves -= elite; p.elites!.forces[key] = (p.elites!.forces[key] ?? 0) + elite; }
|
||||
}
|
||||
export function holdPairedDiscoveryNexusCard(game: Game, actor: string, matches: (c: Card) => boolean,
|
||||
staging?: string[]): Card {
|
||||
const p = pairedDiscoveryNexusPlayer(game, actor), held = p.hand.find(matches);
|
||||
if (held) return held;
|
||||
const at = game.deck.findIndex(matches); assert.ok(at >= 0 && p.hand.length < handLimit(p));
|
||||
const card = game.deck.splice(at, 1)[0]; p.hand.push(card);
|
||||
staging?.push(`Conserved unplayed ${card.id}: original undealt deck → ${actor} hand; retain original starting hands.`);
|
||||
return card;
|
||||
}
|
||||
|
||||
/** Original pair, printed blow and physical token lottery, actual paid typed
|
||||
* collection, inspection/reveal and END Mentat. Stops BEFORE turn-two Storm
|
||||
* at the authenticated collector's real free-entry control. */
|
||||
export function createPairedDiscoveryNexusFixture(options: PairedDiscoveryNexusOptions = {}): PairedDiscoveryNexusFixture {
|
||||
let game = initializePairedDiscoveryNexusForFixture(options);
|
||||
const initial = structuredClone(game), setup = structuredClone(game);
|
||||
const family = game.expansions[0] as 'ix' | 'choam';
|
||||
const actor = (faction: string) => game.players.find(p => p.faction === faction)!.id;
|
||||
const owner = actor(family === 'ix' ? 'ixians' : 'richese');
|
||||
const native = actor(family === 'ix' ? 'tleilaxu' : 'choam'), guild = actor('guild'), fremen = actor('fremen');
|
||||
const collector = family === 'ix' ? owner : guild;
|
||||
const actions: PairedDiscoveryNexusStep[] = [], staging = [
|
||||
'Conserved original unplayed Spice order: printed Hagga Basin, optional Advanced Broken Land; then Great Maker, Sihaya Ridge and optional Advanced Rock Outcroppings.',
|
||||
'Selected actual Shrine supply-token and original scalar Storm/shuffle lotteries only; all UUIDs, actors, setup offers and starting hands remain original.',
|
||||
];
|
||||
game = options.initial ? advancePairedDiscoveryNexus(game, g => g.status === 'playing', actions)
|
||||
: withClassicDiscoveryNexusLottery(() => advancePairedDiscoveryNexus(game, g => g.status === 'playing', actions));
|
||||
const afterSetup = structuredClone(game);
|
||||
orderSpice(game);
|
||||
game = advancePairedDiscoveryNexus(game, g => g.phase === 1 && pairedDiscoveryNexusClean(g), actions);
|
||||
for (let n = 0; !game.discoveries!.tokens.some(t => t.face === 'shrine' && t.status === 'placed') && n < 100; n++) {
|
||||
const step = nextPairedDiscoveryNexusStep(game); assert.ok(step); actions.push(structuredClone(step));
|
||||
game = withClassicDiscoveryNexusToken(game, 'shrine', () => applyAction(game, step.actor, step.action));
|
||||
}
|
||||
const token = game.discoveries!.tokens.find(t => t.face === 'shrine')!; assert.equal(token.status, 'placed');
|
||||
const printed = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'];
|
||||
const source = `${printed.territory}:${printed.sector}`;
|
||||
game = advancePairedDiscoveryNexus(game, g => g.phase === 5 && g.active === collector && pairedDiscoveryNexusClean(g), actions);
|
||||
game = applyPairedDiscoveryNexusStep(game, { actor: collector, action: { type: 'ship', territory: printed.territory,
|
||||
sector: printed.sector, amount: 3, elite: family === 'ix' ? 1 : 0, allyPayment: 0 } }, actions);
|
||||
game = advancePairedDiscoveryNexus(game, g => g.phase === 7 && pairedDiscoveryNexusClean(g), actions);
|
||||
const collection = structuredClone(game);
|
||||
assert.ok(viewGame(game, collector).discoveries!.canInspect.includes(token.id));
|
||||
game = applyPairedDiscoveryNexusStep(game, { actor: collector, action: { type: 'discovery', token: token.id, reveal: false } }, actions);
|
||||
game = applyPairedDiscoveryNexusStep(game, { actor: collector, action: { type: 'discovery', token: token.id, reveal: true } }, actions);
|
||||
game = advancePairedDiscoveryNexus(game, g => g.phase === 8 && pairedDiscoveryNexusClean(g), actions);
|
||||
reservePairedDiscoveryNexusBoard(game, [source, MOBILE_LOCATION]);
|
||||
const claim = family === 'ix' ? 'arrakeen' : 'carthag';
|
||||
placePairedDiscoveryNexus(game, owner, `${claim}:${territory(claim).sectors[0]}`, 1);
|
||||
placePairedDiscoveryNexus(game, native, 'hagga_basin:12', 2);
|
||||
staging.push(`Before actual END Mentat: conserve original unrelated board groups to reserves, keep paid parent/HMS counters, place one ${owner} ${claim} claim counter and two ${native} Hagga Basin worm-source counters. No custody, clocks, paid history or wallets assigned.`);
|
||||
let firstMentat: PairedDiscoveryNexusTransition | undefined;
|
||||
for (let n = 0; game.turn === 1 && n < 100; n++) {
|
||||
const before = structuredClone(game), step = nextPairedDiscoveryNexusStep(game); assert.ok(step);
|
||||
game = applyPairedDiscoveryNexusStep(game, step, actions);
|
||||
if (game.turn === 2) firstMentat = { before, step, after: structuredClone(game) };
|
||||
}
|
||||
assert.ok(firstMentat);
|
||||
game = advancePairedDiscoveryNexus(game, g => g.decision?.kind === 'discoveryEntry', actions);
|
||||
assert.equal(game.phase, 0); assert.equal(game.turn, 2); assert.equal(game.decision!.player, collector);
|
||||
return { initial, setup, afterSetup, collection, firstMentat, game, family, owner, native, collector,
|
||||
guild, fremen, token: token.id, source, claim, actions, staging };
|
||||
}
|
||||
|
||||
/** Native human entry, all storm-order votes, exact Fremen typed reserve ride,
|
||||
* both Spice piles, reciprocal classic alliance and actual closing singletons. */
|
||||
export function closePairedDiscoveryNexus(f: PairedDiscoveryNexusFixture): PairedDiscoveryNexusClosing {
|
||||
const before = structuredClone(f.game), offer = viewGame(before, f.collector).discoveryEntry!;
|
||||
const step: PairedDiscoveryNexusStep = { actor: f.collector, action: { type: 'decision', event: offer.event,
|
||||
accept: true, groups: [{ source: f.source, normal: 1, elite: f.family === 'ix' ? 1 : 0 }] } };
|
||||
let game = applyPairedDiscoveryNexusStep(before, step, f.actions);
|
||||
const entry = { before, step, after: structuredClone(game) };
|
||||
game = advancePairedDiscoveryNexus(game, g => g.greatMaker?.stage === 'vote', f.actions);
|
||||
const vote = structuredClone(game);
|
||||
while (game.decision?.kind === 'greatMakerVote') game = applyPairedDiscoveryNexusStep(game,
|
||||
{ actor: game.decision.player, action: { type: 'decision', event: game.decision.event, yes: true } }, f.actions);
|
||||
assert.equal(game.decision?.kind, 'greatMakerRide'); assert.equal(game.decision!.player, f.fremen);
|
||||
const rideBefore = structuredClone(game), action = greatMakerRideAction(viewGame(game, f.fremen), 'polar_sink', 0, 2, game.advanced ? 1 : 0);
|
||||
assert.ok(action);
|
||||
const rideStep = { actor: f.fremen, action }; game = applyPairedDiscoveryNexusStep(game, rideStep, f.actions);
|
||||
const ride = { before: rideBefore, step: rideStep, after: structuredClone(game) };
|
||||
game = advancePairedDiscoveryNexus(game, g => !!g.nexus && !g.spiceWindow && !g.spiceResolution && pairedDiscoveryNexusClean(g), f.actions);
|
||||
game = applyPairedDiscoveryNexusStep(game, { actor: f.guild, action: { type: 'alliance', target: f.fremen } }, f.actions);
|
||||
game = applyPairedDiscoveryNexusStep(game, { actor: f.fremen, action: { type: 'alliance', target: f.guild } }, f.actions);
|
||||
const alliance = structuredClone(game);
|
||||
game = advancePairedDiscoveryNexus(game, g => g.nexusCards?.phase?.stage === 'drawing', f.actions);
|
||||
const cards = game.nexusCards!.cards!;
|
||||
const faces = f.family === 'ix' ? ['ixians', 'tleilaxu'] as const : ['richese', 'choam'] as const;
|
||||
for (const face of faces) assert.ok(cards.deck.includes(face));
|
||||
cards.deck = [...faces, ...cards.deck.filter(c => !faces.some(face => face === c))];
|
||||
f.staging.push('Conserve undealt all12 singleton order only; actual unallied closing draw actions deal the two native Cunning cards AFTER both Advanced piles and reciprocal alliance.');
|
||||
const drawing = structuredClone(game);
|
||||
for (const actor of [f.owner, f.native]) game = applyPairedDiscoveryNexusStep(game,
|
||||
{ actor, action: { type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'draw', ownRedraws: 0 } }, f.actions);
|
||||
game = advancePairedDiscoveryNexus(game, g => g.nexusCards?.phase?.stage !== 'drawing', f.actions);
|
||||
return { entry, vote, ride, alliance, drawing, afterDraw: structuredClone(game) };
|
||||
}
|
||||
export function pairedDiscoveryRicheseRequest(f: PairedDiscoveryNexusFixture, game: Game): Action {
|
||||
assert.equal(f.family, 'choam'); assert.equal(validGameLocation(game, 'shrine', 0), true);
|
||||
const p = pairedDiscoveryNexusPlayer(game, f.owner), offer = viewGame(game, f.owner).nexusRicheseCunning!;
|
||||
assert.ok(offer && !offer.blocked);
|
||||
return { type: 'ship', territory: 'shrine', sector: 0, noField: p.noField!.tokens.find(t => t.value === 3)!.id,
|
||||
revealedToken: p.noField!.tokens.find(t => t.value === 5)!.id,
|
||||
event: p.noFieldEvent, nexus: offer.event, allyPayment: 0 };
|
||||
}
|
||||
export function settlePairedDiscoveryNexusShipment(game: Game): Game {
|
||||
return advancePairedDiscoveryNexus(game, g => !g.pendingShipment && pairedDiscoveryNexusClean(g));
|
||||
}
|
||||
|
||||
/** Conserved original private identities only, never revealed-stock/paid history.
|
||||
* Select the genuine Face Dancer from its existing stock or exchange with the
|
||||
* exact original unplayed Traitor/Face Dancer custody, before the native battle. */
|
||||
function matchDancer(game: Game, actor: string, leader: string, staging: string[]): void {
|
||||
const stock = pairedDiscoveryNexusPlayer(game, actor).faceDancers!;
|
||||
if (stock.some(c => c.leader === leader && !c.revealed)) return;
|
||||
const dancer = stock.find(c => !c.revealed); assert.ok(dancer);
|
||||
const old = dancer.leader, index = game.traitorReserve!.indexOf(leader);
|
||||
if (index >= 0) game.traitorReserve![index] = old;
|
||||
else {
|
||||
const holder = game.players.find(p => p.traitors.includes(leader)); assert.ok(holder);
|
||||
holder.traitors[holder.traitors.indexOf(leader)] = old;
|
||||
}
|
||||
dancer.leader = leader;
|
||||
staging.push(`Conserved original unplayed private identities: ${leader} ↔ ${old}; no revealed flag or replacement history assigned.`);
|
||||
}
|
||||
/** Native nested battle after actual entry and closing deal. E2 begins only
|
||||
* AFTER real paid two-marker materialization/reveal, never mixed marker plans. */
|
||||
export function preparePairedDiscoveryNexusBattle(f: PairedDiscoveryNexusFixture, state: Game): PairedDiscoveryNexusBattle {
|
||||
let game = advancePairedDiscoveryNexus(state, g => g.phase === 5 && pairedDiscoveryNexusClean(g));
|
||||
reservePairedDiscoveryNexusBoard(game, f.family === 'ix' ? [MOBILE_LOCATION] : ['shrine:0']);
|
||||
const loser = f.family === 'ix' ? f.native : f.guild;
|
||||
if (f.family === 'ix') {
|
||||
placePairedDiscoveryNexus(game, f.owner, 'shrine:0', 6, 3);
|
||||
placePairedDiscoveryNexus(game, f.native, f.source, 3);
|
||||
} else {
|
||||
assert.equal(pairedDiscoveryNexusPlayer(game, f.owner).forces['shrine:0'], 8);
|
||||
assert.equal(pairedDiscoveryNexusPlayer(game, f.owner).noField!.deployed, null);
|
||||
// The collector's earned entry counter is conserved back to its own reserve;
|
||||
// only Richese's actually paid/materialized eight-counter group stays here.
|
||||
const collector = pairedDiscoveryNexusPlayer(game, f.collector);
|
||||
collector.reserves += collector.forces['shrine:0'] ?? 0;
|
||||
delete collector.forces['shrine:0'];
|
||||
}
|
||||
placePairedDiscoveryNexus(game, loser, 'shrine:0', 6);
|
||||
const other = gameTerritories(game).find(t => t.type === 'sand' && t.id !== 'hagga_basin' && t.id !== 'gara_kulon' &&
|
||||
t.sectors.some(s => s >= 9 && s !== game.storm)); assert.ok(other);
|
||||
const key = `${other.id}:${other.sectors.find(s => s >= 9 && s !== game.storm)!}`;
|
||||
for (const actor of [f.owner, loser]) placePairedDiscoveryNexus(game, actor, key, 1);
|
||||
const leader = pairedDiscoveryNexusPlayer(game, f.owner).leaders.filter(l => !l.dead && !isAuditorLeader(l)).sort((a, b) => b.strength - a.strength)[0];
|
||||
const enemy = pairedDiscoveryNexusPlayer(game, loser).leaders.filter(l => !l.dead && !isAuditorLeader(l)).sort((a, b) => a.strength - b.strength)[0];
|
||||
assert.ok(leader && enemy);
|
||||
if (f.family === 'ix') matchDancer(game, f.native, leader.id, f.staging);
|
||||
const weapon = holdPairedDiscoveryNexusCard(game, f.owner, c => c.kind === 'projectile', f.staging).id;
|
||||
const defense = holdPairedDiscoveryNexusCard(game, f.owner, c => c.kind === 'shield', f.staging).id;
|
||||
f.staging.push('Before actual Movement completion: conserve exact original subtype reserve/board groups into the REVEALED Shrine, parent replacement source and second native conflict; retain original hands, earned held cards, wallets and real Nexus deals.');
|
||||
game = advancePairedDiscoveryNexus(game, g => g.phase === 6 && pairedDiscoveryNexusClean(g));
|
||||
const beforeBattle = structuredClone(game), actor = game.active!; assert.ok(actor === f.owner || actor === loser);
|
||||
const battleStep: PairedDiscoveryNexusStep = { actor, action: { type: 'chooseBattle', territory: 'shrine', target: actor === f.owner ? loser : f.owner } };
|
||||
game = applyPairedDiscoveryNexusStep(game, battleStep);
|
||||
game = advancePairedDiscoveryNexus(game, g => !!g.battle && !g.battle.revealed && !nextPairedDiscoveryNexusStep(g));
|
||||
const plans: PairedDiscoveryNexusStep[] = [
|
||||
{ actor: f.owner, action: { type: 'battlePlan', leader: leader.id, dial: f.family === 'ix' ? 2 : 3,
|
||||
support: f.family === 'choam' && game.advanced ? 3 : 0, weapon, defense } },
|
||||
{ actor: loser, action: { type: 'battlePlan', leader: enemy.id, dial: 0, support: 0, weapon: null, defense: null } },
|
||||
];
|
||||
return { game, beforeBattle, battleStep, winner: f.owner, loser, leader: leader.id, enemy: enemy.id, weapon, defense, source: f.source, plans };
|
||||
}
|
||||
export function revealPairedDiscoveryNexusBattle(b: PairedDiscoveryNexusBattle, state = b.game): Game {
|
||||
let game = structuredClone(state);
|
||||
for (const step of b.plans) {
|
||||
game = applyPairedDiscoveryNexusStep(game, step);
|
||||
game = advancePairedDiscoveryNexus(game, g => !g.response && g.decision?.kind !== 'fullPlanOffer');
|
||||
}
|
||||
assert.ok(game.battle?.revealed); return game;
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import assert from 'node:assert/strict';
|
||||
import {
|
||||
RULE_TOPICS,
|
||||
RULE_CHECKLIST_AREAS,
|
||||
PHASE_HELP,
|
||||
phaseRuleId,
|
||||
} from '../game/reference';
|
||||
import { FACTIONS, PHASES } from '../game/catalog';
|
||||
@@ -13,11 +12,9 @@ void test('every phase and base faction has a discoverable rule topic with valid
|
||||
const ids = new Set(RULE_TOPICS.map((t) => t.id));
|
||||
assert.equal(ids.size, RULE_TOPICS.length);
|
||||
for (const topic of RULE_TOPICS) {
|
||||
assert.ok(topic.steps.length > 0, topic.id);
|
||||
for (const related of topic.related ?? [])
|
||||
assert.ok(ids.has(related), `${topic.id} -> ${related}`);
|
||||
}
|
||||
assert.equal(PHASE_HELP.length, PHASES.length);
|
||||
PHASES.forEach((_, i) => assert.ok(ids.has(phaseRuleId(i))));
|
||||
FACTIONS.forEach((f) => assert.ok(ids.has(`faction-${f.id}`)));
|
||||
assert.ok(ids.has('ai-players'));
|
||||
|
||||
Reference in new issue
Block a user