Compose independent Skills with original Tech and Stronghold modules

Unify fresh/live classic and native phase admission, retaining original counts, earned custody, separate income ledgers and physical card/skill continuations. Plain paired E3 phases no longer require cards or Nexus. Coupled44-file471/471, types/lint/build, genuine programmes and390px normal entry pass. Controlled card inputs explicit; no save retrofit or deployment.
This commit is contained in:
Quintine committed 2026-10-17 22:12:44 +11:00
1 parent 17adffa6a7
commit b9330d489e
31 files changed
+797 -104

No files matched your search

+52 -1
View File
@@ -8,7 +8,7 @@ import type { BattleResolutionQuote } from '../game/battle-resolution-quote';
import { quoteIndependentSkillsBattle } from './fixture-independent-skills-battle';
import {
assertChoamIndependentSkillsCustody, choamIndependentSkillsPlayer,
createChoamIndependentSkillsFixture,
createChoamIndependentSkillsFixture, createChoamIndependentSkillsHeldSupportFixture,
finishChoamIndependentSkillsBattle, openChoamIndependentSkillsBattle,
sealChoamIndependentSkillsBoth, type ChoamIndependentSkillsFixture,
} from './fixture-choam-independent-skills';
@@ -159,3 +159,54 @@ void test('The independent CHOAM/Richese setup keeps the separate cache and each
(rival(fixture, fixture.beforePlans).forces[fixture.location] ?? 0),
'The defeated garrison, not armies elsewhere, reached the Tanks');
});
void test('the first real Richese Mentat claim supports a funded second marker without paying or refunding its reserve fee', () => {
const f = createChoamIndependentSkillsHeldSupportFixture({ tech: true, strongholds: true });
const { first } = f;
assert.equal(first.offered.richeseCache!.length, 10);
assert.notEqual(first.trainers[first.choam], 'choam-auditor');
assert.equal(f.firstAfterBattle.strongholdCards!.owners.arrakeen, null);
assert.equal(owner(first, f.firstAfterBattle).forces[first.location], 2,
'The first machine winner really keeps the two undialed marker counters');
assert.equal(f.afterFirstMentat.strongholdCards!.claimedTurn, 1);
assert.equal(f.afterFirstMentat.strongholdCards!.owners.arrakeen, first.richese);
assert.equal(owner(first, f.afterFirstMentat).forces[first.location], 2);
const shipped = owner(first, f.afterMarkerShipment), beforeShipment = owner(first, f.beforeMarkerShipment);
assert.equal(shipped.reserves, beforeShipment.reserves,
'The different concealed token does not borrow physical counters before reveal');
assert.notEqual(shipped.noField!.lastShipped, owner(first, first.afterShipment).noField!.lastShipped,
'The original same-token consecutive-shipment limit remains in force');
assert.equal(owner(first, f.beforePlans).forces[first.location], 2);
assert.equal(rival(first, f.beforePlans).reserves, rival(first, f.beforeEnemyShipment).reserves - 4);
assert.equal(rival(first, f.beforePlans).spice, rival(first, f.beforeEnemyShipment).spice - 4,
'The returning enemy army paid its real four-force stronghold invoice');
const preview = viewGame(f.beforePlans, first.richese).battle!;
assert.equal(preview.strongholdEffects![first.richese], 'arrakeen');
assert.equal(preview.reinforcements!.noField!.materializes, 5);
assert.equal(preview.reinforcements!.noField!.reservesAfterReveal, 9);
assert.equal(preview.reinforcements!.normal, 3);
assert.equal(owner(first, f.revealed).forces[first.location], 7);
assert.equal(owner(first, f.revealed).reserves, 9);
const quote = quoteIndependentSkillsBattle(f.revealed, { returns: f.returns });
const payment = quote.payments.find(entry => entry.player === first.richese)!;
assert.equal(payment.bankSupport, 2);
assert.equal(payment.ownPayment, 0);
assert.equal(payment.cost, 0);
assert.deepEqual(bonusOf(f.revealed, quote, first.richese),
{ bonus: 2, applied: [{ skill: 'mentat', amount: 2, mode: 'skilled' }] });
assert.deepEqual(quote.reinforcements,
[{ player: first.richese, card: REINFORCEMENTS_CARD, normal: 3, elite: 0 }]);
assert.equal(quote.auditor, null, 'The native Auditor never becomes an ordinary trained battle leader');
const after = owner(first, f.afterBattle), revealed = owner(first, f.revealed);
assert.equal(f.afterBattle.lastBattleContext!.winner, first.richese);
assert.equal(after.spice, revealed.spice, 'Held bank support is a discount, never personal income');
assert.equal(after.reserves, revealed.reserves - 3 + 5,
'The three-reserve card fee is still paid before the five real undialed forces return');
assert.equal(after.tanks, revealed.tanks + 3 + 2,
'Free support neither funds extra reserve counters nor rescues the fee from Tanks');
assert.equal(after.forces[first.location] ?? 0, 0);
assert.equal(rival(first, f.afterBattle).tanks, rival(first, f.revealed).tanks + 4);
for (const id of [REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD])
assert.equal(f.afterBattle.discard.filter(card => card.id === id).length, 1);
assertChoamIndependentSkillsCustody(first, f.afterBattle);
});
+55 -4
View File
@@ -5,6 +5,7 @@ import { treacheryDeck } from '../game/cards';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import { ownedTech } from '../game/tech-tokens';
import {
assertEcazIndependentSkillsCustody,
createEcazIndependentSkillsFixture, ecazIndependentSkillsPlayer as player,
@@ -45,8 +46,8 @@ function disposedLeadCards(f: EcazIndependentSkillsFixture, end: Game): void {
}
for (const advanced of [false, true]) {
void test(`original independent cards with all14 ${advanced ? 'Advanced' : 'Basic'} reach the printed lead choice, and the trained Ecaz Suk rescues only its own committed losses`, () => {
const f = createEcazIndependentSkillsFixture({ advanced, lead: 'ecaz' });
void test(`original independent cards with all14 ${advanced ? 'Advanced' : 'Basic Tech'} reach the printed lead choice, and the trained Ecaz Suk rescues only its own committed losses`, () => {
const f = createEcazIndependentSkillsFixture({ advanced, lead: 'ecaz', tech: !advanced });
physicalSource(f.initial, 36);
physicalSource(f.game, 36);
assert.equal(f.beforeLead.decision?.kind, 'ecazBattleLead');
@@ -110,6 +111,13 @@ for (const advanced of [false, true]) {
assert.equal(change(player(f.game, f.opponent).tanks, player(end, f.opponent).tanks), 2);
assert.equal(at(end, f.opponent, f.location), 0);
assert.equal(change(player(f.game, f.opponent).reserves, player(end, f.opponent).reserves), 0);
if (!advanced) {
const tokens = ownedTech(f.game.techTokens, f.opponent);
assert.equal(tokens.length, 1, 'the actual defeated Guild carries one original token');
assert.equal(end.techTokens![tokens[0]].owner, f.ecaz,
'original winner Tech cleanup credits the selected Ecaz plan, after its own card fee and rescue');
disposedLeadCards(f, end);
}
assertEcazIndependentSkillsCustody(end);
});
}
@@ -333,8 +341,8 @@ void test('native paired Ecaz lead pays its own fee, returns its undialed fixed
assertEcazIndependentSkillsCustody(end);
});
void test('native paired Basic Moritani lead pays and withdraws its normal variable army without merging Ecaz fixed losses', () => {
const f = createEcazIndependentSkillsFixture({ paired: true, advanced: false, lead: 'ally' });
void test('native paired Basic Tech Moritani lead pays and withdraws its normal variable army without merging Ecaz fixed losses', () => {
const f = createEcazIndependentSkillsFixture({ paired: true, advanced: false, lead: 'ally', tech: true });
const quote = quoteEcazIndependentSkillsBattle(f);
assert.deepEqual(quote.reinforcements,
[{ player: f.ally, card: REINFORCEMENTS_CARD, normal: 3, elite: 0 }]);
@@ -354,8 +362,51 @@ void test('native paired Basic Moritani lead pays and withdraws its normal varia
assert.equal(at(end, f.ecaz, f.location), 2);
assert.equal(player(end, f.ecaz).leaders.find(leader => leader.id === f.trainer)!.dead, false);
assert.equal(change(player(f.game, f.opponent).tanks, player(end, f.opponent).tanks), 2);
const tokens = ownedTech(f.game.techTokens, f.opponent);
assert.equal(tokens.length, 1);
assert.equal(end.techTokens![tokens[0]].owner, f.ally,
'the selected Moritani winner, not its Ecaz coalition partner, inherits the defeated Guild token');
disposedLeadCards(f, end);
pairedArmies(f, end);
physicalSource(end, 36);
assertEcazIndependentSkillsCustody(end);
});
for (const lead of ['ecaz', 'ally'] as const) {
void test(`earned Arrakeen supports only its Ecaz holder's selected plan: paired ${lead === 'ecaz' ? 'holder' : 'Moritani'} lead keeps card fees, losses and Tech separate`, () => {
const f = createEcazIndependentSkillsFixture({ paired: true, tech: true, strongholds: true, lead });
const claim = f.afterFirstMentat!;
assert.equal(claim.strongholdCards!.claimedTurn, 1);
assert.equal(claim.strongholdCards!.owners.arrakeen, f.ecaz);
assert.equal(at(claim, f.ecaz, f.location), 4);
assert.equal(f.actions.filter(step => step.actor === f.ecaz && step.action.type === 'ship').length, 1,
'the same four paid turn-one counters remain through the claim and real worm alliance');
const quote = quoteEcazIndependentSkillsBattle(f);
const payment = quote.payments.find(payment => payment.player === f.cardUser)!;
assert.equal(payment.bankSupport, lead === 'ecaz' ? 2 : 0);
assert.equal(payment.ownPayment, lead === 'ecaz' ? 0 : 2,
'Moritani does not inherit the Ecaz-held card when Ecaz selects its ally to lead');
assert.deepEqual(quote.reinforcements,
[{ player: f.cardUser, card: REINFORCEMENTS_CARD, normal: 3, elite: 0 }]);
assert.deepEqual(quote.harassWithdraw![0].returned, { normal: 2, elite: 0 });
assert.deepEqual(quote.fixedLosses, [{ owner: f.ecaz, normal: 2, elite: 0 }]);
assert.equal(quote.casualties?.owner, f.ally);
const paid = settleEcazIndependentSkillsBattle(f, { stop: 'rescue' });
assert.equal(player(paid, f.cardUser).spice, player(f.game, f.cardUser).spice - payment.ownPayment,
'support settles before the Ecaz rescue and the separate Collection income');
const end = settleEcazIndependentSkillsBattle(f, { state: paid });
assert.equal(end.phase, 7);
assert.equal(end.lastBattleContext?.winner, f.cardUser);
assert.equal(change(player(f.game, f.ecaz).tanks, player(end, f.ecaz).tanks), lead === 'ecaz' ? 3 : 2);
assert.equal(change(player(f.game, f.ally).tanks, player(end, f.ally).tanks), lead === 'ecaz' ? 2 : 5);
assert.equal(end.lastBattleContext?.sukRescue?.completed, lead === 'ecaz' ? true : undefined);
const lostTokens = ownedTech(f.game.techTokens, f.opponent);
assert.equal(lostTokens.length, 1);
assert.equal(end.techTokens![lostTokens[0]].owner, f.cardUser);
disposedLeadCards(f, end);
pairedArmies(f, end);
physicalSource(end, 36);
assert.deepEqual(end.ecazLoyalty, f.afterSetup.ecazLoyalty);
assertEcazIndependentSkillsCustody(end);
});
}
+103
View File
@@ -32,6 +32,8 @@ export type ChoamIndependentSkillsOptions = {
/** Continue only the original fresh undealt lobby or its own post-start setup. */
initial?: Game;
advanced?: boolean;
tech?: boolean;
strongholds?: boolean;
route?: ChoamIndependentSkillsRoute;
marker?: NoFieldValue;
/** Own physical reserves the Basic 'tuning' route must leave before shipping. */
@@ -251,6 +253,13 @@ export function createChoamIndependentSkillsSetup(
assert.ok(!game.lobbyOptions && !game.deck.length && !game.richeseCache &&
game.players.every((player) => !player.hand.length),
'Only the original fresh undealt lobby accepts the two module selections.');
for (const [type, enabled] of [
['techTokens', options.tech ?? false], ['strongholdCards', options.strongholds ?? false],
] as const) if (enabled) {
const action: Action = { type, enabled };
actions.push({ actor: game.host, action });
game = applyAction(game, game.host, action);
}
for (const option of ['leaderSkills', 'ecazTreachery'] as const) {
const action: Action = { type: 'moduleOption', module: option, enabled: true };
actions.push({ actor: game.host, action });
@@ -329,6 +338,8 @@ export function nextChoamIndependentSkillsStep(
): ChoamIndependentSkillsStep | null {
if (!game.response && !game.phaseOpening && game.decision) {
const decision = game.decision;
if (decision.kind === 'techToken')
return { actor: decision.player, action: { type: 'decision', token: decision.choices[0] } };
if (decision.kind === 'leaderSkillVisibility')
return { actor: decision.player, action: { type: 'leaderSkillVisibility', event: decision.event,
hide: policy.hide?.[decision.player] ?? false } };
@@ -629,3 +640,95 @@ export function finishChoamIndependentSkillsBattle(
}
throw Error('The original independent-card battle aftermath did not reach Collection');
}
/**
* Keep the original turn-one machine winner's two undialed forces in Arrakeen,
* earn its card at the first real Mentat, then ship a different native marker
* into that held city before the original Atreides seat pays to return there.
* No owner, army, wallet, marker or phase receipt is assigned.
*/
export function createChoamIndependentSkillsHeldSupportFixture(
options: Pick<ChoamIndependentSkillsOptions, 'tech' | 'strongholds'> = {},
) {
assert.equal(options.strongholds, true, 'The held-support programme needs original Stronghold Cards');
const first = createChoamIndependentSkillsFixture({
...options, advanced: true, route: 'immediate', stopBeforePlans: true,
});
assert.equal(first.location, 'arrakeen:10');
const actions: ChoamIndependentSkillsStep[] = [];
const firstAfterBattle = finishChoamIndependentSkillsBattle(
sealChoamIndependentSkillsBoth(first, 'reinforcements'), { actions });
let game = firstAfterBattle;
const act = (step: ChoamIndependentSkillsStep) => {
actions.push(structuredClone(step));
game = applyAction(game, step.actor, step.action);
};
const advance = (until: (state: Game) => boolean) => {
for (let count = 0; count < 1600; count++) {
if (until(game)) return;
const step = nextChoamIndependentSkillsStep(game, { hide: first.hide });
assert.ok(step, 'A real native continuation must reach the next held-city action');
act(step);
}
throw Error('Held Arrakeen programme did not reach its next native action');
};
advance(state => state.turn === 2 && state.phase === 0 && choamIndependentSkillsClean(state));
const afterFirstMentat = structuredClone(game);
let beforeMarkerShipment: Game | undefined, afterMarkerShipment: Game | undefined;
let beforeEnemyShipment: Game | undefined, markerShipment: ChoamIndependentSkillsStep | undefined;
const enemyShipment: ChoamIndependentSkillsStep = { actor: first.opponent, action: {
type: 'ship', territory: 'arrakeen', sector: 10, amount: 4,
} };
const waiting = new Set([first.richese, first.opponent]);
while (waiting.size) {
advance(state => state.turn === 2 && state.phase === 5 && !!state.active &&
waiting.has(state.active) && choamIndependentSkillsClean(state));
const actor = game.active!;
if (actor === first.richese) {
beforeMarkerShipment = structuredClone(game);
const richese = choamIndependentSkillsPlayer(game, actor);
const marker = richese.noField!.tokens.find(token => token.value === 5)!;
markerShipment = { actor, action: { type: 'ship', noField: marker.id,
event: richese.noFieldEvent, territory: 'arrakeen', sector: 10 } };
act(markerShipment);
} else {
beforeEnemyShipment = structuredClone(game);
act(enemyShipment);
}
advance(state => state.active === actor && choamIndependentSkillsClean(state) && !state.pendingShipment);
if (actor === first.richese) afterMarkerShipment = structuredClone(game);
act({ actor, action: { type: 'endMovement' } });
waiting.delete(actor);
}
assert.ok(beforeMarkerShipment && afterMarkerShipment && beforeEnemyShipment && markerShipment);
advance(state => state.turn === 2 && state.phase === 6 && choamIndependentSkillsClean(state));
const staging = [...first.staging];
positionCard(game, first.richese, REINFORCEMENTS_CARD, staging);
const beforeBattle = structuredClone(game);
const choice = viewGame(game, first.richese).battleChoices.find(candidate => candidate.territory === 'arrakeen');
assert.ok(choice);
const battleStep: ChoamIndependentSkillsStep = { actor: choice.chooser, action: {
type: 'chooseBattle', territory: 'arrakeen',
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker,
} };
act(battleStep);
advance(state => !!state.battle && !state.battle.revealed && choamIndependentSkillsClean(state) &&
!!state.battle.preLeader?.closed && !state.battle.preparation);
const beforePlans = structuredClone(game);
const plans: ChoamIndependentSkillsStep[] = [
{ actor: first.richese, action: { type: 'battlePlan', dial: 2, support: 2,
leader: first.trainers[first.richese], weapon: REINFORCEMENTS_CARD, defense: HARASS_WITHDRAW_CARD } },
{ actor: first.opponent, action: { type: 'battlePlan', dial: 0, support: 0,
leader: availableLeaders(game, first.opponent, 'arrakeen')[0].id } },
];
for (const plan of plans) {
actions.push(structuredClone(plan));
game = sealChoamIndependentSkillsPlan(game, plan, { hide: first.hide });
}
const revealed = structuredClone(game);
const returns: HarassWithdrawSelection = { [first.location]: { normal: 5, elite: 0 } };
const afterBattle = finishChoamIndependentSkillsBattle(game, { return: returns, actions });
return { first, firstAfterBattle, afterFirstMentat, beforeMarkerShipment, markerShipment,
afterMarkerShipment, beforeEnemyShipment, enemyShipment, beforeBattle, battleStep, beforePlans,
plans, revealed, returns, afterBattle, actions, staging };
}
+12 -1
View File
@@ -32,6 +32,9 @@ export type EcazIndependentSkillsOptions = {
skills?: boolean;
/** The independent physical three-card variant; `false` is the printed no-card control. */
independent?: boolean;
/** Original optional modules, both off by default; Strongholds require Advanced. */
tech?: boolean;
strongholds?: boolean;
/** Real native Battle Plan cards in the lead's own plan. */
cards?: readonly EcazIndependentSkillsCard[];
lead?: 'ecaz' | 'ally';
@@ -111,6 +114,8 @@ export function nextEcazIndependentSkillsStep(game: Game,
return { actor: decision.player, action: { type: 'decision', event: decision.event, draw: false } };
if (decision?.kind === 'faceDance')
return { actor: decision.player, action: { type: 'decision', reveal: false } };
if (decision?.kind === 'techToken')
return { actor: decision.player, action: { type: 'decision', token: decision.choices[0] } };
if (['sukRescue', 'harassWithdraw', 'battleLosses'].includes(decision?.kind ?? '')) return null;
}
return nextStrongholdFactionsNativeStep(game);
@@ -167,6 +172,8 @@ export function assertEcazIndependentSkillsCustody(game: Game): void {
export type EcazIndependentSkillsFixture = {
/** Original fresh lobby/setup snapshots taken by the shared genuine programme. */
initial: Game; afterSetup: Game;
/** Real turn-one Mentat acquisition, before the worm alliance and battle. */
afterFirstMentat?: Game; strongholdTerritory?: string;
/** Original before-lead window and the recorded native lead choice. */
beforeLead: Game; leadStep: EcazIndependentSkillsStep; afterLead: Game;
/** Clean before-plan window (both plans still unsealed) and the real plans. */
@@ -257,6 +264,7 @@ export function createEcazIndependentSkillsFixture(
const draws = skills ? permutationDraws(LEADER_SKILL_CARDS.map(card => card.id), order) : [];
const shared = entropy(() => createHomeworldEcazOccupyFixture({
advanced, skills, independent, homeworlds: false,
tech: options.tech ?? false, strongholds: options.strongholds ?? false,
...(options.paired ? { allyFaction: 'moritani' as const } : {}),
...(skills ? { finishSetup: (initial: Game) => trainEcazSeat(initial, picks) } : {}),
}), draws);
@@ -330,7 +338,9 @@ export function createEcazIndependentSkillsFixture(
returns = defaultHarassWithdrawAllocation(offer.context, offer.dial, offer.support);
}
const fixture: EcazIndependentSkillsFixture = {
initial: shared.initial, afterSetup: shared.afterSetup, beforeLead, leadStep, afterLead,
initial: shared.initial, afterSetup: shared.afterSetup,
afterFirstMentat: shared.afterFirstMentat, strongholdTerritory: shared.strongholdTerritory,
beforeLead, leadStep, afterLead,
beforePlans, plans, game, returns, ecaz: shared.ecaz, ally: shared.ally, opponent: shared.opponent,
territory: shared.territory, location: shared.location, fixedEcazDial: profile.fixedEcazDial,
planDial: profile.fixedEcazDial + variable, support, cardUser, cardIds, cardKinds, trainer,
@@ -355,6 +365,7 @@ export function quoteEcazIndependentSkillsBattle(f: EcazIndependentSkillsFixture
return { id: actor, faction: player.faction, spice: player.spice, hand: player.hand, plan,
leader: player.leaders.find(leader => leader.id === plan.leader),
forces: own.ownForces!,
stronghold: own.strongholdEffects[actor],
leaderSkills: (game.leaderSkills?.assignments ?? []).filter(a => a.owner === actor)
.map(a => ({ skill: a.skill, leader: a.leader,
faceUp: battle.leaderSkillHidden?.[actor] !== true, captured: false })),
+20 -5
View File
@@ -5,7 +5,9 @@ import { territory } from '../game/board';
import { finishEcazOccupySetup, type EcazOccupyFixture } from './fixture-ecaz-occupy';
export type HomeworldEcazStep = { actor: string; action: Action };
export type HomeworldEcazOccupyFixture = EcazOccupyFixture & { actions: HomeworldEcazStep[] };
export type HomeworldEcazOccupyFixture = EcazOccupyFixture & {
actions: HomeworldEcazStep[]; afterFirstMentat?: Game; strongholdTerritory?: string;
};
export const homeworldEcazClean = (game: Game) =>
!game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
@@ -39,6 +41,7 @@ export function nextHomeworldEcazStep(game: Game): HomeworldEcazStep | null {
export function createHomeworldEcazOccupyFixture(options: {
advanced?: boolean; skills?: boolean; independent?: boolean;
allyFaction?: 'emperor' | 'moritani';
tech?: boolean; strongholds?: boolean;
/** No-World control uses the same genuine alliance/Arrakis route. */
homeworlds?: boolean;
/** Select only actual offered setup choices; the captured initial stays untouched. */
@@ -56,6 +59,8 @@ export function createHomeworldEcazOccupyFixture(options: {
game = applyAction(game, actor, action);
};
if (homeworlds) act('ecaz', { type: 'homeworlds', enabled: true });
if (options.tech) act('ecaz', { type: 'techTokens', enabled: true });
if (options.strongholds) act('ecaz', { type: 'strongholdCards', enabled: true });
if (options.independent) act('ecaz', { type: 'moduleOption', module: 'ecazTreachery', enabled: true });
if (options.skills) act('ecaz', { type: 'moduleOption', module: 'leaderSkills', enabled: true });
for (const player of game.players) act(player.id, { type: 'ready' });
@@ -82,10 +87,19 @@ export function createHomeworldEcazOccupyFixture(options: {
orderBlow(false, 0);
if (advanced) orderBlow(false, 1);
reach(g => g.phase === 5 && homeworldEcazClean(g));
const strongholdTerritory = options.strongholds ? 'arrakeen' : undefined;
while (game.phase === 5) {
if (homeworldEcazClean(game)) act(game.active!, { type: 'endMovement' }); else step();
if (!homeworldEcazClean(game)) { step(); continue; }
const actor = game.active!;
if (strongholdTerritory && actor === 'ecaz' && !game.players.find(player => player.id === actor)!.shipped) {
act(actor, { type: 'ship', territory: strongholdTerritory,
sector: territory(strongholdTerritory).sectors[0], amount: 4, elite: 0, allyPayment: 0, smuggler: false });
reach(homeworldEcazClean);
}
act(actor, { type: 'endMovement' });
}
reach(g => g.turn === 2 && g.phase === 0 && !g.phaseOpening);
const afterFirstMentat = options.tech || options.strongholds ? structuredClone(game) : undefined;
orderBlow(true, 0);
orderBlow(false, 1);
if (advanced) orderBlow(false, 2);
@@ -93,7 +107,7 @@ export function createHomeworldEcazOccupyFixture(options: {
act('ecaz', { type: 'alliance', target: ally });
act(ally, { type: 'alliance', target: 'ecaz' });
reach(g => g.phase === 5 && homeworldEcazClean(g));
const battleTerritory = ['arrakeen', 'sietch_tabr', 'habbanya_ridge_sietch']
const battleTerritory = strongholdTerritory ?? ['arrakeen', 'sietch_tabr', 'habbanya_ridge_sietch']
.find(id => territory(id).sectors[0] !== game.storm &&
!game.players.some(player => Object.entries(player.forces).some(([key, amount]) =>
amount > 0 && key.startsWith(`${id}:`))))!;
@@ -103,7 +117,7 @@ export function createHomeworldEcazOccupyFixture(options: {
while (game.phase === 5) {
if (!homeworldEcazClean(game)) { step(); continue; }
const actor = game.active!;
if (!shipped.has(actor)) {
if (!shipped.has(actor) && !(strongholdTerritory && actor === 'ecaz')) {
act(actor, { type: 'ship', territory: battleTerritory, sector,
amount: actor === 'guild' ? 2 : 4,
elite: actor === ally && ally === 'emperor' && (advanced || homeworlds) ? 2 : 0, allyPayment: 0 });
@@ -118,6 +132,7 @@ export function createHomeworldEcazOccupyFixture(options: {
act(choice.chooser, { type: 'chooseBattle', territory: battleTerritory,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker });
if (!options.skills) assert.equal(game.decision?.kind, 'ecazBattleLead');
return { game, initial, afterSetup, actions, ecaz: 'ecaz', ally, opponent: 'guild',
return { game, initial, afterSetup, actions, afterFirstMentat, strongholdTerritory,
ecaz: 'ecaz', ally, opponent: 'guild',
territory: battleTerritory, location, ecazForces: 4 };
}
+55 -7
View File
@@ -1,12 +1,14 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeEcazTreacheryGameForAudit, initializeFactionExpansionsGameForAudit,
initializeStrongholdFactionsGameForAudit, joinGame, newPlayer, type Action, type Game,
initializeLeaderSkillsGameForAudit, initializeStrongholdFactionsGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game,
} from '../game/engine';
import type { FactionId } from '../game/catalog';
import { createTechTokens } from '../game/tech-tokens';
import { createStrongholdCards } from '../game/stronghold-cards';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
export type IndependentCardModulesStep = { actor: string; action: Action };
export type IndependentCardModulesOptions = {
@@ -15,13 +17,37 @@ export type IndependentCardModulesOptions = {
expansions?: Game['expansions'];
tech?: boolean;
strongholds?: boolean;
skills?: boolean;
training?: Partial<Record<FactionId, LeaderSkillId>>;
/** False keeps the original native deck for revealed-card comparisons. */
independent?: boolean;
homeworlds?: boolean;
};
/** Select original modules and independent cards before the undealt native setup.
* No armies, wallets, module owners, phase or history are assigned afterward. */
/** Select only the original undealt fourteen-card lottery, never assignments. */
function skillOfferRolls(game: Game, training: IndependentCardModulesOptions['training']): number[] {
const source = LEADER_SKILL_CARDS.map(card => card.id), selected: LeaderSkillId[] = [];
for (const player of game.players) {
const skill = training?.[player.faction] ?? source.find(card => !selected.includes(card))!;
assert.ok(source.includes(skill) && !selected.includes(skill), 'Use distinct original skill offers');
selected.push(skill);
}
const rest = source.filter(card => !selected.includes(card));
const desired = selected.flatMap(card => [card, rest.shift()!]).concat(rest);
const working = [...source], rolls: number[] = [];
for (let index = working.length - 1; index > 0; index--) {
const from = working.indexOf(desired[index]);
assert.ok(from >= 0 && from <= index);
rolls.push(Math.floor((from + 0.5) * 0x100000000 / (index + 1)));
[working[index], working[from]] = [working[from], working[index]];
}
return rolls;
}
/** Original unused modules and independent cards are selected before the real
* development initializer. Keep the undealt `initial` input for existing native
* constructors; `offered` is the actual post-initializer setup. No armies,
* wallets, owners, phases or history are assigned afterward. */
export function createIndependentCardModulesFixture(options: IndependentCardModulesOptions = {}) {
const factions = options.factions ?? ['emperor', 'atreides', 'guild'];
const advanced = options.advanced ?? false;
@@ -38,22 +64,44 @@ export function createIndependentCardModulesFixture(options: IndependentCardModu
steps.push(step); game = applyAction(game, step.actor, step.action);
}
const initial = structuredClone(game);
const rolls = options.skills ? skillOfferRolls(game, options.training) : [];
let cursor = 0;
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);
if (array instanceof Uint32Array && array.length === 1 && cursor < rolls.length) array[0] = rolls[cursor++];
else new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255);
return array;
};
try {
game = options.independent !== false ? initializeEcazTreacheryGameForAudit(game) :
if (options.skills) {
if (options.independent !== false) game.ecazTreachery = true;
game = initializeLeaderSkillsGameForAudit(game);
} else game = options.independent !== false ? initializeEcazTreacheryGameForAudit(game) :
options.strongholds ? initializeStrongholdFactionsGameForAudit(game) : initializeFactionExpansionsGameForAudit(game);
}
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
game = advanceIndependentCardModules(game, state => state.status === 'playing', steps);
return { initial, game, steps };
const offered = structuredClone(game);
for (let count = 0; game.status === 'setup' && count < 300; count++) {
let step: IndependentCardModulesStep;
if (game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0], offer = game.leaderSkills!.offers[actor];
const own = viewGame(game, actor).leaderSkills!, player = game.players.find(seat => seat.id === actor)!;
const skill = options.training?.[player.faction] ?? offer.cards.find(card => !own.unavailableSkills?.[card]);
const leader = own.eligibleLeaders[0];
assert.ok(skill && offer.cards.includes(skill) && !own.unavailableSkills?.[skill] && leader);
step = { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leader.id } };
} else {
const next = nextIndependentCardModulesStep(game); assert.ok(next, 'Complete original native setup');
step = next;
}
steps.push(structuredClone(step)); game = applyAction(game, step.actor, step.action);
}
assert.equal(game.status, 'playing');
return { initial, offered, game, steps };
}
/** Reuse original native choices, including all phase/module windows. */
+75 -30
View File
@@ -16,6 +16,8 @@ import { traitorDeck } from '../game/traitors';
import { quoteBattleResolution, type ResolutionCombatant } from '../game/battle-resolution-quote';
import { usesNativeBattleSpecial } from '../game/battle-card-slots';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
import { createTechTokens } from '../game/tech-tokens';
import { createStrongholdCards, strongholdBenefit } from '../game/stronghold-cards';
export type IndependentSkillsBattleStep = { actor: string; action: Action };
/** 'same-plan' trains the opposing Warmaster; 'traitor-cancel' adds its real
@@ -30,6 +32,9 @@ export type IndependentSkillsBattleOptions = {
/** Continue only the original fresh classic lobby built by this fixture. */
initial?: Game;
advanced?: boolean;
/** Select original unused phase modules before admission; both default off. */
tech?: boolean;
strongholds?: boolean;
seatIds?: readonly string[];
case?: IndependentSkillsBattleCase;
/** Construction hook: request exact original skill cards for one seat. */
@@ -53,6 +58,8 @@ export type IndependentSkillsBattleFixture = IndependentSkillsBattleSetup & {
location: string;
first: string;
second: string;
/** Present only when a genuine turn-one occupation earned the Arrakeen card. */
afterFirstMentat?: Game;
beforeShipment: Game;
shipment: IndependentSkillsBattleStep;
afterShipment: Game;
@@ -61,6 +68,7 @@ export type IndependentSkillsBattleFixture = IndependentSkillsBattleSetup & {
opponentMove: IndependentSkillsBattleStep;
beforeBattle: Game;
battleStep: IndependentSkillsBattleStep;
beforePlans: Game;
/** Revealed window: both original plans sealed, postures committed. */
game: Game;
plans: IndependentSkillsBattleStep[];
@@ -162,8 +170,11 @@ export function createIndependentSkillsBattleSetup(
joinGame(game, newPlayer(ids[index], ids[index], factions[index]));
assert.equal(game.advanced, options.advanced ?? game.advanced);
assert.deepEqual(game.expansions, []);
assert.ok(!game.leaderSkills && !game.homeworlds && !game.nexusCards && !game.discoveryEnabled &&
!game.techTokens && !game.strongholdCards, 'Only the original classic composition is accepted.');
assert.ok(!game.leaderSkills && !game.homeworlds && !game.nexusCards && !game.discoveryEnabled,
'Only the original classic phase modules are accepted.');
assert.ok(!options.strongholds || game.advanced, 'Original Stronghold Cards require Advanced');
if (options.tech && !game.techTokens) game.techTokens = createTechTokens();
if (options.strongholds && !game.strongholdCards) game.strongholdCards = createStrongholdCards();
assert.equal(game.status, 'lobby', 'Continue only an original fresh classic lobby.');
const staging: string[] = [];
const actions: IndependentSkillsBattleStep[] = [];
@@ -295,8 +306,10 @@ function strongestOther(game: Game, actor: string, excluded: string): string {
return leader.id;
}
/** Real training, a paid native shipment, real within-territory movement and both
* original Battle Plans. Only card and Traitor custody is staged. */
/** Real training, a paid native shipment and both original Battle Plans. With
* Strongholds, that shipment earns Arrakeen at genuine first END Mentat and a
* paid turn-two Guild shipment challenges it; otherwise the original Pasty Mesa
* split movement remains unchanged. Only card and Traitor custody is staged. */
export function createIndependentSkillsBattleFixture(
options: IndependentSkillsBattleOptions = {},
): IndependentSkillsBattleFixture {
@@ -306,55 +319,79 @@ export function createIndependentSkillsBattleFixture(
const staging = [...setup.staging];
const actions = [...setup.actions];
const { owner, opponent, trainers } = setup;
const location = 'pasty_mesa', first = 'pasty_mesa:5', second = 'pasty_mesa:6';
assert.deepEqual(territory(location).sectors, [5, 6, 7, 8]);
const strongholds = !!setup.afterSetup.strongholdCards;
const location = strongholds ? 'arrakeen' : 'pasty_mesa';
const first = `${location}:${strongholds ? 10 : 5}`;
const second = strongholds ? first : 'pasty_mesa:6';
if (!strongholds) assert.deepEqual(territory(location).sectors, [5, 6, 7, 8]);
let game = advanceIndependentSkillsBattle(setup.afterSetup,
(state) => state.phase === 5 && state.active === owner && independentSkillsBattleClean(state), actions);
const beforeShipment = structuredClone(game);
assert.equal(independentSkillsBattlePlayer(game, owner).reserves, 20);
assert.equal(independentSkillsBattlePlayer(game, owner).spice, 10);
const shipment: IndependentSkillsBattleStep = { actor: owner, action: {
type: 'ship', territory: location, sector: 5, amount: 4, elite: 0, allyPayment: 0 } };
type: 'ship', territory: location, sector: strongholds ? 10 : 5, amount: 4, elite: 0, allyPayment: 0 } };
actions.push(structuredClone(shipment));
game = applyAction(game, owner, shipment.action);
game = advanceIndependentSkillsBattle(game, (state) => independentSkillsBattleClean(state) &&
!state.pendingShipment && state.phase === 5 && state.active === owner, actions);
const afterShipment = structuredClone(game);
assert.equal(independentSkillsBattlePlayer(game, owner).spice, 2);
assert.equal(independentSkillsBattlePlayer(game, owner).reserves, 16);
assert.equal(independentSkillsBattlePlayer(game, owner).forces[first], 4);
assert.equal(independentSkillsBattlePlayer(game, opponent).spice, 5 + 8);
staging.push('The Emperor paid the original eight-spice desert shipment of four reserves into Pasty Mesa; that payment reached the Guild as printed shipping income.');
const move: IndependentSkillsBattleStep = { actor: owner, action: {
type: 'move', from: first, amount: 2, territory: location, sector: 6 } };
staging.push(`The Emperor paid its original ${strongholds ? 'four-spice stronghold' : 'eight-spice desert'} shipment of four reserves; the real payment reached the Guild.`);
const move: IndependentSkillsBattleStep = { actor: owner, action: strongholds
? { type: 'endMovement' }
: { type: 'move', from: first, amount: 2, territory: location, sector: 6 } };
actions.push(structuredClone(move));
game = applyAction(game, owner, move.action);
game = advanceIndependentSkillsBattle(game, (state) => independentSkillsBattleClean(state) &&
state.phase === 5 && state.active === owner, actions);
assert.deepEqual(independentSkillsBattlePlayer(game, owner).forces, { [first]: 2, [second]: 2 });
let afterFirstMentat: Game | undefined;
if (strongholds) {
game = advanceIndependentSkillsBattle(game, state => state.turn === 2 && state.phase === 0, actions);
afterFirstMentat = structuredClone(game);
assert.equal(game.strongholdCards!.claimedTurn, 1);
assert.equal(game.strongholdCards!.owners.arrakeen, owner);
staging.push('The four originally shipped Emperor counters held empty Arrakeen through genuine first END Mentat and earned its card; no owner or army was assigned.');
game = advanceIndependentSkillsBattle(game, state => state.turn === 2 && state.phase === 5 &&
state.active === owner && independentSkillsBattleClean(state), actions);
} else {
game = advanceIndependentSkillsBattle(game, (state) => independentSkillsBattleClean(state) &&
state.phase === 5 && state.active === owner, actions);
assert.deepEqual(independentSkillsBattlePlayer(game, owner).forces, { [first]: 2, [second]: 2 });
staging.push('The Emperor moved two originally shipped counters to sector 6, leaving two physical Harass return locations.');
}
const afterMove = structuredClone(game);
staging.push('The Emperor then moved two of those four counters from sector 5 to sector 6 in the same movement turn, so the Harass & Withdraw return allocation is genuinely ambiguous between two physical sectors.');
const endMove: IndependentSkillsBattleStep = { actor: owner, action: { type: 'endMovement' } };
actions.push(structuredClone(endMove));
game = applyAction(game, owner, endMove.action);
game = advanceIndependentSkillsBattle(game, (state) => independentSkillsBattleClean(state) &&
state.phase === 5 && state.active === opponent, actions);
assert.equal(independentSkillsBattlePlayer(game, opponent).forces['tueks_sietch:5'], 5);
const opponentMove: IndependentSkillsBattleStep = { actor: opponent, action: {
type: 'move', from: 'tueks_sietch:5', amount: 5, territory: location, sector: 6 } };
const opponentMove: IndependentSkillsBattleStep = { actor: opponent, action: strongholds
? { type: 'ship', territory: location, sector: 10, amount: 5, elite: 0 }
: { type: 'move', from: 'tueks_sietch:5', amount: 5, territory: location, sector: 6 } };
actions.push(structuredClone(opponentMove));
game = applyAction(game, opponent, opponentMove.action);
game = advanceIndependentSkillsBattle(game, (state) => independentSkillsBattleClean(state) &&
state.phase === 5 && state.active === opponent, actions);
assert.deepEqual(independentSkillsBattlePlayer(game, opponent).forces, { [second]: 5 });
staging.push('The Guild declined the first Advanced movement slot (a real legal Guild choice) and moved its whole army from Tuek’s Sietch into Pasty Mesa sector 6 during its own movement turn.');
!state.pendingShipment && state.phase === 5 && state.active === opponent, actions);
assert.equal(independentSkillsBattlePlayer(game, opponent).forces[second], 5);
staging.push(`The Guild declined its first Advanced movement slot and ${strongholds ? 'paid to ship five original reserves into the held Arrakeen' : 'marched its original five-counter army into Pasty Mesa'}.`);
const endOpponent: IndependentSkillsBattleStep = { actor: opponent, action: { type: 'endMovement' } };
actions.push(structuredClone(endOpponent));
game = applyAction(game, opponent, endOpponent.action);
game = advanceIndependentSkillsBattle(game, (state) => state.phase === 6 && independentSkillsBattleClean(state), actions);
if (strongholds) {
const amount = independentSkillsBattlePlayer(game, owner).spice;
if (amount) {
const bribe: IndependentSkillsBattleStep = { actor: owner,
action: { type: 'bribe', target: setup.observer, amount } };
actions.push(structuredClone(bribe));
game = applyAction(game, owner, bribe.action);
}
staging.push('The Emperor actually bribed its entire remaining wallet to the observer before plans; the held Arrakeen contribution is support budget, never personal spice.');
}
for (const player of game.players) game.deck.push(...player.hand.splice(0));
staging.push('At the real Battle window every original hand returned to the undealt deck before the physical Reinforcements and Harass & Withdraw cards were conserved to the Emperor.');
hold(game, owner, (card) => card.id === REINFORCEMENTS_CARD, staging);
hold(game, owner, (card) => card.id === HARASS_WITHDRAW_CARD, staging);
const worthless = caseKind === 'suk' ? null : hold(game, opponent, (card) => card.kind === 'worthless', staging);
const worthless = caseKind === 'suk' && !strongholds ? null : hold(game, opponent, (card) => card.kind === 'worthless', staging);
const hide: Record<string, boolean> = { [owner]: true, [opponent]: caseKind !== 'suk' };
// The trained leader must be committed (so Warmaster/Suk take effect) exactly
// when the owner's posture decision hides it behind the shield.
@@ -369,15 +406,20 @@ export function createIndependentSkillsBattleFixture(
const beforeBattle = structuredClone(game);
const choice = viewGame(game, game.active!).battleChoices.find((candidate) => candidate.territory === location);
assert.ok(choice);
assert.equal(choice.attacker, owner);
assert.equal(choice.chooser, owner);
if (!strongholds) {
assert.equal(choice.attacker, owner);
assert.equal(choice.chooser, owner);
}
assert.ok(choice.chooser);
const battleStep: IndependentSkillsBattleStep = { actor: choice.chooser, action: {
type: 'chooseBattle', territory: location, target: choice.defender } };
type: 'chooseBattle', territory: location,
target: choice.chooser === choice.attacker ? choice.defender : choice.attacker } };
actions.push(structuredClone(battleStep));
game = applyAction(game, choice.chooser, battleStep.action);
game = advanceIndependentSkillsBattle(game,
(state) => !!state.battle && !state.battle.revealed && independentSkillsBattleClean(state),
actions, { hide });
const beforePlans = structuredClone(game);
const support = advanced ? 2 : 0;
const plans: IndependentSkillsBattleStep[] = [
{ actor: owner, action: { type: 'battlePlan', dial: 2, support, leader: ownerLeader,
@@ -391,10 +433,12 @@ export function createIndependentSkillsBattleFixture(
}
assert.ok(game.battle?.revealed);
staging.push(`Both combatants made their real posture decisions before sealing: the Emperor hid its skilled leader behind the shield, the Guild ${caseKind === 'suk' ? 'kept its skilled leader face up and therefore committed another leader' : 'hid its skilled leader so that the committed Warmaster could resolve'}.`);
const returns: HarassWithdrawSelection = { [first]: { normal: 1, elite: 0 }, [second]: { normal: 1, elite: 0 } };
const returns: HarassWithdrawSelection = strongholds
? { [first]: { normal: 2, elite: 0 } }
: { [first]: { normal: 1, elite: 0 }, [second]: { normal: 1, elite: 0 } };
const fixture: IndependentSkillsBattleFixture = { ...setup, case: caseKind, advanced, location, first, second,
beforeShipment, shipment, afterShipment, move, afterMove, opponentMove, beforeBattle, battleStep,
game, plans, returns, hide, traitor, actions, staging };
afterFirstMentat, beforeShipment, shipment, afterShipment, move, afterMove, opponentMove, beforeBattle, battleStep,
beforePlans, game, plans, returns, hide, traitor, actions, staging };
assertIndependentSkillsBattleCustody(game);
return fixture;
}
@@ -409,6 +453,7 @@ export function quoteIndependentSkillsBattle(game: Game,
const player = independentSkillsBattlePlayer(game, actor);
const view = viewGame(game, actor).battle!;
return { id: actor, faction: player.faction, spice: player.spice, hand: player.hand,
stronghold: game.advanced ? strongholdBenefit(game.strongholdCards, actor, battle.territory, battle.strongholdCopy) : null,
plan: battle.plans[actor],
leader: player.leaders.find((leader) => leader.id === battle.plans[actor].leader),
forces: view.ownForces!,
+13 -2
View File
@@ -9,6 +9,8 @@ import { RECRUITS_CARD_ID } from '../game/recruits';
import { forceRevivalQuote } from '../game/revival';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import { TERRITORIES } from '../game/board';
import { createTechTokens } from '../game/tech-tokens';
import { createStrongholdCards } from '../game/stronghold-cards';
import type { Card } from '../game/cards';
import {
nextIndependentCardModulesStep, type IndependentCardModulesStep,
@@ -18,6 +20,9 @@ export type IndependentSkillsRevivalStep = IndependentCardModulesStep;
export type IndependentSkillsRevivalOptions = {
/** Original Basic or Advanced rules; the independent composition is the same. */
advanced?: boolean;
/** Select original unused phase modules before admission; both default off. */
tech?: boolean;
strongholds?: boolean;
seatIds?: readonly string[];
};
@@ -63,6 +68,8 @@ export type IndependentSkillsRevivalFixture = {
beforeMainRevival: Game;
/** After the payer's real revival: Bank income is deferred and unpaid. */
afterRevival: Game;
/** Original Shipment boundary after Revival pays and resets its Tech income. */
afterPhaseEnd: Game;
/** Turn-two Spice Collection settled, immediately before the Mentat settlement. */
atMentat2: Game;
/** Turn-two Mentat Pause after the deferred income was credited exactly once. */
@@ -224,6 +231,7 @@ export function createIndependentSkillsRevivalFixture(
options: IndependentSkillsRevivalOptions = {},
): IndependentSkillsRevivalFixture {
const advanced = options.advanced ?? false;
assert.ok(!options.strongholds || advanced, 'Original Stronghold Cards require Advanced');
const ids = options.seatIds ?? ['p0', 'p1', 'p2'];
assert.equal(ids.length, 3, 'The revival programme uses the Banker, payer and battle opponent seats');
const [owner, payer, opponent] = ids;
@@ -241,6 +249,8 @@ export function createIndependentSkillsRevivalFixture(
let game = createGame('INDEPENDENTSKILLSREVIVAL', newPlayer(owner, 'Banker', BANKER), advanced);
joinGame(game, newPlayer(payer, 'Payer', PAYER));
joinGame(game, newPlayer(opponent, 'Opponent', OPPONENT));
if (options.tech) game.techTokens = createTechTokens();
if (options.strongholds) game.strongholdCards = createStrongholdCards();
for (const player of game.players) game = record(game, { actor: player.id, action: { type: 'ready' } });
// The marker is set after every lobby action, before the ordinary initializer.
game.ecazTreachery = true;
@@ -495,13 +505,14 @@ export function createIndependentSkillsRevivalFixture(
assert.deepEqual(viewGame(entry.game, owner).spiceBankerIncome?.deferred, [],
`${entry.label} must not earn Bank income`);
const atMentat2 = advanceUntil(game, state => state.turn === 2 && state.phase === 7 && clean(state));
const afterPhaseEnd = advanceUntil(game, state => state.turn === 2 && state.phase === 5 && clean(state));
const atMentat2 = advanceUntil(afterPhaseEnd, state => state.turn === 2 && state.phase === 7 && clean(state));
assert.deepEqual(viewGame(atMentat2, owner).spiceBankerIncome?.deferred, [{ owner, amount: 1 }],
'The deferred Bank income survives until the actual Mentat settlement');
const afterMentat = advanceUntil(atMentat2, state => state.turn === 2 && state.phase === 8 && clean(state));
return {
initial, offered, afterSetup, afterBattle, afterBribe, atMentat1, atRevival,
beforeRecruits, beforeMainRevival, afterRevival, atMentat2, afterMentat,
beforeRecruits, beforeMainRevival, afterRevival, afterPhaseEnd, atMentat2, afterMentat,
owner, payer, opponent, bankerLeader, card: RECRUITS_CARD_ID,
territory: { id: TERRITORY_ID, sector: TERRITORY_SECTOR, type: 'sand' },
battle: {
+10 -3
View File
@@ -228,9 +228,16 @@ export function createIxChoamSkillsTechFixture(options: IxChoamSkillsTechOptions
for (let i = 0; (game.response || game.decision || game.pendingShipment) && i < 200; i++) game = step(game, actions);
// Actual ten-spice desert invoice exhausts Emperor's original bank wallet.
// Any native Heighliners payout can add at most one; no Collection at this pile.
const counter = game.deck.findIndex(card => card.effect === 'karama');
assert.ok(counter >= 0);
ixChoamSkillsTechPlayer(game, actor).hand.push(game.deck.splice(counter, 1)[0]);
const hand = ixChoamSkillsTechPlayer(game, actor).hand;
if (!hand.some(card => card.effect === 'karama')) {
// A real starting deal can put both printed counters in hands, not the deck.
const source = [game.deck, game.discard,
...game.players.filter(player => player.id !== actor).map(player => player.hand)]
.find(zone => zone.some(card => card.effect === 'karama'));
assert.ok(source, 'Conserve an original printed Karama from its actual physical zone');
const counter = source.findIndex(card => card.effect === 'karama');
hand.push(source.splice(counter, 1)[0]);
}
let beforeCharityIncome: Game | undefined;
let afterCharityIncome: Game | undefined;
for (let i = 0; (game.turn !== 2 || game.phase !== 2 || game.phaseOpening || game.response || game.decision)
+6 -1
View File
@@ -18,7 +18,7 @@ import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions'
import { quoteIndependentSkillsBattle } from './fixture-independent-skills-battle';
export type IxIndependentSkillsStep = { actor: string; action: Action };
export type IxIndependentSkillsOptions = { advanced?: boolean };
export type IxIndependentSkillsOptions = { advanced?: boolean; tech?: boolean; strongholds?: boolean };
export const ixIndependentSkillsPlayer = (game: Game, actor: string): Player => {
const player = game.players.find(p => p.id === actor); assert.ok(player); return player;
};
@@ -67,6 +67,8 @@ export function ixIndependentSkillsPolicyActions(game: Game, actor: string, diff
export function nextIxIndependentSkillsStep(game: Game): IxIndependentSkillsStep | null {
if (!game.phaseOpening && !game.response) {
const d = game.decision;
if (d?.kind === 'techToken') return { actor: d.player,
action: { type: 'decision', token: d.choices[0] } };
if (d?.kind === 'leaderSkillVisibility') return { actor: d.player,
action: { type: 'leaderSkillVisibility', event: d.event, hide: d.player === 'ixians' } };
if (d?.kind === 'guildTiming') return { actor: d.player, action: { type: 'decision', take: false } };
@@ -153,6 +155,9 @@ export function createIxIndependentSkillsFixture(options: IxIndependentSkillsOpt
actions.push(structuredClone(step)); game = stepIxIndependentSkills(game, step);
return game;
};
for (const [type, enabled] of [
['techTokens', options.tech ?? false], ['strongholdCards', options.strongholds ?? false],
] as const) if (enabled) record({ actor: game.host, action: { type, enabled } });
for (const option of ['leaderSkills', 'ecazTreachery'] as const)
record({ actor: game.host, action: { type: 'moduleOption', module: option, enabled: true } });
for (const p of game.players) record({ actor: p.id, action: { type: 'ready' } });
+13 -3
View File
@@ -31,6 +31,9 @@ export type MoritaniIndependentSkillsOptions = {
skills?: boolean;
/** The independent three-card Ecaz Treachery variant of printed p. 18. */
cards?: boolean;
/** Original optional modules, both off by default; Strongholds require Advanced. */
tech?: boolean;
strongholds?: boolean;
opponent?: MoritaniIndependentSkillsOpponent;
/** Native Ecaz replaces the classic opponent; both factions keep printed setup.
* `opponent` remains the standalone-only Guild/Harkonnen selector. */
@@ -172,6 +175,8 @@ export function nextMoritaniIndependentSkillsStep(
if (decision?.kind === 'battleCards')
return { actor: decision.player, action: { type: 'decision',
discard: policy.winnerDiscard === 'none' ? [] : [...decision.cards] } };
if (decision?.kind === 'techToken')
return { actor: decision.player, action: { type: 'decision', token: decision.choices[0] } };
if (decision?.kind === 'captureOffer')
return { actor: decision.player,
action: { type: 'decision', accept: policy.capture?.accept ?? false } };
@@ -287,9 +292,12 @@ export function createMoritaniIndependentSkillsFixture(
assert.equal(game.advanced, advanced);
assert.deepEqual(game.expansions, ['ecaz']);
assert.equal(game.status, 'lobby', 'Continue only a fresh lobby of this exact composition.');
assert.ok(!game.homeworlds && !game.nexusCards && !game.discoveryEnabled &&
!game.techTokens && !game.strongholdCards,
'Only the standalone E3/classic composition without other modules is accepted.');
assert.ok(!game.homeworlds && !game.nexusCards && !game.discoveryEnabled,
'Only the original E3/classic composition and optional Tech/Strongholds are accepted.');
assert.ok(!game.techTokens || options.tech,
'A preselected original Tech module needs tech:true.');
assert.ok(!game.strongholdCards || options.strongholds,
'A preselected original Stronghold module needs strongholds:true.');
const actions: MoritaniIndependentSkillsStep[] = [], staging: string[] = [];
const act = (actor: string, action: Action) => {
actions.push({ actor, action: structuredClone(action) });
@@ -299,6 +307,8 @@ export function createMoritaniIndependentSkillsFixture(
const initial = structuredClone(game);
if (skills) act('moritani', { type: 'moduleOption', module: 'leaderSkills', enabled: true });
if (cards) act('moritani', { type: 'moduleOption', module: 'ecazTreachery', enabled: true });
if (options.tech && !game.techTokens) act('moritani', { type: 'techTokens', enabled: true });
if (options.strongholds && !game.strongholdCards) act('moritani', { type: 'strongholdCards', enabled: true });
for (const player of game.players) if (!player.ready) act(player.id, { type: 'ready' });
const start: MoritaniIndependentSkillsStep = { actor: 'moritani',
action: { type: 'start', ...(advanced ? { advancedPreview: true as const } : {}) } };
+51
View File
@@ -156,3 +156,54 @@ void test('normal Bureaucrat remains available in independent all14 play and div
assert.equal(independentSkillsBattlePlayer(game, f.opponent).bribes, 3);
assert.equal(viewGame(game, f.observer).bureaucrat!.usedThisPhase, true);
});
void test('earned Advanced Arrakeen funds support without personal spice, changing neither the fee, the real Suk commitment nor mandatory disposal', () => {
const f = createIndependentSkillsBattleFixture({ advanced: true, tech: true, strongholds: true, case: 'suk' });
assert.equal(f.afterFirstMentat!.strongholdCards!.claimedTurn, 1);
assert.equal(f.afterFirstMentat!.strongholdCards!.owners.arrakeen, f.owner);
assert.equal(owner(f, f.afterFirstMentat!).forces[f.first], 4, 'The original paid shipment earned and held the card');
assert.equal(owner(f, f.beforePlans).spice, 0, 'A real bribe emptied the wallet before support was sealed');
assert.equal(f.plans[0].action.support, 2);
assert.throws(() => applyAction(structuredClone(f.beforePlans), f.owner,
{ ...f.plans[0].action, dial: 3, support: 3 }),
'The funded two-spice support budget cannot become a third personal spice');
const quote = quoteIndependentSkillsBattle(f.game, { returns: f.returns });
assert.deepEqual(quote.payments.find(payment => payment.player === f.owner),
{ player: f.owner, cost: 0, ownPayment: 0, allyPayment: 0, donor: null, bankSupport: 2, freeByTraitor: false });
assert.deepEqual(quote.reinforcements,
[{ player: f.owner, card: REINFORCEMENTS_CARD, normal: 3, elite: 0 }]);
assert.deepEqual(quote.harassWithdraw?.[0].returned, { normal: 2, elite: 0 });
const fee = finishIndependentSkillsBattle(f.game,
{ return: f.returns, until: state => state.decision?.kind === 'sukRescue' });
assert.equal(owner(f, fee).spice, 0, 'The Bank paid support directly, never to the wallet');
assert.equal(owner(f, fee).reserves, 15);
assert.equal(owner(f, fee).tanks, 3, 'The three physical Reinforcements fee counters are not rescue income');
assert.deepEqual(owner(f, fee).forces, { [f.first]: 2 }, 'Only the two dialed casualties remain after Harass');
assert.equal(fee.techTokens!.axlotl.spice, 0, 'A physical fee and Harass return are not a Revival-phase income trigger');
assert.equal(fee.decision?.kind, 'sukRescue');
const rescue = fee.decision!;
const options = rescue.kind === 'sukRescue' ? rescue.options : [];
assert.equal(Math.max(...options.map(option => option.normal + option.elite)), 2,
'Suk can rescue the committed casualties, never the three-counter fee');
const done = finishIndependentSkillsBattle(fee, { return: f.returns });
assert.equal(done.turn, 2);
assert.equal(done.phase, 7);
assert.equal(done.lastBattleContext?.winner, f.owner);
assert.equal(done.lastBattleContext?.sukRescue?.completed, true);
assert.equal(done.pendingSukRescue, null);
assert.equal(owner(f, done).reserves, 16);
assert.equal(owner(f, done).tanks, 3);
assert.deepEqual(owner(f, done).forces, { [f.first]: 1 });
assert.equal(owner(f, done).spice, 2, 'Only the original Collection income arrives after the battle');
assert.equal(opponent(f, done).tanks, 5);
assert.equal(opponent(f, done).reserves, 10);
assert.deepEqual(opponent(f, done).forces, { 'tueks_sietch:5': 5 }, 'The native army was not replaced to build this battle');
const worthless = f.plans[1].action.weapon;
assert.ok(typeof worthless === 'string');
assert.deepEqual(done.lastBattleContext?.mandatoryDiscard?.entries, [{ card: worthless, owner: f.opponent }]);
for (const card of [REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD, worthless]) {
assert.equal(held(done, card).length, 1);
assert.equal(done.discard.filter(entry => entry.id === card).length, 1);
}
assertIndependentSkillsBattleCustody(done);
});
+53
View File
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Game } from '../game/engine';
import { forceRevivalQuote } from '../game/revival';
import { ownedTech } from '../game/tech-tokens';
import {
assertIndependentSkillsRevivalInventory, createIndependentSkillsRevivalFixture,
independentSkillsRevivalPlayer,
@@ -100,3 +101,55 @@ void test('the Advanced programme preserves real paid revival and deferred Banke
assert.deepEqual(viewGame(advanced.afterMentat, advanced.owner).spiceBankerIncome?.deferred, []);
assertIndependentSkillsRevivalInventory(advanced.afterMentat);
});
void test('Basic Tech Recruits reopens exhausted genuine Tanks without repeating payment, Axlotl accrual or normal Banker income', () => {
const f = createIndependentSkillsRevivalFixture({ tech: true });
let game = structuredClone(f.atRevival);
const tokenOwner = game.techTokens!.axlotl.owner!;
const accrual = ownedTech(game.techTokens, tokenOwner).length;
const payerSpice = spiceOf(game, f.payer);
const tokenSpice = spiceOf(game, tokenOwner);
const bankerSpice = spiceOf(game, f.owner);
assert.ok(tokenOwner && accrual > 0);
// These are the same five genuine battle losses, not a supplied Tanks pool.
assert.equal(independentSkillsRevivalPlayer(game, f.payer).tanks, 5);
game = applyAction(game, f.payer, { type: 'revive', amount: 3 });
assert.equal(spiceOf(game, f.payer), payerSpice - 4);
assert.equal(viewGame(game, f.payer).revival.forcesRemaining, 0);
assert.equal(game.techTokens!.axlotl.spice, accrual);
assert.equal(spiceOf(game, tokenOwner), tokenSpice - (tokenOwner === f.payer ? 4 : 0),
'Axlotl is accrued, not spendable, regardless of the actual original custodian');
game = applyAction(game, f.payer, f.playAction.action);
assert.equal(independentSkillsRevivalPlayer(game, f.payer).revived, 3);
assert.equal(independentSkillsRevivalPlayer(game, f.payer).freeForcesRevived, 1);
assert.equal(spiceOf(game, f.payer), payerSpice - 4, 'Activation does not replay the previous charge');
const activated = structuredClone(game);
assert.throws(() => applyAction(game, f.payer, { type: 'revive', amount: 3 }),
'Only two actual Tanks remain, even though the increased ordinary allowance is larger');
assert.deepEqual(game, activated);
game = applyAction(game, f.payer, { type: 'revive', amount: 2 });
assert.equal(spiceOf(game, f.payer), payerSpice - 6, 'Only the new two-spice quote is charged');
const returned = independentSkillsRevivalPlayer(game, f.payer);
assert.equal(returned.tanks, 0);
assert.equal(returned.reserves, 20);
assert.equal(returned.revived, 5);
assert.equal(returned.freeForcesRevived, 2);
assert.equal(game.techTokens!.axlotl.spice, accrual, 'The second real revival cannot accrue again');
assert.equal(game.techTokens!.axlotl.triggeredTurn, 2);
assert.equal(spiceOf(game, tokenOwner), tokenSpice - (tokenOwner === f.payer ? 6 : 0));
const grants = game.spiceBankerIncome!.sources.filter(source => source.grant);
assert.equal(grants.length, 1, 'Normal Banker keeps its four-spice threshold; the second two-spice payment does not qualify');
assert.equal(grants[0].source.kind, 'force-revival');
assert.deepEqual(grants[0].source.bankLegs, [{ payer: f.payer, amount: 4 }]);
assert.deepEqual(viewGame(game, f.owner).spiceBankerIncome?.deferred, [{ owner: f.owner, amount: 1 }]);
assert.equal(spiceOf(game, f.owner), bankerSpice);
const shipment = f.advanceUntil(game, state => state.turn === 2 && state.phase === 5 &&
!state.phaseOpening && !state.response && !state.decision);
assert.equal(shipment.techTokens!.axlotl.spice, 0);
assert.equal(spiceOf(shipment, tokenOwner), tokenSpice - (tokenOwner === f.payer ? 6 : 0) + accrual,
'The original phase end alone pays Axlotl once');
assert.equal(spiceOf(shipment, f.owner), bankerSpice + (tokenOwner === f.owner ? accrual : 0),
'Only actual Tech custody pays now; Banker remains deferred until Mentat');
assert.equal(shipment.discard.filter(card => card.id === f.card).length, 1);
assertIndependentSkillsRevivalInventory(shipment);
});
@@ -145,3 +145,41 @@ void test('all four minimal policies can seal native paid plans and execute proj
assertIxIndependentSkillsCustody(done);
}
});
void test('original native Tech and Stronghold phases keep the typed Suboid fee outside Suk and carry the winner into paid Recruits', () => {
const f = createIxIndependentSkillsRevivalFixture({ advanced: true, tech: true, strongholds: true });
const revealed = revealIxIndependentSkillsBattle(f);
const quote = quoteIxIndependentSkillsBattle(f, revealed);
assert.deepEqual(quote.reinforcements,
[{ player: f.ixians, card: REINFORCEMENTS_CARD, normal: 3, elite: 0 }]);
assert.deepEqual(quote.harassWithdraw![0].locations, f.returns);
const rescue = finishIxIndependentSkillsBattle(f, { state: revealed, stop: 'rescue' });
const initial = player(f.game, f.ixians), charged = player(rescue, f.ixians);
assert.equal(charged.reserves, initial.reserves - 3 + 2,
'Three actual Suboids pay before the physical Suboid and Cyborg return');
assert.equal(charged.elites!.reserves, initial.elites!.reserves + 1);
assert.equal(charged.tanks, 3);
assert.equal(charged.elites!.tanks, 0);
const saved = finishIxIndependentSkillsBattle(f, { state: rescue });
assert.equal(saved.lastBattleContext!.sukRescue!.completed, true);
assert.equal(player(saved, f.ixians).tanks, 3, 'Suk cannot rescue the reserve fee');
assert.equal(player(saved, f.ixians).elites!.tanks, 0);
assert.equal(f.afterBattle.techTokens!.production.owner, f.ixians,
'The real winner takes the original unowned token, not a staged claim');
assert.equal(f.beforeRevival.strongholdCards!.claimedTurn, 1);
assert.equal(f.beforeRevival.strongholdCards!.owners.hidden_mobile_stronghold, f.ixians);
assert.deepEqual(f.price, { free: 1, normalCost: 5, cost: 5 });
const before = player(f.beforeRevival, f.ixians), after = player(f.afterRevival, f.ixians);
assert.equal(after.spice, before.spice - 5);
assert.equal(player(f.afterRevival, f.tleilaxu).spice, player(f.beforeRevival, f.tleilaxu).spice + 5,
'Native paid Cyborg/Suboid revival still pays the real Tleilaxu recipient');
assert.equal(after.reserves, before.reserves + 3);
assert.equal(after.elites!.reserves, before.elites!.reserves + 1);
assert.equal(after.tanks, before.tanks - 3);
assert.equal(after.elites!.tanks, 0);
assert.equal(viewGame(f.afterRevival, f.ixians).revival.freeRemaining, 0);
for (const id of [REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD, RECRUITS_CARD_ID])
assert.equal(f.afterRevival.discard.filter(card => card.id === id).length, 1);
assertIxIndependentSkillsCustody(saved);
assertIxIndependentSkillsCustody(f.afterRevival);
});
@@ -3,6 +3,7 @@ import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import { ownedTech } from '../game/tech-tokens';
import {
advanceMoritaniIndependentSkills,
assertMoritaniIndependentSkillsCustody,
@@ -342,8 +343,10 @@ void test('a real normal Traitor call forfeits the printed power for the game an
});
void test('Advanced paired Skills/cards loss returns selected sectors, then assassinates one unused native Ecaz disc before Mentat replacement', () => {
const f = createMoritaniIndependentSkillsFixture({ paired: true, rules: 'advanced', skills: true, cards: true });
void test('Advanced paired Skills/cards with original Tech/Strongholds return selected sectors, then assassinate one unused native Ecaz disc before Mentat replacement', () => {
const f = createMoritaniIndependentSkillsFixture({
paired: true, rules: 'advanced', skills: true, cards: true, tech: true, strongholds: true,
});
const loyal = f.afterSetup.ecazLoyalty!.card!;
assert.notEqual(f.traitorTarget, loyal);
assert.notEqual(f.traitorTarget, f.winnerLeader);
@@ -401,6 +404,12 @@ void test('Advanced paired Skills/cards loss returns selected sectors, then assa
const beforeMentat = finishMoritaniIndependentSkillsBattle(spent, { winnerDiscard: 'played' },
(game) => game.phase === 7 && moritaniIndependentSkillsClean(game));
assert.equal(beforeMentat.discard.filter((card) => card.id === f.cards.winnerWeapon).length, 1);
const lostTokens = ownedTech(f.game.techTokens, f.moritani);
assert.equal(lostTokens.length, 1, 'the actual losing Moritani army entered with one original token');
assert.equal(beforeMentat.techTokens![lostTokens[0]].owner, f.opponent,
'Assassinate preserves the original winner cleanup and its automatic single-token transfer');
assert.equal(beforeMentat.strongholdCards!.claimedTurn, 0,
'no Stronghold Card is fabricated before the first genuine Mentat acquisition');
assert.equal(beforeMentat.moritaniAssassinate!.opportunities[0].stage, 'revealed');
assert.deepEqual(player(beforeMentat, f.moritani).traitors, [f.traitorTarget]);
const settled = finishMoritaniIndependentSkillsBattle(beforeMentat);
@@ -436,9 +445,11 @@ void test('Basic paired Skills/cards loss leaves the unused trained Ecaz disc an
assertMoritaniIndependentSkillsCustody(settled);
});
void test('paired Advanced Skills without independent cards still pays the printed Ecaz assassination bounty', () => {
const f = createMoritaniIndependentSkillsFixture({ paired: true, rules: 'advanced', skills: true });
void test('paired Advanced Skills with original phase modules pays the printed Ecaz bounty and winner Tech without independent cards', () => {
const f = createMoritaniIndependentSkillsFixture({ paired: true, rules: 'advanced', skills: true,
tech: true, strongholds: true });
assert.equal(f.pending.lastBattleContext!.winner, f.opponent);
assert.equal(f.initial.nexusCards, undefined);
assert.equal(f.returns, null);
assert.equal(f.cards.reinforcements, null);
assert.equal(f.cards.harass, null);
@@ -447,10 +458,21 @@ void test('paired Advanced Skills without independent cards still pays the print
const physical = [...f.pending.deck, ...f.pending.discard, ...f.pending.players.flatMap((seat) => seat.hand)];
assert.equal(physical.length, 33);
assert.equal(physical.some((card) => card.id === REINFORCEMENTS_CARD || card.id === HARASS_WITHDRAW_CARD), false);
const defeatedTokens = Object.entries(f.pending.techTokens!).filter(([, token]) =>
token.owner === f.moritani).map(([id]) => id);
const spent = chooseMoritaniIndependentSkillsAssassination(f.pending, f.traitorTarget);
assert.equal(owned(f, spent, f.traitorTarget).dead, true);
assert.equal(spent.moritaniAssassinate!.opportunities[0].bounty, 4);
assert.equal(player(spent, f.moritani).spice, player(f.pending, f.moritani).spice + 4);
assert.equal(spent.leaderSkills!.assignments.some((entry) => entry.leader === f.traitorTarget), false);
assertMoritaniIndependentSkillsCustody(spent);
const settled = finishMoritaniIndependentSkillsBattle(spent, { winnerDiscard: 'played' });
assert.equal(settled.phase, 8, 'plain paired Skills keeps the original phase envelope without a card-module dependency');
assert.equal(settled.discard.filter(card => card.id === f.cards.winnerWeapon).length, 1);
assert.equal(settled.moritaniAssassinate!.opportunities.length, 1);
assert.equal(settled.moritaniAssassinate!.opportunities[0].stage, 'replaced');
assert.equal(settled.traitorReserve!.length, f.pending.traitorReserve!.length - 1);
assert.equal(Object.entries(settled.techTokens!).filter(([id, token]) =>
defeatedTokens.includes(id) && token.owner === f.opponent).length, 1);
assertMoritaniIndependentSkillsCustody(settled);
});