Compose native faction Tech and Advanced Strongholds without Skills
This commit is contained in:
1 parent
7cdb9b5a62
commit
2da3c9273b
16 files changed
+1358
-45
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 }])
|
||||
|
||||
Reference in new issue
Block a user