Connect Tleilaxu Nexus free leader and paid force revival
This commit is contained in:
1 parent
4bf6cfc875
commit
72e37a8978
22 files changed
+1589
-80
No files matched your search
@@ -0,0 +1,370 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeNexusGameForAudit, joinGame, newPlayer,
|
||||
type Action, type Game, type Player,
|
||||
} from '../game/engine';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import { location, territory, validGameLocation } from '../game/board';
|
||||
import { treacheryDeck } from '../game/cards';
|
||||
import { validateNexusCards } from '../game/nexus-cards';
|
||||
import { tleilaxuSecretAllyEvent, tleilaxuSecretAllyOptions } from '../game/nexus-tleilaxu-revival';
|
||||
import { shipmentAvailable } from '../game/shipment-opportunity';
|
||||
import { traitorDeck } from '../game/traitors';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
|
||||
/** One real native step: the actor is always the seat the engine expects. */
|
||||
export type NexusTleilaxuSecretAllyStep = { actor: string; action: Action };
|
||||
export type NexusTleilaxuSecretAllyFixtureOptions = {
|
||||
/** Only an original undealt classic lobby, or omit for a fresh Atreides/Guild/Emperor table. */
|
||||
initial?: Game;
|
||||
advanced?: boolean;
|
||||
seatIds?: readonly [string, string, string];
|
||||
};
|
||||
export type NexusTleilaxuSecretAllyDeadLeader = { id: string; name: string; deaths: number };
|
||||
export type NexusTleilaxuSecretAllyFixture = {
|
||||
initial: Game;
|
||||
afterSetup: Game;
|
||||
beforeShipment: Game;
|
||||
shipment: NexusTleilaxuSecretAllyStep;
|
||||
beforeBattle: Game;
|
||||
battleActions: NexusTleilaxuSecretAllyStep[];
|
||||
afterBattle: Game;
|
||||
beforeBlow: Game;
|
||||
beforeDraw: Game;
|
||||
draw: NexusTleilaxuSecretAllyStep;
|
||||
afterDraw: Game;
|
||||
/** Quiet turn-two Revival with the physical Tleilaxu card held by an unallied owner. */
|
||||
game: Game;
|
||||
owner: string;
|
||||
opponent: string;
|
||||
partner: string;
|
||||
ownerFaction: FactionId;
|
||||
deadLeader: NexusTleilaxuSecretAllyDeadLeader;
|
||||
nexusEvent: string;
|
||||
actions: NexusTleilaxuSecretAllyStep[];
|
||||
staging: string[];
|
||||
/** Raw JSON-safe scenario facts for the parent's throwaway API and phone smoke. */
|
||||
scenario: Record<string, unknown>;
|
||||
};
|
||||
export function nexusTleilaxuSecretAllyPlayer(game: Game, id: string): Player {
|
||||
const player = game.players.find(candidate => candidate.id === id);
|
||||
assert.ok(player, `Missing classic seat ${id}`);
|
||||
return player;
|
||||
}
|
||||
export const nexusTleilaxuSecretAllyClean = (game: Game): boolean =>
|
||||
!game.phaseOpening && !game.response && !game.decision;
|
||||
|
||||
/** Scoped producer entropy: every original shuffle keeps its printed order, so
|
||||
* the real deal, real draws and real battle chronology stay reproducible. Only
|
||||
* the initializer and genuine actions run inside this window. */
|
||||
function identityRandom<T>(run: () => 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 run(); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
|
||||
/** The closing Nexus draw is a private table choice, so the shared native
|
||||
* continuation stops there instead of drawing a card for a seat. */
|
||||
export function nextNexusTleilaxuSecretAllyNativeStep(
|
||||
game: Game,
|
||||
): NexusTleilaxuSecretAllyStep | null {
|
||||
if (game.nexusCards?.phase?.stage === 'drawing' && nexusTleilaxuSecretAllyClean(game)) return null;
|
||||
return nextStrongholdFactionsNativeStep(game);
|
||||
}
|
||||
export function stepNexusTleilaxuSecretAlly(
|
||||
game: Game,
|
||||
actions?: NexusTleilaxuSecretAllyStep[],
|
||||
): Game {
|
||||
const next = nextNexusTleilaxuSecretAllyNativeStep(game);
|
||||
assert.ok(next, `Original continuation stopped before ${game.status}/${game.turn}/${game.phase}`);
|
||||
actions?.push(structuredClone(next));
|
||||
return applyAction(game, next.actor, next.action);
|
||||
}
|
||||
export function advanceNexusTleilaxuSecretAlly(
|
||||
game: Game,
|
||||
until: (state: Game) => boolean,
|
||||
actions?: NexusTleilaxuSecretAllyStep[],
|
||||
): Game {
|
||||
for (let count = 0; count < 2000; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
game = stepNexusTleilaxuSecretAlly(game, actions);
|
||||
}
|
||||
throw Error('Original absent-Tleilaxu Nexus chronology did not reach its boundary');
|
||||
}
|
||||
/** Turn-pinned phase boundary; a turn change is an error, not a success. */
|
||||
export function advanceNexusTleilaxuSecretAllyToPhase(
|
||||
game: Game,
|
||||
phase: number,
|
||||
actions?: NexusTleilaxuSecretAllyStep[],
|
||||
): Game {
|
||||
const turn = game.turn;
|
||||
const reached = advanceNexusTleilaxuSecretAlly(game,
|
||||
state => state.turn === turn && state.phase >= phase && nexusTleilaxuSecretAllyClean(state),
|
||||
actions);
|
||||
assert.equal(reached.turn, turn, 'The requested phase belongs to the current turn');
|
||||
return reached;
|
||||
}
|
||||
|
||||
/** Original undealt Shai-Hulud singleton to the front of the physical Spice Deck;
|
||||
* nothing is added, removed or replaced. */
|
||||
function frontUndealtShaiHulud(game: Game, staging: string[]): void {
|
||||
const index = game.spiceDeck.findIndex(card =>
|
||||
'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper);
|
||||
assert.ok(index >= 0, 'The original undealt Spice Deck still holds a Shai-Hulud singleton');
|
||||
const [card] = game.spiceDeck.splice(index, 1);
|
||||
game.spiceDeck.unshift(card);
|
||||
staging.push('Ordered one original undealt Shai-Hulud Spice singleton to the front; not a natural shuffle history.');
|
||||
}
|
||||
|
||||
/** Fresh classic Nexus admission only: base seats, no expansions, offline audit seam. */
|
||||
export function initializeNexusTleilaxuSecretAllySetup(
|
||||
options: NexusTleilaxuSecretAllyFixtureOptions = {},
|
||||
): Game {
|
||||
const advanced = options.advanced ?? false;
|
||||
if (options.initial) {
|
||||
let supplied = structuredClone(options.initial);
|
||||
assert.equal(supplied.status, 'lobby');
|
||||
assert.equal(supplied.advanced, advanced);
|
||||
assert.equal(supplied.turn, 1);
|
||||
assert.equal(supplied.phase, 0);
|
||||
assert.ok(!supplied.setupStage);
|
||||
assert.deepEqual([...supplied.expansions], []);
|
||||
assert.equal(supplied.players.length, 3);
|
||||
assert.ok(supplied.players.every(player => player.reserves === 20 && player.tanks === 0 &&
|
||||
player.spice === 0 && Object.keys(player.forces).length === 0 && player.hand.length === 0),
|
||||
'Only a fresh undealt classic lobby may be continued');
|
||||
if (!supplied.nexusCards) supplied.nexusCards = { cards: null, phase: null };
|
||||
for (const player of supplied.players) if (!player.ready) supplied=applyAction(supplied, player.id, { type: 'ready' });
|
||||
return identityRandom(() => initializeNexusGameForAudit(supplied));
|
||||
}
|
||||
const ids = options.seatIds ?? ['atreides', 'guild', 'emperor'];
|
||||
assert.equal(new Set(ids).size, 3);
|
||||
let game = createGame('NEXUSTLEILAXUSECRETALLY', newPlayer(ids[0], 'Atreides', 'atreides'), advanced);
|
||||
joinGame(game, newPlayer(ids[1], 'Guild', 'guild'));
|
||||
joinGame(game, newPlayer(ids[2], 'Emperor', 'emperor'));
|
||||
assert.deepEqual(game.expansions, []);
|
||||
assert.ok(game.players.every(player => player.faction === 'atreides' || player.faction === 'guild' || player.faction === 'emperor'));
|
||||
game.nexusCards ??= { cards: null, phase: null };
|
||||
for (const player of game.players) if (!player.ready) game=applyAction(game, player.id, { type: 'ready' });
|
||||
return identityRandom(() => initializeNexusGameForAudit(game));
|
||||
}
|
||||
|
||||
/** Physical census of the original pieces; no module owner is assumed. */
|
||||
export function nexusTleilaxuSecretAllyInventory(game: Game): void {
|
||||
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
|
||||
...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? [])];
|
||||
assert.deepEqual(cards.map(card => card.id).sort(), treacheryDeck(game.expansions).map(card => card.id).sort());
|
||||
assert.equal(new Set(cards.map(card => card.id)).size, cards.length);
|
||||
for (const player of game.players) {
|
||||
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, n) => sum + n, 0), 20, player.faction);
|
||||
if (player.elites) {
|
||||
assert.equal(player.elites.reserves + player.elites.tanks +
|
||||
Object.values(player.elites.forces).reduce((sum, n) => sum + n, 0),
|
||||
player.faction === 'emperor' ? 5 : 3, player.faction);
|
||||
assert.ok(player.elites.reserves <= player.reserves && player.elites.tanks <= player.tanks);
|
||||
}
|
||||
}
|
||||
assert.deepEqual([...(game.traitorReserve ?? []),
|
||||
...game.players.flatMap(player => [...player.traitors, ...player.traitorChoices])].sort(),
|
||||
traitorDeck(game.players, false).sort());
|
||||
assert.ok(game.nexusCards?.cards, 'The Nexus inventory must exist');
|
||||
validateNexusCards(game.nexusCards.cards, game.players);
|
||||
}
|
||||
|
||||
/** Genuine Basic/Advanced absent-Tleilaxu chronology: one real Emperor shipment
|
||||
* into the printed Atreides position, one real battle that kills an own leader and
|
||||
* eight own counters, the real turn-two Shai-Hulud Nexus occurrence with its real
|
||||
* reciprocal alliance, and the real closing Nexus draw. No hand, wallet, board
|
||||
* counter, Tanks, alliance, phase, death or pending frame is written. */
|
||||
export function createNexusTleilaxuSecretAllyFixture(
|
||||
options: NexusTleilaxuSecretAllyFixtureOptions = {},
|
||||
): NexusTleilaxuSecretAllyFixture {
|
||||
const initial = initializeNexusTleilaxuSecretAllySetup(options);
|
||||
return identityRandom(() => {
|
||||
const advanced = initial.advanced;
|
||||
const actions: NexusTleilaxuSecretAllyStep[] = [], staging: string[] = [];
|
||||
let game = advanceNexusTleilaxuSecretAlly(initial, state => state.status === 'playing', actions);
|
||||
const afterSetup = structuredClone(game);
|
||||
assert.ok(game.players.every(player => player.traitors.length > 0 && player.hand.length > 0),
|
||||
'The real setup deals traitors and starting Treachery Cards');
|
||||
const owner = game.players.find(player => player.faction === 'atreides')!.id;
|
||||
const opponent = game.players.find(player => player.faction === 'emperor')!.id;
|
||||
const partner = game.players.find(player => player.faction === 'guild')!.id;
|
||||
assert.equal(game.players.length, 3, 'One absent-faction holder, one aggressor and one alliance partner');
|
||||
assert.ok(!game.players.some(player => player.faction === 'tleilaxu'), 'Secret Ally needs Tleilaxu absent');
|
||||
assert.ok(game.players.every(player => player.ally === null));
|
||||
const weapon = nexusTleilaxuSecretAllyPlayer(game, opponent).hand
|
||||
.find(card => card.kind === 'projectile' || card.kind === 'poison');
|
||||
assert.ok(weapon, 'The real Emperor deal must hold one printed leader-killing weapon');
|
||||
nexusTleilaxuSecretAllyInventory(game);
|
||||
|
||||
// ---- Turn one, real Movement: the Emperor ships one ordinary counter into Arrakeen.
|
||||
game = advanceNexusTleilaxuSecretAllyToPhase(game, 5, actions);
|
||||
for (let count = 0; game.active !== opponent; count++) {
|
||||
assert.ok(count < 12, 'Every original seat receives one real Movement turn');
|
||||
game = stepNexusTleilaxuSecretAlly(game, actions);
|
||||
}
|
||||
const beforeShipment = structuredClone(game);
|
||||
const sector = territory('arrakeen').sectors[0];
|
||||
const target = location('arrakeen', sector);
|
||||
assert.ok(validGameLocation(game, 'arrakeen', sector));
|
||||
assert.ok(shipmentAvailable(game, nexusTleilaxuSecretAllyPlayer(game, opponent)),
|
||||
'The Emperor still holds the original turn-one shipment');
|
||||
const shipment: NexusTleilaxuSecretAllyStep = { actor: opponent, action: {
|
||||
type: 'ship', territory: 'arrakeen', sector, amount: 1, elite: 0, allyPayment: 0 } };
|
||||
actions.push(structuredClone(shipment));
|
||||
game = applyAction(game, opponent, shipment.action);
|
||||
game=advanceNexusTleilaxuSecretAlly(game,nexusTleilaxuSecretAllyClean,actions);
|
||||
assert.equal(nexusTleilaxuSecretAllyPlayer(game, opponent).forces[target], 1);
|
||||
assert.equal(nexusTleilaxuSecretAllyPlayer(game, owner).forces[target], 10,
|
||||
'The printed Atreides position keeps its full real setup army');
|
||||
staging.push('The real Emperor seat ships one ordinary reserve counter into the printed Atreides Arrakeen location; no counter, bank or phase is written.');
|
||||
|
||||
// ---- Turn one, real Battle: the Emperor's real weapon kills the defending disc.
|
||||
game = advanceNexusTleilaxuSecretAllyToPhase(game, 6, actions);
|
||||
const chooser = game.active!;
|
||||
assert.ok([owner, opponent].includes(chooser), 'The real battle chooser must be one of the two co-located seats');
|
||||
const battleActions: NexusTleilaxuSecretAllyStep[] = [];
|
||||
if (!game.battle) {
|
||||
const choose: NexusTleilaxuSecretAllyStep = { actor: chooser, action: {
|
||||
type: 'chooseBattle', territory: 'arrakeen',
|
||||
target: chooser === owner ? opponent : owner } };
|
||||
battleActions.push(structuredClone(choose));
|
||||
actions.push(structuredClone(choose));
|
||||
game = applyAction(game, choose.actor, choose.action);
|
||||
}
|
||||
game = advanceNexusTleilaxuSecretAlly(game,
|
||||
state => nexusTleilaxuSecretAllyClean(state) && !nextNexusTleilaxuSecretAllyNativeStep(state), actions);
|
||||
assert.ok(game.battle && !game.battle.revealed, 'Both combatants still seal a private plan');
|
||||
const beforeBattle = structuredClone(game);
|
||||
const ownLeader = nexusTleilaxuSecretAllyPlayer(game, owner).leaders
|
||||
.filter(leader => !leader.dead).sort((a, b) => a.strength - b.strength)[0];
|
||||
const otherLeader = nexusTleilaxuSecretAllyPlayer(game, opponent).leaders
|
||||
.filter(leader => !leader.dead).sort((a, b) => a.strength - b.strength)[0];
|
||||
assert.ok(ownLeader && otherLeader);
|
||||
// Basic dials the lost exchange directly; Advanced expresses the same eight
|
||||
// committed ordinary counters on its half-step dial with no support spice.
|
||||
const plans: NexusTleilaxuSecretAllyStep[] = [
|
||||
{ actor: owner, action: { type: 'battlePlan', dial: advanced ? 4 : 8, support: 0,
|
||||
leader: ownLeader.id, weapon: null, defense: null } },
|
||||
{ actor: opponent, action: { type: 'battlePlan', dial: 0, support: 0,
|
||||
leader: otherLeader.id, weapon: weapon.id, defense: null } },
|
||||
];
|
||||
for (const plan of plans) {
|
||||
battleActions.push(structuredClone(plan));
|
||||
actions.push(structuredClone(plan));
|
||||
game = applyAction(game, plan.actor, plan.action);
|
||||
}
|
||||
assert.ok(game.battle?.revealed, 'Both sealed plans are revealed');
|
||||
game = advanceNexusTleilaxuSecretAlly(game, state => state.turn === 2 && state.phase === 1 &&
|
||||
nexusTleilaxuSecretAllyClean(state) && !state.spiceSequence && !state.spiceResolution &&
|
||||
!state.spiceWindow && !state.nexus && state.ready.length === 0, actions);
|
||||
const afterBattle = structuredClone(game);
|
||||
const deadLeader = nexusTleilaxuSecretAllyPlayer(game, owner).leaders
|
||||
.find(leader => leader.id === ownLeader.id)!;
|
||||
assert.equal(deadLeader.dead, true, 'The real opposing weapon killed this own leader disc');
|
||||
assert.equal(deadLeader.deaths, 1);
|
||||
assert.equal(nexusTleilaxuSecretAllyPlayer(game, owner).tanks, 8,
|
||||
'Exactly the eight real dialed ordinary counters reached the Tanks');
|
||||
staging.push('Real turn-one battle in the printed Atreides stronghold: the opposing printed weapon killed one own disc and the real exchange sent eight ordinary counters to the Tanks.');
|
||||
|
||||
// ---- Turn two, real Spice Blow and Nexus: one Shai-Hulud occurrence and the real draw.
|
||||
const beforeBlow = structuredClone(game);
|
||||
frontUndealtShaiHulud(game, staging);
|
||||
game = advanceNexusTleilaxuSecretAlly(game,
|
||||
state => state.nexus === true && !state.spiceWindow && nexusTleilaxuSecretAllyClean(state), actions);
|
||||
assert.equal(game.nexus, true, 'The real Shai-Hulud appearance opened the Nexus');
|
||||
const otherSeats = game.players.filter(player => player.id !== owner).map(player => player.id);
|
||||
assert.deepEqual([...otherSeats].sort(), [opponent, partner].sort());
|
||||
const alliance: NexusTleilaxuSecretAllyStep[] = [
|
||||
{ actor: otherSeats[0], action: { type: 'alliance', target: otherSeats[1] } },
|
||||
{ actor: otherSeats[1], action: { type: 'alliance', target: otherSeats[0] } },
|
||||
];
|
||||
for (const entry of alliance) {
|
||||
actions.push(structuredClone(entry));
|
||||
game = applyAction(game, entry.actor, entry.action);
|
||||
}
|
||||
assert.equal(nexusTleilaxuSecretAllyPlayer(game, opponent).ally, partner);
|
||||
assert.equal(nexusTleilaxuSecretAllyPlayer(game, owner).ally, null, 'The holder must stay unallied');
|
||||
staging.push('The two non-holder seats form the real reciprocal Nexus alliance the closing window requires; the holder stays unallied.');
|
||||
game = advanceNexusTleilaxuSecretAlly(game,
|
||||
state => state.nexusCards?.phase?.stage === 'drawing', actions);
|
||||
assert.equal(game.turn, 2);
|
||||
assert.equal(game.phase, 1);
|
||||
const phase = game.nexusCards!.phase!;
|
||||
assert.deepEqual(phase.eligible, [owner], 'Only the unallied holder receives the closing opportunity');
|
||||
assert.deepEqual(phase.done, []);
|
||||
const cards = game.nexusCards!.cards!;
|
||||
assert.equal(cards.hands[owner], null, 'No Nexus hand is granted before the real draw');
|
||||
const index = cards.deck.indexOf('tleilaxu');
|
||||
assert.ok(index >= 0, 'The twelve-card Nexus inventory still holds the physical Tleilaxu singleton');
|
||||
cards.deck.unshift(cards.deck.splice(index, 1)[0]);
|
||||
staging.push('Ordered the original undealt Tleilaxu Nexus singleton before the real closing draw; no hand is granted.');
|
||||
const beforeDraw = structuredClone(game);
|
||||
const draw: NexusTleilaxuSecretAllyStep = { actor: owner, action: { type: 'nexusCardChoice',
|
||||
turn: game.turn, card: cards.hands[owner], choice: 'draw', ownRedraws: 0 } };
|
||||
actions.push(structuredClone(draw));
|
||||
game = applyAction(game, owner, draw.action);
|
||||
assert.equal(game.nexusCards!.cards!.hands[owner], 'tleilaxu',
|
||||
'The real closing draw puts the physical Tleilaxu card in the holder hand');
|
||||
assert.equal(game.nexusCards!.phase?.stage, 'complete');
|
||||
const afterDraw = structuredClone(game);
|
||||
nexusTleilaxuSecretAllyInventory(game);
|
||||
|
||||
// ---- Quiet turn-two Revival with the real held card.
|
||||
game = advanceNexusTleilaxuSecretAllyToPhase(game, 4, actions);
|
||||
assert.equal(game.turn, 2);
|
||||
assert.equal(game.phase, 4);
|
||||
assert.ok(nexusTleilaxuSecretAllyClean(game), 'The card needs the quiet Revival window');
|
||||
assert.equal(game.nexusCards!.phase?.stage, 'complete');
|
||||
const holder = nexusTleilaxuSecretAllyPlayer(game, owner);
|
||||
const dead = holder.leaders.find(leader => leader.dead);
|
||||
assert.equal(dead?.id, deadLeader.id);
|
||||
assert.equal(holder.tanks, 8, 'The real Tanks custody survives Movement, Battle and Collection');
|
||||
const offer = tleilaxuSecretAllyOptions(game, holder);
|
||||
assert.equal(offer.blocked, null, 'The physical state must support the owner-only offer');
|
||||
assert.deepEqual(offer.leaders.map(leader => leader.id), [deadLeader.id]);
|
||||
assert.equal(offer.maxForces, 5);
|
||||
assert.equal(offer.maxElite, 0, 'This faction owns no typed counters');
|
||||
const nexusEvent = tleilaxuSecretAllyEvent(game, holder);
|
||||
assert.equal(offer.event, nexusEvent);
|
||||
nexusTleilaxuSecretAllyInventory(game);
|
||||
|
||||
const deadLeaderFact: NexusTleilaxuSecretAllyDeadLeader = {
|
||||
id: deadLeader.id, name: deadLeader.name, deaths: deadLeader.deaths,
|
||||
};
|
||||
const scenario = {
|
||||
code: game.code,
|
||||
advanced: game.advanced,
|
||||
turn: game.turn,
|
||||
phase: game.phase,
|
||||
owner, opponent, partner,
|
||||
ownerFaction: holder.faction,
|
||||
nexusCard: game.nexusCards!.cards!.hands[owner],
|
||||
nexusDeckCount: game.nexusCards!.cards!.deck.length,
|
||||
event: nexusEvent,
|
||||
deadLeader: { ...deadLeaderFact },
|
||||
deadForces: { normal: holder.tanks, elite: holder.elites?.tanks ?? 0 },
|
||||
seats: game.players.map(player => ({ id: player.id, faction: player.faction, ally: player.ally,
|
||||
spice: player.spice, tanks: player.tanks, reserves: player.reserves, revived: player.revived })),
|
||||
offer: { blocked: offer.blocked, maxForces: offer.maxForces, maxElite: offer.maxElite,
|
||||
leaders: offer.leaders.map(leader => leader.id) },
|
||||
action: { type: 'nexusTleilaxuRevival', event: nexusEvent,
|
||||
leader: deadLeader.id, amount: offer.maxForces, elite: 0 },
|
||||
staging: [...staging],
|
||||
};
|
||||
assert.ok(game.spiceDeck.length > 0, 'The real spice deck keeps its remaining undealt cards');
|
||||
return { initial, afterSetup, beforeShipment, shipment, beforeBattle, battleActions,
|
||||
afterBattle, beforeBlow, beforeDraw, draw, afterDraw, game, owner, opponent, partner,
|
||||
ownerFaction: holder.faction, deadLeader: deadLeaderFact, nexusEvent, actions, staging, scenario };
|
||||
});
|
||||
}
|
||||
@@ -46,7 +46,7 @@ function deposited(
|
||||
|
||||
void test('high Southern Hemisphere returns only the newly revived Fedaykin subset for each supported revival source', () => {
|
||||
for (const advanced of [false, true])
|
||||
for (const source of ['normal', 'emperorExtra', 'ghola'] as const) {
|
||||
for (const source of ['normal', 'emperorExtra', 'ghola', 'nexusTleilaxu'] as const) {
|
||||
const before = fixture('fremen', 3, advanced);
|
||||
before.players[0].elites!.reserves = 1;
|
||||
const group = { amount: 4, elite: 2, free: source === 'normal' ? 2 : 0 };
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
|
||||
function fixture(
|
||||
kind: 'fedaykin' | 'tleilax' = 'fedaykin',
|
||||
source: 'normal' | 'emperorExtra' | 'ghola' = 'normal',
|
||||
source: 'normal' | 'emperorExtra' | 'ghola' | 'nexusTleilaxu' = 'normal',
|
||||
) {
|
||||
const players = [
|
||||
newPlayer('p', 'Owner', kind === 'fedaykin' ? 'fremen' : 'tleilaxu'),
|
||||
@@ -85,7 +85,7 @@ function choose(
|
||||
}
|
||||
|
||||
void test('typed revival receipt survives every source and JSON round trip without aliasing its quote', () => {
|
||||
for (const source of ['normal', 'emperorExtra', 'ghola'] as const) {
|
||||
for (const source of ['normal', 'emperorExtra', 'ghola', 'nexusTleilaxu'] as const) {
|
||||
const { g, original, frame } = fixture('fedaykin', source);
|
||||
const copy = JSON.parse(JSON.stringify(frame)) as HomeworldRevivalReturn;
|
||||
validateHomeworldRevivalReturn(g, copy);
|
||||
|
||||
@@ -32,24 +32,11 @@ function fixture(): GameView {
|
||||
};
|
||||
v.nexusTleilaxu = {
|
||||
cunning: { event: 'dancer-event', count: 1, blocked: null },
|
||||
secretAlly: null,
|
||||
};
|
||||
return v;
|
||||
}
|
||||
|
||||
void test('every bot profile takes the complete offered replacement off turn without a choice handshake', () => {
|
||||
for (const difficulty of DIFFICULTIES)
|
||||
for (const count of [1, 2, 3]) {
|
||||
const v = fixture();
|
||||
v.players[0].bot = difficulty;
|
||||
v.nexusTleilaxu!.cunning!.count = count;
|
||||
v.decision = { kind: 'nullentropy', player: 'a' };
|
||||
const before = structuredClone(v);
|
||||
assert.deepEqual(botActions(v), [
|
||||
{ type: 'nexusFaceDancers', event: 'dancer-event' },
|
||||
]);
|
||||
assert.deepEqual(v, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('all bot profiles preserve Truth, closing draw, automatic and preceding exchange priorities', () => {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { createGame, newPlayer, viewGame, type GameView } from '../game/engine';
|
||||
import { NexusTleilaxu } from '../components/nexus-tleilaxu';
|
||||
import { nexusFaceDancersAction } from '../game/nexus-tleilaxu-options';
|
||||
|
||||
/** Explicit projected offers isolate presentation/consumer validation. These
|
||||
@@ -36,31 +33,10 @@ function fixture(): GameView {
|
||||
};
|
||||
v.nexusTleilaxu = {
|
||||
cunning: { event: 'dancer-event', count: 2, blocked: null },
|
||||
secretAlly: null,
|
||||
};
|
||||
return v;
|
||||
}
|
||||
function html(game: GameView, busy = false) {
|
||||
return renderToStaticMarkup(
|
||||
createElement(NexusTleilaxu, { game, busy, act() {} }),
|
||||
);
|
||||
}
|
||||
|
||||
void test('Cunning explains the ordered complete replacement and submits no selection or extra confirmation', () => {
|
||||
const v = fixture();
|
||||
const before = structuredClone(v);
|
||||
assert.deepEqual(nexusFaceDancersAction(v, 'dancer-event'), {
|
||||
type: 'nexusFaceDancers',
|
||||
event: 'dancer-event',
|
||||
});
|
||||
const text = html(v);
|
||||
assert.match(text, /secretly draw their replacements/);
|
||||
assert.match(text, /Then shuffle the set-aside cards/);
|
||||
assert.match(text, /unrevealed Face Dancers stay in your hand/);
|
||||
assert.match(text, /Replace 2 revealed Face Dancers/);
|
||||
assert.doesNotMatch(text, /type="checkbox"|<select|Confirm|disabled=""/);
|
||||
assert.deepEqual(v, before);
|
||||
assert.match(html(v, true), /disabled=""/);
|
||||
});
|
||||
|
||||
void test('Cunning binds owner, held card, event and server block while preserving anytime decision access', () => {
|
||||
const v = fixture();
|
||||
@@ -70,8 +46,6 @@ void test('Cunning binds owner, held card, event and server block while preservi
|
||||
assert.equal(nexusFaceDancersAction(v, 'stale'), null);
|
||||
v.nexusTleilaxu!.cunning!.blocked = 'Finish the current Nexus return first.';
|
||||
assert.equal(nexusFaceDancersAction(v, 'dancer-event'), null);
|
||||
assert.match(html(v), /Finish the current Nexus return first/);
|
||||
assert.match(html(v), /disabled=""/);
|
||||
v.nexusTleilaxu!.cunning!.blocked = null;
|
||||
v.nexusCards!.card = 'harkonnen';
|
||||
assert.equal(nexusFaceDancersAction(v, 'dancer-event'), null);
|
||||
@@ -93,7 +67,6 @@ void test('Cunning controls honor Truth and exchange priority and never inspect
|
||||
},
|
||||
});
|
||||
assert.ok(nexusFaceDancersAction(v, 'dancer-event'));
|
||||
assert.match(html(v), /Replace 2/);
|
||||
v.truthtrance = { stage: 'priority', queue: [], passed: [], question: null };
|
||||
assert.equal(nexusFaceDancersAction(v, 'dancer-event'), null);
|
||||
v.truthtrance = null;
|
||||
@@ -117,17 +90,5 @@ void test('Cunning controls honor Truth and exchange priority and never inspect
|
||||
assert.equal(nexusFaceDancersAction(v, 'dancer-event'), null);
|
||||
}
|
||||
v.nexusTleilaxu = null;
|
||||
assert.equal(html(v), '');
|
||||
});
|
||||
|
||||
void test('unavailable Tleilaxu modes are explained only from the viewer’s own held Nexus card', () => {
|
||||
const v = fixture();
|
||||
v.nexusTleilaxu = { cunning: null };
|
||||
v.me = 'a';
|
||||
assert.match(html(v), /Betrayal is unavailable while its private response timing is pending/);
|
||||
v.players = v.players.filter((player) => player.faction !== 'tleilaxu');
|
||||
assert.match(html(v), /Secret Ally revival is unavailable while its revival-limit ruling is pending/);
|
||||
assert.doesNotMatch(html(v), /<button/);
|
||||
v.nexusCards!.card = null;
|
||||
assert.equal(html(v), '');
|
||||
});
|
||||
@@ -0,0 +1,473 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, newPlayer, type Game, type Player } from '../game/engine';
|
||||
import { createAuditorLeader, leaders } from '../game/cards';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import { createNexusCards } from '../game/nexus-cards';
|
||||
import { forceRevivalRemaining, leaderRevivalOptions, newRevivalRules } from '../game/revival';
|
||||
import { createDukeVidal, DUKE_VIDAL_ID } from '../game/duke-vidal';
|
||||
import {
|
||||
NEXUS_TLEILAXU_FORCE_GROUP_CAP,
|
||||
NEXUS_TLEILAXU_POLICY,
|
||||
TleilaxuSecretAllyError,
|
||||
quoteTleilaxuSecretAlly,
|
||||
tleilaxuSecretAllyBlock,
|
||||
tleilaxuSecretAllyEvent,
|
||||
tleilaxuSecretAllyForceBounds,
|
||||
tleilaxuSecretAllyOptions,
|
||||
} from '../game/nexus-tleilaxu-revival';
|
||||
|
||||
/** A Treachery card id, i.e. what a battle plan uses for a Cheap Hero. */
|
||||
const CHEAP_HERO = 'treachery-17';
|
||||
|
||||
function holdNexusCard(g: Game, holder: string) {
|
||||
const cards = createNexusCards(
|
||||
g.players.map((seat) => ({
|
||||
id: seat.id,
|
||||
faction: seat.faction,
|
||||
ally: seat.ally,
|
||||
})),
|
||||
() => 0.5,
|
||||
);
|
||||
cards.deck = cards.deck.filter((card) => card !== 'tleilaxu');
|
||||
cards.hands[holder] = 'tleilaxu';
|
||||
g.nexusCards = { cards, phase: null };
|
||||
}
|
||||
|
||||
function fixture(advanced = true, faction: FactionId = 'atreides') {
|
||||
const g = createGame('NEXUSREVIVAL', newPlayer('a', 'Holder', faction), advanced);
|
||||
g.players.push(newPlayer('h', 'Rival', 'harkonnen'));
|
||||
Object.assign(g, {
|
||||
status: 'playing',
|
||||
phase: 4,
|
||||
turn: 3,
|
||||
order: ['a', 'h'],
|
||||
revivalRules: newRevivalRules(),
|
||||
});
|
||||
for (const p of g.players) {
|
||||
p.spice = 20;
|
||||
p.tanks = 10;
|
||||
p.elites = { reserves: 0, tanks: 0, forces: {}, revived: 0 };
|
||||
}
|
||||
holdNexusCard(g, 'a');
|
||||
return g;
|
||||
}
|
||||
|
||||
function kill(p: Player, indexes: number[], deaths = 1) {
|
||||
for (const index of indexes) {
|
||||
p.leaders[index].dead = true;
|
||||
p.leaders[index].deaths = deaths;
|
||||
}
|
||||
}
|
||||
|
||||
void test('actual held card, unallied non-Tleilaxu holder, quiet Revival and an owner-only event bind the projection', () => {
|
||||
const g = fixture();
|
||||
kill(g.players[0], [0]);
|
||||
const before = structuredClone(g);
|
||||
const offer = tleilaxuSecretAllyOptions(g, g.players[0]);
|
||||
assert.equal(offer.blocked, null);
|
||||
assert.equal(tleilaxuSecretAllyBlock(g, g.players[0]), null);
|
||||
assert.equal(offer.event, tleilaxuSecretAllyEvent(g, g.players[0]));
|
||||
assert.equal(
|
||||
offer.event,
|
||||
JSON.stringify(['nexusTleilaxuSecretAlly', 3, 4, 'a']),
|
||||
);
|
||||
assert.equal(offer.policy, NEXUS_TLEILAXU_POLICY);
|
||||
assert.deepEqual(offer.leaders, [
|
||||
{
|
||||
id: g.players[0].leaders[0].id,
|
||||
name: g.players[0].leaders[0].name,
|
||||
kind: 'leader',
|
||||
early: true,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(g, before);
|
||||
|
||||
g.nexusCards!.cards!.hands.a = null;
|
||||
assert.match(tleilaxuSecretAllyBlock(g, g.players[0])!, /holder of the Tleilaxu Nexus card/);
|
||||
g.nexusCards!.cards!.hands.a = 'tleilaxu';
|
||||
|
||||
g.players[0].ally = 'h';
|
||||
g.players[1].ally = 'a';
|
||||
assert.match(tleilaxuSecretAllyBlock(g, g.players[0])!, /allied player/);
|
||||
g.players[0].ally = null;
|
||||
g.players[1].ally = null;
|
||||
|
||||
g.players.push(newPlayer('t', 'Native', 'tleilaxu'));
|
||||
assert.match(tleilaxuSecretAllyBlock(g, g.players[0])!, /Tleilaxu absent/);
|
||||
g.players.pop();
|
||||
|
||||
g.phase = 5;
|
||||
assert.match(tleilaxuSecretAllyBlock(g, g.players[0])!, /during Revival/);
|
||||
assert.throws(
|
||||
() =>
|
||||
quoteTleilaxuSecretAlly(g, g.players[0], {
|
||||
leader: g.players[0].leaders[0].id,
|
||||
amount: 0,
|
||||
elite: 0,
|
||||
}),
|
||||
(error: unknown) =>
|
||||
error instanceof TleilaxuSecretAllyError && error.reason === 'timing',
|
||||
);
|
||||
g.phase = 4;
|
||||
g.status = 'lobby';
|
||||
assert.match(tleilaxuSecretAllyBlock(g, g.players[0])!, /started game/);
|
||||
g.status = 'playing';
|
||||
|
||||
g.pendingCapture = { player: 'a', loser: 'h', territory: 'arrakeen' };
|
||||
assert.match(tleilaxuSecretAllyBlock(g, g.players[0])!, /Finish the current interaction/);
|
||||
g.pendingCapture = null;
|
||||
assert.equal(tleilaxuSecretAllyBlock(g, g.players[0]), null);
|
||||
});
|
||||
|
||||
void test('the roster waiver belongs to the card alone and never opens the ordinary cycle', () => {
|
||||
const g = fixture();
|
||||
const p = g.players[0];
|
||||
kill(p, [0]);
|
||||
// Ordinary rules still refuse: the cohort has never been opened (cycle 0).
|
||||
assert.deepEqual(leaderRevivalOptions(g, p).leaders, []);
|
||||
assert.equal(leaderRevivalOptions(g, p).cycle, 0);
|
||||
const offer = tleilaxuSecretAllyOptions(g, p);
|
||||
assert.deepEqual(
|
||||
offer.leaders.map((leader) => [leader.id, leader.kind, leader.early]),
|
||||
[[p.leaders[0].id, 'leader', true]],
|
||||
);
|
||||
// The synthetic cycle is a local projection only.
|
||||
assert.equal(p.revivalCycle, 0);
|
||||
assert.deepEqual(leaderRevivalOptions(g, p).leaders, []);
|
||||
assert.deepEqual(g.revivalRules!.earlyBlocked, []);
|
||||
|
||||
// A second death is not returned early while the cohort has never been opened,
|
||||
// and the same disc is an ordinary candidate once that cohort level is open.
|
||||
const second = fixture();
|
||||
const seat = second.players[0];
|
||||
kill(seat, [0], 2);
|
||||
const withheld = tleilaxuSecretAllyOptions(second, seat);
|
||||
assert.deepEqual(withheld.leaders, []);
|
||||
assert.match(withheld.blocked!, /one own dead leader/);
|
||||
seat.revivalCycle = 2;
|
||||
assert.deepEqual(
|
||||
tleilaxuSecretAllyOptions(second, seat).leaders.map((leader) => [leader.id, leader.early]),
|
||||
[[seat.leaders[0].id, false]],
|
||||
);
|
||||
});
|
||||
|
||||
void test('an opened ordinary cohort keeps canonical eligibility and is never marked early', () => {
|
||||
const g = fixture();
|
||||
const p = g.players[0];
|
||||
for (const leader of p.leaders) {
|
||||
leader.dead = true;
|
||||
leader.deaths = 1;
|
||||
}
|
||||
const offer = tleilaxuSecretAllyOptions(g, p);
|
||||
assert.deepEqual(
|
||||
offer.leaders.map((leader) => leader.id),
|
||||
p.leaders.map((leader) => leader.id),
|
||||
);
|
||||
assert.ok(offer.leaders.every((leader) => leader.kind === 'leader' && !leader.early));
|
||||
});
|
||||
|
||||
void test('one own dead leader is mandatory: zero forces is legal, a force-only return is not, and the used slot closes it', () => {
|
||||
const g = fixture();
|
||||
const p = g.players[0];
|
||||
const offer = tleilaxuSecretAllyOptions(g, p);
|
||||
assert.deepEqual(offer.leaders, []);
|
||||
assert.match(offer.blocked!, /one own dead leader/);
|
||||
assert.ok(offer.maxForces > 0);
|
||||
assert.throws(
|
||||
() =>
|
||||
quoteTleilaxuSecretAlly(g, p, {
|
||||
leader: p.leaders[0].id,
|
||||
amount: 0,
|
||||
elite: 0,
|
||||
}),
|
||||
(error: unknown) =>
|
||||
error instanceof TleilaxuSecretAllyError && error.reason === 'leader',
|
||||
);
|
||||
kill(p, [0]);
|
||||
assert.equal(quoteTleilaxuSecretAlly(g, p, { leader: p.leaders[0].id, amount: 0, elite: 0 }).cost, 0);
|
||||
p.leaderRevived = true;
|
||||
assert.deepEqual(tleilaxuSecretAllyOptions(g, p).leaders, []);
|
||||
assert.throws(
|
||||
() => quoteTleilaxuSecretAlly(g, p, { leader: p.leaders[0].id, amount: 0, elite: 0 }),
|
||||
TleilaxuSecretAllyError,
|
||||
);
|
||||
});
|
||||
|
||||
void test('exact paid typed counters: one bank spice each, physical Tanks, and the Advanced one-per-turn cap', () => {
|
||||
const g = fixture();
|
||||
const p = g.players[0];
|
||||
p.elites!.tanks = 3;
|
||||
kill(p, [0]);
|
||||
const offer = tleilaxuSecretAllyOptions(g, p);
|
||||
assert.equal(offer.maxForces, NEXUS_TLEILAXU_FORCE_GROUP_CAP);
|
||||
assert.equal(offer.maxElite, 1);
|
||||
assert.deepEqual(
|
||||
quoteTleilaxuSecretAlly(g, p, { leader: p.leaders[0].id, amount: 4, elite: 1 }),
|
||||
{ leader: p.leaders[0].id, kind: 'leader', amount: 4, elite: 1, cost: 4, limit: 5 },
|
||||
);
|
||||
assert.throws(
|
||||
() => quoteTleilaxuSecretAlly(g, p, { leader: p.leaders[0].id, amount: 4, elite: 2 }),
|
||||
TleilaxuSecretAllyError,
|
||||
);
|
||||
assert.throws(
|
||||
() => quoteTleilaxuSecretAlly(g, p, { leader: p.leaders[0].id, amount: 1, elite: 2 }),
|
||||
TleilaxuSecretAllyError,
|
||||
);
|
||||
assert.throws(
|
||||
() => quoteTleilaxuSecretAlly(g, p, { leader: p.leaders[0].id, amount: 6, elite: 0 }),
|
||||
TleilaxuSecretAllyError,
|
||||
);
|
||||
p.elites!.tanks = 0;
|
||||
kill(p, [1, 2, 3, 4]);
|
||||
p.tanks = 2;
|
||||
assert.equal(tleilaxuSecretAllyForceBounds(g, p).maxForces, 2);
|
||||
assert.throws(
|
||||
() => quoteTleilaxuSecretAlly(g, p, { leader: p.leaders[0].id, amount: 3, elite: 0 }),
|
||||
TleilaxuSecretAllyError,
|
||||
);
|
||||
});
|
||||
|
||||
void test('Ixian Cyborgs and Basic typed counters keep identity without the Advanced cap or ordinary pricing', () => {
|
||||
const advanced = fixture(true, 'ixians');
|
||||
advanced.players[0].elites = { reserves: 0, tanks: 3, forces: {}, revived: 4 };
|
||||
kill(advanced.players[0], [0]);
|
||||
const ix = tleilaxuSecretAllyOptions(advanced, advanced.players[0]);
|
||||
assert.equal(ix.maxElite, 3);
|
||||
assert.equal(
|
||||
quoteTleilaxuSecretAlly(advanced, advanced.players[0], {
|
||||
leader: advanced.players[0].leaders[0].id,
|
||||
amount: 5,
|
||||
elite: 3,
|
||||
}).cost,
|
||||
5,
|
||||
);
|
||||
const basic = fixture(false, 'emperor');
|
||||
basic.players[0].elites = { reserves: 0, tanks: 2, forces: {}, revived: 5 };
|
||||
kill(basic.players[0], [0]);
|
||||
const emperor = tleilaxuSecretAllyOptions(basic, basic.players[0]);
|
||||
assert.equal(emperor.maxForces, 5);
|
||||
assert.equal(emperor.maxElite, 2);
|
||||
});
|
||||
|
||||
void test('the shared ordinary ceiling is raised to five, counts earlier returns, and never doubles or shrinks a native limit', () => {
|
||||
const g = fixture();
|
||||
const p = g.players[0];
|
||||
kill(p, [0]);
|
||||
p.revived = 4;
|
||||
assert.equal(tleilaxuSecretAllyOptions(g, p).maxForces, 1);
|
||||
assert.throws(
|
||||
() => quoteTleilaxuSecretAlly(g, p, { leader: p.leaders[0].id, amount: 2, elite: 0 }),
|
||||
TleilaxuSecretAllyError,
|
||||
);
|
||||
p.revived = 5;
|
||||
const spent = tleilaxuSecretAllyOptions(g, p);
|
||||
assert.equal(spent.maxForces, 0);
|
||||
assert.equal(spent.blocked, null);
|
||||
assert.equal(quoteTleilaxuSecretAlly(g, p, { leader: p.leaders[0].id, amount: 0, elite: 0 }).cost, 0);
|
||||
assert.equal(tleilaxuSecretAllyForceBounds(g, p).ceiling, 5);
|
||||
|
||||
// The group is not an independent extra five: its paid counters come out of
|
||||
// the same ordinary allowance, in either order.
|
||||
const ordered = fixture();
|
||||
const holder = ordered.players[0];
|
||||
kill(holder, [0]);
|
||||
const ordinary = forceRevivalRemaining(ordered, holder);
|
||||
assert.equal(ordinary, 3);
|
||||
const group = quoteTleilaxuSecretAlly(ordered, holder, {
|
||||
leader: holder.leaders[0].id,
|
||||
amount: 2,
|
||||
elite: 0,
|
||||
});
|
||||
holder.revived += group.amount;
|
||||
assert.equal(forceRevivalRemaining(ordered, holder), ordinary - 2);
|
||||
assert.deepEqual(ordered.revivalRules!.expanded, []);
|
||||
|
||||
const choam = fixture(true, 'choam');
|
||||
kill(choam.players[0], [0]);
|
||||
choam.players[0].revived = 6;
|
||||
assert.equal(tleilaxuSecretAllyForceBounds(choam, choam.players[0]).ceiling, 20);
|
||||
assert.equal(tleilaxuSecretAllyOptions(choam, choam.players[0]).maxForces, NEXUS_TLEILAXU_FORCE_GROUP_CAP);
|
||||
|
||||
const recruits = fixture();
|
||||
kill(recruits.players[0], [0]);
|
||||
recruits.recruits = { turn: recruits.turn, player: 'h', card: 'ecaz-recruits' };
|
||||
assert.equal(tleilaxuSecretAllyForceBounds(recruits, recruits.players[0]).ceiling, 7);
|
||||
assert.equal(tleilaxuSecretAllyOptions(recruits, recruits.players[0]).maxForces, NEXUS_TLEILAXU_FORCE_GROUP_CAP);
|
||||
assert.equal(
|
||||
quoteTleilaxuSecretAlly(recruits, recruits.players[0], {
|
||||
leader: recruits.players[0].leaders[0].id,
|
||||
amount: 5,
|
||||
elite: 0,
|
||||
}).cost,
|
||||
5,
|
||||
);
|
||||
});
|
||||
|
||||
void test('a native Fremen free-rate restriction never rejects the explicit paid grant', () => {
|
||||
const g = fixture();
|
||||
const p = g.players[0];
|
||||
p.faction = 'fremen';
|
||||
p.leaders = leaders('fremen');
|
||||
p.revived = 3;
|
||||
p.freeForcesRevived = 3;
|
||||
kill(p, [0]);
|
||||
// The ordinary paid Fremen path is closed without Tleilaxu in play.
|
||||
assert.equal(forceRevivalRemaining(g, p), 0);
|
||||
assert.equal(
|
||||
quoteTleilaxuSecretAlly(g, p, { leader: p.leaders[0].id, amount: 2, elite: 0 }).cost,
|
||||
2,
|
||||
);
|
||||
assert.throws(
|
||||
() => quoteTleilaxuSecretAlly(g, p, { leader: p.leaders[0].id, amount: 3, elite: 0 }),
|
||||
TleilaxuSecretAllyError,
|
||||
);
|
||||
});
|
||||
|
||||
void test('Kwisatz and Auditor keep their own canonical return cycles inside the grant', () => {
|
||||
const g = fixture(true, 'atreides');
|
||||
const p = g.players[0];
|
||||
p.kwisatz = { dead: true, revivalCycle: 1 };
|
||||
const offer = tleilaxuSecretAllyOptions(g, p);
|
||||
assert.deepEqual(offer.leaders, [
|
||||
{ id: 'kwisatz', name: 'Kwisatz Haderach', kind: 'kwisatz', early: true },
|
||||
]);
|
||||
assert.equal(
|
||||
quoteTleilaxuSecretAlly(g, p, { leader: 'kwisatz', amount: 0, elite: 0 }).kind,
|
||||
'kwisatz',
|
||||
);
|
||||
p.kwisatz = { dead: true, revivalCycle: 2 };
|
||||
assert.deepEqual(tleilaxuSecretAllyOptions(g, p).leaders, []);
|
||||
assert.throws(
|
||||
() => quoteTleilaxuSecretAlly(g, p, { leader: 'kwisatz', amount: 0, elite: 0 }),
|
||||
TleilaxuSecretAllyError,
|
||||
);
|
||||
p.kwisatz = { dead: true, revivalCycle: 1 };
|
||||
g.revivalRules!.earlyBlocked.push('a:kwisatz');
|
||||
assert.deepEqual(tleilaxuSecretAllyOptions(g, p).leaders, []);
|
||||
g.revivalRules!.earlyBlocked.length = 0;
|
||||
p.kwisatz = { dead: false, revivalCycle: 1 };
|
||||
kill(p, [0]);
|
||||
g.revivalRules!.earlyBlocked.push(`a:${p.leaders[0].id}`);
|
||||
assert.deepEqual(tleilaxuSecretAllyOptions(g, p).leaders, []);
|
||||
|
||||
const choam = fixture(true, 'choam');
|
||||
const seat = choam.players[0];
|
||||
const auditor = createAuditorLeader();
|
||||
auditor.dead = true;
|
||||
auditor.deaths = 1;
|
||||
seat.leaders.push(auditor);
|
||||
kill(seat, [0]);
|
||||
assert.deepEqual(tleilaxuSecretAllyOptions(choam, seat).leaders, [
|
||||
{ id: auditor.id, name: 'Auditor', kind: 'leader', early: false },
|
||||
{ id: seat.leaders[0].id, name: seat.leaders[0].name, kind: 'leader', early: true },
|
||||
]);
|
||||
});
|
||||
|
||||
void test('the shared Ecaz Duke is one more own identity and its custody blocks are named, not silently narrowed', () => {
|
||||
const shared = fixture(false, 'ecaz');
|
||||
shared.dukeVidal = createDukeVidal();
|
||||
shared.dukeVidal!.leader.dead = true;
|
||||
shared.dukeVidal!.leader.deaths = 1;
|
||||
const p = shared.players[0];
|
||||
const offer = tleilaxuSecretAllyOptions(shared, p);
|
||||
assert.deepEqual(offer.leaders, [
|
||||
{ id: DUKE_VIDAL_ID, name: shared.dukeVidal!.leader.name, kind: 'duke', early: false },
|
||||
]);
|
||||
assert.equal(
|
||||
quoteTleilaxuSecretAlly(shared, p, { leader: DUKE_VIDAL_ID, amount: 1, elite: 0 }).cost,
|
||||
1,
|
||||
);
|
||||
|
||||
const contested = fixture(true, 'ecaz');
|
||||
contested.dukeVidal = createDukeVidal();
|
||||
contested.dukeVidal!.leader.dead = true;
|
||||
contested.dukeVidal!.leader.deaths = 1;
|
||||
const seat = contested.players[0];
|
||||
assert.match(tleilaxuSecretAllyOptions(contested, seat).blocked!, /Advanced Harkonnen/);
|
||||
kill(seat, [0]);
|
||||
const withLeader = tleilaxuSecretAllyOptions(contested, seat);
|
||||
assert.equal(withLeader.blocked, null);
|
||||
assert.ok(withLeader.leaders.every((leader) => leader.kind === 'leader'));
|
||||
});
|
||||
|
||||
void test('living, captured, foreign ghola, wrong-faction and Cheap Hero custody are never candidates', () => {
|
||||
const g = fixture();
|
||||
const p = g.players[0];
|
||||
kill(p, [0]);
|
||||
p.leaders[1].dead = true;
|
||||
p.leaders[1].deaths = 1;
|
||||
p.leaders[1].capturedBy = 'h';
|
||||
p.leaders[2].dead = true;
|
||||
p.leaders[2].deaths = 1;
|
||||
p.leaders[2].gholaBy = 'h';
|
||||
p.leaders.push({
|
||||
id: CHEAP_HERO,
|
||||
name: 'Cheap Hero',
|
||||
strength: 0,
|
||||
faction: 'atreides',
|
||||
dead: true,
|
||||
deaths: 1,
|
||||
});
|
||||
p.leaders.push({
|
||||
id: 'harkonnen-0',
|
||||
name: 'Foreign Disc',
|
||||
strength: 6,
|
||||
faction: 'harkonnen',
|
||||
dead: true,
|
||||
deaths: 1,
|
||||
});
|
||||
const before = structuredClone(g);
|
||||
const offer = tleilaxuSecretAllyOptions(g, p);
|
||||
assert.deepEqual(
|
||||
offer.leaders.map((leader) => leader.id),
|
||||
[p.leaders[0].id],
|
||||
);
|
||||
assert.deepEqual(g, before);
|
||||
for (const id of [CHEAP_HERO, p.leaders[1].id, p.leaders[2].id, 'harkonnen-0', 'nobody'])
|
||||
assert.throws(
|
||||
() => quoteTleilaxuSecretAlly(g, p, { leader: id, amount: 0, elite: 0 }),
|
||||
TleilaxuSecretAllyError,
|
||||
);
|
||||
});
|
||||
|
||||
void test('every offered candidate and bound quotes successfully and every overage is refused', () => {
|
||||
const g = fixture();
|
||||
const p = g.players[0];
|
||||
p.elites!.tanks = 3;
|
||||
kill(p, [0, 1]);
|
||||
const offer = tleilaxuSecretAllyOptions(g, p);
|
||||
assert.equal(offer.blocked, null);
|
||||
assert.deepEqual(
|
||||
offer.leaders.map((leader) => leader.id),
|
||||
[p.leaders[0].id, p.leaders[1].id],
|
||||
);
|
||||
for (const leader of offer.leaders)
|
||||
for (let amount = 0; amount <= offer.maxForces; amount++)
|
||||
for (let elite = 0; elite <= Math.min(amount, offer.maxElite); elite++) {
|
||||
const quote = quoteTleilaxuSecretAlly(g, p, { leader: leader.id, amount, elite });
|
||||
assert.equal(quote.kind, leader.kind);
|
||||
assert.equal(quote.cost, amount);
|
||||
assert.equal(quote.limit, offer.maxForces);
|
||||
}
|
||||
const target = offer.leaders[0].id;
|
||||
assert.throws(
|
||||
() => quoteTleilaxuSecretAlly(g, p, { leader: target, amount: offer.maxForces + 1, elite: 0 }),
|
||||
TleilaxuSecretAllyError,
|
||||
);
|
||||
assert.throws(
|
||||
() => quoteTleilaxuSecretAlly(g, p, { leader: target, amount: 1.5, elite: 0 }),
|
||||
TleilaxuSecretAllyError,
|
||||
);
|
||||
assert.throws(
|
||||
() => quoteTleilaxuSecretAlly(g, p, { leader: target, amount: -1, elite: -1 }),
|
||||
TleilaxuSecretAllyError,
|
||||
);
|
||||
p.spice = 0;
|
||||
assert.throws(
|
||||
() => quoteTleilaxuSecretAlly(g, p, { leader: target, amount: 1, elite: 0 }),
|
||||
(error: unknown) =>
|
||||
error instanceof TleilaxuSecretAllyError && error.reason === 'price',
|
||||
);
|
||||
assert.equal(quoteTleilaxuSecretAlly(g, p, { leader: target, amount: 0, elite: 0 }).cost, 0);
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { forceRevivalQuote } from '../game/revival';
|
||||
import {
|
||||
NEXUS_TLEILAXU_FORCE_GROUP_CAP, tleilaxuSecretAllyEvent,
|
||||
} from '../game/nexus-tleilaxu-revival';
|
||||
import {
|
||||
advanceNexusTleilaxuSecretAlly, createNexusTleilaxuSecretAllyFixture,
|
||||
nexusTleilaxuSecretAllyClean, nexusTleilaxuSecretAllyInventory,
|
||||
nexusTleilaxuSecretAllyPlayer as player,
|
||||
type NexusTleilaxuSecretAllyFixture,
|
||||
} from './fixture-nexus-tleilaxu-secret-ally';
|
||||
|
||||
function secretAllyOffer(game:Game,id:string) {
|
||||
return viewGame(game,id).nexusTleilaxu?.secretAlly??null;
|
||||
}
|
||||
/** The exact committed action shape of this card, not a fixture-level stand-in. */
|
||||
const cardAction = (event: string, leader: string, amount: number, elite = 0): Action => ({
|
||||
type: 'nexusTleilaxuRevival', event, leader, amount, elite,
|
||||
});
|
||||
function rejected(game: Game, actor: string, action: Action): void {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before, 'A refused action cannot move counters, spice, cards or the leader');
|
||||
}
|
||||
function quietRevival(game: Game, fixture: NexusTleilaxuSecretAllyFixture): Game {
|
||||
return advanceNexusTleilaxuSecretAlly(game, state =>
|
||||
state.turn === fixture.game.turn && state.phase === 4 && nexusTleilaxuSecretAllyClean(state));
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
const band = advanced ? 'Advanced' : 'Basic';
|
||||
|
||||
void test(`${band} absent-Tleilaxu Nexus: one own dead leader returns with paid counters exactly once`, () => {
|
||||
const f = createNexusTleilaxuSecretAllyFixture({ advanced });
|
||||
assert.equal(secretAllyOffer(f.game,f.opponent),null);
|
||||
assert.equal(secretAllyOffer(f.game,f.partner),null);
|
||||
const offer = secretAllyOffer(f.game, f.owner)!;
|
||||
const before = player(f.game, f.owner);
|
||||
const returned = applyAction(f.game, f.owner, cardAction(offer.event, f.deadLeader.id, 2));
|
||||
const holder = player(returned, f.owner);
|
||||
const leader = holder.leaders.find(l => l.id === f.deadLeader.id)!;
|
||||
assert.equal(leader.dead, false, 'The card returns the offered own disc');
|
||||
assert.equal(leader.deaths, 1, 'The original death history is preserved');
|
||||
assert.equal(holder.tanks, before.tanks - 2);
|
||||
assert.equal(holder.reserves, before.reserves + 2, 'Returned counters enter the ordinary reserves');
|
||||
assert.equal(holder.spice, before.spice - 2, 'One bank spice per exact returned counter, leader included for nothing');
|
||||
assert.equal(returned.nexusCards!.cards!.hands[f.owner], null);
|
||||
assert.equal(returned.nexusCards!.cards!.discard.filter(card => card === 'tleilaxu').length, 1);
|
||||
assert.equal(secretAllyOffer(returned, f.owner), null, 'The spent physical card cannot offer again');
|
||||
rejected(returned, f.owner, cardAction(offer.event, f.deadLeader.id, 2));
|
||||
nexusTleilaxuSecretAllyInventory(returned);
|
||||
});
|
||||
|
||||
void test(`${band} absent-Tleilaxu Nexus: the shared five ceiling counts earlier ordinary returns instead of adding five more`, () => {
|
||||
const f = createNexusTleilaxuSecretAllyFixture({ advanced });
|
||||
const before = player(f.game, f.owner);
|
||||
let game = applyAction(f.game, f.owner, { type: 'revive', amount: 3 });
|
||||
game = quietRevival(game, f);
|
||||
const ordinary = player(game, f.owner);
|
||||
assert.equal(ordinary.revived, 3);
|
||||
assert.equal(ordinary.tanks, before.tanks - 3);
|
||||
assert.equal(ordinary.spice, before.spice - forceRevivalQuote(f.game, before, 3).cost);
|
||||
const offer = secretAllyOffer(game, f.owner)!;
|
||||
assert.equal(offer.blocked, null);
|
||||
assert.equal(offer.maxForces, NEXUS_TLEILAXU_FORCE_GROUP_CAP - ordinary.revived,
|
||||
'Three ordinary counters leave only two card counters of the shared five');
|
||||
rejected(game, f.owner, cardAction(offer.event, f.deadLeader.id, 5));
|
||||
rejected(game, f.owner, cardAction(offer.event, f.deadLeader.id, 3));
|
||||
const paid = applyAction(game, f.owner, cardAction(offer.event, f.deadLeader.id, 2));
|
||||
const holder = player(paid, f.owner);
|
||||
assert.equal(holder.tanks, before.tanks - 5, 'Five physical counters return, never three plus five');
|
||||
assert.equal(holder.reserves, before.reserves + 5);
|
||||
assert.equal(holder.spice, ordinary.spice - 2);
|
||||
nexusTleilaxuSecretAllyInventory(paid);
|
||||
});
|
||||
|
||||
void test(`${band} absent-Tleilaxu Nexus: card-first and ordinary-first share the same five-return ceiling`,()=>{
|
||||
const f=createNexusTleilaxuSecretAllyFixture({advanced});
|
||||
const offer=secretAllyOffer(f.game,f.owner)!;
|
||||
const card=applyAction(f.game,f.owner,cardAction(offer.event,f.deadLeader.id,1));
|
||||
const price=forceRevivalQuote(card,player(card,f.owner),4).cost;
|
||||
const done=quietRevival(applyAction(card,f.owner,{type:'revive',amount:4}),f);
|
||||
assert.equal(player(done,f.owner).revived,5);
|
||||
assert.equal(player(done,f.owner).tanks,3);
|
||||
assert.equal(player(done,f.owner).spice,player(card,f.owner).spice-price);
|
||||
assert.equal(player(done,f.owner).leaderRevived,true);
|
||||
rejected(done,f.owner,{type:'revive',amount:1});
|
||||
});
|
||||
|
||||
void test(`${band} absent-Tleilaxu Nexus: zero counters still returns exactly one own leader free`, () => {
|
||||
const f = createNexusTleilaxuSecretAllyFixture({ advanced });
|
||||
const offer = secretAllyOffer(f.game, f.owner)!;
|
||||
const before = player(f.game, f.owner);
|
||||
const returned = applyAction(f.game, f.owner, cardAction(offer.event, f.deadLeader.id, 0));
|
||||
const holder = player(returned, f.owner);
|
||||
assert.equal(holder.leaders.find(l => l.id === f.deadLeader.id)!.dead, false);
|
||||
assert.equal(holder.tanks, before.tanks);
|
||||
assert.equal(holder.reserves, before.reserves);
|
||||
assert.equal(holder.spice, before.spice, 'The leader is free; only exact counters are priced');
|
||||
assert.equal(returned.nexusCards!.cards!.discard.filter(card => card === 'tleilaxu').length, 1);
|
||||
rejected(returned, f.owner, cardAction(offer.event, f.deadLeader.id, 0));
|
||||
nexusTleilaxuSecretAllyInventory(returned);
|
||||
});
|
||||
|
||||
void test(`${band} absent-Tleilaxu Nexus: foreign actor, stale event, living leader, overage and wrong phase leave everything untouched`, () => {
|
||||
const f = createNexusTleilaxuSecretAllyFixture({ advanced });
|
||||
const offer = secretAllyOffer(f.game, f.owner)!;
|
||||
const living = player(f.game, f.owner).leaders.find(l => !l.dead)!.id;
|
||||
for (const actor of [f.opponent, f.partner])
|
||||
rejected(f.game, actor, cardAction(offer.event, f.deadLeader.id, 1));
|
||||
rejected(f.game, f.owner, cardAction(tleilaxuSecretAllyEvent({ turn: f.game.turn + 1, phase: 4 }, player(f.game, f.owner)), f.deadLeader.id, 1));
|
||||
rejected(f.game, f.owner, cardAction('stale', f.deadLeader.id, 1));
|
||||
rejected(f.game, f.owner, cardAction(offer.event, living, 0));
|
||||
rejected(f.game, f.owner, cardAction(offer.event, f.deadLeader.id, NEXUS_TLEILAXU_FORCE_GROUP_CAP + 1));
|
||||
rejected(f.game, f.owner, cardAction(offer.event, f.deadLeader.id, -1));
|
||||
rejected(f.game, f.owner, cardAction(offer.event, f.deadLeader.id, 1, 2));
|
||||
rejected(f.game, f.owner, cardAction(offer.event, f.deadLeader.id, NEXUS_TLEILAXU_FORCE_GROUP_CAP + 0.5));
|
||||
// An unused physical card stays held, so the next quiet Revival opens a new opportunity.
|
||||
const laterTurn = advanceNexusTleilaxuSecretAlly(f.game, state =>
|
||||
state.turn === 3 && state.phase === 4 && nexusTleilaxuSecretAllyClean(state));
|
||||
assert.equal(laterTurn.nexusCards!.cards!.hands[f.owner], 'tleilaxu');
|
||||
assert.equal(player(laterTurn, f.owner).tanks, 8);
|
||||
const later = secretAllyOffer(laterTurn, f.owner);
|
||||
assert.ok(later);
|
||||
assert.equal(later.blocked, null);
|
||||
assert.equal(later.event, tleilaxuSecretAllyEvent(laterTurn, player(laterTurn, f.owner)));
|
||||
assert.notEqual(later.event, offer.event, 'A later quiet Revival is its own opportunity');
|
||||
rejected(laterTurn, f.owner, cardAction(offer.event, f.deadLeader.id, 1));
|
||||
const settled = applyAction(laterTurn, f.owner, cardAction(later.event, f.deadLeader.id, 1));
|
||||
assert.equal(player(settled, f.owner).leaders.find(l => l.id === f.deadLeader.id)!.dead, false);
|
||||
assert.equal(player(settled, f.owner).tanks, 7);
|
||||
assert.equal(settled.nexusCards!.cards!.discard.filter(card => card === 'tleilaxu').length, 1);
|
||||
nexusTleilaxuSecretAllyInventory(settled);
|
||||
});
|
||||
}
|
||||
|
||||
void test('absent-Tleilaxu Nexus: the spent card continues the real turn and the next Revival unchanged', () => {
|
||||
const f = createNexusTleilaxuSecretAllyFixture({ advanced: false });
|
||||
const offer = secretAllyOffer(f.game, f.owner)!;
|
||||
const applied = applyAction(f.game, f.owner, cardAction(offer.event, f.deadLeader.id, 1));
|
||||
assert.equal(player(applied, f.owner).leaders.find(l => l.id === f.deadLeader.id)!.dead, false);
|
||||
assert.equal(player(applied, f.owner).tanks, 7);
|
||||
const quiet = nexusTleilaxuSecretAllyClean(applied) ? applied : advanceNexusTleilaxuSecretAlly(applied,
|
||||
state => state.turn === 2 && state.phase === 4 && nexusTleilaxuSecretAllyClean(state));
|
||||
assert.equal(quiet.turn, 2);
|
||||
assert.equal(quiet.phase, 4,
|
||||
'The spent card never rewrites the current Revival phase');
|
||||
const next = advanceNexusTleilaxuSecretAlly(quiet, state =>
|
||||
state.turn === 3 && state.phase === 4 && nexusTleilaxuSecretAllyClean(state));
|
||||
assert.equal(next.phase, 4);
|
||||
const holder = player(next, f.owner);
|
||||
assert.equal(holder.leaders.find(l => l.id === f.deadLeader.id)!.dead, false, 'Revival survives Movement, Battle, Collection and the next turn');
|
||||
assert.equal(holder.revived, 0, 'The real turn boundary resets the ordinary ledger');
|
||||
assert.equal(holder.tanks, 7);
|
||||
assert.equal(next.nexusCards!.cards!.hands[f.owner], null);
|
||||
assert.equal(next.nexusCards!.cards!.discard.filter(card => card === 'tleilaxu').length, 1,
|
||||
'Exactly one physical Tleilaxu card was spent');
|
||||
assert.equal(secretAllyOffer(next, f.owner), null);
|
||||
assert.ok(Number.isSafeInteger(holder.spice) && holder.spice >= 0, 'The wallet remains a valid bank balance');
|
||||
nexusTleilaxuSecretAllyInventory(next);
|
||||
});
|
||||
Reference in new issue
Block a user