Compose native faction Tech and Advanced Strongholds without Skills

This commit is contained in:
Quintine committed 2026-10-04 14:59:12 +11:00
1 parent 7cdb9b5a62
commit 2da3c9273b
16 files changed
+1358 -45

No files matched your search

+278
View File
@@ -0,0 +1,278 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeFactionExpansionsGameForAudit,
initializeStrongholdFactionsGameForAudit, joinGame, newPlayer, viewGame, type Game,
} from '../game/engine';
import type { FactionId } from '../game/catalog';
import { treacheryDeck } from '../game/cards';
import { createTechTokens, ownedTech } from '../game/tech-tokens';
import { quoteBattleResolution, type ResolutionCombatant } from '../game/battle-resolution-quote';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import { strongholdControllers } from '../game/stronghold-cards';
import { finishEcazOccupySetup } from './fixture-ecaz-occupy';
import { advanceEcazStronghold, ecazStrongholdSeat as player, finishEcazStronghold } from './fixture-ecaz-strongholds';
import {
advanceMoritaniStrongholds, holdMoritaniStrongholdsTraitor, nextMoritaniStrongholdsStep,
resolveMoritaniStrongholdsBattle, stageMoritaniStrongholdsBattle,
} from './fixture-moritani-strongholds';
import { quoteStrongholdFactionsBattle } from './fixture-stronghold-factions';
import {
createEcazNativeTechFixture, createMoritaniNativeTechFixture,
initializeE3NativeTechSetup, revealEcazNativeTechBattle,
} from './fixture-e3-native-tech';
function lobby(roster: FactionId[], advanced = true, expansions: Game['expansions'] = ['ecaz']): Game {
let game = createGame('E3TECHGUARD', newPlayer(`auth-${roster[0]}`, roster[0], roster[0]), advanced, expansions);
for (const faction of roster.slice(1)) joinGame(game, newPlayer(`auth-${faction}`, faction, faction));
game.techTokens = createTechTokens();
for (const p of game.players) game = applyAction(game, p.id, { type: 'ready' });
return game;
}
function physical(game: Game): void {
const cards = [...game.deck, ...game.discard, ...(game.auction?.cards.slice(game.auction.index) ?? []),
...game.players.flatMap(p => p.hand)].map(c => c.id).sort();
assert.deepEqual(cards, treacheryDeck(['ecaz']).map(c => c.id).sort(), 'Every original selected physical card retains one custodian.');
assert.equal(new Set(cards).size, cards.length);
for (const p of game.players) {
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, n) => sum + n, 0), 20);
if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0),
p.faction === 'emperor' ? 5 : 3);
}
assert.equal(Object.keys(game.techTokens!).length, 3);
for (const token of Object.values(game.techTokens!))
assert.ok(token.owner === null || game.players.some(p => p.id === token.owner));
const moritani = game.players.find(p => p.faction === 'moritani');
if (moritani) {
assert.equal(moritani.leaders.length, 5, 'The five original Moritani leader discs remain physically present.');
assert.equal(new Set(moritani.leaders.map(l => l.id)).size, 5);
assert.equal(game.moritaniTerror!.tokens.length, 6);
assert.equal(new Set(game.moritaniTerror!.tokens.map(t => t.id)).size, 6);
const traitors = [...game.players.flatMap(p => p.traitors), ...(game.traitorReserve ?? []),
...(game.ecazLoyalty?.card ? [game.ecazLoyalty.card] : []),
...(game.moritaniAssassinate?.opportunities.flatMap(o => o.stage === 'replaced' && o.card ? [o.card] : []) ?? [])];
assert.equal(new Set(traitors).size, traitors.length, 'Held, replacement-reserve and revealed spent Traitors have distinct physical custody.');
}
}
for (const native of ['ecaz', 'moritani'] as const) for (const advanced of [false, true])
for (const seats of [3, 6]) void test(`${advanced ? 'Advanced' : 'Basic'} standalone ${native} plain Tech: ${seats} original seats complete real setup and receive three first-Storm tokens`, () => {
const roster: FactionId[] = [native, 'guild', 'emperor', 'atreides', 'fremen', 'beneGesserit'].slice(0, seats) as FactionId[];
const admitted = lobby(roster, advanced);
const setup = initializeE3NativeTechSetup({ initial: admitted, native, advanced });
let game = finishEcazOccupySetup(setup);
const actor = game.players.find(p => p.faction === native)!;
assert.equal(actor.id, `auth-${native}`);
physical(game);
game = advanceMoritaniStrongholds(game, g => g.phase === 2 && !g.phaseOpening && !g.response && !g.decision);
const owners = Object.values(game.techTokens!).map(token => token.owner);
assert.ok(owners.every(owner => typeof owner === 'string' && game.players.some(p => p.id === owner)));
assert.equal(new Set(owners).size, 3, 'Actual first-Storm order supplies unassigned tokens to three distinct seats.');
physical(game);
});
for (const strongholds of [false, true]) for (const lead of ['ecaz', 'ally'] as const)
void test(`Advanced standalone Ecaz ${strongholds ? 'Stronghold+Tech' : 'plain Tech'}: selected ${lead} earns only the enemy token after original holder reward and typed owner casualties`, () => {
const f = createEcazNativeTechFixture({ strongholds });
const result = revealEcazNativeTechBattle(f, lead, true);
const { before, revealed, pending, actor } = result;
const battle = revealed.battle!, publicBattle = viewGame(revealed, actor).battle!;
const side = (id: string): ResolutionCombatant => {
const p = player(revealed, id), view = viewGame(revealed, id).battle!;
assert.ok(view.ownForces);
return { id, faction: p.faction, ally: p.ally, spice: p.spice, hand: p.hand,
plan: battle.plans[id], leader: p.leaders.find(l => l.id === battle.plans[id].leader),
forces: view.ownForces, stronghold: view.strongholdEffects[id] };
};
const quote = quoteBattleResolution({ advanced: true, turn: revealed.turn, territory: f.territory,
ecazOccupy: publicBattle.ecazOccupy!.profile!, aggressor: publicBattle.aggressor,
attacker: side(battle.attacker), defender: side(battle.defender),
voters: publicBattle.traitorVoters.map(id => ({ id, called: false, traitors: player(revealed, id).traitors,
beneficiary: [battle.attacker, battle.defender].includes(id) ? id : actor })),
participants: revealed.players, physicalCards: [...revealed.deck, ...revealed.discard, ...revealed.players.flatMap(p => p.hand)],
pendingAuditorPresent: false, pendingRetentionPresent: false });
assert.deepEqual(quote.fixedLosses, [{ owner: f.ecaz, normal: 3, elite: 0 }]);
assert.equal(quote.casualties!.owner, f.ally, 'The Guild variable army owns its losses even when Ecaz seals the winning plan.');
assert.deepEqual(quote.casualties!.options, [{ normal: 3, elite: 0,
paidNormal: battle.plans[actor].support, paidElite: 0 }]);
const income = strongholds && lead === 'ecaz' ? 2 : 0;
assert.deepEqual(quote.strongholdIncome, income ? [{ player: actor, amount: income }] : []);
const payment = quote.payments.find(p => p.player === actor)!;
assert.equal(player(pending, actor).spice, player(before, actor).spice - payment.ownPayment + income);
assert.equal(pending.decision?.player, actor);
const enemyToken = ownedTech(before.techTokens, f.opponent);
assert.equal(enemyToken.length, 1);
assert.equal(pending.techTokens![enemyToken[0]].owner, f.opponent,
'Stronghold reward is already paid while actual winner-card cleanup still precedes Tech custody.');
const game = finishEcazStronghold(pending), nonLead = lead === 'ecaz' ? f.ally : f.ecaz;
assert.equal(game.lastBattleContext!.winner, actor);
assert.equal(game.techTokens![enemyToken[0]].owner, actor);
assert.equal(ownedTech(game.techTokens, actor).length, 2);
assert.equal(ownedTech(game.techTokens, nonLead).length, 1);
assert.equal(ownedTech(game.techTokens, f.opponent).length, 0);
assert.equal(player(game, f.ecaz).tanks - player(before, f.ecaz).tanks, 3);
assert.equal(player(game, f.ally).tanks - player(before, f.ally).tanks, 3);
assert.equal(player(game, f.opponent).tanks - player(before, f.opponent).tanks, 8);
assert.equal(player(game, f.ecaz).forces[f.location], 2);
assert.equal(player(game, f.ally).forces[f.location], 1);
const progress = viewGame(game, actor).victoryProgress.find(p => p.player === actor)!;
assert.equal(progress.strongholds.length, 3);
assert.equal(progress.jointlyOccupied.length, 1);
assert.equal(progress.techStronghold, false, 'Two plus one allied tokens do not produce a joint third-token stronghold.');
assert.equal(progress.qualifies, false);
physical(game);
const collection = quoteSpiceCollection(game).receipts.find(p => p.player === actor)!;
const nextTurn = advanceEcazStronghold(game, g => g.turn === 4 && g.phase === 0);
assert.equal(nextTurn.status, 'playing', 'Original end-Mentat does not infer an allied three-token victory.');
assert.deepEqual(nextTurn.winner, []);
assert.equal(player(nextTurn, actor).spice, player(pending, actor).spice + collection.strongholds + collection.collected,
'Only actual native Collection follows the completed reward; the earlier Stronghold battle income does not replay.');
});
for (const lead of ['ecaz', 'ally'] as const)
void test(`Basic plain Tech Ecaz ${lead}: even original coalition pays no support and attributes the token to its actual selected lead`, () => {
const f = createEcazNativeTechFixture({ advanced: false });
const result = revealEcazNativeTechBattle(f, lead);
const token = ownedTech(result.before.techTokens, f.opponent)[0];
assert.equal(player(result.pending, result.actor).spice, player(result.before, result.actor).spice);
const game = finishEcazStronghold(result.pending);
assert.equal(game.techTokens![token].owner, result.actor);
assert.equal(player(game, f.ecaz).forces[f.location], 2);
assert.equal(player(game, f.ally).forces[f.location], 1);
assert.equal(player(game, f.ecaz).tanks - player(result.before, f.ecaz).tanks, 2);
assert.equal(player(game, f.ally).tanks - player(result.before, f.ally).tanks, 3);
physical(game);
});
void test('Basic native Tech Ecaz retains the actual odd-force chooseBattle ruling guard', () => {
const f = createEcazNativeTechFixture({ advanced: false, ecazForces: 3, chooseBattle: false });
assert.throws(() => applyAction(f.game, f.battleAction.actor, f.battleAction.action), /Odd-force Basic Occupy/);
physical(f.game);
});
for (const strongholds of [false, true])
void test(`Advanced Moritani ${strongholds ? 'Stronghold+Tech' : 'plain Tech'}: loss retains original effects, assassination pays one bounty, actual winner earns Tech, and Mentat replaces one held Traitor`, () => {
const f = createMoritaniNativeTechFixture({ strongholds });
assert.equal(f.afterSetup.players.find(p => p.id === f.moritani)!.leaders.length, 5);
assert.equal(f.afterPlacement.moritaniTerror!.tokens.filter(t => t.status === 'available').length, 5);
assert.equal(f.afterPlacement.moritaniTerror!.tokens.filter(t => t.status === 'placed').length, 1);
const discs = f.afterPlacement.moritaniTerror!.tokens.map(t => ({ ...t }));
const pending = f.pending, quote = quoteStrongholdFactionsBattle(f.revealed);
assert.equal(pending.decision?.kind, 'moritaniAssassinate');
assert.equal(pending.lastBattleContext!.winner, f.opponent);
const income = strongholds ? 4 : 0;
assert.deepEqual(quote.strongholdIncome, income ? [{ player: f.moritani, amount: income }] : []);
assert.equal(player(pending, f.moritani).spice, player(f.revealed, f.moritani).spice - 1 + income);
assert.equal(player(pending, f.opponent).spice, player(f.revealed, f.opponent).spice - 3);
if (strongholds) {
assert.equal(pending.strongholdCards!.owners.tueks_sietch, f.moritani);
assert.equal(strongholdControllers(pending.players, false).tueks_sietch, f.opponent);
}
const token = ownedTech(pending.techTokens, f.moritani)[0];
assert.ok(token);
const target = player(pending, f.opponent).leaders.find(l => l.id === f.target)!;
const held = [...player(pending, f.moritani).traitors], reserve = [...pending.traitorReserve!];
const decision = pending.decision;
assert.ok(decision?.kind === 'moritaniAssassinate');
let game = applyAction(pending, f.moritani, { type: 'decision', event: decision.event, card: f.target });
const paid = player(game, f.moritani).spice;
assert.equal(paid, player(pending, f.moritani).spice + target.strength);
assert.equal(player(game, f.opponent).leaders.find(l => l.id === f.target)!.deaths, target.deaths + 1);
assert.deepEqual(player(game, f.moritani).traitors, held, 'Revealed original physical Traitor remains held until Mentat.');
assert.equal(game.techTokens![token].owner, f.moritani);
assert.equal(game.decision?.kind, 'battleCards');
game = applyAction(game, f.opponent, { type: 'decision', discard: f.opponentCards });
game = advanceMoritaniStrongholds(game, g => !g.battle && !g.response && !g.decision && !g.pendingTreacheryDiscard);
assert.equal(game.techTokens![token].owner, f.opponent);
assert.equal(ownedTech(game.techTokens, f.opponent).length, 2);
assert.equal(ownedTech(game.techTokens, f.moritani).length, 0);
assert.equal(player(game, f.moritani).tanks - player(f.revealed, f.moritani).tanks, 6);
assert.equal(player(game, f.opponent).tanks - player(f.revealed, f.opponent).tanks, 3);
assert.equal(player(game, f.moritani).spice, paid);
assert.deepEqual(game.moritaniTerror!.tokens, discs, 'Original assassination does not consume a Terror disc.');
for (const card of [...f.moritaniCards, ...f.opponentCards]) assert.equal(game.discard.filter(c => c.id === card).length, 1);
physical(game);
game = advanceMoritaniStrongholds(game, g => g.phase === 8);
const receipt = game.moritaniAssassinate!.opportunities.at(-1)!;
assert.equal(receipt.stage, 'replaced'); assert.equal(receipt.bounty, target.strength);
assert.equal(receipt.replacement, reserve[0]);
assert.deepEqual(game.traitorReserve, reserve.slice(1));
assert.deepEqual(player(game, f.moritani).traitors, [...held.filter(id => id !== f.target), reserve[0]]);
assert.equal(player(game, f.moritani).spice, paid, 'Neither Stronghold income nor bounty replays at Mentat.');
const rival = viewGame(game, f.opponent).moritaniAssassinate!.history.at(-1)!;
assert.equal(rival.card, f.target); assert.equal('replacement' in rival, false);
game = advanceMoritaniStrongholds(game, g => g.turn === 3 && g.phase === 0);
assert.equal(game.traitorReserve!.length, reserve.length - 1);
assert.equal(player(game, f.moritani).spice, paid);
assert.equal(game.techTokens![token].owner, f.opponent);
if (strongholds) assert.equal(game.strongholdCards!.owners.tueks_sietch, f.opponent);
physical(game);
});
for (const strongholds of [false, true])
void test(`Advanced Moritani ${strongholds ? 'Stronghold+Tech' : 'plain Tech'}: genuine normal Traitor call forfeits the later original loss assassination across turns`, () => {
const f = createMoritaniNativeTechFixture({ strongholds, normalCall: true });
assert.equal(f.pending.lastBattleContext!.winner, f.moritani);
assert.equal(f.pending.moritaniAssassinate!.normalTraitorCall, true);
assert.equal(f.pending.moritaniAssassinate!.opportunities.length, 0);
assert.equal(player(f.pending, f.opponent).leaders.find(l => l.id === f.opponentLeader)!.dead, true);
assert.equal(ownedTech(f.pending.techTokens, f.moritani).length, 2);
let game = advanceMoritaniStrongholds(f.pending, g => g.turn === 3 && g.phase === 5 && !g.response && !g.phaseOpening && !g.decision);
const emperor = game.players.find(p => p.faction === 'emperor')!.id;
stageMoritaniStrongholdsBattle(game, f.moritani, emperor, 'tueks_sietch');
const loser = player(game, f.moritani).leaders.filter(l => !l.dead).sort((a, b) => a.strength - b.strength)[0];
const winner = player(game, emperor).leaders.filter(l => !l.dead).sort((a, b) => b.strength - a.strength)[0];
const target = player(game, emperor).leaders.find(l => !l.dead && l.id !== winner.id)!;
holdMoritaniStrongholdsTraitor(game, f.moritani, target.id);
const tokens = ownedTech(game.techTokens, f.moritani), reserve = [...game.traitorReserve!];
game = advanceMoritaniStrongholds(game, g => g.phase === 6 && !g.response && !g.phaseOpening && !g.decision);
const actor = game.active!;
assert.ok([f.moritani, emperor].includes(actor));
game = applyAction(game, actor, { type: 'chooseBattle', territory: 'tueks_sietch', target: actor === f.moritani ? emperor : f.moritani });
game = advanceMoritaniStrongholds(game, g => !!g.battle && !g.battle.revealed && !nextMoritaniStrongholdsStep(g));
game = applyAction(game, f.moritani, { type: 'battlePlan', leader: loser.id, dial: 0, support: 0 });
game = applyAction(game, emperor, { type: 'battlePlan', leader: winner.id, dial: 2, support: 2 });
game = resolveMoritaniStrongholdsBattle(game, f.moritani);
assert.equal(game.lastBattleContext!.winner, emperor);
assert.equal(game.moritaniAssassinate!.normalTraitorCall, true);
assert.equal(game.moritaniAssassinate!.opportunities.length, 0);
assert.equal(player(game, emperor).leaders.find(l => l.id === target.id)!.dead, false);
assert.deepEqual(game.traitorReserve, reserve, 'No assassination means no hidden replacement draw.');
assert.equal(tokens.filter(token => game.techTokens![token].owner === emperor).length, 1,
'A real victory selects one loser token, not both allied or held tokens.');
assert.equal(ownedTech(game.techTokens, emperor).length, 2);
physical(game);
});
for (const strongholds of [false, true]) {
const initialize = strongholds ? initializeStrongholdFactionsGameForAudit : initializeFactionExpansionsGameForAudit;
void test(`${strongholds ? 'Stronghold+Tech' : 'plain Tech'} initializer rejects paired E3 and mixed selected families without admitting unrelated overlays`, () => {
assert.throws(() => initialize(lobby(['ecaz', 'moritani', 'guild'])));
assert.throws(() => initialize(lobby(['ecaz', 'ixians', 'guild'], true, ['ecaz', 'ix'])));
assert.throws(() => initialize(lobby(['moritani', 'choam', 'guild'], true, ['ecaz', 'choam'])));
assert.throws(() => initialize(lobby(['moritani', 'harkonnen', 'guild'])));
assert.throws(() => initialize(lobby(['ecaz', 'guild'])));
if (strongholds) assert.throws(() => initialize(lobby(['ecaz', 'guild', 'emperor'], false)));
for (const overlay of ['leaderSkills', 'homeworlds', 'nexusCards', 'discoveryEnabled', 'ecazTreachery',
'semutaPreview', 'advancedPreview', 'kullPreview', 'nexusKullPreview', 'guildBetrayalPreview',
'richeseBetrayalPreview', 'nexusIxianReplacementPreview', 'nexusIxianBetrayalPreview', 'nexusHarkonnenBetrayalPreview']) {
const game = lobby(['ecaz', 'guild', 'emperor']); Object.assign(game, { [overlay]: true });
assert.throws(() => initialize(game), `${overlay} remains excluded from the bounded native Tech family.`);
}
for (const stale of ['owner', 'spice', 'triggeredTurn'] as const) {
const game = lobby(['ecaz', 'guild', 'emperor']);
Object.assign(game.techTokens!.axlotl, { [stale]: stale === 'owner' ? game.host : 1 });
assert.throws(() => initialize(game));
}
});
}
void test('Basic standalone Moritani Tech retains its original Harkonnen-compatible setup boundary', () => {
const game = finishEcazOccupySetup(initializeE3NativeTechSetup({
initial: lobby(['moritani', 'harkonnen', 'guild'], false), native: 'moritani', advanced: false,
}));
assert.equal(player(game, 'auth-moritani').reserves, 14);
assert.equal(player(game, 'auth-harkonnen').traitors.length, 4);
physical(game);
});
+257
View File
@@ -0,0 +1,257 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeFactionExpansionsGameForAudit,
initializeStrongholdFactionsGameForAudit, joinGame, newPlayer, viewGame, type Game,
} from '../game/engine';
import { territory } from '../game/board';
import { createTechTokens } from '../game/tech-tokens';
import { finishEcazOccupySetup, stageEcazOccupyCard } from './fixture-ecaz-occupy';
import {
advanceEcazStronghold, ecazStrongholdFixture, ecazStrongholdSeat as player,
openEcazStronghold, type EcazStrongholdFixture,
} from './fixture-ecaz-strongholds';
import {
advanceMoritaniStrongholds, createMoritaniStrongholdsFixture,
holdMoritaniStrongholdsTraitor, nextMoritaniStrongholdsStep,
resolveMoritaniStrongholdsBattle, stageMoritaniStrongholdsBattle,
type MoritaniStrongholdsFixture,
} from './fixture-moritani-strongholds';
import { nextStrongholdFactionsNativeStep, type StrongholdFactionsNativeStep } from './fixture-stronghold-factions';
export type E3NativeTechOptions = {
/** Original authenticated lobby or its undealt native setup; identities are retained. */
initial?: Game;
native?: 'ecaz' | 'moritani';
advanced?: boolean;
strongholds?: boolean;
};
const clean = (game: Game) => !game.response && !game.phaseOpening && !game.decision;
/** Scope deterministic entropy to the genuine first initializer, never a later deal. */
export function initializeE3NativeTechSetup(options: E3NativeTechOptions = {}): Game {
const native = options.native ?? 'ecaz';
const advanced = options.advanced ?? options.initial?.advanced ?? true;
const strongholds = options.strongholds ?? false;
let game: Game;
if (options.initial) game = structuredClone(options.initial);
else {
game = createGame('E3NATIVETECH', newPlayer(native, native, native), advanced, ['ecaz']);
joinGame(game, newPlayer('guild', 'Guild', 'guild'));
joinGame(game, newPlayer('emperor', 'Emperor', 'emperor'));
if (native === 'ecaz') {
for (const [actor, position] of [['ecaz', 6], ['guild', 4], ['emperor', 5]] as const)
if (viewGame(game, actor).playerPositions[actor] !== position)
game = applyAction(game, actor, { type: 'seatPosition', position });
}
}
assert.equal(game.advanced, advanced);
assert.deepEqual(game.expansions, ['ecaz']);
assert.ok(game.players.some(p => p.faction === native));
if (game.status === 'setup') {
assert.equal(game.turn, 1); assert.equal(game.phase, 0);
assert.ok(game.players.every(p => p.hand.length === 0));
assert.ok(game.techTokens);
assert.equal(!!game.strongholdCards, strongholds);
return game;
}
assert.equal(game.status, 'lobby', 'Started games cannot be retrofitted with Tech.');
if (!game.techTokens) game.techTokens = createTechTokens();
for (const p of game.players) if (!p.ready) game = applyAction(game, p.id, { type: 'ready' });
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
assert.ok(array);
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255);
return array;
};
try {
return strongholds ? initializeStrongholdFactionsGameForAudit(game)
: initializeFactionExpansionsGameForAudit(game);
} finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
function reserveBoard(game: Game): void {
for (const p of game.players) {
p.reserves += Object.values(p.forces).reduce((sum, n) => sum + n, 0); p.forces = {};
if (p.elites) {
p.elites.reserves += Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0);
p.elites.forces = {};
}
if (p.advisors) p.advisors = {};
}
}
function place(game: Game, actor: string, location: string, count: number): void {
const p = player(game, actor);
assert.ok(Number.isSafeInteger(count) && count >= 0 && p.reserves >= count);
p.reserves -= count; p.forces[location] = count;
}
function orderBlow(game: Game, worm: boolean, position: number): void {
const index = game.spiceDeck.findIndex((card, i) => i >= position && (worm
? 'worm' in card && !card.greatMaker && !card.suppressed : 'territory' in card));
assert.ok(index >= position, 'Only unplayed original Spice Cards are ordered.');
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
}
export type EcazNativeTechOptions = Omit<E3NativeTechOptions, 'native'> & {
ecazForces?: number;
/** Expose original chooseBattle for the preserved Basic odd-force rejection. */
chooseBattle?: boolean;
};
export type EcazNativeTechFixture = EcazStrongholdFixture & { battleAction: StrongholdFactionsNativeStep };
function battleChoice(game: Game, kind: string): StrongholdFactionsNativeStep {
const choice = viewGame(game, game.active!).battleChoices.find(b => b.territory === kind);
assert.ok(choice, 'The actual conserved armies must quote an original battle.');
return { actor: choice.chooser, action: { type: 'chooseBattle', territory: kind,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } };
}
/** Original setup, worm Nexus, reciprocal alliance and (when selected) real
* Stronghold claim. Controlled board/card custody follows setup; wallets, phases,
* Tech owners and aftermath receipts are never assigned. */
export function createEcazNativeTechFixture(options: EcazNativeTechOptions = {}): EcazNativeTechFixture {
const setup = initializeE3NativeTechSetup({ ...options, native: 'ecaz' });
if (options.strongholds) {
const f = ecazStrongholdFixture({ initial: setup, kind: 'sietch_tabr', ecazForces: options.ecazForces });
return { ...f, battleAction: battleChoice(f.beforeBattle, f.territory) };
}
const initial = structuredClone(setup), staging: string[] = [];
let game = finishEcazOccupySetup(setup);
const afterSetup = structuredClone(game);
const ecaz = game.players.find(p => p.faction === 'ecaz')!.id;
const ally = game.players.find(p => p.faction === 'guild')!.id;
const opponent = game.players.find(p => p.faction === 'emperor')!.id;
assert.equal(game.players.length, 3); assert.ok(ecaz && ally && opponent);
const kind = 'sietch_tabr', location = `${kind}:${territory(kind).sectors[0]}`;
reserveBoard(game); orderBlow(game, false, 0); orderBlow(game, false, 1);
staging.push('After genuine setup: return physical counters to their own reserves; retain native wallets, cards, leaders and identities.');
game = advanceEcazStronghold(game, g => g.turn === 2 && g.phase === 0 && clean(g));
orderBlow(game, true, 0); orderBlow(game, false, 1); orderBlow(game, false, 2);
game = advanceEcazStronghold(game, g => g.phase === 1 && !!g.nexus && clean(g) && !g.spiceWindow && !g.spiceResolution);
game = applyAction(game, ecaz, { type: 'alliance', target: ally });
game = applyAction(game, ally, { type: 'alliance', target: ecaz });
game = advanceEcazStronghold(game, g => g.phase === 8 && clean(g));
reserveBoard(game); place(game, ecaz, location, 1); place(game, ally, location, 1);
let beforeMentat: Game | undefined, mentatStep: StrongholdFactionsNativeStep | undefined;
for (let count = 0; game.turn === 2 && count < 200; count++) {
const next = nextStrongholdFactionsNativeStep(game); assert.ok(next);
const before = structuredClone(game);
game = applyAction(game, next.actor, next.action);
if (game.turn === 3) { beforeMentat = before; mentatStep = next; }
}
assert.ok(beforeMentat && mentatStep);
const afterMentat = structuredClone(game);
orderBlow(game, false, 0); orderBlow(game, false, 1);
game = advanceEcazStronghold(game, g => g.phase === 5 && clean(g));
const ecazForces = options.ecazForces ?? (game.advanced ? 5 : 4), allyForces = 4;
reserveBoard(game); place(game, ecaz, location, ecazForces);
place(game, ally, location, allyForces); place(game, opponent, location, 8);
stageEcazOccupyCard(game, opponent, card => card.effect === 'karama');
staging.push(`Before original movement completion: conserve ${ecazForces} Ecaz, four Guild and eight Emperor counters in Tabr; conserved Karama custody exposes original Occupy response.`);
game = advanceEcazStronghold(game, g => g.phase === 6 && clean(g));
const beforeBattle = structuredClone(game), battleAction = battleChoice(game, kind);
if (options.chooseBattle !== false) game = applyAction(game, battleAction.actor, battleAction.action);
return { initial, afterSetup, beforeMentat, mentatStep, afterMentat, beforeBattle, game,
ecaz, ally, opponent, territory: kind, location, ecazForces, allyForces, staging, battleAction };
}
export function revealEcazNativeTechBattle(f: EcazNativeTechFixture, lead: 'ecaz' | 'ally', victoryPosition = false) {
const actor = lead === 'ecaz' ? f.ecaz : f.ally;
const staged = { ...f, game: structuredClone(f.game) };
if (victoryPosition) {
const holds = ['arrakeen', 'carthag', 'habbanya_ridge_sietch', 'tueks_sietch']
.filter(id => territory(id).sectors[0] !== staged.game.storm);
for (const [index, owner] of [f.ecaz, f.ally].entries()) {
const hold = holds[index];
assert.ok(hold);
place(staged.game, owner, `${hold}:${territory(hold).sectors[0]}`, 1);
f.staging.push(`Before selected-lead choice: conserve one reserve ${owner} counter alone in ${hold}; three alliance holds but only one jointly occupied hold.`);
}
}
const shield = stageEcazOccupyCard(staged.game, actor, c => c.kind === 'shield');
let game = openEcazStronghold(staged, lead);
const before = structuredClone(game), profile = viewGame(game, actor).battle!.ecazOccupy!.profile!;
const own = player(game, actor).leaders.filter(l => !l.dead && !l.usedAt).sort((a, b) => b.strength - a.strength)[0];
const enemy = player(game, f.opponent).leaders.filter(l => !l.dead && !l.usedAt).sort((a, b) => a.strength - b.strength)[0];
const support = game.advanced ? Math.min(3, player(game, actor).spice) : 0;
const variableDial = game.advanced ? (3 + support) / 2 : 3;
assert.ok(own && enemy);
game = applyAction(game, actor, { type: 'battlePlan', leader: own.id,
dial: profile.fixedEcazDial + variableDial, support, defense: shield });
game = applyAction(game, f.opponent, { type: 'battlePlan', leader: enemy.id, dial: game.advanced ? 2 : 0, support: 0 });
assert.ok(game.battle?.revealed);
const revealed = structuredClone(game);
for (const voter of viewGame(game, actor).battle!.traitorVoters)
game = applyAction(game, voter, { type: 'traitorCall', call: false });
assert.equal(game.decision?.kind, 'battleCards', 'The original winner-card window precedes Tech transfer.');
return { before, revealed, pending: game, actor, shield };
}
export type MoritaniNativeTechOptions = Omit<E3NativeTechOptions, 'native'> & { normalCall?: boolean };
/** Genuine six-disc Terror supply: the first printed Mentat placement leaves
* five supply discs and one placed disc. Advanced losses use original assassination. */
export function createMoritaniNativeTechFixture(options: MoritaniNativeTechOptions = {}): MoritaniStrongholdsFixture {
const setup = initializeE3NativeTechSetup({ ...options, native: 'moritani' });
if (options.strongholds) return createMoritaniStrongholdsFixture({ initial: setup, normalCall: options.normalCall });
let game = setup;
const initial = structuredClone(game), sourceActions: StrongholdFactionsNativeStep[] = [], staging: string[] = [];
const run = (step: StrongholdFactionsNativeStep) => {
sourceActions.push(structuredClone(step)); game = applyAction(game, step.actor, step.action);
};
for (let count = 0; game.status === 'setup' && count < 200; count++) {
const next = nextMoritaniStrongholdsStep(game); assert.ok(next); run(next);
}
assert.equal(game.status, 'playing', 'Genuine native Moritani setup must complete.');
const afterSetup = structuredClone(game);
const moritani = game.players.find(p => p.faction === 'moritani')!.id;
const opponent = game.players.find(p => p.faction === 'guild')!.id;
assert.equal(game.players.length, 3); assert.ok(moritani && opponent);
orderBlow(game, false, 0); orderBlow(game, false, 1);
for (let count = 0; !(game.phase === 8 && game.decision?.kind === 'moritaniPlacement') && count < 1800; count++) {
const next = nextMoritaniStrongholdsStep(game); assert.ok(next);
if (next.action.type === 'stormDial' && game.turn === 1) next.action.amount = 4;
run(next);
}
assert.equal(game.decision?.kind, 'moritaniPlacement');
const beforePlacement = structuredClone(game), token = game.moritaniTerror!.tokens.find(t => t.status === 'available');
assert.ok(token);
run({ actor: moritani, action: { type: 'decision', token: token.id, territory: 'arrakeen' } });
game = advanceMoritaniStrongholds(game, g => g.phase === 8 && clean(g), sourceActions);
const afterPlacement = structuredClone(game), kind = 'tueks_sietch';
reserveBoard(game); place(game, moritani, `${kind}:${territory(kind).sectors[0]}`, 1);
place(game, opponent, 'carthag:11', 1);
let beforeFirstMentat: Game | undefined, firstMentatStep: StrongholdFactionsNativeStep | undefined;
for (let count = 0; game.turn === 1 && count < 200; count++) {
const next = nextMoritaniStrongholdsStep(game); assert.ok(next);
const before = structuredClone(game);
sourceActions.push(structuredClone(next)); game = applyAction(game, next.actor, next.action);
if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = next; }
}
assert.ok(beforeFirstMentat && firstMentatStep);
const afterFirstMentat = structuredClone(game);
orderBlow(game, false, 0); orderBlow(game, false, 1);
game = advanceMoritaniStrongholds(game, g => g.phase === 5 && clean(g), sourceActions);
stageMoritaniStrongholdsBattle(game, moritani, opponent, kind, staging);
const own = player(game, moritani).leaders.filter(l => !l.dead).sort((a, b) => a.strength - b.strength)[0];
const enemy = player(game, opponent).leaders.filter(l => !l.dead).sort((a, b) => b.strength - a.strength)[0];
const target = player(game, opponent).leaders.find(l => !l.dead && l.id !== enemy.id)!;
assert.ok(own && enemy && target);
holdMoritaniStrongholdsTraitor(game, moritani, options.normalCall ? enemy.id : target.id, staging);
const moritaniCards = [stageEcazOccupyCard(game, moritani, c => c.kind === 'worthless')];
const opponentCards = [stageEcazOccupyCard(game, opponent, c => c.kind === 'worthless')];
game = advanceMoritaniStrongholds(game, g => g.phase === 6 && clean(g), sourceActions);
const beforeBattle = structuredClone(game), battleAction = battleChoice(game, kind); run(battleAction);
game = advanceMoritaniStrongholds(game, g => !!g.battle && !g.battle.revealed && !nextMoritaniStrongholdsStep(g), sourceActions);
const planActions: StrongholdFactionsNativeStep[] = [
{ actor: moritani, action: { type: 'battlePlan', leader: own.id, dial: 1,
support: game.advanced ? 1 : 0, weapon: moritaniCards[0] } },
{ actor: opponent, action: { type: 'battlePlan', leader: enemy.id, dial: 3,
support: game.advanced ? 3 : 0, weapon: opponentCards[0] } },
];
const prepared = structuredClone(game);
for (const plan of planActions) run(plan);
assert.ok(game.battle?.revealed);
const revealed = structuredClone(game);
const pending = resolveMoritaniStrongholdsBattle(game, moritani, options.normalCall, sourceActions);
return { initial, afterSetup, beforePlacement, afterPlacement, beforeFirstMentat, firstMentatStep,
afterFirstMentat, beforeBattle, game: prepared, revealed, pending, moritani, opponent,
holder: moritani, kind, target: target.id, moritaniLeader: own.id, opponentLeader: enemy.id,
moritaniCards, opponentCards, battleAction, planActions, sourceActions, staging };
}
+277
View File
@@ -0,0 +1,277 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeFactionExpansionsGameForAudit,
initializeStrongholdFactionsGameForAudit, joinGame, newPlayer, type Game,
} from '../game/engine';
import { MOBILE_LOCATION, territory } from '../game/board';
import type { Card } from '../game/cards';
import type { FactionId } from '../game/catalog';
import { nativeFactionTechProfile } from '../game/faction-module-profile';
import { createTechTokens } from '../game/tech-tokens';
import type { StrongholdFactionsNativeStep } from './fixture-stronghold-factions';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
export type NativeTechFamily = 'ix-choam' | 'richese' | 'tleilaxu';
export type NativeTechFactionsOptions = {
initial?: Game;
family?: NativeTechFamily;
strongholds?: boolean;
advanced?: boolean;
ownerFaction?: 'ixians' | 'choam';
stone?: boolean;
};
export type NativeTechFactionsFixture = {
initial: Game;
afterSetup: Game;
beforeFirstMentat?: Game;
firstMentatStep?: StrongholdFactionsNativeStep;
afterFirstMentat?: Game;
beforeShipment?: Game;
shipmentStep?: StrongholdFactionsNativeStep;
afterShipment?: Game;
acquisition?: { before: Game; after: Game; steps: StrongholdFactionsNativeStep[] };
game: Game;
beforeBattle: Game;
battleStep: StrongholdFactionsNativeStep;
planActions: StrongholdFactionsNativeStep[];
owner: string;
opponent: string;
family: NativeTechFamily;
kind: 'arrakeen' | 'sietch_tabr' | 'habbanya_ridge_sietch';
location: string;
actions: StrongholdFactionsNativeStep[];
staging: string[];
};
export const nativeTechFactionsPlayer = (game: Game, actor: string) => {
const player = game.players.find(p => p.id === actor);
assert.ok(player, `Missing native Tech seat ${actor}`);
return player;
};
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
export function nextNativeTechFactionsStep(game: Game): StrongholdFactionsNativeStep | null {
if (!game.phaseOpening && !game.response && game.decision?.kind === 'techToken')
return { actor: game.decision.player, action: { type: 'decision', token: game.decision.choices[0] } };
return nextRicheseStrongholdsNativeStep(game);
}
function run(game: Game, step: StrongholdFactionsNativeStep, actions: StrongholdFactionsNativeStep[]): Game {
actions.push(structuredClone(step));
return applyAction(game, step.actor, step.action);
}
function advance(game: Game, until: (state: Game) => boolean, actions: StrongholdFactionsNativeStep[]): Game {
for (let n = 0; n < 1800; n++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const step = nextNativeTechFactionsStep(game);
assert.ok(step, 'The original battle requires an unsealed human plan');
// Real first-turn dials leave every printed fixture site outside the next Storm band.
if (game.turn === 1 && step.action.type === 'stormDial') step.action.amount = 0;
game = run(game, step, actions);
}
throw Error('Native Tech continuation did not reach its requested boundary');
}
function withSetupEntropy<T>(action: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
assert.ok(array);
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255);
return array;
};
try { return action(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Original fresh native setup; no Leader Skills, public-start bypass or redeal.
* Default initializer entropy is deterministic; supplied undealt setup is continued
* as saved. Actual wallets and Tech assignments are never staged or repaired. */
export function initializeNativeTechFactionsSetup(options: NativeTechFactionsOptions = {}): Game {
const family = options.family ?? 'ix-choam';
const factions: FactionId[] = family === 'ix-choam' ? ['ixians', 'choam', 'guild']
: family === 'richese' ? ['richese', 'choam', 'guild'] : ['ixians', 'tleilaxu', 'guild'];
const decks = family === 'ix-choam' ? ['ix', 'choam'] : family === 'richese' ? ['choam'] : ['ix'];
const strongholds = options.strongholds ?? true;
let game = options.initial ? structuredClone(options.initial)
: createGame('NATIVETECHFACTIONS', newPlayer(factions[0], factions[0], factions[0]), options.advanced ?? true, decks);
if (!options.initial) for (const faction of factions.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
assert.deepEqual(game.players.map(p => p.faction).sort(), [...factions].sort());
assert.deepEqual([...game.expansions].sort(), [...decks].sort());
assert.ok(game.status === 'lobby' || game.status === 'setup' && game.turn === 1 && game.phase === 0 && game.players.every(p => !p.hand.length), 'Use the original fresh undealt native lobby or setup');
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
if (strongholds) assert.equal(game.advanced, true);
if (game.status === 'setup') {
assert.ok(nativeFactionTechProfile(game), 'Continue only the bounded original native Tech setup without unrelated overlays');
assert.equal(!!game.strongholdCards, strongholds);
return game;
}
if (!game.techTokens) game = applyAction(game, game.host, { type: 'techTokens', enabled: true });
assert.deepEqual(game.techTokens, createTechTokens());
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
const initialize = () => strongholds ? initializeStrongholdFactionsGameForAudit(game) : initializeFactionExpansionsGameForAudit(game);
return options.initial ? initialize() : withSetupEntropy(initialize);
}
function reserveBoard(game: Game) {
for (const player of game.players) {
player.reserves += Object.values(player.forces).reduce((a, b) => a + b, 0);
player.forces = {};
if (player.elites) {
player.elites.reserves += Object.values(player.elites.forces).reduce((a, b) => a + b, 0);
player.elites.forces = {};
}
}
}
function place(game: Game, actor: string, key: string, count: number, elite = 0) {
const player = nativeTechFactionsPlayer(game, actor);
assert.ok(player.reserves >= count && (player.elites?.reserves ?? 0) >= elite && count >= elite);
player.reserves -= count; player.forces[key] = (player.forces[key] ?? 0) + count;
if (elite) { player.elites!.reserves -= elite; player.elites!.forces[key] = (player.elites!.forces[key] ?? 0) + elite; }
}
function heldCard(game: Game, actor: string, kind: Card['kind'], staging: string[]) {
const player = nativeTechFactionsPlayer(game, actor), held = player.hand.find(c => c.kind === kind);
if (held) return held.id;
const index = game.deck.findIndex(c => c.kind === kind); assert.ok(index >= 0);
assert.ok(player.hand.length < (player.faction === 'choam' ? 5 : 4));
const card = game.deck.splice(index, 1)[0]; player.hand.push(card);
staging.push(`Conserved ${card.id}: remaining native deck → ${actor}'s hand`);
return card.id;
}
/** Controlled positions conserve native physical counters/cards after genuine
* setup. Real ship, cache acquisition, final Mentat, plans, payments and aftermath
* remain applyAction decisions. No wallet, owner, phase or earned receipt assignment. */
export function createNativeTechFactionsFixture(options: NativeTechFactionsOptions = {}): NativeTechFactionsFixture {
const family = options.family ?? 'ix-choam', strongholds = options.strongholds ?? true;
const actions: StrongholdFactionsNativeStep[] = [], staging: string[] = [];
let game = initializeNativeTechFactionsSetup(options);
const initial = structuredClone(game);
for (let n = 0; game.status === 'setup' && n < 200; n++) {
const step = nextNativeTechFactionsStep(game); assert.ok(step);
// Preserve the native Ix remainder shuffle. Seed only the actual first
// Face Dancer draw, as in the existing Tleilaxu Stronghold fixture.
if (family === 'tleilaxu' && game.decision?.kind === 'ixSetup') {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const original = crypto.getRandomValues.bind(crypto);
let remainderCalls = Math.max(0, game.ixSetupCards!.length - 2);
let faceCalls = game.players.reduce((sum, p) => sum + p.leaders.length, 0) + 1 - new Set(game.players.flatMap(p => p.traitors)).size - 1;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
assert.ok(array);
if (remainderCalls > 0) { remainderCalls--; Reflect.apply(original, crypto, [array]); return array; }
if (faceCalls-- > 0) { new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255); return array; }
Reflect.apply(original, crypto, [array]); return array;
};
try { game = run(game, step, actions); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
staging.push('Scoped first-draw entropy: only original native Face Dancer Traitor Deck shuffle; preserve Ix remainder and restore immediately.');
} else game = run(game, step, actions);
}
assert.equal(game.status, 'playing');
const afterSetup = structuredClone(game);
const owner = game.players.find(p => p.faction === (family === 'richese' ? 'richese' : family === 'tleilaxu' ? 'ixians' : options.ownerFaction ?? 'ixians'))!.id;
const opponent = game.players.find(p => p.faction === 'guild')!.id;
const kind = family === 'richese' ? 'habbanya_ridge_sietch' : family === 'tleilaxu' ? 'sietch_tabr' : 'arrakeen';
const location = `${kind}:${territory(kind).sectors[0]}`;
let acquisition: NativeTechFactionsFixture['acquisition'];
if (family === 'richese') {
game = advance(game, state => state.decision?.kind === 'richeseCache', actions);
const before = structuredClone(game), steps: StrongholdFactionsNativeStep[] = [];
while (!nativeTechFactionsPlayer(game, owner).hand.some(c => c.id === 'richese-stone-burner')) {
const step = nextNativeTechFactionsStep(game); assert.ok(step);
steps.push(structuredClone(step)); game = run(game, step, actions);
assert.ok(steps.length < 100);
}
acquisition = { before, steps, after: structuredClone(game) };
}
game = advance(game, state => state.phase === 5 && clean(state) && (family !== 'richese' || state.active === owner), actions);
reserveBoard(game);
staging.push('After real setup: return existing board counters through their own subtype reserves; keep actual native bank balances and assigned Tech custody.');
let beforeShipment: Game | undefined, shipmentStep: StrongholdFactionsNativeStep | undefined, afterShipment: Game | undefined;
if (family === 'richese') {
const token = nativeTechFactionsPlayer(game, owner).noField!.tokens.find(t => t.value === 5)!;
beforeShipment = structuredClone(game);
shipmentStep = { actor: owner, action: { type: 'ship', noField: token.id, event: nativeTechFactionsPlayer(game, owner).noFieldEvent, territory: kind, sector: territory(kind).sectors[0] } };
game = run(game, shipmentStep, actions);
game = advance(game, clean, actions); afterShipment = structuredClone(game);
}
let beforeFirstMentat: Game | undefined, firstMentatStep: StrongholdFactionsNativeStep | undefined, afterFirstMentat: Game | undefined;
if (strongholds) {
game = advance(game, state => state.phase === 8 && clean(state), actions);
reserveBoard(game);
if (family !== 'richese') place(game, owner, location, 1);
const ix = game.players.find(p => p.faction === 'ixians');
if (ix) place(game, ix.id, MOBILE_LOCATION, 6, 3);
staging.push('Before actual first END Mentat: native owner controls its printed battle site (Richese by real marker); Ix retains six original HMS counters including three cyborgs. Nobody controls three Strongholds.');
while (game.turn === 1) {
const step = nextNativeTechFactionsStep(game); assert.ok(step);
const before = structuredClone(game); game = run(game, step, actions);
if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = step; }
}
afterFirstMentat = structuredClone(game);
assert.equal(game.strongholdCards!.owners[kind], owner);
game = advance(game, state => state.phase === 5 && clean(state), actions);
reserveBoard(game);
}
if (family === 'richese' && options.stone) {
const token = nativeTechFactionsPlayer(game, owner).noField!.deployed!.tokenId;
game = run(game, { actor: owner, action: { type: 'revealNoField', event: nativeTechFactionsPlayer(game, owner).noFieldEvent, token } }, actions);
reserveBoard(game);
}
if (family !== 'richese' || options.stone) place(game, owner, location, 6, nativeTechFactionsPlayer(game, owner).faction === 'ixians' ? 3 : 0);
place(game, opponent, location, 6);
if (family === 'tleilaxu') {
const tleilaxu = game.players.find(p => p.faction === 'tleilaxu')!.id;
place(game, tleilaxu, `arrakeen:${territory('arrakeen').sectors[0]}`, 3);
}
staging.push('Before genuine movement completion: conserved six opposing counters, six winner counters (Ix three suboids/three cyborgs) or actual Richese marker; Tleilaxu has three physical Arrakeen replacement sources. No money or ownership staging.');
const player = nativeTechFactionsPlayer(game, owner);
const leader = family === 'tleilaxu'
? player.leaders.filter(l => !l.dead && game.players.find(p => p.faction === 'tleilaxu')!.faceDancers!.some(c => c.leader === l.id && !c.revealed)).sort((a, b) => b.strength - a.strength)[0]
: player.leaders.filter(l => l.strength > 0 && !l.dead).sort((a, b) => b.strength - a.strength)[0];
assert.ok(leader, 'The original native draw must supply the winner leader');
const enemyLeader = family === 'tleilaxu' || options.ownerFaction === 'choam'
? nativeTechFactionsPlayer(game, opponent).leaders.filter(l => !l.dead).sort((a, b) => a.strength - b.strength)[0]
: nativeTechFactionsPlayer(game, opponent).leaders.find(l => l.strength === 5 && !l.dead);
assert.ok(enemyLeader);
const weapon = family === 'tleilaxu' ? heldCard(game, owner, 'projectile', staging) : options.stone ? 'richese-stone-burner' : null;
const defense = family === 'tleilaxu' ? heldCard(game, owner, 'shield', staging) : null;
game = advance(game, state => state.phase === 6 && clean(state), actions);
const beforeBattle = structuredClone(game), actor = game.active!;
assert.ok(actor === owner || actor === opponent);
const battleStep: StrongholdFactionsNativeStep = { actor, action: { type: 'chooseBattle', territory: kind, target: actor === owner ? opponent : owner } };
game = run(game, battleStep, actions);
game = advance(game, state => !!state.battle && !state.battle.revealed && !nextNativeTechFactionsStep(state), actions);
const ix = player.faction === 'ixians';
const dial = family === 'tleilaxu' ? 2 : family === 'richese' ? options.stone ? 2 : 1 : ix ? game.advanced ? 6 : 4 : 3;
const support = game.advanced ? family === 'tleilaxu' ? 1 : family === 'richese' ? dial : 3 : 0;
const enemyDial = options.ownerFaction === 'choam' ? 2 : family === 'richese' ? options.stone ? 2 : 0 : 1.5;
const planActions: StrongholdFactionsNativeStep[] = [
{ actor: owner, action: { type: 'battlePlan', dial, support, leader: leader.id, weapon, defense } },
{ actor: opponent, action: { type: 'battlePlan', dial: game.advanced ? enemyDial : 1, support: game.advanced ? enemyDial === 2 ? 2 : enemyDial ? 1 : 0 : 0, leader: enemyLeader.id, weapon: null, defense: null } },
];
return { initial, afterSetup, beforeFirstMentat, firstMentatStep, afterFirstMentat, beforeShipment, shipmentStep, afterShipment, acquisition, game, beforeBattle, battleStep, planActions, owner, opponent, family, kind, location, actions, staging };
}
export function revealNativeTechFactionsBattle(fixture: Pick<NativeTechFactionsFixture, 'game' | 'planActions'>): Game {
let game = structuredClone(fixture.game);
for (const step of fixture.planActions) {
game = applyAction(game, step.actor, step.action);
while (game.response || game.decision?.kind === 'fullPlanOffer') {
const next = nextNativeTechFactionsStep(game); assert.ok(next);
game = applyAction(game, next.actor, next.action);
}
}
assert.ok(game.battle?.revealed);
return game;
}
export function advanceNativeTechFactions(state: Game, until: (game: Game) => boolean): Game {
return advance(structuredClone(state), until, []);
}
export function finishNativeTechFactionsBattle(state: Game): Game {
return advanceNativeTechFactions(state, game => !game.battle && !game.decision && !game.response && !game.pendingTreacheryDiscard);
}
export function finishNativeTechFactionsTurn(state: Game): Game {
return advanceNativeTechFactions(state, game => game.turn > state.turn);
}
+264
View File
@@ -0,0 +1,264 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
applyAction, createGame, initializeFactionExpansionsGameForAudit,
initializeStrongholdFactionsGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game,
} from '../game/engine';
import { botActions } from '../game/bots';
import { territory } from '../game/board';
import type { FactionId } from '../game/catalog';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import { createLeaderSkills } from '../game/leader-skills';
import { ownedTech, TECH_TOKENS } from '../game/tech-tokens';
import { presenceAt } from '../game/force-presence';
import { strongholdBenefit, strongholdControllers } from '../game/stronghold-cards';
import { quoteStrongholdFactionsBattle } from './fixture-stronghold-factions';
import {
advanceNativeTechFactions, createNativeTechFactionsFixture, finishNativeTechFactionsBattle,
finishNativeTechFactionsTurn, nativeTechFactionsPlayer as player, revealNativeTechFactionsBattle,
type NativeTechFactionsFixture,
} from './fixture-native-tech-factions';
function lobby(factions: FactionId[], expansions = ['ix'], advanced = true): Game {
let game = createGame('NATIVETECHBOUNDARY', newPlayer('seat-0', factions[0], factions[0]), advanced, expansions);
for (let n = 1; n < factions.length; n++) joinGame(game, newPlayer(`seat-${n}`, factions[n], factions[n]));
game = applyAction(game, game.host, { type: 'techTokens', enabled: true });
for (const p of game.players) game = applyAction(game, p.id, { type: 'ready' });
return game;
}
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before, 'Rejected native decisions cannot consume counters, cards, tokens or money');
}
function inventory(game: Game) {
return [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []), ...(game.ixSetupCards ?? []),
...(game.ixAuction?.cards ?? []), ...(game.auction?.cards.slice(game.auction.index) ?? []),
...game.players.flatMap(p => p.hand)].map(c => c.id).sort();
}
function custody(game: Game, original: Game) {
assert.deepEqual(inventory(game), inventory(original), 'Every original physical card retains exactly one custody location');
for (const p of game.players) {
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0), 20, p.faction);
if (p.elites) {
const originalElite = player(original, p.id).elites!;
const total = originalElite.reserves + originalElite.tanks + Object.values(originalElite.forces).reduce((a, b) => a + b, 0);
assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((a, b) => a + b, 0), total);
for (const [key, n] of Object.entries(p.elites.forces)) assert.ok(n <= (p.forces[key] ?? 0));
}
}
}
function money(revealed: Game, resolved: Game) {
const quote = quoteStrongholdFactionsBattle(revealed);
const collection = resolved.phase === 7 && !resolved.phaseOpening
? quoteSpiceCollection(resolved).receipts : [];
for (const p of revealed.players) {
const payment = quote.payments.find(r => r.player === p.id)?.ownPayment ?? 0;
const stronghold = quote.strongholdIncome.filter(r => r.player === p.id).reduce((a, r) => a + r.amount, 0);
const bounty = quote.bounty?.player === p.id ? quote.bounty.amount : 0;
const choam = quote.choamIncome?.owner === p.id ? quote.choamIncome.amount : 0;
const collected = collection.filter(r => r.player === p.id).reduce((a, r) => a + r.strongholds + r.collected, 0);
assert.equal(player(resolved, p.id).spice, p.spice - payment + stronghold + bounty + choam + collected, p.faction);
}
return quote;
}
function award(fixture: NativeTechFactionsFixture, game: Game) {
const tokens = ownedTech(fixture.game.techTokens, fixture.opponent);
assert.equal(tokens.length, 1, 'Actual three-seat first Storm gives the loser one native token');
assert.equal(game.lastBattleContext!.winner, fixture.owner);
assert.equal(game.techTokens![tokens[0]].owner, fixture.owner);
assert.equal(ownedTech(game.techTokens, fixture.owner).length, 2);
assert.equal(ownedTech(game.techTokens, fixture.opponent).length, 0);
assert.equal(game.techTokens![tokens[0]].spice, fixture.game.techTokens![tokens[0]].spice, 'Reward changes custody, not bank receipts');
}
void test('native Tech seats three through six complete actual Basic/Advanced setup and first Storm with three distinct physical recipients', () => {
const factions: FactionId[] = ['ixians', 'tleilaxu', 'emperor', 'fremen', 'guild', 'harkonnen'];
for (const advanced of [false, true]) for (let count = 3; count <= 6; count++) {
const setup = initializeFactionExpansionsGameForAudit(lobby(factions.slice(0, count), ['ix'], advanced));
const storm = advanceNativeTechFactions(setup, g => g.status === 'playing' && g.phase === 1 && !g.phaseOpening && !g.response && !g.decision);
assert.equal(storm.techTokens!.heighliners.owner, storm.players.find(p => p.faction === 'ixians')!.id);
assert.equal(storm.techTokens!.axlotl.owner, storm.players.find(p => p.faction === 'tleilaxu')!.id);
const recipients = TECH_TOKENS.map(t => storm.techTokens![t.id].owner);
assert.equal(new Set(recipients).size, 3);
assert.ok(recipients.every(id => storm.players.some(p => p.id === id)));
}
});
void test('native Tech rejects below-three, missing-family, wrong-deck and already-owned inventory without dealing', () => {
const invalid = [
lobby(['ixians', 'guild']),
lobby(['emperor', 'fremen', 'guild']),
lobby(['ixians', 'emperor', 'guild'], ['choam']),
];
const owned = lobby(['ixians', 'emperor', 'guild']);
owned.techTokens!.production.owner = owned.host; invalid.push(owned);
for (const game of invalid) {
const before = structuredClone(game);
assert.throws(() => initializeFactionExpansionsGameForAudit(game));
assert.throws(() => initializeStrongholdFactionsGameForAudit(game));
assert.deepEqual(game, before);
}
});
void test('Advanced Stronghold constraint and unrelated modules stay excluded from both native Tech initializers', () => {
const basic = lobby(['ixians', 'choam', 'guild'], ['ix', 'choam'], false);
reject(basic, basic.host, { type: 'strongholdCards', enabled: true });
assert.throws(() => initializeStrongholdFactionsGameForAudit(basic), /Advanced/);
const overlays: ((g: Game) => void)[] = [
g => { g.leaderSkills = createLeaderSkills(() => 0.5); },
g => { g.homeworlds = { custody: null }; },
g => { g.nexusCards = { cards: null, phase: null }; },
g => { g.discoveryEnabled = true; },
g => { g.ecazTreachery = true; },
g => { g.semutaPreview = true; },
];
for (const overlay of overlays) {
const game = lobby(['ixians', 'choam', 'guild'], ['ix', 'choam']); overlay(game);
const before = structuredClone(game);
assert.throws(() => initializeFactionExpansionsGameForAudit(game));
assert.throws(() => initializeStrongholdFactionsGameForAudit(game));
assert.deepEqual(game, before);
}
});
for (const strongholds of [false, true]) void test(`${strongholds ? 'Advanced Stronghold+Tech' : 'Advanced Tech'} Ix: actual support, cyborg casualties, equal physical substitution and original loser token reward`, () => {
const fixture = createNativeTechFactionsFixture({ strongholds });
const revealed = revealNativeTechFactionsBattle(fixture), quote = quoteStrongholdFactionsBattle(revealed);
assert.equal(quote.winner, fixture.owner);
assert.equal(quote.casualties!.forces.normalFixedHalf, true);
assert.ok(quote.casualties!.options.every(o => o.normal === 0 && o.elite === 3));
const payment = quote.payments.find(p => p.player === fixture.owner)!;
assert.equal(payment.bankSupport, strongholds ? 2 : 0);
assert.equal(payment.ownPayment, strongholds ? 1 : 3);
const pending = advanceNativeTechFactions(revealed, g => g.decision?.kind === 'ixSubstitution');
assert.equal(player(pending, fixture.owner).tanks, 3);
assert.equal(player(pending, fixture.owner).elites!.tanks, 3);
assert.equal(player(pending, fixture.owner).forces[fixture.location], 3);
assert.equal(player(pending, fixture.owner).elites!.forces[fixture.location] ?? 0, 0);
assert.equal(player(pending, fixture.owner).spice, player(revealed, fixture.owner).spice - payment.ownPayment);
assert.deepEqual(ownedTech(pending.techTokens, fixture.opponent), ownedTech(revealed.techTokens, fixture.opponent), 'Substitution precedes original Tech award');
reject(pending, fixture.owner, { type: 'decision', sources: { [fixture.location]: 3 }, recover: { [fixture.location]: 4 } });
reject(pending, fixture.opponent, { type: 'decision', sources: { [fixture.location]: 3 }, recover: { [fixture.location]: 3 } });
const exchanged = applyAction(pending, fixture.owner, { type: 'decision', sources: { [fixture.location]: 3 }, recover: { [fixture.location]: 3 } });
const resolved = finishNativeTechFactionsBattle(exchanged);
assert.equal(player(resolved, fixture.owner).forces[fixture.location], 3);
assert.equal(player(resolved, fixture.owner).elites!.forces[fixture.location], 3);
assert.equal(player(resolved, fixture.owner).tanks, 3, 'Three sacrificed suboids replace three recovered cyborgs, not extra counters');
assert.equal(player(resolved, fixture.owner).elites!.tanks, 0);
assert.equal(player(resolved, fixture.opponent).tanks, 6);
money(revealed, resolved); award(fixture, resolved); custody(resolved, fixture.initial);
if (strongholds) {
assert.equal(fixture.afterFirstMentat!.strongholdCards!.owners.arrakeen, fixture.owner);
assert.equal(resolved.strongholdCards!.owners.arrakeen, fixture.owner);
const mentat = finishNativeTechFactionsTurn(resolved);
assert.equal(mentat.strongholdCards!.owners.arrakeen, fixture.owner);
custody(mentat, fixture.initial);
}
});
void test('CHOAM Stronghold+Tech: own paid support is debited, only opponent bank support earns CHOAM income, and the winner gets the original token', () => {
const fixture = createNativeTechFactionsFixture({ ownerFaction: 'choam' });
const revealed = revealNativeTechFactionsBattle(fixture), resolved = finishNativeTechFactionsBattle(revealed);
const quote = money(revealed, resolved), payment = quote.payments.find(p => p.player === fixture.owner)!;
assert.equal(payment.cost, 1); assert.equal(payment.bankSupport, 2); assert.equal(payment.ownPayment, 1);
assert.deepEqual(quote.choamIncome, { owner: fixture.owner, amount: 1 });
assert.equal(quote.payments.find(p => p.player === fixture.opponent)!.ownPayment, 2);
assert.equal(player(resolved, fixture.owner).tanks, 3);
assert.equal(player(resolved, fixture.owner).forces[fixture.location], 3);
award(fixture, resolved); custody(resolved, fixture.initial);
assert.equal(finishNativeTechFactionsTurn(resolved).strongholdCards!.owners.arrakeen, fixture.owner);
});
void test('Richese Stronghold+Tech: real one-spice marker shipment and post-plan five-force materialization retain Habbanya and award the loser token', () => {
const fixture = createNativeTechFactionsFixture({ family: 'richese' });
const shipped = player(fixture.afterShipment!, fixture.owner), payer = player(fixture.beforeShipment!, fixture.owner);
assert.equal(shipped.spice, payer.spice - 1); assert.equal(shipped.reserves, payer.reserves);
assert.equal(presenceAt(shipped, fixture.kind), 1);
assert.equal(fixture.afterFirstMentat!.strongholdCards!.owners[fixture.kind], fixture.owner);
const sealed = applyAction(fixture.game, fixture.owner, fixture.planActions[0].action);
assert.ok(player(sealed, fixture.owner).noField!.deployed);
assert.equal(player(sealed, fixture.owner).forces[fixture.location], undefined);
const revealed = revealNativeTechFactionsBattle(fixture);
assert.equal(player(revealed, fixture.owner).noField!.deployed, null);
assert.equal(player(revealed, fixture.owner).forces[fixture.location], 5);
const resolved = finishNativeTechFactionsBattle(revealed), quote = money(revealed, resolved);
assert.equal(quote.payments.find(p => p.player === fixture.owner)!.ownPayment, 1);
assert.equal(player(resolved, fixture.owner).tanks, 1);
assert.equal(player(resolved, fixture.owner).forces[fixture.location], 4);
assert.equal(player(resolved, fixture.opponent).tanks, 6);
award(fixture, resolved); custody(resolved, fixture.initial);
assert.equal(finishNativeTechFactionsTurn(resolved).strongholdCards!.owners[fixture.kind], fixture.owner);
});
void test('Richese Stronghold+Tech: actual unbid cache Stone, legal materialized battle and kill/ignore outcomes retain the original typed reward', () => {
const fixture = createNativeTechFactionsFixture({ family: 'richese', stone: true });
assert.ok(fixture.acquisition!.before.richeseCache!.some(c => c.id === 'richese-stone-burner'));
assert.ok(player(fixture.acquisition!.after, fixture.owner).hand.some(c => c.id === 'richese-stone-burner'));
assert.equal(fixture.acquisition!.after.richeseCache!.some(c => c.id === 'richese-stone-burner'), false);
for (const mode of ['kill', 'ignore'] as const) {
const revealed = revealNativeTechFactionsBattle(fixture);
assert.equal(revealed.decision?.kind, 'stoneBurner');
const chosen = applyAction(revealed, fixture.owner, { type: 'decision', event: revealed.battle!.event, mode });
const resolved = finishNativeTechFactionsBattle(chosen), quote = money(chosen, resolved);
assert.equal(quote.winner, fixture.owner);
assert.equal(player(resolved, fixture.owner).forces[fixture.location], 4);
assert.equal(player(resolved, fixture.owner).tanks, 2);
assert.equal(player(resolved, fixture.opponent).tanks, 6);
assert.ok(player(resolved, fixture.owner).hand.some(c => c.id === 'richese-stone-burner'));
for (const plan of fixture.planActions) assert.equal(player(resolved, plan.actor).leaders.find(l => l.id === plan.action.leader)!.dead, mode === 'kill');
award(fixture, resolved); custody(resolved, fixture.initial);
}
});
void test('Tleilaxu Stronghold+Tech: original Ix bounty/cards/token settle before actual Face Dance; replacement returns surviving subtypes without transferring rewards', () => {
const fixture = createNativeTechFactionsFixture({ family: 'tleilaxu' });
const revealed = revealNativeTechFactionsBattle(fixture);
const faceDance = advanceNativeTechFactions(revealed, g => g.decision?.kind === 'faceDance');
const quote = money(revealed, faceDance);
assert.deepEqual(quote.strongholdIncome, [{ player: fixture.owner, amount: 1 }]);
assert.equal(quote.bounty!.player, fixture.owner);
award(fixture, faceDance);
const winner = player(faceDance, fixture.owner), tleilaxu = faceDance.decision!.player;
const original = player(revealed, fixture.owner), losses = quote.casualties!.options[0];
assert.equal(winner.tanks, original.tanks + losses.normal + losses.elite);
assert.equal(winner.elites!.tanks, original.elites!.tanks + losses.elite);
for (const id of [fixture.planActions[0].action.weapon, fixture.planActions[0].action.defense]) assert.ok(winner.hand.some(c => c.id === id));
const remaining = winner.forces[fixture.location], elite = winner.elites!.forces[fixture.location] ?? 0;
const board = `arrakeen:${territory('arrakeen').sectors[0]}`;
reject(faceDance, tleilaxu, { type: 'decision', reveal: true, sources: { reserves: remaining + 1 }, sector: territory(fixture.kind).sectors[0] });
const after = applyAction(faceDance, tleilaxu, { type: 'decision', reveal: true, sources: { [board]: 2, reserves: remaining - 2 }, sector: territory(fixture.kind).sectors[0] });
const returned = player(after, fixture.owner), replacement = player(after, tleilaxu);
assert.equal(returned.forces[fixture.location] ?? 0, 0);
assert.equal(returned.elites!.forces[fixture.location] ?? 0, 0);
assert.equal(returned.reserves, winner.reserves + remaining);
assert.equal(returned.elites!.reserves, winner.elites!.reserves + elite);
assert.equal(returned.tanks, winner.tanks); assert.equal(returned.elites!.tanks, winner.elites!.tanks);
assert.equal(returned.spice, winner.spice); assert.deepEqual(returned.hand, winner.hand);
assert.deepEqual(after.techTokens, faceDance.techTokens, 'A later replacement never claws back or redirects original winner Tech');
assert.equal(replacement.forces[fixture.location], remaining); assert.equal(replacement.forces[board], 1);
assert.equal(replacement.reserves, player(faceDance, tleilaxu).reserves - remaining + 2);
assert.equal(strongholdControllers(after.players, true)[fixture.kind], tleilaxu);
assert.equal(strongholdBenefit(after.strongholdCards, fixture.owner, fixture.kind), fixture.kind);
assert.equal(strongholdBenefit(after.strongholdCards, tleilaxu, fixture.kind), null);
assert.equal(returned.leaders.find(l => l.id === fixture.planActions[0].action.leader)!.dead, true);
custody(after, fixture.initial);
const mentat = finishNativeTechFactionsTurn(after);
assert.equal(mentat.strongholdCards!.owners[fixture.kind], tleilaxu);
assert.deepEqual(mentat.techTokens, after.techTokens);
custody(mentat, fixture.initial);
});
void test('representative native bot continues the genuine Stronghold+Tech Face Dance with legal physical sources', () => {
const fixture = createNativeTechFactionsFixture({ family: 'tleilaxu' });
const game = advanceNativeTechFactions(revealNativeTechFactionsBattle(fixture), g => g.decision?.kind === 'faceDance');
const actor = game.decision!.player, view = viewGame(game, actor);
view.players.find(p => p.id === actor)!.bot = 'Easy';
const action = botActions(view).find(a => a.type === 'decision'); assert.ok(action);
assert.equal(action.reveal, true);
const after = applyAction(game, actor, action);
assert.equal(player(after, fixture.owner).forces[fixture.location] ?? 0, 0);
assert.equal(player(after, actor).forces[fixture.location], player(game, fixture.owner).forces[fixture.location]);
assert.deepEqual(after.techTokens, game.techTokens);
custody(after, fixture.initial);
});
+1 -8
View File
@@ -1047,6 +1047,7 @@ void test('Stronghold faction admission rejects incompatible modules, history, o
game => { game.strongholdCards = createStrongholdCards(); game.strongholdCards.claimedTurn = 1; },
game => { game.nexusCards = { cards: null, phase: null }; },
game => { game.leaderSkills = createLeaderSkills(() => 0); },
game => { game.homeworlds = { custody: null }; },
];
for (const mutate of variants) {
const game = structuredClone(native);
@@ -1058,14 +1059,6 @@ void test('Stronghold faction admission rejects incompatible modules, history, o
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), before);
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), seats);
}
for (const type of ['homeworlds', 'techTokens']) {
const game = applyAction(native, native.host, { type, enabled: true });
game.players.forEach(player => { player.ready = true; });
db.prepare('UPDATE rooms SET state=?,version=7 WHERE code=?').run(JSON.stringify(game), native.code);
const before = db.prepare('SELECT * FROM rooms ORDER BY code').all();
assert.throws(() => startPrototypeRoom(db, native.code, 7, 'stronghold-factions'));
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), before);
}
const original = structuredClone(native);
for (const action of [{ type: 'start', profile: 'stronghold-factions' },
{ type: 'initializeStrongholdFactionsGameForAudit' }, { type: 'strongholdFactions', enabled: true }])