Compose Discovery Strongholds and repair mandatory Ecaz battles
This commit is contained in:
1 parent
e798914aab
commit
b0267827b5
19 files changed
+1243
-39
No files matched your search
@@ -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 }));
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user