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:
1 parent
17adffa6a7
commit
b9330d489e
31 files changed
+797
-104
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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!,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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' } });
|
||||
|
||||
@@ -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 } : {}) } };
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user