Compose Discovery Strongholds and repair mandatory Ecaz battles

This commit is contained in:
Quintine committed 2026-10-05 22:13:53 +11:00
1 parent e798914aab
commit b0267827b5
19 files changed
+1243 -39

No files matched your search

+17 -2
View File
@@ -48,7 +48,7 @@ void test('Discovery cannot redeal started components or silently remove another
const overlays: Game[] = [structuredClone(initial), structuredClone(initial), structuredClone(initial)];
overlays[0].homeworlds = { custody: null };
overlays[1].nexusCards = { cards: null, phase: null };
overlays[2].strongholdCards = createStrongholdCards();
overlays[2].advanced = false; overlays[2].strongholdCards = createStrongholdCards();
for (const game of overlays) assert.throws(() => initializeDiscoveryGameForAudit(game), RuleError);
});
@@ -58,7 +58,7 @@ void test('classic Discovery Skills does not silently drop native families or un
const overlays = Array.from({ length: 5 }, () => lobby(['guild', 'emperor', 'harkonnen'], []));
overlays[0].homeworlds = { custody: null };
overlays[1].nexusCards = { cards: null, phase: null };
overlays[2].strongholdCards = createStrongholdCards();
overlays[2].advanced = false; overlays[2].strongholdCards = createStrongholdCards();
overlays[3].mentatQuestionPreview = true;
overlays[4].spiceBankerIncomePreview = true;
for (const game of overlays) assert.throws(() => initializeLeaderSkillsGameForAudit(game), RuleError);
@@ -89,3 +89,18 @@ void test('Discovery Nexus rejects native families, used components and incompat
started.nexusCards = { cards: null, phase: null };
assert.throws(() => initializeNexusGameForAudit(started), RuleError);
});
void test('Discovery original Stronghold composition rejects already claimed cards instead of granting or redealing them', () => {
for (const initialize of [initializeDiscoveryGameForAudit, initializeLeaderSkillsGameForAudit]) {
const owner = lobby(['guild', 'emperor', 'fremen'], []);
owner.strongholdCards = createStrongholdCards();
owner.strongholdCards.owners.arrakeen = 'guild';
const before = JSON.stringify(owner);
assert.throws(() => initialize(owner), RuleError);
assert.equal(JSON.stringify(owner), before);
const settled = lobby(['guild', 'emperor', 'fremen'], []);
settled.strongholdCards = createStrongholdCards();
settled.strongholdCards.claimedTurn = 1;
assert.throws(() => initialize(settled), RuleError);
}
});
@@ -0,0 +1,227 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { MOBILE_LOCATION, MOBILE_STRONGHOLD, gameDistance, gameTerritories } from '../game/board';
import { strongholdControllers } from '../game/stronghold-cards';
import { quoteStrongholdFactionsBattle } from './fixture-stronghold-factions';
import {
assertDiscoveryNativeStrongholdCustody,
createDiscoveryNativeStrongholdFixture, discoveryNativeStrongholdPlayer,
discoveryNativeStrongholdReload, finishDiscoveryNativeStrongholdBattle,
prepareDiscoveryNativeStrongholdBattle, revealDiscoveryNativeStrongholdBattle,
} from './fixture-discovery-native-strongholds';
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 controls cannot debit wallets, move counters or change retained card custody');
}
function legalPolicies(game: Game, actor: string): void {
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, actor);
view.players.find(p => p.id === actor)!.bot = difficulty;
const choices = botActions(view);
assert.ok(choices.length, `${difficulty} supplies an actual native continuation`);
for (const action of choices) {
const next = applyAction(discoveryNativeStrongholdReload(game), actor, action);
assertDiscoveryNativeStrongholdCustody(next);
}
}
}
void test('native Advanced Discovery blow/reveal and original final Mentat produce retained cards without redealing authenticated setup', () => {
for (const kind of ['ix-hms-invoice', 'richese-nested-defense'] as const) {
const fixture = createDiscoveryNativeStrongholdFixture({ kind, tech: true });
const { owner } = fixture;
const original = discoveryNativeStrongholdReload(fixture.setup);
const fromSetup = createDiscoveryNativeStrongholdFixture({ kind, initial: fixture.setup });
assert.deepEqual(fixture.setup, original);
assert.deepEqual(fromSetup.setup, original, 'An original undealt native setup is continued, never initialized twice');
assert.equal(fixture.setup.strongholdCards!.claimedTurn, 0);
assert.ok(Object.values(fixture.setup.strongholdCards!.owners).every(id => id === null));
const old = discoveryNativeStrongholdPlayer(fixture.beforeBlow, owner);
const blown = discoveryNativeStrongholdPlayer(fixture.afterBlow, owner);
assert.equal(old.forces['hagga_basin:12'], 2);
assert.equal(blown.forces['hagga_basin:12'], undefined);
assert.equal(blown.tanks, old.tanks + 2);
if (kind === 'ix-hms-invoice') {
assert.equal(old.elites!.forces['hagga_basin:12'], 1);
assert.equal(blown.elites!.forces['hagga_basin:12'], undefined);
assert.equal(blown.elites!.tanks, old.elites!.tanks + 1);
assert.equal(discoveryNativeStrongholdPlayer(fixture.afterSetup, owner).forces[MOBILE_LOCATION], 6);
assert.equal(discoveryNativeStrongholdPlayer(fixture.afterSetup, owner).elites!.forces[MOBILE_LOCATION], 3);
}
const token = fixture.afterBlow.discoveries!.tokens.find(t => t.id === fixture.token)!;
assert.equal(token.status, 'placed');
assert.equal(token.territory, 'gara_kulon');
assert.equal(token.sector, 8);
assert.ok(fixture.afterBlow.spiceDiscard.flat().some(c => 'territory' in c && c.discovery === 'discovery-hagga-basin'));
assert.equal(gameTerritories(fixture.beforeReveal).some(t => t.id === 'shrine'), false);
assert.equal(gameTerritories(fixture.afterReveal).some(t => t.id === 'shrine'), true);
assert.equal(fixture.beforeFirstMentat.phase, 8);
assert.equal(fixture.beforeFirstMentat.turn, 1);
assert.equal(fixture.beforeFirstMentat.strongholdCards!.claimedTurn, 0);
const claimed = applyAction(discoveryNativeStrongholdReload(fixture.beforeFirstMentat),
fixture.firstMentatStep.actor, fixture.firstMentatStep.action);
assert.equal(claimed.turn, 2);
assert.deepEqual(claimed.strongholdCards, fixture.afterFirstMentat.strongholdCards);
assert.deepEqual(claimed.strongholdCards!.owners,
strongholdControllers(fixture.beforeFirstMentat.players, kind === 'ix-hms-invoice'));
assert.equal(claimed.strongholdCards!.owners[kind === 'ix-hms-invoice' ? MOBILE_STRONGHOLD : 'carthag'], owner);
assert.deepEqual(fixture.beforeBattle.strongholdCards, claimed.strongholdCards);
for (const snapshot of [fixture.setup, fixture.afterSetup, fixture.beforeReveal, claimed, fixture.beforeBattle])
assertDiscoveryNativeStrongholdCustody(snapshot);
}
});
void test('earned HMS card composes with actual typed Discovery free entry, paid nested shipment and optional original industry', () => {
for (const tech of [false, true]) {
const f = createDiscoveryNativeStrongholdFixture({ tech });
const before = discoveryNativeStrongholdPlayer(f.entry.before, f.owner);
const entered = discoveryNativeStrongholdPlayer(f.entry.after, f.owner);
const offer = viewGame(f.entry.before, f.owner).discoveryEntry!;
assert.deepEqual(offer.sources, [{ source: f.source, normal: 2, elite: 1 }]);
legalPolicies(f.entry.before, f.owner);
assert.equal(entered.forces['shrine:0'], 2);
assert.equal(entered.elites!.forces['shrine:0'], 1);
assert.equal(entered.forces[f.source], 1);
assert.equal(entered.elites!.forces[f.source] ?? 0, 0);
assert.equal(entered.reserves, before.reserves);
assert.equal(entered.elites!.reserves, before.elites!.reserves);
assert.equal(entered.spice, before.spice);
assert.equal(entered.shipped, before.shipped);
assert.equal(entered.moved, before.moved);
assert.equal(f.entry.after.discoveryEntry, undefined);
reject(f.entry.after, f.owner, f.entry.step.action);
const shipping = discoveryNativeStrongholdPlayer(f.shipment.before, f.owner);
const shipped = discoveryNativeStrongholdPlayer(f.shipment.after, f.owner);
assert.equal(gameDistance(f.shipment.before, f.source, 'shrine:0'), 1);
assert.equal(shipped.forces['shrine:0'], 4);
assert.equal(shipped.elites!.forces['shrine:0'], 2);
assert.equal(shipped.reserves, shipping.reserves - 2);
assert.equal(shipped.elites!.reserves, shipping.elites!.reserves - 1);
assert.equal(shipped.spice, shipping.spice - 2);
assert.equal(discoveryNativeStrongholdPlayer(f.shipment.after, f.opponent).spice,
discoveryNativeStrongholdPlayer(f.shipment.before, f.opponent).spice + 2);
assert.equal(shipped.forces[MOBILE_LOCATION], 6);
assert.equal(shipped.elites!.forces[MOBILE_LOCATION], 3);
assert.equal(shipped.shipped, true);
assert.deepEqual(f.shipment.after.strongholdCards, f.afterFirstMentat.strongholdCards);
reject(f.shipment.after, f.owner, f.shipment.step.action);
if (tech) {
assert.equal(f.shipment.after.techTokens!.heighliners.owner, f.owner);
assert.equal(f.shipment.after.techTokens!.heighliners.triggeredTurn, 2);
assert.equal(f.shipment.after.techTokens!.heighliners.spice, 1);
assert.equal(f.beforeBattle.techTokens!.heighliners.spice, 0);
assert.equal(discoveryNativeStrongholdPlayer(f.beforeBattle, f.owner).spice, shipped.spice + 1);
} else assert.equal(f.beforeBattle.techTokens, undefined);
assertDiscoveryNativeStrongholdCustody(f.shipment.after);
}
});
void test('original HMS declaration copies CURRENT Arrakeen control before plans; native CHOAM invoice includes bank support but pays no cash subsidy', () => {
const f = createDiscoveryNativeStrongholdFixture({ tech: true });
assert.equal(f.game.decision?.kind, 'strongholdCopy');
assert.equal(strongholdControllers(f.game.players, true).arrakeen, f.owner);
assert.ok(f.game.decision?.kind === 'strongholdCopy');
assert.deepEqual(f.game.decision.choices, ['arrakeen', 'sietch_tabr']);
assert.equal(f.game.strongholdCards!.owners.sietch_tabr, null, 'Current control offers a choice without fabricating retained card custody');
assert.equal(f.game.strongholdCards!.owners[MOBILE_STRONGHOLD], f.owner);
legalPolicies(f.game, f.owner);
reject(f.game, f.opponent, f.copyStep!.action);
reject(f.game, f.owner, { ...f.copyStep!.action, event: 'stale-original-declaration' });
reject(f.game, f.owner, { ...f.copyStep!.action, stronghold: 'carthag' });
const ready = prepareDiscoveryNativeStrongholdBattle(f);
assert.equal(viewGame(ready, f.owner).battle!.strongholdEffects[f.owner], 'arrakeen');
assert.equal(viewGame(ready, f.owner).battle!.ownForces!.normalFixedHalf, true);
const revealed = revealDiscoveryNativeStrongholdBattle(f);
assert.equal(discoveryNativeStrongholdPlayer(revealed, f.owner).spice,
discoveryNativeStrongholdPlayer(ready, f.owner).spice, 'Sealing the plan never credits spendable bank support');
const quote = quoteStrongholdFactionsBattle(revealed);
assert.equal(quote.winner, f.owner);
assert.deepEqual(quote.payments.find(p => p.player === f.owner), {
player: f.owner, cost: 1, ownPayment: 1, allyPayment: 0, donor: null, bankSupport: 2, freeByTraitor: false,
});
assert.deepEqual(quote.choamIncome, { owner: f.choam, amount: 1 });
assert.deepEqual(quote.casualties!.options, [{ normal: 0, elite: 3, paidNormal: 0, paidElite: 3 }]);
const done = finishDiscoveryNativeStrongholdBattle(revealed);
assert.equal(discoveryNativeStrongholdPlayer(done, f.owner).tanks,
discoveryNativeStrongholdPlayer(revealed, f.owner).tanks + 3);
assert.equal(discoveryNativeStrongholdPlayer(done, f.owner).elites!.tanks,
discoveryNativeStrongholdPlayer(revealed, f.owner).elites!.tanks + 3);
assert.equal(discoveryNativeStrongholdPlayer(done, f.owner).forces[MOBILE_LOCATION], 3);
assert.equal(discoveryNativeStrongholdPlayer(done, f.owner).elites!.forces[MOBILE_LOCATION] ?? 0, 0);
assert.equal(done.phase, 6, 'A real second conflict prevents unrelated Collection from contaminating this invoice');
assert.equal(done.lastBattleContext!.winner, f.owner);
assert.equal(discoveryNativeStrongholdPlayer(done, f.owner).spice,
discoveryNativeStrongholdPlayer(revealed, f.owner).spice - 1);
assert.equal(discoveryNativeStrongholdPlayer(done, f.choam).spice,
discoveryNativeStrongholdPlayer(revealed, f.choam).spice + 1);
assert.deepEqual(done.strongholdCards, f.afterFirstMentat.strongholdCards);
const expectedTech = structuredClone(f.beforeBattle.techTokens!);
assert.equal(Object.values(expectedTech).filter(t => t.owner === f.opponent).length, 1);
for (const token of Object.values(expectedTech)) if (token.owner === f.opponent) token.owner = f.owner;
assert.deepEqual(done.techTokens, expectedTech, 'Original winner takes the defeated seat’s one actual token only after battle cleanup');
assertDiscoveryNativeStrongholdCustody(done);
assert.deepEqual(normalizeAutomaticGame(discoveryNativeStrongholdReload(done)), done);
});
void test('physical E2 No-Field shipment/reveal at native nested Shrine does not import retained Carthag poison protection or parent armies', () => {
const f = createDiscoveryNativeStrongholdFixture({ kind: 'richese-nested-defense', tech: true });
assert.equal(f.parent, 'gara_kulon', 'Printed native placement is a sand parent, never an invented Carthag token');
const before = discoveryNativeStrongholdPlayer(f.shipment.before, f.owner);
const shipped = discoveryNativeStrongholdPlayer(f.shipment.after, f.owner);
const token = before.noField!.tokens.find(t => t.value === 5)!;
const originalTokens = structuredClone(before.noField!.tokens);
legalPolicies(f.shipment.before, f.owner);
assert.equal(shipped.spice, before.spice - 1);
assert.equal(discoveryNativeStrongholdPlayer(f.shipment.after, f.opponent).spice,
discoveryNativeStrongholdPlayer(f.shipment.before, f.opponent).spice + 1);
assert.deepEqual(shipped.forces, before.forces);
assert.equal(shipped.reserves, before.reserves);
assert.deepEqual(shipped.noField!.deployed!.location, { territory: 'shrine', sector: 0 });
assert.deepEqual(shipped.noField!.tokens, originalTokens);
const reveal = f.markerReveal!;
reject(reveal.before, f.opponent, reveal.step.action);
reject(reveal.before, f.owner, { ...reveal.step.action, event: before.noFieldEvent });
const materialized = discoveryNativeStrongholdPlayer(reveal.after, f.owner);
assert.equal(materialized.forces['shrine:0'], 5);
assert.equal(materialized.reserves, shipped.reserves - 5);
assert.equal(materialized.spice, shipped.spice);
assert.equal(materialized.noField!.deployed, null);
assert.equal(materialized.noField!.lastShipped, token.id);
assert.deepEqual(materialized.noField!.tokens, originalTokens);
reject(reveal.after, f.owner, reveal.step.action);
assert.equal(discoveryNativeStrongholdPlayer(f.beforeBattle, f.opponent).forces[f.source], 1);
assert.equal(f.beforeBattle.strongholdCards!.owners.carthag, f.owner);
const ready = prepareDiscoveryNativeStrongholdBattle(f);
assert.equal(viewGame(ready, f.owner).battle!.strongholdEffects[f.owner], null);
assert.equal(viewGame(ready, f.owner).battle!.ownForces!.normal, 5);
assert.equal(viewGame(ready, f.opponent).battle!.ownForces!.normal, 6, 'Parent-sector counter is not a nested fighter');
const revealed = revealDiscoveryNativeStrongholdBattle(f), quote = quoteStrongholdFactionsBattle(revealed);
assert.equal(quote.winner, f.opponent);
assert.equal(quote.strongholdIncome.length, 0);
const done = finishDiscoveryNativeStrongholdBattle(revealed);
assert.equal(discoveryNativeStrongholdPlayer(done, f.owner).leaders.find(l => l.id === f.leader)!.dead, true,
'A real Shield in Shrine does not gain the poison coverage of a retained Carthag card elsewhere');
assert.equal(discoveryNativeStrongholdPlayer(done, f.owner).tanks,
discoveryNativeStrongholdPlayer(revealed, f.owner).tanks + 5);
assert.equal(discoveryNativeStrongholdPlayer(done, f.owner).forces['shrine:0'], undefined);
assert.equal(discoveryNativeStrongholdPlayer(done, f.opponent).forces['shrine:0'], 6);
assert.equal(discoveryNativeStrongholdPlayer(done, f.opponent).forces[f.source], 1);
assert.equal(discoveryNativeStrongholdPlayer(done, f.opponent).spice,
discoveryNativeStrongholdPlayer(revealed, f.opponent).spice + quote.bounty!.amount);
assert.ok(done.discard.some(c => c.id === f.defense));
assert.ok(discoveryNativeStrongholdPlayer(done, f.opponent).hand.some(c => c.id === f.weapon));
const expectedTech = structuredClone(f.beforeBattle.techTokens!);
assert.equal(Object.values(expectedTech).filter(t => t.owner === f.owner).length, 1);
for (const token of Object.values(expectedTech)) if (token.owner === f.owner) token.owner = f.opponent;
assert.deepEqual(done.techTokens, expectedTech, 'Retained Carthag custody cannot divert the native loser’s Tech reward');
assert.deepEqual(done.strongholdCards, f.afterFirstMentat.strongholdCards);
assert.equal(done.phase, 6);
assertDiscoveryNativeStrongholdCustody(done);
assert.deepEqual(normalizeAutomaticGame(discoveryNativeStrongholdReload(done)), done);
});
@@ -0,0 +1,189 @@
import test from 'node:test';
import assert from 'node:assert/strict';
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 { createStrongholdCards } from '../game/stronghold-cards';
import { ownedTech } from '../game/tech-tokens';
import type { SukRescueOption } from '../game/suk-graduate';
import { quoteClassicSkillsStrongholdBattle } from './fixture-classic-skills-stronghold';
import { advanceClassicDiscoverySkills, classicDiscoverySkillsClean, createClassicDiscoverySkillsFixture,
settleClassicDiscoverySkillsArrival } from './fixture-discovery-classic-skills';
import { createDiscoverySkillsStrongholdsFixture, discoverySkillsStrongholdsPlayer as player,
finishDiscoverySkillsStrongholdsBattle, prepareDiscoverySkillsStrongholdsBattle,
revealDiscoverySkillsStrongholdsBattle } from './fixture-discovery-skills-strongholds';
function legalPolicies(game: Game, actor: string): void {
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, actor); view.players.find(seat => seat.id === actor)!.bot = difficulty;
const actions = botActions(view);
assert.ok(actions.length, `${difficulty} has an original owned continuation.`);
for (const action of actions) {
const after = applyAction(structuredClone(game), actor, action);
assert.equal(ordinaryTotal(after, actor), ordinaryTotal(game, actor),
`${difficulty}'s actual entry, paid shipment or plan preserves its physical force inventory.`);
}
}
}
function reject(game: Game, actor: string, action: Action): void {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
}
function ordinaryTotal(game: Game, actor: string): number {
const seat = player(game, actor);
return seat.reserves + seat.tanks + Object.values(seat.forces).reduce((sum, count) => sum + count, 0);
}
void test('authenticated Advanced original lobby and undealt offers retain identities, unused cards, real training and the first end-Mentat claim before free entry', () => {
const lobby = createGame('HUMANDISCOVERYSKILLS', newPlayer('human-guild', 'Guild', 'guild'), true);
joinGame(lobby, newPlayer('human-emperor', 'Emperor', 'emperor'));
joinGame(lobby, newPlayer('human-harkonnen', 'Harkonnen', 'harkonnen'));
const unchanged = structuredClone(lobby);
const fixture = createDiscoverySkillsStrongholdsFixture({ initial: lobby, tech: true });
assert.deepEqual(lobby, unchanged);
assert.deepEqual(fixture.setup.players.map(seat => seat.id), lobby.players.map(seat => seat.id));
assert.deepEqual(fixture.setup.strongholdCards, createStrongholdCards());
assert.equal(fixture.setup.leaderSkills!.deck.length + Object.values(fixture.setup.leaderSkills!.offers)
.reduce((sum, offer) => sum + offer.cards.length, 0), 14);
const claimed = applyAction(structuredClone(fixture.beforeFirstMentat), fixture.firstMentatStep.actor, fixture.firstMentatStep.action);
assert.equal(claimed.turn, 2);
assert.equal(claimed.strongholdCards!.owners.arrakeen, fixture.owner);
assert.equal(claimed.strongholdCards!.claimedTurn, 1);
const before = player(fixture.entryWindow, fixture.owner);
legalPolicies(fixture.entryWindow, fixture.owner);
reject(fixture.entryWindow, fixture.opponent, fixture.entryStep.action);
const entered = applyAction(structuredClone(fixture.entryWindow), fixture.entryStep.actor, fixture.entryStep.action);
assert.equal(player(entered, fixture.owner).forces['cistern:0'], 3);
assert.equal(player(entered, fixture.owner).forces[`${fixture.discovery.parent}:${fixture.discovery.parentSector}`] ?? 0, 0);
assert.equal(player(entered, fixture.owner).spice, before.spice);
assert.equal(player(entered, fixture.owner).reserves, before.reserves);
assert.equal(player(entered, fixture.owner).moved, before.moved);
assert.equal(player(entered, fixture.owner).shipped, before.shipped);
assert.equal(viewGame(entered, fixture.owner).leaderSkills!.assignments.find(assignment => assignment.owner === fixture.owner)!.leader, fixture.discovery.leader);
const admitted = createDiscoverySkillsStrongholdsFixture({ initial: fixture.setup, tech: true });
assert.deepEqual(admitted.setup, fixture.setup, 'An admitted original setup is never redealt.');
assert.equal(admitted.game.strongholdCards!.owners.arrakeen, fixture.owner);
});
void test('held Arrakeen support pays only the actual subsidy while trained physical Suk rescue precedes original winner cleanup and Tech reward for all four policies', () => {
const fixture = createDiscoverySkillsStrongholdsFixture({ tech: true, support: 3, ownerDial: 4,
ownerWeapon: 'projectile', ownerDefense: 'shield', opponentWeapon: 'projectile' });
const revealed = revealDiscoverySkillsStrongholdsBattle(fixture);
const quote = quoteClassicSkillsStrongholdBattle(revealed);
const payment = quote.payments.find(receipt => receipt.player === fixture.owner)!;
assert.equal(payment.bankSupport, 2); assert.equal(payment.ownPayment, 1);
assert.equal(quote.casualties!.options[0].normal, 5);
assert.equal(quote.sukGraduate?.mode, 'skilled');
const original = player(revealed, fixture.owner), enemy = player(revealed, fixture.opponent);
const token = ownedTech(revealed.techTokens, fixture.opponent); assert.equal(token.length, 1);
const rescue = finishDiscoverySkillsStrongholdsBattle(revealed, { stop: 'skill' });
assert.ok(rescue.decision?.kind === 'sukRescue');
assert.equal(player(rescue, fixture.owner).spice, original.spice - 1 + 3, 'Only actual own support is debited; the dead printed enemy disc pays its real bounty.');
assert.equal(player(rescue, fixture.opponent).tanks, enemy.tanks + 8);
assert.deepEqual(rescue.techTokens, revealed.techTokens);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(rescue, fixture.owner); view.players.find(seat => seat.id === fixture.owner)!.bot = difficulty;
const action = botActions(view).find(candidate => candidate.type === 'decision');
assert.ok(action && typeof action.choice === 'number');
const selected: SukRescueOption = rescue.decision.options[action.choice];
assert.equal(selected.normal, 3); assert.equal(selected.elite, 0); assert.equal(selected.kept?.key, fixture.key);
reject(rescue, fixture.opponent, action);
const cleanup = applyAction(structuredClone(rescue), fixture.owner, action);
assert.equal(player(cleanup, fixture.owner).forces[fixture.key], 4);
assert.equal(player(cleanup, fixture.owner).reserves, original.reserves + 2);
assert.equal(player(cleanup, fixture.owner).tanks, original.tanks + 2);
assert.equal(player(cleanup, fixture.owner).battleLosses, original.battleLosses + 2);
assert.equal(ordinaryTotal(cleanup, fixture.owner), ordinaryTotal(revealed, fixture.owner));
assert.equal(cleanup.decision?.kind, 'battleCards');
assert.deepEqual(cleanup.techTokens, revealed.techTokens);
const done = finishDiscoverySkillsStrongholdsBattle(cleanup);
assert.equal(done.techTokens![token[0]].owner, fixture.owner);
assert.deepEqual(ownedTech(done.techTokens, fixture.opponent), []);
assert.equal(done.lastBattleContext!.winner, fixture.owner);
assert.equal(done.lastBattleContext!.sukRescue!.completed, true);
assert.equal(viewGame(done, fixture.owner).leaderSkills!.assignments.find(assignment => assignment.owner === fixture.owner)!.faceUp, true);
}
});
void test('unheld Arrakeen supplies no subsidy; a held Arrakeen card never donates its territory-only bank benefit to the actual nested Cistern battle', () => {
for (const options of [{ holder: 'unclaimed' as const }, { nestedBattle: true }]) {
const fixture = createDiscoverySkillsStrongholdsFixture({ ...options, support: 2, ownerDial: 4,
ownerWeapon: 'projectile', ownerDefense: 'shield', opponentWeapon: 'projectile' });
const revealed = revealDiscoverySkillsStrongholdsBattle(fixture);
assert.equal(viewGame(revealed, fixture.owner).battle!.strongholdEffects[fixture.owner], null);
const quote = quoteClassicSkillsStrongholdBattle(revealed);
const payment = quote.payments.find(receipt => receipt.player === fixture.owner)!;
assert.equal(payment.bankSupport, 0); assert.equal(payment.ownPayment, 2);
const rescue = finishDiscoverySkillsStrongholdsBattle(revealed, { stop: 'skill' });
assert.equal(player(rescue, fixture.owner).spice, player(revealed, fixture.owner).spice - 2 + 3);
assert.equal(rescue.lastBattleContext!.territory, options.nestedBattle ? 'cistern' : 'arrakeen');
assert.equal(rescue.strongholdCards!.owners.arrakeen, options.nestedBattle ? fixture.owner : null);
}
});
void test('held Carthag actual Shield protects the normal Prana Bindu-trained disc and its matching physical defense bonus wins; unheld poison still kills', () => {
for (const holder of ['owner', 'unclaimed'] as const) {
const fixture = createDiscoverySkillsStrongholdsFixture({ kind: 'carthag', holder, skill: 'prana-bindu-adept', hide: false,
ownerDial: 0.5, support: 0, opponentDial: 1, opponentSupport: 1, ownerDefense: 'shield', opponentWeapon: 'poison' });
const revealed = revealDiscoverySkillsStrongholdsBattle(fixture);
const quote = quoteClassicSkillsStrongholdBattle(revealed);
assert.equal(quote.winner, holder === 'owner' ? fixture.owner : fixture.opponent);
const cleanup = finishDiscoverySkillsStrongholdsBattle(revealed, { stop: 'cleanup' });
assert.equal(player(cleanup, fixture.owner).leaders.find(leader => leader.id === fixture.leader)!.dead, holder !== 'owner');
if (holder === 'owner') {
const bonus = quote.leaderSkillBonuses[revealed.battle!.attacker === fixture.owner ? 'attacker' : 'defender'];
assert.deepEqual(bonus.applied, [{ skill: 'prana-bindu-adept', amount: 1, mode: 'normal' }]);
assert.equal(player(cleanup, fixture.owner).spice, player(revealed, fixture.owner).spice);
}
}
});
void test('held Sietch Tabr victory income and sole Cistern Collection remain separate; Tabr creates no city Collection payment', () => {
const fixture = createDiscoverySkillsStrongholdsFixture({ kind: 'sietch_tabr', skill: 'planetologist', ownerDial: 2, support: 2,
opponentDial: 1, opponentSupport: 1, ownerDefense: 'shield' });
const revealed = revealDiscoverySkillsStrongholdsBattle(fixture), quote = quoteClassicSkillsStrongholdBattle(revealed);
assert.equal(quote.winner, fixture.owner);
assert.deepEqual(quote.strongholdIncome, [{ player: fixture.owner, amount: 1 }]);
const cleanup = finishDiscoverySkillsStrongholdsBattle(revealed, { stop: 'cleanup' });
const before = player(revealed, fixture.owner).spice;
assert.equal(player(cleanup, fixture.owner).spice, before - 2 + 1);
const collected = advanceClassicDiscoverySkills(finishDiscoverySkillsStrongholdsBattle(cleanup), state => state.phase === 7 && classicDiscoverySkillsClean(state));
assert.equal(player(collected, fixture.owner).forces['cistern:0'], 3);
assert.equal(player(collected, fixture.owner).forces[fixture.key], 6);
assert.equal(player(collected, fixture.owner).spice, before - 2 + 1 + 2,
'Only actual Tabr battle income and sole Cistern Collection pay; Tabr is not an income-paying city.');
const nextTurn = advanceClassicDiscoverySkills(collected, state => state.turn === 3);
assert.equal(nextTurn.strongholdCards!.claimedTurn, 2, 'The next real end-Mentat must accept the actually entered nested force keys.');
assert.equal(nextTurn.strongholdCards!.owners.sietch_tabr, fixture.owner);
assert.equal(player(nextTurn, fixture.owner).forces['cistern:0'], 3);
});
void test('real held-card nested shipment charges one spice, does not consume free-entry movement, and has four legal native policies', () => {
const fixture = createDiscoverySkillsStrongholdsFixture();
const window = fixture.shipmentWindow, before = player(window, fixture.owner);
legalPolicies(window, fixture.owner);
let shipped = applyAction(structuredClone(window), fixture.paidShipmentStep.actor, fixture.paidShipmentStep.action);
shipped = settleClassicDiscoverySkillsArrival(shipped);
assert.equal(player(shipped, fixture.owner).spice, before.spice - 1);
assert.equal(player(shipped, fixture.owner).reserves, before.reserves - 1);
assert.equal(player(shipped, fixture.owner).forces['cistern:0'], 4);
assert.equal(player(shipped, fixture.owner).moved, before.moved);
assert.equal(player(shipped, fixture.owner).shipped, true);
assert.equal(shipped.strongholdCards!.owners.arrakeen, fixture.owner);
const prepared = prepareDiscoverySkillsStrongholdsBattle(fixture);
legalPolicies(prepared, fixture.owner);
});
void test('selected canonical unused cards are retained while Basic admission and injected held receipts remain outside original controls', () => {
const original = createClassicDiscoverySkillsFixture({ advanced: true, strongholdCards: true });
assert.deepEqual(original.setup.strongholdCards, createStrongholdCards());
const actualOffers = structuredClone(original.setup);
const resumed = createDiscoverySkillsStrongholdsFixture({ initial: actualOffers });
assert.deepEqual(actualOffers, original.setup, 'The authenticated original offers remain untouched.');
assert.deepEqual(resumed.setup, original.setup);
assert.equal(resumed.game.leaderSkills!.assignments.find(assignment => assignment.owner === resumed.owner)!.skill, 'planetologist',
'Human setup continues its actual available offer, not a demanded Suk replacement.');
assert.throws(() => createClassicDiscoverySkillsFixture({ strongholdCards: true }));
const forged = structuredClone(original.setup); forged.strongholdCards!.owners.arrakeen = original.collector;
assert.throws(() => createClassicDiscoverySkillsFixture({ initial: forged }));
});
+17
View File
@@ -212,3 +212,20 @@ void test('Actual Stronghold initializer excludes Basic, E1/E2 mixtures, native
for (const player of pair.players) pair = applyAction(pair, player.id, { type: 'ready' });
assert.throws(() => initializeStrongholdFactionsGameForAudit(pair));
});
for (const lead of ['ecaz', 'ally'] as const)
void test(`live original Discovery coalition preserves ${lead} lead support, physical allied losses and held-card custody`, () => {
const fixture = ecazStrongholdFixture({ discoveries: true, tech: true });
const { game, afterBattle, before, actor, quote } = reveal(fixture, lead, { variable: 3 });
const payment = quote.payments.find(receipt => receipt.player === actor)!;
assert.equal(payment.bankSupport, lead === 'ecaz' ? 2 : 0);
assert.equal(payment.ownPayment, lead === 'ecaz' ? 1 : 3);
assert.equal(seat(before, actor).spice - seat(afterBattle, actor).spice, payment.ownPayment);
assert.equal(seat(game, fixture.ecaz).forces[fixture.location], 2);
assert.equal(seat(game, fixture.ecaz).tanks - seat(before, fixture.ecaz).tanks, 3);
assert.equal(seat(game, fixture.ally).forces[fixture.location], 1);
assert.equal(seat(game, fixture.ally).tanks - seat(before, fixture.ally).tanks, 3);
assert.equal(game.lastBattleContext!.winner, actor);
assert.equal(game.strongholdCards!.owners.arrakeen, fixture.ecaz);
assert.deepEqual(game.discoveries, before.discoveries);
});
+19 -5
View File
@@ -8,6 +8,7 @@ import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-car
import { DISCOVERY_CARD_PLACEMENTS, DISCOVERY_TOKEN_BY_ID, type DiscoveryTokenFace } from '../game/discoveries';
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
import { nextSpiceBankerIncomeNativeStep } from './fixture-spice-banker-income';
import { createStrongholdCards } from '../game/stronghold-cards';
export type ClassicDiscoverySkillsStep = { actor: string; action: Action };
export type ClassicDiscoverySkillsOptions = {
@@ -15,6 +16,8 @@ export type ClassicDiscoverySkillsOptions = {
initial?: Game;
advanced?: boolean;
tech?: boolean;
/** Preserve the selected, still-unused original Advanced module at setup. */
strongholdCards?: boolean;
seats?: 2 | 3 | 4 | 5 | 6;
collector?: 'guild' | 'harkonnen';
skill?: LeaderSkillId;
@@ -118,7 +121,15 @@ export function createClassicDiscoverySkillsFixture(options: ClassicDiscoverySki
assert.ok(game.status === 'lobby' || (game.status === 'setup' && game.turn === 1 && game.phase === 0));
const controlledOffers = game.status === 'lobby';
assert.deepEqual(game.expansions, []);
assert.ok(!game.homeworlds && !game.nexusCards && !game.strongholdCards && !game.spiceBankerIncomePreview);
assert.ok(!game.homeworlds && !game.nexusCards && !game.spiceBankerIncomePreview);
if (options.strongholdCards || game.strongholdCards) {
assert.equal(game.advanced, true);
if (!game.strongholdCards) {
assert.equal(game.status, 'lobby', 'Strongholds must be selected before the original deal.');
game = applyAction(game, game.host, { type: 'strongholdCards', enabled: true });
}
assert.deepEqual(game.strongholdCards, createStrongholdCards(), 'Never accept an earned or injected held-card receipt.');
}
const collector = game.players.find(player => player.faction === collectorFaction)!.id;
const opponent = game.players.find(player => player.faction === 'emperor')!.id;
const skillOwner = game.players.find(player => player.faction === (options.skillOwner ?? collectorFaction))!.id;
@@ -181,11 +192,14 @@ export function createClassicDiscoverySkillsFixture(options: ClassicDiscoverySki
return { initial, setup, game, actions, staging, collector, opponent, leader,
token: token.id, face, parent: placement.territory, parentSector: placement.sector };
}
export function revealClassicDiscoverySkills(fixture: ClassicDiscoverySkillsFixture): Game {
export function revealClassicDiscoverySkills(fixture: ClassicDiscoverySkillsFixture, actions?: ClassicDiscoverySkillsStep[]): Game {
let game = fixture.game;
if (viewGame(game, fixture.collector).discoveries!.canInspect.some(token => token === fixture.token))
game = applyAction(game, fixture.collector, { type: 'discovery', token: fixture.token, reveal: false });
return applyAction(game, fixture.collector, { type: 'discovery', token: fixture.token, reveal: true });
if (viewGame(game, fixture.collector).discoveries!.canInspect.some(token => token === fixture.token)) {
const inspect: ClassicDiscoverySkillsStep = { actor: fixture.collector, action: { type: 'discovery', token: fixture.token, reveal: false } };
actions?.push(inspect); game = applyAction(game, inspect.actor, inspect.action);
}
const reveal: ClassicDiscoverySkillsStep = { actor: fixture.collector, action: { type: 'discovery', token: fixture.token, reveal: true } };
actions?.push(reveal); return applyAction(game, reveal.actor, reveal.action);
}
export function classicDiscoverySkillsEntryWindow(fixture: ClassicDiscoverySkillsFixture): Game {
return advanceClassicDiscoverySkills(revealClassicDiscoverySkills(fixture), game =>
@@ -0,0 +1,318 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, handLimit, initializeDiscoveryGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game, type Player,
} from '../game/engine';
import { MOBILE_LOCATION, MOBILE_STRONGHOLD, gameTerritories, territory } from '../game/board';
import { isAuditorLeader, treacheryDeck, type Card } from '../game/cards';
import { DISCOVERY_CARD_PLACEMENTS, validateDiscoveryState } from '../game/discoveries';
import { richeseCards } from '../game/richese-cards';
import { validateRicheseNoField } from '../game/richese-no-field';
import { createStrongholdCards, strongholdControllers } from '../game/stronghold-cards';
import { traitorDeck } from '../game/traitors';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
import { nextDiscoveryNativeMarkerStep } from './fixture-discovery-native-marker';
export type DiscoveryNativeStrongholdStep = { actor: string; action: Action };
export type DiscoveryNativeStrongholdKind = 'ix-hms-invoice' | 'richese-nested-defense';
export type DiscoveryNativeStrongholdOptions = {
/** Authenticated fresh lobby or original undealt Discovery/Stronghold audit setup. */
initial?: Game;
kind?: DiscoveryNativeStrongholdKind;
tech?: boolean;
};
export type DiscoveryNativeStrongholdFixture = {
initial: Game;
setup: Game;
afterSetup: Game;
beforeBlow: Game;
afterBlow: Game;
beforeReveal: Game;
afterReveal: Game;
beforeFirstMentat: Game;
firstMentatStep: DiscoveryNativeStrongholdStep;
afterFirstMentat: Game;
entry: { before: Game; step: DiscoveryNativeStrongholdStep; after: Game };
shipment: { before: Game; step: DiscoveryNativeStrongholdStep; after: Game };
markerReveal: { before: Game; step: DiscoveryNativeStrongholdStep; after: Game } | null;
beforeBattle: Game;
battleStep: DiscoveryNativeStrongholdStep;
copyStep: DiscoveryNativeStrongholdStep | null;
game: Game;
kind: DiscoveryNativeStrongholdKind;
owner: string;
collector: string;
opponent: string;
choam: string;
token: string;
parent: string;
source: string;
location: string;
leader: string;
opponentLeader: string;
defense: string | null;
weapon: string | null;
plans: DiscoveryNativeStrongholdStep[];
actions: DiscoveryNativeStrongholdStep[];
staging: string[];
};
export const discoveryNativeStrongholdReload = (game: Game): Game => JSON.parse(JSON.stringify(game));
export function discoveryNativeStrongholdPlayer(game: Game, actor: string): Player {
const p = game.players.find(player => player.id === actor); assert.ok(p); return p;
}
export function assertDiscoveryNativeStrongholdCustody(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 ?? [])];
assert.deepEqual(cards.map(c => c.id).sort(), [...treacheryDeck(game.expansions),
...(game.players.some(p => p.faction === 'richese') ? richeseCards() : [])].map(c => c.id).sort());
assert.equal(new Set(cards.map(c => c.id)).size, cards.length);
assert.deepEqual([...(game.traitorReserve ?? []), ...game.players.flatMap(p => [
...p.traitors, ...p.traitorChoices, ...(p.faceDancers ?? []).map(c => c.leader),
])].sort(), traitorDeck(game.players, game.expansions.includes('ix')).sort());
for (const p of game.players) {
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0), 20, p.faction);
if (p.elites) {
assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((a, b) => a + b, 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!);
}
export const discoveryNativeStrongholdClean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
/** Existing native policies; leave declarations and unsealed plans to the caller. */
export function nextDiscoveryNativeStrongholdStep(game: Game): DiscoveryNativeStrongholdStep | null {
if (game.decision?.kind === 'discoveryEntry') return { actor: game.decision.player,
action: { type: 'decision', event: game.decision.event, accept: false } };
if (game.decision?.kind === 'techToken') return { actor: game.decision.player,
action: { type: 'decision', token: game.decision.choices[0] } };
if (game.decision?.kind === 'strongholdCopy' || game.decision?.kind === 'faceDance') return null;
if (game.battle) return nextStrongholdFactionsNativeStep(game);
return game.players.some(p => p.faction === 'richese')
? nextDiscoveryNativeMarkerStep(game) : nextStrongholdFactionsNativeStep(game);
}
function entropy<T>(operation: () => T, scalar?: number): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const original = crypto.getRandomValues.bind(crypto);
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array) array.fill(scalar ?? 0xffffffff);
else Reflect.apply(original, crypto, [array]);
return array;
};
try { return operation(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
function act(game: Game, step: DiscoveryNativeStrongholdStep, actions?: DiscoveryNativeStrongholdStep[], scalar?: number): Game {
actions?.push(structuredClone(step));
return entropy(() => applyAction(game, step.actor, step.action), scalar);
}
export function advanceDiscoveryNativeStronghold(state: Game, until: (g: Game) => boolean,
actions?: DiscoveryNativeStrongholdStep[], scalar?: number): Game {
let game = state;
for (let n = 0; n < 1800; n++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const step = nextDiscoveryNativeStrongholdStep(game); assert.ok(step, 'A real human declaration or plan is required');
game = act(game, step, actions, scalar);
}
throw Error('Native Discovery/Stronghold policy did not reach its requested original window');
}
function place(game: Game, actor: string, location: string, amount: number, elite = 0): void {
const p = discoveryNativeStrongholdPlayer(game, actor);
assert.ok(p.reserves - (p.elites?.reserves ?? 0) >= amount - elite && amount >= elite);
assert.ok((p.elites?.reserves ?? 0) >= elite);
p.reserves -= amount; p.forces[location] = (p.forces[location] ?? 0) + amount;
if (elite) { p.elites!.reserves -= elite; p.elites!.forces[location] = (p.elites!.forces[location] ?? 0) + elite; }
}
function reserveOtherLocations(game: Game, kept: string[]): void {
for (const p of game.players) for (const [key, amount] of Object.entries(p.forces)) {
if (kept.includes(key)) continue;
p.reserves += amount; delete p.forces[key];
if (p.elites) { p.elites.reserves += p.elites.forces[key] ?? 0; delete p.elites.forces[key]; }
}
}
function held(game: Game, actor: string, kind: Card['kind'], staging: string[]): string {
const p = discoveryNativeStrongholdPlayer(game, actor), owned = p.hand.find(c => c.kind === kind);
if (owned) return owned.id;
const index = game.deck.findIndex(c => c.kind === kind);
assert.ok(index >= 0 && p.hand.length < handLimit(p));
const card = game.deck.splice(index, 1)[0]; p.hand.push(card);
staging.push(`Conserved ${card.id}: original undealt native deck → ${actor}'s hand.`);
return card.id;
}
/** Original Advanced E1+E2 or E2 setup, printed Hagga Basin blow, actual
* inspection/reveal, first END Mentat claim, original free entry and paid shipment.
* Entropy controls only original deals/remainder shuffles and the physical token
* lottery. No held-card owner, paid continuation, phase or timestamp is forged. */
export function createDiscoveryNativeStrongholdFixture(options: DiscoveryNativeStrongholdOptions = {}): DiscoveryNativeStrongholdFixture {
const kind = options.kind ?? 'ix-hms-invoice', ix = kind === 'ix-hms-invoice';
const factions = ix ? ['ixians', 'choam', 'guild'] as const : ['richese', 'choam', 'guild'] as const;
const expansions = ix ? ['ix', 'choam'] : ['choam'];
let game = options.initial ? structuredClone(options.initial)
: createGame('DISCOVERYNATIVEHELD', newPlayer(factions[0], factions[0], factions[0]), true, expansions);
if (!options.initial) for (const faction of factions.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
assert.ok(game.status === 'lobby' || game.status === 'setup' && game.turn === 1 && game.phase === 0 && game.players.every(p => p.hand.length === 0));
assert.equal(game.advanced, true); assert.deepEqual([...game.expansions].sort(), [...expansions].sort());
assert.deepEqual(game.players.map(p => p.faction).sort(), [...factions].sort());
assert.ok(!game.leaderSkills && !game.nexusCards && !game.homeworlds);
if (game.status === 'lobby') {
if (options.tech && !game.techTokens) game = applyAction(game, game.host, { type: 'techTokens', enabled: true });
game.discoveryEnabled = true;
if (!game.strongholdCards) game.strongholdCards = createStrongholdCards();
for (const p of game.players) if (!p.ready) game = applyAction(game, p.id, { type: 'ready' });
}
const initial = structuredClone(game);
if (game.status === 'lobby') game = entropy(() => initializeDiscoveryGameForAudit(game));
const setup = structuredClone(game);
assert.equal(game.strongholdCards!.claimedTurn, 0);
assert.ok(Object.values(game.strongholdCards!.owners).every(owner => owner === null));
const actions: DiscoveryNativeStrongholdStep[] = [], staging = [
'Controlled only original native deal/remainder entropy; selected the actual Shrine token placement lottery.',
'Reordered four original unplayed Spice Cards: printed Hagga Basin Discovery, then three ordinary blows.',
];
game = advanceDiscoveryNativeStronghold(game, g => g.status === 'playing', actions);
const afterSetup = structuredClone(game);
const owner = game.players.find(p => p.faction === factions[0])!.id;
const opponent = game.players.find(p => p.faction === 'guild')!.id;
const choam = game.players.find(p => p.faction === 'choam')!.id, collector = ix ? owner : opponent;
const printed = 'discovery-hagga-basin', placement = DISCOVERY_CARD_PLACEMENTS[printed];
const source = `${placement.territory}:${placement.sector}`;
const cardIndex = game.spiceDeck.findIndex(c => 'territory' in c && c.discovery === printed); assert.ok(cardIndex >= 0);
game.spiceDeck.unshift(game.spiceDeck.splice(cardIndex, 1)[0]);
for (let position = 1; position < 4; position++) {
const index = game.spiceDeck.findIndex((c, i) => i >= position && 'territory' in c && !c.discovery);
assert.ok(index >= position); game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
}
game = advanceDiscoveryNativeStronghold(game, g => g.phase === 1 && discoveryNativeStrongholdClean(g), actions);
// Actual blow destroys conserved typed counters in its PRINTED territory;
// placement is the separate printed Gara Kulon destination, not the blow site.
const blowLocation = 'hagga_basin:12';
place(game, owner, blowLocation, 2, ix ? 1 : 0);
staging.push(`Conserved original reserves → ${blowLocation}: two ${factions[0]} counters${ix ? ', including one Cyborg' : ''} before the actual blow.`);
const beforeBlow = structuredClone(game);
const eligible = game.discoveries!.tokens.filter(t => t.status === 'supply' && t.type === 'hiereg');
const selected = eligible.findIndex(t => t.face === 'shrine'); assert.ok(selected >= 0);
game = advanceDiscoveryNativeStronghold(game,
g => g.discoveries!.tokens.some(t => t.face === 'shrine' && t.status === 'placed'), actions,
Math.floor((selected + 0.5) / eligible.length * 0x100000000));
const afterBlow = structuredClone(game), token = game.discoveries!.tokens.find(t => t.face === 'shrine')!;
game = advanceDiscoveryNativeStronghold(game, g => g.phase === 5 && g.active === collector && discoveryNativeStrongholdClean(g), actions);
game = act(game, { actor: collector, action: { type: 'ship', territory: placement.territory,
sector: placement.sector, amount: ix ? 3 : 1, elite: ix ? 1 : 0, allyPayment: 0 } }, actions);
game = advanceDiscoveryNativeStronghold(game, g => g.phase === 7 && discoveryNativeStrongholdClean(g), actions);
const beforeReveal = structuredClone(game);
assert.ok(viewGame(game, collector).discoveries!.canInspect.includes(token.id));
game = act(game, { actor: collector, action: { type: 'discovery', token: token.id, reveal: false } }, actions);
game = act(game, { actor: collector, action: { type: 'discovery', token: token.id, reveal: true } }, actions);
const afterReveal = structuredClone(game);
game = advanceDiscoveryNativeStronghold(game, g => g.phase === 8 && discoveryNativeStrongholdClean(g), actions);
reserveOtherLocations(game, [source, ...(ix ? [MOBILE_LOCATION] : [])]);
const claim = ix ? 'arrakeen' : 'carthag';
place(game, owner, `${claim}:${territory(claim).sectors[0]}`, 1);
staging.push('Before actual first END Mentat: return unrelated original board counters to their own reserves, preserve native HMS/Discovery garrisons, and place one conserved claim counter.');
let beforeFirstMentat: Game | undefined, firstMentatStep: DiscoveryNativeStrongholdStep | undefined;
for (let n = 0; game.turn === 1 && n < 100; n++) {
const step = nextDiscoveryNativeStrongholdStep(game); assert.ok(step);
const before = structuredClone(game); game = act(game, step, actions);
if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = step; }
}
assert.ok(beforeFirstMentat && firstMentatStep);
const afterFirstMentat = structuredClone(game);
assert.equal(game.strongholdCards!.owners[claim], owner);
assert.deepEqual(game.strongholdCards!.owners, strongholdControllers(beforeFirstMentat.players, ix));
game = advanceDiscoveryNativeStronghold(game, g => g.decision?.kind === 'discoveryEntry', actions);
assert.equal(game.decision!.player, collector);
const entryBefore = structuredClone(game), offer = viewGame(game, collector).discoveryEntry!;
const entryStep: DiscoveryNativeStrongholdStep = { actor: collector, action: ix
? { type: 'decision', event: offer.event, accept: true, groups: [{ source, normal: 1, elite: 1 }] }
: { type: 'decision', event: offer.event, accept: false } };
game = act(game, entryStep, actions);
game = advanceDiscoveryNativeStronghold(game, g => !g.discoveryEntry && !g.decision && !g.response, actions);
const entry = { before: entryBefore, step: entryStep, after: structuredClone(game) };
game = advanceDiscoveryNativeStronghold(game, g => g.phase === 5 && g.active === owner && discoveryNativeStrongholdClean(g), actions);
const shipmentBefore = structuredClone(game), player = discoveryNativeStrongholdPlayer(game, owner);
const noField = player.noField?.tokens.find(t => t.value === 5);
const shipmentStep: DiscoveryNativeStrongholdStep = { actor: owner, action: ix
? { type: 'ship', territory: 'shrine', sector: 0, amount: 2, elite: 1, allyPayment: 0 }
: { type: 'ship', territory: 'shrine', sector: 0, noField: noField!.id, event: player.noFieldEvent, allyPayment: 0 } };
game = act(game, shipmentStep, actions);
game = advanceDiscoveryNativeStronghold(game, g => !g.pendingShipment && discoveryNativeStrongholdClean(g), actions);
const shipment = { before: shipmentBefore, step: shipmentStep, after: structuredClone(game) };
let markerReveal: DiscoveryNativeStrongholdFixture['markerReveal'] = null;
if (!ix) {
const before = structuredClone(game);
const step: DiscoveryNativeStrongholdStep = { actor: owner, action: { type: 'revealNoField',
token: noField!.id, event: discoveryNativeStrongholdPlayer(game, owner).noFieldEvent } };
game = act(game, step, actions); markerReveal = { before, step, after: structuredClone(game) };
}
if (ix) {
place(game, owner, `sietch_tabr:${territory('sietch_tabr').sectors[0]}`, 1);
staging.push('After earned first-Mentat custody: one conserved Ixian reserve counter → Sietch Tabr, giving a second CURRENT copy choice without claiming its unheld card.');
}
const location = ix ? MOBILE_LOCATION : 'shrine:0', battleTerritory = ix ? MOBILE_STRONGHOLD : 'shrine';
place(game, opponent, location, 6);
// A second actual conflict keeps Battle open so bank/Tech custody can be
// inspected before unrelated Collection or the next Mentat checkpoint.
const other = gameTerritories(game).find(t => t.type === 'sand' && t.id !== placement.territory && t.sectors.some(s => s >= 9));
assert.ok(other); const otherLocation = `${other.id}:${other.sectors.find(s => s >= 9)!}`;
place(game, owner, otherLocation, 1); place(game, opponent, otherLocation, 1);
staging.push(`Conserved opponent reserve counters → ${location} (six); one counter per combatant → ${otherLocation} for a second original conflict. Retain earned first-Mentat cards and all paid/free arrivals.`);
const defense = !ix ? held(game, owner, 'shield', staging) : null;
const weapon = !ix ? held(game, opponent, 'poison', staging) : null;
const leader = discoveryNativeStrongholdPlayer(game, owner).leaders.filter(l => !l.dead && !isAuditorLeader(l)).sort((a, b) => b.strength - a.strength)[0];
const opponentLeader = discoveryNativeStrongholdPlayer(game, opponent).leaders.filter(l => !l.dead && !isAuditorLeader(l)).sort((a, b) => a.strength - b.strength)[0];
assert.ok(leader && opponentLeader);
game = advanceDiscoveryNativeStronghold(game, g => g.phase === 6 && discoveryNativeStrongholdClean(g), actions);
const beforeBattle = structuredClone(game), actor = game.active!;
assert.ok(actor === owner || actor === opponent);
const battleStep: DiscoveryNativeStrongholdStep = { actor, action: { type: 'chooseBattle', territory: battleTerritory, target: actor === owner ? opponent : owner } };
game = act(game, battleStep, actions);
for (let n = 0; n < 120; n++) {
if (game.decision?.kind === 'strongholdCopy') break;
const step = nextDiscoveryNativeStrongholdStep(game); if (!step) break;
game = act(game, step, actions);
}
const copyStep: DiscoveryNativeStrongholdStep | null = game.decision?.kind === 'strongholdCopy'
? { actor: owner, action: { type: 'decision', event: game.decision.event, stronghold: 'arrakeen' } } : null;
assert.equal(Boolean(copyStep), ix);
const plans: DiscoveryNativeStrongholdStep[] = [
{ actor: owner, action: { type: 'battlePlan', leader: leader.id, dial: ix ? 6 : 0, support: ix ? 3 : 0, weapon: null, defense } },
{ actor: opponent, action: { type: 'battlePlan', leader: opponentLeader.id, dial: 0, support: 0, weapon, defense: null } },
];
assertDiscoveryNativeStrongholdCustody(game);
return { initial, setup, afterSetup, beforeBlow, afterBlow, beforeReveal, afterReveal,
beforeFirstMentat, firstMentatStep, afterFirstMentat, entry, shipment, markerReveal,
beforeBattle, battleStep, copyStep, game, kind, owner, collector, opponent, choam,
token: token.id, parent: placement.territory, source, location, leader: leader.id,
opponentLeader: opponentLeader.id, defense, weapon, plans, actions, staging };
}
export function prepareDiscoveryNativeStrongholdBattle(fixture: DiscoveryNativeStrongholdFixture): Game {
let game = discoveryNativeStrongholdReload(fixture.game);
if (fixture.copyStep) game = act(game, fixture.copyStep);
for (let n = 0; n < 120; n++) {
const step = nextDiscoveryNativeStrongholdStep(game);
if (!step) { assert.ok(game.battle && !game.battle.revealed); return game; }
game = act(game, step);
}
throw Error('Original native pre-plan window did not close');
}
export function revealDiscoveryNativeStrongholdBattle(fixture: DiscoveryNativeStrongholdFixture): Game {
let game = prepareDiscoveryNativeStrongholdBattle(fixture);
for (const plan of fixture.plans) {
game = act(game, plan);
while (game.response || game.decision?.kind === 'fullPlanOffer') {
const next = nextDiscoveryNativeStrongholdStep(game); assert.ok(next); game = act(game, next);
}
}
assert.ok(game.battle?.revealed); return game;
}
export function finishDiscoveryNativeStrongholdBattle(state: Game): Game {
return advanceDiscoveryNativeStronghold(state, g => !g.battle && !g.decision && !g.response && !g.pendingTreacheryDiscard);
}
@@ -0,0 +1,198 @@
import assert from 'node:assert/strict';
import { applyAction, handLimit, viewGame, type Action, type Game, type Player } from '../game/engine';
import { territory } from '../game/board';
import type { Card } from '../game/cards';
import type { LeaderSkillId } from '../game/leader-skill-cards';
import { createStrongholdCards, strongholdControllers, type StrongholdId } from '../game/stronghold-cards';
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
import {
advanceClassicDiscoverySkills, classicDiscoverySkillsClean, createClassicDiscoverySkillsFixture,
nextClassicDiscoverySkillsStep, revealClassicDiscoverySkills,
type ClassicDiscoverySkillsFixture, type ClassicDiscoverySkillsOptions,
} from './fixture-discovery-classic-skills';
import { finishClassicSkillsStrongholdBattle, nextClassicSkillsStrongholdStep } from './fixture-classic-skills-stronghold';
export type DiscoverySkillsStrongholdsStep = { actor: string; action: Action };
export type DiscoverySkillsStrongholdsOptions = {
/** Fresh authenticated lobby or original undealt setup, never a played save. */
initial?: Game;
tech?: boolean;
kind?: Exclude<StrongholdId, 'hidden_mobile_stronghold'>;
holder?: 'owner' | 'unclaimed';
skill?: LeaderSkillId;
hide?: boolean;
nestedBattle?: boolean;
ownerDial?: number;
support?: number;
opponentDial?: number;
opponentSupport?: number;
ownerWeapon?: Card['kind'];
ownerDefense?: Card['kind'];
opponentWeapon?: Card['kind'];
opponentDefense?: Card['kind'];
skillOwner?: ClassicDiscoverySkillsOptions['skillOwner'];
};
export type DiscoverySkillsStrongholdsFixture = {
discovery: ClassicDiscoverySkillsFixture;
initial: Game;
setup: Game;
beforeFirstMentat: Game;
firstMentatStep: DiscoverySkillsStrongholdsStep;
entryWindow: Game;
entryStep: DiscoverySkillsStrongholdsStep;
entered: Game;
shipmentWindow: Game;
paidShipmentStep: DiscoverySkillsStrongholdsStep;
game: Game;
owner: string;
opponent: string;
leader: string;
kind: StrongholdId;
key: string;
hide: boolean;
actions: DiscoverySkillsStrongholdsStep[];
planActions: DiscoverySkillsStrongholdsStep[];
staging: string[];
};
export function discoverySkillsStrongholdsPlayer(game: Game, actor: string): Player {
const player = game.players.find(candidate => candidate.id === actor);
assert.ok(player); return player;
}
function reserveExcept(game: Game, keep: string[]): void {
for (const player of game.players) for (const key of Object.keys(player.forces)) {
if (keep.includes(key)) continue;
const elite = player.elites?.forces[key] ?? 0;
player.reserves += player.forces[key];
if (player.elites) { player.elites.reserves += elite; delete player.elites.forces[key]; }
delete player.forces[key];
if (player.advisors) delete player.advisors[key.split(':')[0]];
}
}
function place(game: Game, actor: string, key: string, amount: number): void {
const player = discoverySkillsStrongholdsPlayer(game, actor);
assert.ok(player.reserves - (player.elites?.reserves ?? 0) >= amount);
player.reserves -= amount; player.forces[key] = (player.forces[key] ?? 0) + amount;
}
function held(game: Game, actor: string, matches: (card: Card) => boolean, staging: string[], used: string[] = []): Card {
const player = discoverySkillsStrongholdsPlayer(game, actor);
const match = (card: Card) => matches(card) && !used.includes(card.id);
const existing = player.hand.find(match); if (existing) return existing;
assert.ok(player.hand.length < handLimit(player));
const index = game.deck.findIndex(match);
const donor = index < 0 ? game.players.find(candidate => candidate.id !== actor && candidate.hand.some(match)) : undefined;
assert.ok(index >= 0 || donor, 'Original base33 card custody must supply the staged card.');
const card = index >= 0 ? game.deck.splice(index, 1)[0] : donor!.hand.splice(donor!.hand.findIndex(match), 1)[0];
player.hand.push(card);
staging.push(`Conserved ${card.id}: ${donor?.id ?? 'original deck'} custody to ${actor}'s hand.`);
return card;
}
/** Exact human sequence: create -> entryStep in entryWindow -> shipping controls
* in shipmentWindow -> prepare -> planActions -> finish. All phases, training,
* reveal, first end-Mentat custody and free entry use original actions. Explicit
* force/card staging conserves counters and base33 custody, not earned receipts. */
export function createDiscoverySkillsStrongholdsFixture(options: DiscoverySkillsStrongholdsOptions = {}): DiscoverySkillsStrongholdsFixture {
let skill = options.skill;
if (!skill && options.initial?.status === 'setup') {
const initial = options.initial;
const actor = initial.players.find(seat => seat.faction === (options.skillOwner ?? 'guild'))?.id;
assert.ok(actor && initial.leaderSkills);
const assignment = initial.leaderSkills.assignments.find(candidate => candidate.owner === actor);
const view = viewGame(initial, actor).leaderSkills!;
skill = assignment?.skill ?? initial.leaderSkills.offers[actor]?.cards.find(candidate =>
!['spice-banker', 'mentat', 'diplomat'].includes(candidate) && !view.unavailableSkills?.[candidate]);
assert.ok(skill, 'Actual undealt offers need an available source-clear original skill; never substitute an injected offer.');
}
const discovery = createClassicDiscoverySkillsFixture({ initial: options.initial, advanced: true,
strongholdCards: true, tech: options.tech, skill: skill ?? 'suk-graduate', skillOwner: options.skillOwner,
face: 'cistern', amount: 3 });
const owner = discovery.collector, opponent = discovery.opponent;
const kind = options.kind ?? 'arrakeen', hide = options.hide ?? true;
const strongholdKey = `${kind}:${territory(kind).sectors[0]}`;
const actions: DiscoverySkillsStrongholdsStep[] = [...discovery.actions];
const staging = [...discovery.staging];
let game = revealClassicDiscoverySkills(discovery, actions);
game = advanceClassicDiscoverySkills(game, state => state.phase === 8 && classicDiscoverySkillsClean(state), actions);
assert.deepEqual(game.strongholdCards, createStrongholdCards());
reserveExcept(game, [`${discovery.parent}:${discovery.parentSector}`]);
if (options.holder !== 'unclaimed') place(game, owner, strongholdKey, 1);
staging.push('Before original first end-Mentat, conserved all non-source board counters through their own reserves; preserved the three Discovery source forces and placed one ordinary claimant in the named stronghold.');
let beforeFirstMentat: Game | undefined, firstMentatStep: DiscoverySkillsStrongholdsStep | undefined;
for (let count = 0; game.turn === 1 && count < 200; count++) {
const next = nextClassicDiscoverySkillsStep(game), before = structuredClone(game);
actions.push(next); game = applyAction(game, next.actor, next.action);
if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = next; }
}
assert.ok(beforeFirstMentat && firstMentatStep);
assert.equal(game.strongholdCards!.claimedTurn, 1);
assert.deepEqual(game.strongholdCards!.owners, strongholdControllers(beforeFirstMentat.players, false));
assert.equal(game.strongholdCards!.owners[kind], options.holder === 'unclaimed' ? null : owner);
game = advanceClassicDiscoverySkills(game, state => state.turn === 2 && state.decision?.kind === 'discoveryEntry' && state.decision.player === owner, actions);
const entryWindow = structuredClone(game), entryView = viewGame(game, owner);
const entryAction = discoveryEntryMoveAction(entryView, entryView.discoveryEntry!.sources); assert.ok(entryAction);
const entryStep: DiscoverySkillsStrongholdsStep = { actor: owner, action: entryAction };
actions.push(entryStep); game = applyAction(game, owner, entryAction);
const entered = structuredClone(game);
// Original Weather Control owns a deterministic safe storm, not a staged dial.
const weather = held(game, owner, card => card.effect === 'weather', staging);
game = advanceClassicDiscoverySkills(game, state => state.phase === 0 && classicDiscoverySkillsClean(state), actions);
const weatherStep: DiscoverySkillsStrongholdsStep = { actor: owner, action: { type: 'card', card: weather.id, amount: 0 } };
actions.push(weatherStep); game = applyAction(game, owner, weatherStep.action);
game = advanceClassicDiscoverySkills(game, state => state.phase === 5 && state.active === owner && classicDiscoverySkillsClean(state), actions);
const shipmentWindow = structuredClone(game);
reserveExcept(game, ['cistern:0']);
const key = options.nestedBattle ? 'cistern:0' : strongholdKey;
const ownerForces = discoverySkillsStrongholdsPlayer(game, owner).forces[key] ?? 0;
place(game, owner, key, 8 - ownerForces); place(game, opponent, key, 8);
staging.push(`Before original turn-two movement completion, conserved eight ordinary counters per combatant at ${key}; retained three actually entered Cistern forces and original first-Mentat custody.`);
const used: string[] = [];
const card = (actor: string, kind: Card['kind'] | undefined): string | null => {
if (!kind) return null;
const physical = held(game, actor, candidate => candidate.kind === kind, staging, used);
used.push(physical.id); return physical.id;
};
const ownerWeapon = card(owner, options.ownerWeapon), ownerDefense = card(owner, options.ownerDefense);
const opponentWeapon = card(opponent, options.opponentWeapon), opponentDefense = card(opponent, options.opponentDefense);
const untrained = (actor: string) => {
const eligible = discoverySkillsStrongholdsPlayer(game, actor).leaders.filter(leader => !leader.dead && !leader.usedAt &&
!game.leaderSkills!.assignments.some(assignment => assignment.leader === leader.id));
const leader = eligible.find(candidate => candidate.strength === 3) ?? eligible[0]; assert.ok(leader); return leader.id;
};
const leader = hide ? discovery.leader : untrained(owner);
const enemyLeader = untrained(opponent);
game = advanceClassicDiscoverySkills(game, state => state.phase === 6 && !!state.active && classicDiscoverySkillsClean(state), actions);
const actor = game.active!; assert.ok(actor === owner || actor === opponent);
const battleStep: DiscoverySkillsStrongholdsStep = { actor, action: { type: 'chooseBattle', territory: options.nestedBattle ? 'cistern' : kind, target: actor === owner ? opponent : owner } };
actions.push(battleStep); game = applyAction(game, actor, battleStep.action);
const planActions: DiscoverySkillsStrongholdsStep[] = [
{ actor: owner, action: { type: 'battlePlan', dial: options.ownerDial ?? 4, support: options.support ?? 2, leader, weapon: ownerWeapon, defense: ownerDefense } },
{ actor: opponent, action: { type: 'battlePlan', dial: options.opponentDial ?? 0, support: options.opponentSupport ?? 0, leader: enemyLeader, weapon: opponentWeapon, defense: opponentDefense } },
];
const paidShipmentStep: DiscoverySkillsStrongholdsStep = { actor: owner,
action: { type: 'ship', territory: 'cistern', sector: 0, amount: 1, elite: 0, allyPayment: 0 } };
return { discovery, initial: discovery.initial, setup: discovery.setup, beforeFirstMentat, firstMentatStep,
entryWindow, entryStep, entered, shipmentWindow, paidShipmentStep, game, owner, opponent, leader, kind, key, hide, actions, planActions, staging };
}
export function prepareDiscoverySkillsStrongholdsBattle(fixture: DiscoverySkillsStrongholdsFixture): Game {
let game = structuredClone(fixture.game);
for (let count = 0; count < 160; count++) {
let next = nextClassicSkillsStrongholdStep(game);
if (!next && game.battle?.preLeader?.closed === false) next = nextClassicDiscoverySkillsStep(game);
if (!next) { assert.ok(game.battle && !game.battle.revealed); return game; }
if (next.action.type === 'leaderSkillVisibility') next.action.hide = next.actor === fixture.owner && fixture.hide;
game = applyAction(game, next.actor, next.action);
}
throw Error('Original Discovery skills/Strongholds pre-plan continuation did not open.');
}
export function revealDiscoverySkillsStrongholdsBattle(fixture: DiscoverySkillsStrongholdsFixture): Game {
let game = prepareDiscoverySkillsStrongholdsBattle(fixture);
for (const next of fixture.planActions) {
game = applyAction(game, next.actor, next.action);
while (game.response || game.phaseOpening || game.decision?.kind === 'fullPlanOffer') {
const continuation = nextClassicDiscoverySkillsStep(game);
game = applyAction(game, continuation.actor, continuation.action);
}
}
assert.ok(game.battle?.revealed); return game;
}
export const finishDiscoverySkillsStrongholdsBattle = finishClassicSkillsStrongholdBattle;
+16 -5
View File
@@ -1,10 +1,11 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, initializeStrongholdFactionsGameForAudit, joinGame, newPlayer,
import { applyAction, createGame, initializeDiscoveryGameForAudit, initializeStrongholdFactionsGameForAudit, joinGame, newPlayer,
viewGame, type Game } from '../game/engine';
import { territory } from '../game/board';
import { STRONGHOLD_CARDS, type StrongholdId } from '../game/stronghold-cards';
import { createStrongholdCards, STRONGHOLD_CARDS, type StrongholdId } from '../game/stronghold-cards';
import { finishEcazOccupySetup, stageEcazOccupyCard } from './fixture-ecaz-occupy';
import { nextStrongholdFactionsNativeStep, type StrongholdFactionsNativeStep } from './fixture-stronghold-factions';
import { createTechTokens } from '../game/tech-tokens';
export type EcazStrongholdOptions = {
/** Ready authenticated lobby, or its original fresh native setup. Seats are never replaced. */
@@ -13,6 +14,8 @@ export type EcazStrongholdOptions = {
ecazForces?: number;
allyForces?: number;
opponentForces?: number;
discoveries?: boolean;
tech?: boolean;
};
export type EcazStrongholdFixture = {
initial: Game; afterSetup: Game; beforeMentat: Game; mentatStep: StrongholdFactionsNativeStep;
@@ -64,14 +67,21 @@ function orderBlow(game: Game, worm: boolean, position: number): void {
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(at, 1)[0]);
}
/** Determinism is limited to the original native shuffle, never a saved deal. */
function initialize(game: Game): Game {
function initialize(game: Game, discoveries: boolean): Game {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
assert.ok(array);
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255);
return array;
};
try { return initializeStrongholdFactionsGameForAudit(game); }
try {
if (discoveries) {
game.discoveryEnabled = true;
game.strongholdCards ??= createStrongholdCards();
return initializeDiscoveryGameForAudit(game);
}
return initializeStrongholdFactionsGameForAudit(game);
}
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
@@ -108,7 +118,8 @@ export function ecazStrongholdFixture(options: EcazStrongholdOptions = {}): Ecaz
'The original native seats must be Ecaz, Guild and Emperor.');
if (game.status === 'lobby') {
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
game = initialize(game);
if (options.tech && !game.techTokens) game.techTokens = createTechTokens();
game = initialize(game, options.discoveries === true);
}
const initial = structuredClone(game);
assert.equal(game.strongholdCards!.claimedTurn, 0);