Offer Semuta on native combat power cancellation costs
This commit is contained in:
1 parent
b3ea9f9c97
commit
3a01c8f1c0
11 files changed
+1562
-10
No files matched your search
@@ -0,0 +1,299 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createGame, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame,
|
||||
type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { gameDistance, location, splitLocation, TERRITORIES } from '../game/board';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { actTruthMovement } from './fixture-truth-movement';
|
||||
import { nextSemutaIxianAuctionStep } from './fixture-semuta-ixian-auction';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
|
||||
export type SemutaCombatCapabilitiesKind = 'kwisatz' | 'ixElite';
|
||||
export type SemutaCombatCapabilitiesStep = { actor: string; action: Action };
|
||||
export const actSemutaCombatCapabilities = actTruthMovement;
|
||||
export const semutaCombatCapabilitiesPlayer = (game: Game, id: string) => {
|
||||
const player = game.players.find(p => p.id === id); assert.ok(player); return player;
|
||||
};
|
||||
export const semutaCombatCapabilitiesClean = (game: Game) => !game.response && !game.decision &&
|
||||
!game.phaseOpening && !game.truthtrance && !game.pendingTreacheryDiscard;
|
||||
export function semutaCombatCapabilitiesInventory(game: Game): string[] {
|
||||
const ids = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
|
||||
...(game.ixSetupCards ?? []), ...(game.ixAuction?.cards ?? []), ...game.players.flatMap(p => p.hand),
|
||||
...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? [])]
|
||||
.map(card => card.id).sort();
|
||||
assert.equal(new Set(ids).size, ids.length, 'Every original main/cache card retains one physical zone');
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** Native acknowledgements only. A legal Battle Plan is always caller-owned. */
|
||||
export function nextSemutaCombatCapabilities(game: Game): SemutaCombatCapabilitiesStep | null {
|
||||
if (game.truthtrance) return null;
|
||||
const pending = game.pendingTreacheryDiscard;
|
||||
if (pending) {
|
||||
const reaction = pending.reaction;
|
||||
if (reaction?.stage !== 'offer') return null;
|
||||
const actor = game.players.find(p => !reaction.passed.includes(p.id)); assert.ok(actor);
|
||||
return { actor: actor.id, action: { type: 'semutaPass', event: pending.batch.event } };
|
||||
}
|
||||
if (game.phaseOpening) {
|
||||
const actor = game.players.find(p => !game.phaseOpening!.passed.includes(p.id)); assert.ok(actor);
|
||||
return { actor: actor.id, action: { type: 'ready' } };
|
||||
}
|
||||
if (game.response) {
|
||||
const actor = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(actor);
|
||||
return { actor: actor.id, action: { type: 'passResponse' } };
|
||||
}
|
||||
if (game.status === 'setup' && game.setupStage === 'traitors') {
|
||||
const emperor = semutaCombatCapabilitiesPlayer(game, 'emperor');
|
||||
const atreides = game.players.find(p => p.faction === 'atreides');
|
||||
const held = emperor.traitorChoices.find(id => atreides?.leaders.some(leader => leader.id === id));
|
||||
if (held) return { actor: emperor.id, action: { type: 'traitor', leader: held } };
|
||||
}
|
||||
const decision = game.decision;
|
||||
if (decision?.kind === 'fullPlanOffer')
|
||||
return { actor: decision.player, action: { type: 'decision', decline: true } };
|
||||
if (decision?.kind === 'battleCards')
|
||||
return { actor: decision.player, action: { type: 'decision', discard: [] } };
|
||||
if (decision?.kind === 'ixSubstitution')
|
||||
return { actor: decision.player, action: { type: 'decision', decline: true } };
|
||||
if (decision?.kind === 'battleLosses')
|
||||
return { actor: decision.player, action: { type: 'decision', choice: 0 } };
|
||||
const battle = game.battle;
|
||||
if (!decision && battle?.preLeader && !battle.preLeader.closed) {
|
||||
const actor = [battle.attacker, battle.defender].find(id => !battle.preLeader!.ready.includes(id)); assert.ok(actor);
|
||||
return { actor, action: { type: 'battlePreparationReady', event: battle.preLeader.event } };
|
||||
}
|
||||
if (!decision && battle?.preparation)
|
||||
return { actor: battle.preparation.owner, action: { type: 'declineBattlePower' } };
|
||||
if (!decision && battle?.revealed) {
|
||||
const voter = viewGame(game, battle.attacker).battle!.traitorVoters.find(id => battle.traitorCalls[id] === undefined);
|
||||
if (voter) return { actor: voter, action: { type: 'traitorCall', call: false } };
|
||||
if (battle.outcomeWindow && !battle.outcomeWindow.closed) {
|
||||
const actor = [battle.attacker, battle.defender].find(id => !battle.outcomeWindow!.ready.includes(id)); assert.ok(actor);
|
||||
return { actor, action: { type: 'battleOutcomeReady', event: battle.outcomeWindow.event } };
|
||||
}
|
||||
}
|
||||
if (!decision && battle && !battle.revealed) return null;
|
||||
return nextSemutaIxianAuctionStep(game);
|
||||
}
|
||||
export function advanceSemutaCombatCapabilities(game: Game, until: (state: Game) => boolean,
|
||||
actions: SemutaCombatCapabilitiesStep[] = []): Game {
|
||||
for (let count = 0; count < 2400; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const next = nextSemutaCombatCapabilities(game);
|
||||
assert.ok(next, `Supply the real owned control at ${game.turn}/${game.phase}/${game.decision?.kind}/${game.response?.kind}`);
|
||||
game = actSemutaCombatCapabilities(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The native combat capability programme did not reach its finite boundary');
|
||||
}
|
||||
export function passSemutaCombatCapabilities(game: Game, actions: SemutaCombatCapabilitiesStep[] = []): Game {
|
||||
const event = game.pendingTreacheryDiscard?.batch.event; assert.ok(event);
|
||||
for (let count = 0; game.pendingTreacheryDiscard?.batch.event === event && count < game.players.length; count++) {
|
||||
const next = nextSemutaCombatCapabilities(game); assert.ok(next && next.action.type === 'semutaPass');
|
||||
game = actSemutaCombatCapabilities(game, next.actor, next.action, actions);
|
||||
}
|
||||
assert.notEqual(game.pendingTreacheryDiscard?.batch.event, event); return game;
|
||||
}
|
||||
export function claimSemutaCombatCapabilities(game: Game, holder: string,
|
||||
actions: SemutaCombatCapabilitiesStep[] = []): Game {
|
||||
const pending = game.pendingTreacheryDiscard; assert.ok(pending && pending.batch.entries.length === 1);
|
||||
const event = pending.batch.event, card = pending.batch.entries[0].card.id;
|
||||
game = actSemutaCombatCapabilities(game, holder, { type: 'semutaCommit', event }, actions);
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
|
||||
game = actSemutaCombatCapabilities(game, holder, { type: 'semutaSelect', event, card }, actions);
|
||||
return game;
|
||||
}
|
||||
export function allowSemutaCombatCapabilities(game: Game, actions: SemutaCombatCapabilitiesStep[] = []): Game {
|
||||
const kind = game.response?.kind; assert.ok(kind === 'kwisatz' || kind === 'eliteStrength');
|
||||
for (let count = 0; game.response?.kind === kind && count < game.players.length; count++) {
|
||||
const actor = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(actor);
|
||||
game = actSemutaCombatCapabilities(game, actor.id, { type: 'passResponse' }, actions);
|
||||
}
|
||||
assert.notEqual(game.response?.kind, kind); return game;
|
||||
}
|
||||
/** Finish only real preparation. Sealing plans remains a separate caller act. */
|
||||
export function finishSemutaCombatCapabilities(game: Game, actions: SemutaCombatCapabilitiesStep[] = []): Game {
|
||||
return advanceSemutaCombatCapabilities(game, state => !!state.battle &&
|
||||
semutaCombatCapabilitiesClean(state) && !state.battle.preparation && state.battle.preLeader?.closed !== false, actions);
|
||||
}
|
||||
function leader(game: Game, owner: string, strongest = true) {
|
||||
const choices = semutaCombatCapabilitiesPlayer(game, owner).leaders.filter(row => !row.dead && !row.capturedBy && !row.usedAt)
|
||||
.sort((a, b) => strongest ? b.strength - a.strength : a.strength - b.strength);
|
||||
assert.ok(choices[0]); return choices[0];
|
||||
}
|
||||
|
||||
/** Original setup, unopened deals/Spice and paid cache1 only. KH is earned by
|
||||
* actual seven-counter battle loss; Cyborgs actually ship and move to battle.
|
||||
* No played hand, force, wallet, casualty, clock or pending control is installed. */
|
||||
export function createSemutaCombatCapabilitiesFixture(options: { kind: SemutaCombatCapabilitiesKind }) {
|
||||
const kind = options.kind, advanced = kind === 'kwisatz';
|
||||
const richese = 'richese', holder = 'choam', owner = advanced ? 'atreides' : 'ixians', spender = 'emperor';
|
||||
let game = createGame('SEMUTACOMBATCAPABILITIES', newPlayer(richese, 'Richese', 'richese'), advanced,
|
||||
advanced ? ['choam'] : ['choam', 'ix']);
|
||||
for (const faction of ['choam', advanced ? 'atreides' as const : 'ixians' as const, 'emperor'] as const)
|
||||
joinGame(game, newPlayer(faction, faction, faction));
|
||||
const actions: SemutaCombatCapabilitiesStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actSemutaCombatCapabilities(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaCombatCapabilities(game, until, actions); };
|
||||
const transition = (run: () => void) => {
|
||||
const before = structuredClone(game), at = actions.length; run();
|
||||
return { before, steps: structuredClone(actions.slice(at)), after: structuredClone(game) };
|
||||
};
|
||||
if (!advanced) {
|
||||
act(owner, { type: 'seatPosition', position: 5 });
|
||||
act(spender, { type: 'seatPosition', position: 3 });
|
||||
}
|
||||
for (const p of game.players) act(p.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game);
|
||||
if (advanced) game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game));
|
||||
else act(game.host, { type: 'start' });
|
||||
const setup = structuredClone(game);
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.semutaPreview, true);
|
||||
assert.ok(game.players.every(p => !p.hand.length));
|
||||
const originalInventory = semutaCombatCapabilitiesInventory(game);
|
||||
assert.equal(originalInventory.length, advanced ? 45 : 57);
|
||||
const costCard = game.deck.find(card => card.effect === 'karama' && !card.id.startsWith('ix-'));
|
||||
const shield = game.deck.find(card => card.kind === 'shield');
|
||||
const ordinary = game.deck.filter(card => card.kind === 'projectile' && !card.effect).slice(0, 3);
|
||||
assert.ok(costCard && shield); assert.equal(ordinary.length, 3);
|
||||
const selected = [ordinary[0].id, ordinary[1].id, shield.id, costCard.id,
|
||||
...(!advanced ? [ordinary[2].id] : [])];
|
||||
const dealOrder = selected.map((card, position) => {
|
||||
const from = game.deck.findIndex(row => row.id === card); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { card, position, from };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game), cost = costCard.id;
|
||||
reach(state => state.status === 'playing'); const afterSetup = structuredClone(game);
|
||||
assert.ok(semutaCombatCapabilitiesPlayer(game, spender).hand.some(card => card.id === cost));
|
||||
assert.ok(semutaCombatCapabilitiesPlayer(game, owner).hand.some(card => card.id === shield.id));
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort(), front: Game['spiceDeck'] = [];
|
||||
for (let index = 0; index < (advanced ? 4 : 2); index++) {
|
||||
const at = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(at >= 0);
|
||||
front.push(game.spiceDeck.splice(at, 1)[0]);
|
||||
}
|
||||
game.spiceDeck.unshift(...front);
|
||||
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
|
||||
let beforeBid: Game | undefined, bidAt = -1;
|
||||
for (let count = 0; count < 900 && !semutaCombatCapabilitiesPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
|
||||
const next = nextSemutaCombatCapabilities(game); assert.ok(next);
|
||||
if (next.actor === holder && next.action.type === 'richeseBid' && next.action.amount === 1) {
|
||||
beforeBid = structuredClone(game); bidAt = actions.length;
|
||||
}
|
||||
act(next.actor, next.action);
|
||||
}
|
||||
assert.ok(beforeBid && bidAt >= 0);
|
||||
const acquired = structuredClone(game), acquisition = { before: beforeBid,
|
||||
steps: structuredClone(actions.slice(bidAt)), after: acquired, price: 1 };
|
||||
assert.equal(semutaCombatCapabilitiesPlayer(beforeBid, holder).spice - semutaCombatCapabilitiesPlayer(game, holder).spice, 1);
|
||||
assert.equal(semutaCombatCapabilitiesPlayer(game, richese).spice - semutaCombatCapabilitiesPlayer(beforeBid, richese).spice, 1);
|
||||
const shipments: ReturnType<typeof transition>[] = [], moves: ReturnType<typeof transition>[] = [];
|
||||
let earningBattle: { before: Game; steps: SemutaCombatCapabilitiesStep[]; after: Game } | null = null;
|
||||
let territory = 'imperial_basin', sector = 9;
|
||||
if (!advanced) {
|
||||
const candidate = TERRITORIES.filter(row => row.type === 'sand').flatMap(row => row.sectors.map(s => location(row.id, s)))
|
||||
.find(key => splitLocation(key).sector !== game.storm && gameDistance(game, 'polar_sink:0', key,
|
||||
point => splitLocation(point).sector === game.storm) === 1);
|
||||
assert.ok(candidate, 'The native Cyborg needs a real adjacent desert destination');
|
||||
({ territory, sector } = splitLocation(candidate));
|
||||
}
|
||||
let ownerTurn = false, opponentTurn = false;
|
||||
reach(state => state.turn === 1 && state.phase === 5 && semutaCombatCapabilitiesClean(state));
|
||||
while (game.turn === 1 && game.phase === 5) {
|
||||
if (!semutaCombatCapabilitiesClean(game)) {
|
||||
const next = nextSemutaCombatCapabilities(game); assert.ok(next); act(next.actor, next.action); continue;
|
||||
}
|
||||
const actor = game.active; assert.ok(actor);
|
||||
if (actor === spender && !opponentTurn) {
|
||||
shipments.push(transition(() => {
|
||||
act(spender, { type: 'ship', territory, sector, amount: 1, elite: 0, allyPayment: 0 });
|
||||
reach(semutaCombatCapabilitiesClean);
|
||||
}));
|
||||
opponentTurn = true;
|
||||
}
|
||||
if (actor === owner && !ownerTurn) {
|
||||
if (!advanced) {
|
||||
assert.ok(opponentTurn, 'The native Emperor ships before the Cyborg moves into its group');
|
||||
shipments.push(transition(() => {
|
||||
act(owner, { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 1, allyPayment: 0 });
|
||||
reach(semutaCombatCapabilitiesClean);
|
||||
}));
|
||||
}
|
||||
moves.push(transition(() => {
|
||||
act(owner, advanced
|
||||
? { type: 'move', from: 'arrakeen:10', amount: 7, elite: 0, territory, sector }
|
||||
: { type: 'move', forces: { 'polar_sink:0': 1 }, eliteForces: { 'polar_sink:0': 1 }, territory, sector });
|
||||
reach(semutaCombatCapabilitiesClean);
|
||||
}));
|
||||
ownerTurn = true;
|
||||
}
|
||||
act(actor, { type: 'endMovement' });
|
||||
}
|
||||
assert.ok(ownerTurn && opponentTurn);
|
||||
const choose = () => {
|
||||
const actor = game.active; assert.ok(actor === owner || actor === spender);
|
||||
act(actor, { type: 'chooseBattle', territory, target: actor === owner ? spender : owner });
|
||||
};
|
||||
reach(state => state.phase === 6 && !state.battle && semutaCombatCapabilitiesClean(state));
|
||||
if (advanced) {
|
||||
const before = structuredClone(game), start = actions.length;
|
||||
assert.equal(semutaCombatCapabilitiesPlayer(game, owner).battleLosses, 0);
|
||||
choose(); game = finishSemutaCombatCapabilities(game, actions);
|
||||
const strong = leader(game, owner), weak = leader(game, spender, false);
|
||||
assert.equal(strong.strength, 5);
|
||||
act(owner, { type: 'battlePlan', leader: strong.id, dial: 3.5, support: 0 });
|
||||
act(spender, { type: 'battlePlan', leader: weak.id, dial: 0, support: 0 });
|
||||
reach(state => !state.battle && !state.pendingTreacheryDiscard);
|
||||
assert.equal(game.lastBattleContext?.winner, owner);
|
||||
assert.equal(semutaCombatCapabilitiesPlayer(game, owner).battleLosses, 7);
|
||||
assert.equal(semutaCombatCapabilitiesPlayer(game, owner).tanks, 7);
|
||||
assert.equal(semutaCombatCapabilitiesPlayer(game, owner).forces['arrakeen:10'], 3);
|
||||
earningBattle = { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
|
||||
reach(state => state.turn === 2 && state.phase === 5 && state.active === spender && semutaCombatCapabilitiesClean(state));
|
||||
territory = 'arrakeen'; sector = 10;
|
||||
shipments.push(transition(() => {
|
||||
act(spender, { type: 'ship', territory, sector, amount: 1, elite: 0, allyPayment: 0 });
|
||||
reach(semutaCombatCapabilitiesClean);
|
||||
}));
|
||||
act(spender, { type: 'endMovement' });
|
||||
reach(state => state.phase === 6 && !state.battle && semutaCombatCapabilitiesClean(state));
|
||||
}
|
||||
const beforeBattle = structuredClone(game), openingAt = actions.length;
|
||||
choose();
|
||||
const responseKind = advanced ? 'kwisatz' as const : 'eliteStrength' as const;
|
||||
reach(state => state.response?.kind === responseKind && state.response.owner === owner);
|
||||
assert.ok(game.battle && !game.battle.revealed);
|
||||
assert.equal(game.battle.territory, territory);
|
||||
assert.ok(semutaCombatCapabilitiesPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
|
||||
assert.ok(semutaCombatCapabilitiesPlayer(game, spender).hand.some(card => card.id === cost));
|
||||
if (!advanced) {
|
||||
assert.equal(semutaCombatCapabilitiesPlayer(game, owner).elites?.forces[location(territory, sector)], 1);
|
||||
assert.equal(viewGame(game, owner).battle?.ownForces?.eliteStrength, 2);
|
||||
}
|
||||
const traitorLeader = advanced ? semutaCombatCapabilitiesPlayer(game, owner).leaders.find(row =>
|
||||
semutaCombatCapabilitiesPlayer(game, spender).traitors.includes(row.id) && !row.dead) : undefined;
|
||||
if (advanced) assert.ok(traitorLeader, 'The original dealt Emperor Traitor supplies a real KH protection test');
|
||||
const ownerLeader = traitorLeader ?? leader(game, owner), opponentLeader = leader(game, spender, false);
|
||||
const ownerPlan: Action = { type: 'battlePlan', leader: ownerLeader.id, dial: 0, support: 0 };
|
||||
const opponentPlan: Action = { type: 'battlePlan', leader: opponentLeader.id, dial: 0, support: 0 };
|
||||
const allowedPlan: Action = advanced ? { ...ownerPlan, kwisatz: true } : { ...ownerPlan, dial: 2 };
|
||||
const canceledPlan: Action = advanced ? { ...ownerPlan, kwisatz: false } : { ...ownerPlan, dial: 1 };
|
||||
assert.deepEqual(semutaCombatCapabilitiesInventory(game), originalInventory);
|
||||
return { game, kind, responseKind, advanced, holder, spender, owner, richese, cost,
|
||||
semuta: SEMUTA_DRUG_ID, action: { type: 'card', card: cost, mode: 'cancel' } as Action,
|
||||
originalResponse: structuredClone(game.response!), originalBattle: structuredClone(game.battle!),
|
||||
beforeBattle, openingSteps: structuredClone(actions.slice(openingAt)), ownerPlan, opponentPlan, allowedPlan, canceledPlan,
|
||||
combatant: owner, opponent: spender,
|
||||
ownerLeader: ownerLeader.id, opponentLeader: opponentLeader.id, territory, sector,
|
||||
earningBattle, shipments, moves, setup, lobby, beforeStartingDeal, afterSetup, dealOrder,
|
||||
acquired, acquisition, originalInventory, originalSpice, actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
'Only original unopened main/Spice positions and native scalar lotteries are selected. Original setup deals the printed Karama to Emperor and the real Shield to the capability owner; CHOAM buys the original cache Semuta for one spice.',
|
||||
advanced ? 'Actually move seven original Atreides counters out of Arrakeen, ship the opposing Emperor counter, allow native preparation, seal unarmed plans, decline both Traitor calls and finish the shared outcome. All seven dialed counters really enter Tanks; a later actual Arrakeen shipment creates the earned-KH battle.'
|
||||
: 'Original lobby circle choices give Emperor its combined turn first. Emperor actually ships the opposing desert counter; Ix actually pays to ship one Cyborg to Polar Sink, then moves that physical Cyborg into the occupied adjacent desert. Original HMS setup and every remaining native choice are retained.',
|
||||
'Stop before the actual capability counter cost. No battle, casualty, skill, hand, wallet, clock or pending control is installed. Later Semuta/preparation/plan choices remain real caller actions.',
|
||||
] };
|
||||
}
|
||||
export type SemutaCombatCapabilitiesFixture = ReturnType<typeof createSemutaCombatCapabilitiesFixture>;
|
||||
@@ -0,0 +1,224 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createGame, initializeSemutaGameForAudit, joinGame, newPlayer,
|
||||
type Action, type Game, type Plan,
|
||||
} from '../game/engine';
|
||||
import { gameTerritories } from '../game/board';
|
||||
import { isAdvisor } from '../game/advisors';
|
||||
import { quoteCombatResponse } from '../game/combat-response-quote';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
import {
|
||||
actSemutaSpiritualForesight, nextSemutaSpiritualForesight,
|
||||
semutaSpiritualForesightClean, semutaSpiritualForesightInventory, semutaSpiritualForesightPlayer,
|
||||
} from './fixture-semuta-spiritual-foresight';
|
||||
import { passSemutaFremenGuildMovement, claimSemutaFremenGuildMovement } from './fixture-semuta-fremen-guild-movement';
|
||||
|
||||
export type SemutaCombatDeclarationsKind = 'voice' | 'prescience';
|
||||
export type SemutaCombatDeclarationsStep = { actor: string; action: Action };
|
||||
export const actSemutaCombatDeclarations = actSemutaSpiritualForesight;
|
||||
export const passSemutaCombatDeclarations = passSemutaFremenGuildMovement;
|
||||
export const claimSemutaCombatDeclarations = claimSemutaFremenGuildMovement;
|
||||
export const semutaCombatDeclarationsPlayer = semutaSpiritualForesightPlayer;
|
||||
export const semutaCombatDeclarationsInventory = semutaSpiritualForesightInventory;
|
||||
export const semutaCombatDeclarationsClean = semutaSpiritualForesightClean;
|
||||
|
||||
/** Original preparation controls only; declaration counters and fresh costs
|
||||
* remain explicit. No plan or battle result is chosen by the native driver. */
|
||||
export function nextSemutaCombatDeclarations(game: Game): SemutaCombatDeclarationsStep | null {
|
||||
if (game.pendingTreacheryDiscard || game.truthtrance ||
|
||||
game.response?.kind === 'voice' || game.response?.kind === 'prescience') return null;
|
||||
if (!game.response && !game.phaseOpening && game.battle) {
|
||||
if (game.decision?.kind === 'fullPlanOffer') return { actor: game.decision.player,
|
||||
action: { type: 'decision', decline: true } };
|
||||
const b = game.battle;
|
||||
if (!game.decision && b.preLeader && !b.preLeader.closed) {
|
||||
const actor = [b.attacker, b.defender].find(player => !b.preLeader!.ready.includes(player)); assert.ok(actor);
|
||||
return { actor, action: { type: 'battlePreparationReady', event: b.preLeader.event } };
|
||||
}
|
||||
if (!game.decision && b.preparation) {
|
||||
if (b.preparation.kind === 'prescienceAnswer') {
|
||||
assert.equal(b.prescience?.field, 'weapon');
|
||||
return { actor: b.preparation.owner, action: { type: 'prescienceAnswer', value: null } };
|
||||
}
|
||||
assert.ok(b.preparation.kind === 'voice' || b.preparation.kind === 'prescience');
|
||||
return { actor: b.preparation.owner, action: { type: 'declineBattlePower' } };
|
||||
}
|
||||
if (!game.decision) return null;
|
||||
}
|
||||
return nextSemutaSpiritualForesight(game);
|
||||
}
|
||||
export function advanceSemutaCombatDeclarations(game: Game, until: (state: Game) => boolean,
|
||||
actions: SemutaCombatDeclarationsStep[] = []): Game {
|
||||
for (let count = 0; count < 1800; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const step = nextSemutaCombatDeclarations(game);
|
||||
assert.ok(step, 'Choose the original declaration, fresh cost or private plan before continuing');
|
||||
game = actSemutaCombatDeclarations(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The original combat-declaration programme did not reach its finite boundary');
|
||||
}
|
||||
/** Pass only the original declared combat advantage. Its next preparation or
|
||||
* pending opponent answer remains a separate original control. */
|
||||
export function allowSemutaCombatDeclarations(game: Game,
|
||||
actions: SemutaCombatDeclarationsStep[] = []): Game {
|
||||
const kind = game.response?.kind; assert.ok(kind === 'voice' || kind === 'prescience');
|
||||
for (let count = 0; count < game.players.length && game.response?.kind === kind; count++) {
|
||||
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
|
||||
game = actSemutaCombatDeclarations(game, actor.id, { type: 'passResponse' }, actions);
|
||||
}
|
||||
assert.notEqual(game.response?.kind, kind); return game;
|
||||
}
|
||||
/** Continue native preparation to legal private-plan readiness. In particular,
|
||||
* an allowed Prescience gets its real null weapon answer; a prevented one does
|
||||
* not. No plans, payments, result acknowledgements or fresh discards are made. */
|
||||
export function finishSemutaCombatDeclarations(game: Game,
|
||||
actions: SemutaCombatDeclarationsStep[] = []): Game {
|
||||
for (let count = 0; count < 40; count++) {
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null, 'Resolve the actual fresh cost first');
|
||||
assert.ok(game.battle && !game.battle.revealed);
|
||||
assert.ok(game.response?.kind !== 'voice' && game.response?.kind !== 'prescience',
|
||||
'Resolve the original declared power first');
|
||||
if (!game.response && !game.decision && !game.battle.preparation &&
|
||||
(!game.battle.preLeader || game.battle.preLeader.closed)) return game;
|
||||
const step = nextSemutaCombatDeclarations(game); assert.ok(step);
|
||||
game = actSemutaCombatDeclarations(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The original combat declaration did not reach private-plan readiness');
|
||||
}
|
||||
|
||||
/** Genuine Basic/public or original Advanced Semuta setup, paid cache1, and
|
||||
* direct BG fighter shipment into Atreides' native Arrakeen garrison. Every
|
||||
* setup, stance, battle, shared readiness and declaration is an actual action. */
|
||||
export function createSemutaCombatDeclarationsFixture(options: {
|
||||
kind: SemutaCombatDeclarationsKind; advanced?: boolean;
|
||||
}) {
|
||||
const kind = options.kind, advanced = options.advanced ?? false;
|
||||
const richese = 'richese', holder = 'choam', bg = 'beneGesserit', atreides = 'atreides', spender = 'emperor';
|
||||
const owner = kind === 'voice' ? bg : atreides;
|
||||
let game = createGame('SEMUTACOMBATDECLARATIONS', newPlayer(richese, 'Richese', 'richese'), advanced, ['choam']);
|
||||
for (const faction of [holder, bg, atreides, spender] as const) joinGame(game, newPlayer(faction, faction, faction));
|
||||
const initial = structuredClone(game), actions: SemutaCombatDeclarationsStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actSemutaCombatDeclarations(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaCombatDeclarations(game, until, actions); };
|
||||
const transition = (run: () => void) => {
|
||||
const before = structuredClone(game), start = actions.length; run();
|
||||
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
|
||||
};
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game);
|
||||
if (advanced) game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game));
|
||||
else act(game.host, { type: 'start' });
|
||||
const setup = structuredClone(game), initialization = { before: lobby, after: setup,
|
||||
entry: advanced ? 'original-undealt-semuta-preview' : 'public-basic-start' };
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.advanced, advanced); assert.equal(game.semutaPreview, true);
|
||||
assert.ok(game.players.every(player => player.hand.length === 0));
|
||||
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
|
||||
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
|
||||
const originalInventory = semutaCombatDeclarationsInventory(game); assert.equal(originalInventory.length, 45);
|
||||
assert.equal(game.deck.length, 35); assert.equal(game.richeseCache!.length, 10);
|
||||
const printed = game.deck.find(card => card.effect === 'karama'); assert.ok(printed);
|
||||
const ordinary = game.deck.filter(card => card.kind === 'projectile' && !card.effect).slice(0, 4);
|
||||
assert.equal(ordinary.length, 4);
|
||||
const deal = [...ordinary.map(card => card.id), printed.id], cost = printed.id;
|
||||
const dealOrder = deal.map((card, position) => {
|
||||
const from = game.deck.findIndex(candidate => candidate.id === card); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { card, from, position };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game);
|
||||
reach(state => state.status === 'playing'); const afterSetup = structuredClone(game);
|
||||
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), deal);
|
||||
assert.equal(semutaCombatDeclarationsPlayer(game, bg).forces['polar_sink:0'], 1);
|
||||
assert.equal(semutaCombatDeclarationsPlayer(game, atreides).forces['arrakeen:10'], 10);
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const spiceOrder: { position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
|
||||
for (let position = 0; position < (advanced ? 2 : 1); position++) {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery &&
|
||||
['hagga_basin', 'rock_outcroppings', 'broken_land', 'oh_gap'].includes(card.territory));
|
||||
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card);
|
||||
spiceOrder.push({ position, from, card: structuredClone(card) });
|
||||
}
|
||||
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
|
||||
let beforeBid: Game | undefined, bid: SemutaCombatDeclarationsStep | undefined, bidAt = -1;
|
||||
for (let count = 0; count < 900 && !semutaCombatDeclarationsPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
|
||||
const step = nextSemutaCombatDeclarations(game); assert.ok(step);
|
||||
if (step.actor === holder && step.action.type === 'richeseBid' && step.action.amount === 1) {
|
||||
beforeBid = structuredClone(game); bid = structuredClone(step); bidAt = actions.length;
|
||||
}
|
||||
act(step.actor, step.action);
|
||||
}
|
||||
assert.ok(beforeBid && bid && bidAt >= 0);
|
||||
const acquired = structuredClone(game), acquisition = { before: beforeBid, bid,
|
||||
steps: structuredClone(actions.slice(bidAt)), after: acquired, price: 1 };
|
||||
assert.equal(semutaCombatDeclarationsPlayer(beforeBid, holder).spice - semutaCombatDeclarationsPlayer(game, holder).spice, 1);
|
||||
assert.equal(semutaCombatDeclarationsPlayer(game, richese).spice - semutaCombatDeclarationsPlayer(beforeBid, richese).spice, 1);
|
||||
reach(state => state.turn === 1 && state.phase === 5 && state.active === bg && semutaCombatDeclarationsClean(state));
|
||||
const shipmentAction: Action = { type: 'ship', territory: 'arrakeen', sector: 10, amount: 2, elite: 0, allyPayment: 0 };
|
||||
const shipment = transition(() => { act(bg, shipmentAction); reach(semutaCombatDeclarationsClean); });
|
||||
assert.equal(semutaCombatDeclarationsPlayer(shipment.before, bg).spice - semutaCombatDeclarationsPlayer(game, bg).spice, 2);
|
||||
assert.equal(semutaCombatDeclarationsPlayer(game, bg).forces['arrakeen:10'], 2);
|
||||
assert.equal(isAdvisor(semutaCombatDeclarationsPlayer(game, bg), 'arrakeen'), false);
|
||||
act(bg, { type: 'endMovement' });
|
||||
reach(state => state.turn === 1 && state.phase === 6 && !state.battle && semutaCombatDeclarationsClean(state));
|
||||
const beforeBattle = structuredClone(game), chooser = game.active!;
|
||||
assert.ok(chooser === bg || chooser === atreides);
|
||||
const battleAction: Action = { type: 'chooseBattle', territory: 'arrakeen', target: chooser === bg ? atreides : bg };
|
||||
const battle = transition(() => {
|
||||
act(chooser, battleAction);
|
||||
reach(state => !state.response && !state.decision && state.battle?.preparation?.kind === 'voice');
|
||||
});
|
||||
const readiness = transition(() => {
|
||||
assert.ok(game.battle?.preLeader);
|
||||
for (const actor of [game.battle.attacker, game.battle.defender])
|
||||
act(actor, { type: 'battlePreparationReady', event: game.battle!.preLeader!.event });
|
||||
});
|
||||
assert.equal(game.battle?.preLeader?.closed, true);
|
||||
let declinedVoice: ReturnType<typeof transition> | null = null;
|
||||
if (kind === 'prescience') declinedVoice = transition(() => { act(bg, { type: 'declineBattlePower' }); });
|
||||
const beforeDeclaration = structuredClone(game);
|
||||
const nativeAction: Action = kind === 'voice'
|
||||
? { type: 'voice', kind: 'projectile', must: false } : { type: 'prescience', field: 'weapon' };
|
||||
const declaration = transition(() => { act(owner, nativeAction); });
|
||||
assert.equal(game.response?.kind, kind); assert.equal(game.response?.owner, owner);
|
||||
assert.equal(game.phaseOpening ?? null, null); assert.equal(game.decision ?? null, null);
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null);
|
||||
assert.deepEqual(game.battle?.plans, {}); assert.equal(game.battle?.revealed, false);
|
||||
if (kind === 'voice') {
|
||||
assert.deepEqual(game.battle?.voice, { target: atreides, kind: 'projectile', must: false });
|
||||
assert.deepEqual(game.battle?.preparation, { kind: 'prescience', owner: atreides, beneficiary: atreides });
|
||||
} else {
|
||||
assert.deepEqual(game.battle?.prescience, { player: atreides, field: 'weapon' });
|
||||
assert.deepEqual(game.battle?.preparation, { kind: 'prescienceAnswer', owner: bg, beneficiary: atreides });
|
||||
}
|
||||
const source = structuredClone(game), originalResponse = structuredClone(game.response!), originalBattle = structuredClone(game.battle!);
|
||||
const input = { ...game, territoryIds: gameTerritories(game).map(territory => territory.id) };
|
||||
const expected = quoteCombatResponse(input, { kind: 'response', response: { kind, owner }, canceled: true });
|
||||
const allowed = quoteCombatResponse(input, { kind: 'response', response: { kind, owner }, canceled: false });
|
||||
const plans: Record<string, Plan> = {};
|
||||
for (const actor of [atreides, bg]) {
|
||||
const leader = semutaCombatDeclarationsPlayer(game, actor).leaders.find(leader => !leader.dead); assert.ok(leader);
|
||||
plans[actor] = { dial: actor === atreides ? 1 : 0, support: actor === atreides && advanced ? 1 : 0,
|
||||
leader: leader.id, weapon: null, defense: null };
|
||||
}
|
||||
assert.ok(semutaCombatDeclarationsPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
|
||||
assert.ok(semutaCombatDeclarationsPlayer(game, spender).hand.some(card => card.id === cost));
|
||||
assert.deepEqual(semutaCombatDeclarationsInventory(game), originalInventory);
|
||||
const action: Action = { type: 'card', card: cost, mode: 'cancel' };
|
||||
return { game, source, beforeCost: source, kind, advanced, richese, holder, spender, owner, bg, atreides,
|
||||
cost, semuta: SEMUTA_DRUG_ID, action, nativeAction, originalResponse, originalBattle, expected, allowed, plans,
|
||||
initial, lobby, setup, initialization, beforeStartingDeal, afterSetup, deal, dealOrder, originalSpice, spiceOrder,
|
||||
acquired, acquisition, shipment, shipmentAction, beforeBattle, chooser, battleAction, battle, readiness,
|
||||
declinedVoice, beforeDeclaration, declaration, originalInventory, actions, sourceActions: actions,
|
||||
sourceActionCount: actions.length,
|
||||
staging: [
|
||||
'Public bare Basic start or original Advanced Semuta initializer on the genuinely ready undealt R/C/BG/A/E lobby. Only unopened original starting-card/Spice positions and scalar lotteries are selected; original main35/cache10 remain physical. Emperor really receives printed Karama, and CHOAM pays1 for original cache Semuta.',
|
||||
'BG makes its actual prediction and (Advanced) Polar Sink advisorSetup. During its real turn BG pays2 to ship two original reserves directly as fighters into Atreides native Arrakeen10. No stance or force is installed; earlier optional native choices are actually declined.',
|
||||
'At the original first Battle phase the native chooser selects that actual battle. Both combatants really ready the shared Richese pre-leader event, leaving private plans empty.',
|
||||
kind === 'voice' ? 'BG declares a real ban on projectile weapons. Stop at native Voice response, retaining the original next Prescience preparation owned by Atreides.' :
|
||||
'BG actually declines its earlier Voice. Atreides declares ordinary weapon Prescience. Stop at its native response with the original BG prescienceAnswer unsubmitted and no inspected value.',
|
||||
'No played hand, wallet, force, clock, battle, declaration or snapshot is installed. Later preparation/full-plan offer is answered only through current original controls; the supplied unarmed plans are real legal continuation actions, not preinstalled commitments.',
|
||||
] };
|
||||
}
|
||||
export type SemutaCombatDeclarationsFixture = ReturnType<typeof createSemutaCombatDeclarationsFixture>;
|
||||
@@ -0,0 +1,216 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { TERRITORIES } from '../game/board';
|
||||
import { quoteCombatResponse } from '../game/combat-response-quote';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
import {
|
||||
actSemutaDisasterWorm, nextSemutaDisasterWorm, passSemutaDisasterWorm, claimSemutaDisasterWorm,
|
||||
semutaDisasterWormPlayer, semutaDisasterWormInventory, semutaDisasterWormClean,
|
||||
} from './fixture-semuta-disaster-worm';
|
||||
|
||||
export type SemutaCombatForcesKind = 'eliteStrength' | 'fremenSupport' | 'choamBattleAid';
|
||||
export type SemutaCombatForcesStep = { actor: string; action: Action };
|
||||
export const actSemutaCombatForces = actSemutaDisasterWorm;
|
||||
export const passSemutaCombatForces = passSemutaDisasterWorm;
|
||||
export const claimSemutaCombatForces = claimSemutaDisasterWorm;
|
||||
export const semutaCombatForcesPlayer = semutaDisasterWormPlayer;
|
||||
export const semutaCombatForcesInventory = semutaDisasterWormInventory;
|
||||
export const semutaCombatForcesClean = semutaDisasterWormClean;
|
||||
export const semutaCombatForcesPhysical = (game: Game) => ({
|
||||
players: game.players.map(p => ({ id: p.id, spice: p.spice, forces: p.forces, elites: p.elites,
|
||||
reserves: p.reserves, tanks: p.tanks, shipped: p.shipped, moved: p.moved, ally: p.ally })),
|
||||
aid: game.aid, spice: game.spice,
|
||||
});
|
||||
|
||||
/** Native controls only; no plan, new card play or fresh reaction is selected.
|
||||
* The sole real mutual CHOAM alliance earns a positive one-spice pledge. */
|
||||
export function nextSemutaCombatForces(game: Game): SemutaCombatForcesStep | null {
|
||||
if (game.pendingTreacheryDiscard || game.truthtrance) return null;
|
||||
if (!game.phaseOpening && !game.response && game.decision?.kind === 'choamBattleFunding')
|
||||
return { actor: game.decision.player, action: { type: 'decision', amount: 1 } };
|
||||
return nextSemutaDisasterWorm(game);
|
||||
}
|
||||
export const nextSemutaCombatForcesStep = nextSemutaCombatForces;
|
||||
export function advanceSemutaCombatForces(game: Game, until: (state: Game) => boolean,
|
||||
actions: SemutaCombatForcesStep[] = []): Game {
|
||||
for (let count = 0; count < 2200; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const step = nextSemutaCombatForces(game);
|
||||
assert.ok(step, `Supply the original private plan/fresh control at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
game = actSemutaCombatForces(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The native force-power programme did not reach its finite boundary');
|
||||
}
|
||||
/** Allow this one actual check; later checks remain available to finish. */
|
||||
export function allowSemutaCombatForces(game: Game, actions: SemutaCombatForcesStep[] = []): Game {
|
||||
const response = game.response; assert.ok(response);
|
||||
assert.ok(response.kind === 'eliteStrength' || response.kind === 'fremenSupport' || response.kind === 'choamBattleAid');
|
||||
for (let count = 0; game.response?.kind === response.kind && game.response.owner === response.owner && count < game.players.length; count++) {
|
||||
const actor = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(actor);
|
||||
game = actSemutaCombatForces(game, actor.id, { type: 'passResponse' }, actions);
|
||||
}
|
||||
return game;
|
||||
}
|
||||
/** Complete only native remaining checks/preparation, then stop before any
|
||||
* private plan is sealed or support is paid. */
|
||||
export function finishSemutaCombatForces(game: Game, actions: SemutaCombatForcesStep[] = []): Game {
|
||||
return advanceSemutaCombatForces(game, state => !!state.battle && !state.battle.revealed &&
|
||||
semutaCombatForcesClean(state) && !state.battle.preparation && state.battle.preLeader?.closed !== false, actions);
|
||||
}
|
||||
|
||||
/** Genuine Advanced native elites/support/aid. Semuta is paid cache1; all
|
||||
* physical counters and the CHOAM alliance arise through original actions. */
|
||||
export function createSemutaCombatForcesFixture(options: {
|
||||
kind?: SemutaCombatForcesKind; eliteOwner?: 'fremen' | 'emperor';
|
||||
} = {}) {
|
||||
const kind = options.kind ?? 'eliteStrength', eliteOwner = options.eliteOwner ?? 'fremen';
|
||||
const richese = 'richese', holder = 'choam', fremen = 'fremen', emperor = 'emperor', spender = 'guild';
|
||||
const combatant = kind === 'fremenSupport' ? fremen : kind === 'choamBattleAid' ? emperor : eliteOwner;
|
||||
const owner = kind === 'choamBattleAid' ? holder : combatant, opponent = richese;
|
||||
const territory = 'the_great_flat', sector = 15, location = `${territory}:${sector}`;
|
||||
let game = createGame('SEMUTACOMBATFORCES', newPlayer(richese, 'Richese', 'richese'), true, ['choam']);
|
||||
for (const faction of ['choam', 'fremen', 'emperor', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction));
|
||||
const initial = structuredClone(game), actions: SemutaCombatForcesStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actSemutaCombatForces(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaCombatForces(game, until, actions); };
|
||||
const transition = (run: () => void) => {
|
||||
const before = structuredClone(game), at = actions.length; run();
|
||||
return { before, steps: structuredClone(actions.slice(at)), after: structuredClone(game) };
|
||||
};
|
||||
for (const p of game.players) act(p.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game);
|
||||
game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game));
|
||||
const setup = structuredClone(game), initialization = { before: lobby, after: setup, entry: 'original-undealt-advanced-semuta-preview' };
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.advanced, true); assert.equal(game.semutaPreview, true);
|
||||
assert.ok(game.players.every(p => p.hand.length === 0));
|
||||
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
|
||||
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
|
||||
const originalInventory = semutaCombatForcesInventory(game); assert.equal(originalInventory.length, 45);
|
||||
const karamas = game.deck.filter(card => card.effect === 'karama'); assert.equal(karamas.length, 2);
|
||||
const ordinary = game.deck.filter(card => card.kind === 'projectile').slice(0, 3); assert.equal(ordinary.length, 3);
|
||||
const selected = [ordinary[0], ordinary[1], ordinary[2], karamas[1], karamas[0]];
|
||||
const dealOrder = selected.map((card, position) => {
|
||||
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { position, from, card: card.id };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game);
|
||||
reach(state => state.status === 'playing');
|
||||
const afterSetup = structuredClone(game), cost = karamas[0].id, independentKarama = karamas[1].id;
|
||||
assert.deepEqual(game.players.flatMap(p => p.hand.map(card => card.id)), selected.map(card => card.id));
|
||||
assert.equal(semutaCombatForcesPlayer(game, fremen).elites?.reserves, 3);
|
||||
assert.equal(semutaCombatForcesPlayer(game, emperor).elites?.reserves, 5);
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const spiceOrder: { turn: number; position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
|
||||
const orderSpice = (faces: string[]) => {
|
||||
for (let position = 0; position < faces.length; position++) {
|
||||
const face = faces[position];
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position &&
|
||||
(face === 'worm' ? 'worm' in card && !card.suppressed && !card.thumper && !card.greatMaker
|
||||
: 'territory' in card && card.territory === face && !card.discovery));
|
||||
assert.ok(from >= position, `Choose the original still-unopened ${face} Spice Card`);
|
||||
const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card);
|
||||
spiceOrder.push({ turn: game.turn, position, from, card: structuredClone(card) });
|
||||
}
|
||||
};
|
||||
orderSpice(['funeral_plain', 'hagga_basin']);
|
||||
let beforeBid: Game | undefined, bid: SemutaCombatForcesStep | undefined, bidAt = -1;
|
||||
for (let count = 0; count < 900 && !semutaCombatForcesPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
|
||||
const step = nextSemutaCombatForces(game); assert.ok(step);
|
||||
if (step.actor === holder && step.action.type === 'richeseBid' && step.action.amount === 1) {
|
||||
beforeBid = structuredClone(game); bid = structuredClone(step); bidAt = actions.length;
|
||||
}
|
||||
act(step.actor, step.action);
|
||||
}
|
||||
assert.ok(beforeBid && bid && bidAt >= 0);
|
||||
const acquired = structuredClone(game), acquisition = { before: beforeBid, bid,
|
||||
steps: structuredClone(actions.slice(bidAt)), after: acquired, price: 1 };
|
||||
assert.equal(semutaCombatForcesPlayer(beforeBid, holder).spice - semutaCombatForcesPlayer(game, holder).spice, 1);
|
||||
let alliance: { before: Game; steps: SemutaCombatForcesStep[]; after: Game } | null = null;
|
||||
if (kind === 'choamBattleAid') {
|
||||
reach(state => state.turn === 2 && state.phase === 1 && semutaCombatForcesClean(state) &&
|
||||
!state.spiceSequence && !state.spiceResolution && !state.spiceWindow && !state.nexus && state.ready.length === 0);
|
||||
orderSpice(['worm', 'the_great_flat', 'broken_land']);
|
||||
reach(state => state.turn === 2 && state.phase === 1 && state.nexus && !state.spiceWindow &&
|
||||
!state.spiceResolution && semutaCombatForcesClean(state));
|
||||
alliance = transition(() => {
|
||||
act(holder, { type: 'alliance', target: emperor }); act(emperor, { type: 'alliance', target: holder });
|
||||
});
|
||||
assert.equal(semutaCombatForcesPlayer(game, holder).ally, emperor);
|
||||
assert.equal(semutaCombatForcesPlayer(game, emperor).ally, holder);
|
||||
}
|
||||
const turn = kind === 'choamBattleAid' ? 2 : 1;
|
||||
reach(state => state.turn === turn && state.phase === 5 && semutaCombatForcesClean(state));
|
||||
const shipments: { actor: string; amount: number; elite: number; cost: number; before: Game;
|
||||
steps: SemutaCombatForcesStep[]; after: Game }[] = [];
|
||||
for (let count = 0; game.turn === turn && game.phase === 5 && count < 160; count++) {
|
||||
if (semutaCombatForcesClean(game) && (game.active === combatant || game.active === opponent) &&
|
||||
!semutaCombatForcesPlayer(game, game.active!).shipped) {
|
||||
const actor = game.active!, elite = Number(actor === combatant), paid = actor === fremen ? 0 : 2;
|
||||
const shipment = transition(() => {
|
||||
act(actor, { type: 'ship', territory, sector, amount: 1, elite, allyPayment: 0 });
|
||||
reach(semutaCombatForcesClean);
|
||||
});
|
||||
assert.equal(semutaCombatForcesPlayer(shipment.before, actor).spice - semutaCombatForcesPlayer(game, actor).spice, paid);
|
||||
shipments.push({ actor, amount: 1, elite, cost: paid, ...shipment });
|
||||
} else {
|
||||
const step = nextSemutaCombatForces(game); assert.ok(step); act(step.actor, step.action);
|
||||
}
|
||||
}
|
||||
assert.equal(shipments.length, 2);
|
||||
assert.equal(semutaCombatForcesPlayer(game, combatant).forces[location], 1);
|
||||
assert.equal(semutaCombatForcesPlayer(game, combatant).elites?.forces[location], 1);
|
||||
assert.equal(semutaCombatForcesPlayer(game, opponent).forces[location], 1);
|
||||
reach(state => state.turn === turn && state.phase === 6 && semutaCombatForcesClean(state) && !!state.active);
|
||||
const beforeBattle = structuredClone(game), choice = viewGame(game, game.active!).battleChoices.find(candidate =>
|
||||
candidate.territory === territory && [candidate.attacker, candidate.defender].includes(combatant) &&
|
||||
[candidate.attacker, candidate.defender].includes(opponent));
|
||||
assert.ok(choice); assert.equal(choice.chooser, game.active);
|
||||
const battleAction: Action = { type: 'chooseBattle', territory,
|
||||
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker };
|
||||
act(choice.chooser, battleAction);
|
||||
const earlierControls = transition(() => { reach(state => state.response?.kind === kind && state.response.owner === owner); });
|
||||
assert.ok(game.battle && !game.battle.revealed && game.response);
|
||||
assert.equal(game.response.kind, kind); assert.equal(game.response.owner, owner);
|
||||
assert.ok(!game.phaseOpening && !game.decision && !game.pendingTreacheryDiscard);
|
||||
assert.deepEqual(game.battle.plans, {});
|
||||
const originalResponse = structuredClone(game.response), originalBattle = structuredClone(game.battle);
|
||||
const expected = quoteCombatResponse({ status: game.status, phase: game.phase, advanced: game.advanced,
|
||||
turn: game.turn, territoryIds: TERRITORIES.map(t => t.id), players: game.players, battle: game.battle },
|
||||
{ kind: 'response', response: { kind, owner }, canceled: true });
|
||||
const ownLeader = semutaCombatForcesPlayer(game, combatant).leaders.filter(l => !l.dead)
|
||||
.sort((a, b) => b.strength - a.strength || a.id.localeCompare(b.id))[0];
|
||||
const otherLeader = semutaCombatForcesPlayer(game, opponent).leaders.filter(l => !l.dead)
|
||||
.sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id))[0];
|
||||
assert.ok(ownLeader && otherLeader);
|
||||
const allowedPlan: Action = { type: 'battlePlan', leader: ownLeader.id, dial: 2,
|
||||
support: combatant === fremen ? 0 : 1, allyPayment: kind === 'choamBattleAid' ? 1 : 0, weapon: null, defense: null };
|
||||
const canceledPlan: Action = { ...allowedPlan,
|
||||
...(kind === 'eliteStrength' ? { dial: 1 } : kind === 'fremenSupport' ? { support: 1 } : { allyPayment: 0 }) };
|
||||
const opponentPlan: Action = { type: 'battlePlan', leader: otherLeader.id, dial: 0, support: 0,
|
||||
allyPayment: 0, weapon: null, defense: null };
|
||||
const source = structuredClone(game), action: Action = { type: 'card', card: cost, mode: 'cancel' };
|
||||
assert.ok(semutaCombatForcesPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
|
||||
assert.ok(semutaCombatForcesPlayer(game, spender).hand.some(card => card.id === cost));
|
||||
assert.deepEqual(semutaCombatForcesInventory(game), originalInventory);
|
||||
return { game, source, beforeCost: source, kind, eliteOwner, advanced: true as const, holder, spender, owner,
|
||||
recipient: combatant, combatant, opponent, richese, fremen, emperor, guild: spender, cost, karama: cost,
|
||||
independentKarama, semuta: SEMUTA_DRUG_ID, action, originalResponse, originalBattle, expected,
|
||||
originalPhysical: structuredClone(semutaCombatForcesPhysical(game)), territory, sector, location, turn,
|
||||
allowedPlan, canceledPlan, opponentPlan, ownLeader: ownLeader.id, opponentLeader: otherLeader.id,
|
||||
initial, lobby, setup, initialization, beforeStartingDeal, afterSetup, deal: selected.map(card => card.id), dealOrder,
|
||||
originalInventory, originalSpice, spiceOrder, acquisition, acquired, alliance, shipments, beforeBattle,
|
||||
battleAction, choice, earlierControls, originalAid: structuredClone(game.aid),
|
||||
actions, steps: actions, sourceActions: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
'Original Advanced Semuta preview initializer runs on the genuinely ready undealt R/C/F/E/G lobby, main35/cache10 and no optional modules. Only unopened starting-card/Spice positions and scalar lotteries are selected. No Basic-to-Advanced conversion.',
|
||||
'Guild and Emperor each receive a distinct original printed Karama. CHOAM really buys cache Semuta for one. Fremen really places ten normals in Sietch Tabr and retains three original reserve Fedaykin; Emperor retains five original reserve Sardaukar.',
|
||||
'Richese has no starting garrison: it actually pays two to ship one normal to Great Flat15. The actual opposing combatant really ships one original elite there: Fremen free entry, or Emperor paid two. Native unused turns, Guild timing and income responses complete normally.',
|
||||
kind === 'choamBattleAid' ? 'First-turn movement is really skipped. An original turn2 worm on the empty first Funeral pile earns Nexus; CHOAM and Emperor actually propose/accept alliance. Both real turn2 shipments and the original battle declaration earn native CHOAM funding1. Its earlier Emperor elite check is actually allowed before the aid response.' : kind === 'fremenSupport' ? 'At the actual first-turn unarmed/unsealed battle, the earlier Fedaykin strength check receives real passes before the free-support response.' : 'The first actual unrevealed battle reaches its native positive elite-strength check. Emperor faces Richese, never Fremen; the printed against-Fremen exclusion is preserved.',
|
||||
'Stop before Guild spends its originally dealt printed Karama. No played hand, wallet, force, elite, phase, alliance, plan, battle, response or power queue is installed. Helpers finish native remaining checks and stop before private plans or support payments.',
|
||||
],
|
||||
};
|
||||
}
|
||||
export type SemutaCombatForcesFixture = ReturnType<typeof createSemutaCombatForcesFixture>;
|
||||
@@ -0,0 +1,196 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { maxCombatDial } from '../game/combat';
|
||||
import { quoteStrongholdFactionsBattle } from './fixture-stronghold-factions';
|
||||
import {
|
||||
createSemutaCombatCapabilitiesFixture as fixture, actSemutaCombatCapabilities as act,
|
||||
passSemutaCombatCapabilities as pass, claimSemutaCombatCapabilities as claim,
|
||||
allowSemutaCombatCapabilities as allow, finishSemutaCombatCapabilities as finish,
|
||||
semutaCombatCapabilitiesPlayer as player, semutaCombatCapabilitiesInventory as inventory,
|
||||
type SemutaCombatCapabilitiesFixture,
|
||||
} from './fixture-semuta-combat-capabilities';
|
||||
|
||||
const variants = [{ kind: 'kwisatz' as const }, { kind: 'ixElite' as const }];
|
||||
const physical = (game: Game) => ({
|
||||
turn: game.turn, phase: game.phase, active: game.active, order: game.order, ready: game.ready,
|
||||
storm: game.storm, spiceDeck: game.spiceDeck, spiceDiscard: game.spiceDiscard,
|
||||
movementRemaining: game.movementRemaining, pendingArrivalReaction: game.pendingArrivalReaction,
|
||||
players: game.players.map(p => ({ id: p.id, spice: p.spice, reserves: p.reserves, tanks: p.tanks,
|
||||
forces: p.forces, elites: p.elites, leaders: p.leaders, moved: p.moved, shipped: p.shipped,
|
||||
battleLosses: p.battleLosses, kwisatz: p.kwisatz })),
|
||||
});
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
function nativeSource(f: SemutaCombatCapabilitiesFixture) {
|
||||
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, f.advanced); assert.equal(f.setup.semutaPreview, true);
|
||||
assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0));
|
||||
assert.deepEqual(f.game.players.map(p => p.faction), ['richese', 'choam', f.advanced ? 'atreides' : 'ixians', 'emperor']);
|
||||
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama'));
|
||||
assert.equal(f.acquisition.price, 1);
|
||||
assert.ok(f.acquisition.steps.some(step => step.actor === f.holder && step.action.type === 'richeseBid' && step.action.amount === 1));
|
||||
assert.equal(player(f.acquisition.before, f.holder).spice - player(f.acquisition.after, f.holder).spice, 1);
|
||||
assert.equal(player(f.acquisition.after, f.richese).spice - player(f.acquisition.before, f.richese).spice, 1);
|
||||
assert.equal(f.shipments.length, 2); assert.equal(f.moves.length, 1);
|
||||
for (const shipment of f.shipments) {
|
||||
const step = shipment.steps.find(row => row.action.type === 'ship'); assert.ok(step);
|
||||
assert.equal(step.action.amount, 1);
|
||||
assert.equal(player(shipment.before, step.actor).reserves - player(shipment.after, step.actor).reserves, 1);
|
||||
assert.ok(player(shipment.before, step.actor).spice > player(shipment.after, step.actor).spice,
|
||||
'Every counter in this battle source is paid by its original native shipment');
|
||||
}
|
||||
if (f.advanced) {
|
||||
assert.ok(f.earningBattle); assert.equal(f.game.turn, 2);
|
||||
assert.equal(player(f.earningBattle.before, f.owner).battleLosses, 0);
|
||||
assert.ok(f.moves[0].steps.some(step => step.actor === f.owner && step.action.type === 'move' && step.action.amount === 7));
|
||||
assert.ok(f.earningBattle.steps.some(step => step.actor === f.owner && step.action.type === 'battlePlan' &&
|
||||
step.action.dial === 3.5 && step.action.support === 0));
|
||||
assert.equal(f.earningBattle.steps.filter(step => step.action.type === 'traitorCall' && step.action.call === false).length, 2);
|
||||
assert.equal(f.earningBattle.steps.filter(step => step.action.type === 'battleOutcomeReady').length, 2);
|
||||
assert.equal(f.earningBattle.after.lastBattleContext?.winner, f.owner);
|
||||
assert.equal(player(f.earningBattle.after, f.owner).battleLosses, 7);
|
||||
assert.equal(player(f.earningBattle.after, f.owner).tanks, 7);
|
||||
assert.equal(player(f.game, f.owner).forces['arrakeen:10'], 3);
|
||||
assert.equal(player(f.game, f.owner).battleLosses, 7);
|
||||
assert.ok(player(f.game, f.spender).traitors.includes(f.ownerLeader), 'The opponent really holds the dealt leader Traitor');
|
||||
} else {
|
||||
assert.equal(f.earningBattle, null); assert.equal(f.game.turn, 1);
|
||||
assert.ok(f.actions.some(step => step.action.type === 'decision' && step.action.card ===
|
||||
player(f.afterSetup, f.owner).hand.find(card => card.kind === 'shield')?.id));
|
||||
assert.ok(f.moves[0].steps.some(step => step.actor === f.owner && step.action.type === 'move' &&
|
||||
JSON.stringify(step.action.forces) === JSON.stringify({ 'polar_sink:0': 1 }) &&
|
||||
JSON.stringify(step.action.eliteForces) === JSON.stringify({ 'polar_sink:0': 1 })));
|
||||
assert.equal(viewGame(f.game, f.owner).battle?.ownForces?.elite, 1);
|
||||
assert.equal(viewGame(f.game, f.owner).battle?.ownForces?.eliteStrength, 2);
|
||||
}
|
||||
assert.equal(f.game.phase, 6); assert.equal(f.game.response?.kind, f.responseKind);
|
||||
assert.equal(f.game.response?.owner, f.owner); assert.equal(f.game.battle?.revealed, false);
|
||||
assert.deepEqual(f.game.battle?.plans, {}); assert.deepEqual(f.game.battle, f.originalBattle);
|
||||
assert.deepEqual(f.game.response, f.originalResponse); assert.equal(f.game.phaseOpening ?? null, null);
|
||||
assert.equal(f.game.pendingTreacheryDiscard ?? null, null); assert.equal(f.game.decision ?? null, null);
|
||||
assert.equal(f.originalInventory.length, f.advanced ? 45 : 57);
|
||||
assert.deepEqual(inventory(f.game), f.originalInventory); assert.equal(f.sourceActionCount, f.actions.length);
|
||||
}
|
||||
function open(f: SemutaCombatCapabilitiesFixture) {
|
||||
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
|
||||
assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaCombatDiscard');
|
||||
if (pending.continuation.kind !== 'karamaCombatDiscard') throw Error('Use the original combat capability cost');
|
||||
assert.equal(pending.batch.cause, `karama:${f.responseKind}`); assert.equal(pending.reaction?.stage, 'offer');
|
||||
assert.deepEqual(pending.batch.entries.map(entry => ({ card: entry.card.id, by: entry.discardedBy, face: entry.publicFace })),
|
||||
[{ card: f.cost, by: f.spender, face: true }]);
|
||||
assert.deepEqual(pending.continuation.use.response, f.originalResponse);
|
||||
assert.deepEqual(pending.continuation.resume.response, f.originalResponse);
|
||||
assert.deepEqual(game.battle, f.originalBattle); assert.deepEqual(physical(game), physical(f.game));
|
||||
assert.equal(game.response ?? null, null); assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
|
||||
assert.equal(game.discard.filter(card => card.id === f.cost).length, 1);
|
||||
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
|
||||
assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||
}
|
||||
function beforePlans(state: Game, f: SemutaCombatCapabilitiesFixture, canceled: boolean) {
|
||||
assert.equal(state.pendingTreacheryDiscard ?? null, null); assert.deepEqual(physical(state), physical(f.game));
|
||||
assert.equal(state.battle?.event, f.originalBattle.event); assert.equal(state.battle?.revealed, false);
|
||||
assert.deepEqual(state.battle?.plans, {});
|
||||
const steps: { actor: string; action: Action }[] = [], game = finish(state, steps);
|
||||
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
|
||||
assert.equal(game.battle?.event, f.originalBattle.event); assert.deepEqual(game.battle?.powerChecks, []);
|
||||
assert.equal(game.battle?.preLeader?.closed, true); assert.equal(game.battle?.preparation, undefined);
|
||||
assert.deepEqual(game.battle?.plans, {}); assert.deepEqual(physical(game), physical(f.game));
|
||||
assert.ok(steps.every(step => step.action.type !== 'battlePlan' && step.action.type !== 'card'));
|
||||
if (f.advanced) {
|
||||
assert.equal(game.battle?.kwisatzBlocked ?? false, canceled);
|
||||
assert.equal(player(game, f.owner).battleLosses, 7);
|
||||
if (canceled) reject(game, f.owner, { ...f.ownerPlan, kwisatz: true });
|
||||
} else {
|
||||
assert.equal(game.battle?.eliteBlocked?.includes(f.owner) ?? false, canceled);
|
||||
const forces = viewGame(game, f.owner).battle!.ownForces!;
|
||||
assert.equal(forces.elite, 1); assert.equal(forces.eliteStrength, canceled ? 1 : 2);
|
||||
assert.equal(maxCombatDial(forces), canceled ? 1 : 2);
|
||||
if (canceled) reject(game, f.owner, { ...f.ownerPlan, dial: 2 });
|
||||
}
|
||||
const sealed = act(structuredClone(game), f.owner,
|
||||
f.advanced ? { ...f.ownerPlan, kwisatz: !canceled } : { ...f.ownerPlan, dial: canceled ? 1 : 2 });
|
||||
assert.equal(sealed.battle?.revealed, false); assert.ok(sealed.battle?.plans[f.owner]);
|
||||
assert.deepEqual(inventory(sealed), f.originalInventory); return game;
|
||||
}
|
||||
function prevented(state: Game, f: SemutaCombatCapabilitiesFixture, claimed: boolean) {
|
||||
const game = beforePlans(state, f, true);
|
||||
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
|
||||
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.cost).length, Number(claimed));
|
||||
assert.equal(game.discard.filter(card => card.id === f.cost).length, Number(!claimed));
|
||||
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.semuta).length, Number(!claimed));
|
||||
assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimed));
|
||||
assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||
}
|
||||
|
||||
for (const variant of variants) for (const claimed of [false, true]) void test(
|
||||
`${variant.kind === 'kwisatz' ? 'Advanced earned KH' : 'Basic native Cyborg'} used cancellation cost Semuta ${claimed ? 'claim' : 'pass'} parks the original battle and resumes one check`, () => {
|
||||
const f = fixture(variant); nativeSource(f); const offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
|
||||
const game = prevented(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
|
||||
reject(game, f.holder, { type: 'semutaCommit', event }); reject(game, f.holder, { type: 'semutaPass', event });
|
||||
},
|
||||
);
|
||||
|
||||
void test('earned KH allowance keeps its actual two-strength bonus and protects the genuinely held Traitor; prevention removes both', () => {
|
||||
const f = fixture({ kind: 'kwisatz' }); nativeSource(f);
|
||||
for (const canceled of [false, true]) {
|
||||
let game = canceled ? prevented(pass(open(f)), f, false) : beforePlans(allow(structuredClone(f.game)), f, false);
|
||||
game = act(game, f.owner, { ...f.ownerPlan, kwisatz: !canceled });
|
||||
game = act(game, f.spender, f.opponentPlan);
|
||||
assert.equal(game.battle?.revealed, true); assert.equal(game.battle?.plans[f.owner].kwisatz ?? false, !canceled);
|
||||
if (canceled) {
|
||||
const called = act(structuredClone(game), f.spender, { type: 'traitorCall', call: true });
|
||||
assert.equal(called.battle?.traitorCalls[f.spender], true);
|
||||
} else reject(game, f.spender, { type: 'traitorCall', call: true });
|
||||
const voters = viewGame(game, f.owner).battle!.traitorVoters;
|
||||
for (const voter of voters) game = act(game, voter, { type: 'traitorCall', call: false });
|
||||
assert.ok(game.battle?.outcomeWindow && !game.battle.outcomeWindow.closed);
|
||||
const quote = quoteStrongholdFactionsBattle(game); assert.ok(quote.scores);
|
||||
const strength = player(game, f.owner).leaders.find(row => row.id === f.ownerLeader)!.strength;
|
||||
assert.equal(quote.scores[game.battle!.attacker === f.owner ? 'attacker' : 'defender'], strength + (canceled ? 0 : 2));
|
||||
assert.equal(player(game, f.owner).battleLosses, 7);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Basic Cyborg allowance retains a legal two-strength dial after actual paid shipment and movement', () => {
|
||||
const f = fixture({ kind: 'ixElite' }); nativeSource(f);
|
||||
const game = beforePlans(allow(structuredClone(f.game)), f, false);
|
||||
assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost));
|
||||
assert.ok(player(game, f.holder).hand.some(card => card.id === f.semuta));
|
||||
});
|
||||
|
||||
void test('fresh KH and Cyborg cost reactions reject stale foreign and underlying controls without moving the battle cursor', () => {
|
||||
for (const variant of variants) {
|
||||
const f = fixture(variant), game = open(f), event = game.pendingTreacheryDiscard!.batch.event;
|
||||
reject(game, f.holder, { type: 'semutaCommit', event: `${event}:stale` });
|
||||
reject(game, f.spender, { type: 'semutaCommit', event }); reject(game, f.spender, f.action);
|
||||
reject(game, f.owner, { type: 'passResponse' }); reject(game, f.owner, f.ownerPlan);
|
||||
reject(game, f.spender, f.opponentPlan); reject(game, f.owner, { type: 'declineBattlePower' });
|
||||
reject(game, f.owner, { type: 'battlePreparationReady', event: f.originalBattle.event });
|
||||
assert.deepEqual(game.battle, f.originalBattle); assert.deepEqual(physical(game), physical(f.game));
|
||||
prevented(pass(game), f, false);
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal profiles legally answer the actual earned-KH and Basic Cyborg used-cost opportunities', () => {
|
||||
for (const variant of variants) {
|
||||
const f = fixture(variant), offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const own = viewGame(offered, f.holder); own.players.find(p => p.id === f.holder)!.bot = difficulty;
|
||||
const before = structuredClone(own), action = botActions(own)[0]; assert.ok(action, difficulty);
|
||||
assert.deepEqual(own, before); assert.ok(action.type === 'semutaPass' || action.type === 'semutaCommit');
|
||||
assert.equal(action.event, event);
|
||||
let game = act(structuredClone(offered), f.holder, action);
|
||||
const claimed = action.type === 'semutaCommit';
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') {
|
||||
const selecting = viewGame(game, f.holder); selecting.players.find(p => p.id === f.holder)!.bot = difficulty;
|
||||
const selected = botActions(selecting)[0]; assert.ok(selected);
|
||||
assert.equal(selected.type, 'semutaSelect'); assert.equal(selected.card, f.cost);
|
||||
game = act(game, f.holder, selected);
|
||||
} else if (game.pendingTreacheryDiscard) game = pass(game);
|
||||
prevented(game, f, claimed);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import {
|
||||
createSemutaCombatDeclarationsFixture as fixture, actSemutaCombatDeclarations as act,
|
||||
passSemutaCombatDeclarations as pass, claimSemutaCombatDeclarations as claim,
|
||||
allowSemutaCombatDeclarations as allow, finishSemutaCombatDeclarations as finish,
|
||||
semutaCombatDeclarationsPlayer as player, semutaCombatDeclarationsInventory as inventory,
|
||||
type SemutaCombatDeclarationsFixture,
|
||||
} from './fixture-semuta-combat-declarations';
|
||||
|
||||
const variants = [
|
||||
{ kind: 'voice' as const, advanced: false },
|
||||
{ kind: 'voice' as const, advanced: true },
|
||||
{ kind: 'prescience' as const, advanced: false },
|
||||
{ kind: 'prescience' as const, advanced: true },
|
||||
];
|
||||
const physical = (game: Game) => ({
|
||||
turn: game.turn, phase: game.phase, storm: game.storm, order: game.order, active: game.active, ready: game.ready,
|
||||
movementRemaining: game.movementRemaining, spice: game.spice, spiceDeck: game.spiceDeck, spiceDiscard: game.spiceDiscard,
|
||||
pendingArrivalReaction: game.pendingArrivalReaction, pendingAmbassador: game.pendingAmbassador,
|
||||
pendingTerrorEntry: game.pendingTerrorEntry,
|
||||
players: game.players.map(player => ({ id: player.id, spice: player.spice,
|
||||
reserves: player.reserves, tanks: player.tanks, forces: player.forces, elites: player.elites,
|
||||
leaders: player.leaders, shipped: player.shipped, moved: player.moved, revived: player.revived, ally: player.ally })),
|
||||
});
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
function nativeSource(f: SemutaCombatDeclarationsFixture) {
|
||||
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, f.advanced); assert.equal(f.setup.semutaPreview, true);
|
||||
assert.ok(f.beforeStartingDeal.players.every(player => player.hand.length === 0));
|
||||
assert.deepEqual(f.game.players.map(player => player.faction), ['richese', 'choam', 'beneGesserit', 'atreides', 'emperor']);
|
||||
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama'));
|
||||
assert.equal(player(f.afterSetup, f.bg).forces['polar_sink:0'], 1);
|
||||
assert.equal(player(f.afterSetup, f.atreides).forces['arrakeen:10'], 10);
|
||||
if (f.advanced) assert.ok(f.actions.some(step => step.actor === f.bg && step.action.type === 'advisorSetup' &&
|
||||
step.action.territory === 'polar_sink' && step.action.sector === 0));
|
||||
assert.ok(f.actions.some(step => step.actor === f.bg && step.action.type === 'predict'));
|
||||
assert.equal(f.acquisition.price, 1);
|
||||
assert.ok(f.acquisition.steps.some(step => step.actor === f.holder && step.action.type === 'richeseBid' && step.action.amount === 1));
|
||||
assert.equal(player(f.acquisition.before, f.holder).spice - player(f.acquisition.after, f.holder).spice, 1);
|
||||
assert.equal(player(f.acquisition.after, f.richese).spice - player(f.acquisition.before, f.richese).spice, 1);
|
||||
assert.equal(player(f.shipment.before, f.bg).spice - player(f.shipment.after, f.bg).spice, 2);
|
||||
assert.equal(player(f.shipment.after, f.bg).reserves, player(f.shipment.before, f.bg).reserves - 2);
|
||||
assert.equal(player(f.shipment.after, f.bg).forces['arrakeen:10'], 2);
|
||||
assert.equal(player(f.game, f.bg).forces['arrakeen:10'], 2);
|
||||
assert.equal(player(f.game, f.bg).advisors?.arrakeen, undefined);
|
||||
assert.equal(player(f.game, f.atreides).forces['arrakeen:10'], 10);
|
||||
assert.deepEqual(f.battle.steps[0], { actor: f.chooser, action: f.battleAction });
|
||||
assert.equal(f.readiness.steps.length, 2);
|
||||
assert.ok(f.readiness.steps.every(step => step.action.type === 'battlePreparationReady' && step.action.event === f.originalBattle.event));
|
||||
assert.deepEqual(new Set(f.readiness.steps.map(step => step.actor)), new Set([f.bg, f.atreides]));
|
||||
assert.equal(f.originalBattle.preLeader?.closed, true);
|
||||
assert.deepEqual(f.declaration.steps, [{ actor: f.owner, action: f.nativeAction }]);
|
||||
assert.equal(f.game.phase, 6); assert.equal(f.game.turn, 1);
|
||||
assert.equal(f.game.response?.kind, f.kind); assert.equal(f.game.response?.owner, f.owner);
|
||||
assert.equal(f.game.phaseOpening ?? null, null); assert.equal(f.game.decision ?? null, null);
|
||||
assert.equal(f.game.pendingTreacheryDiscard ?? null, null); assert.equal(f.game.battle?.territory, 'arrakeen');
|
||||
assert.equal(f.game.battle?.revealed, false); assert.deepEqual(f.game.battle?.plans, {});
|
||||
assert.deepEqual(f.game.battle, f.originalBattle);
|
||||
if (f.kind === 'voice') {
|
||||
assert.equal(f.declinedVoice, null);
|
||||
assert.deepEqual(f.game.battle?.voice, { target: f.atreides, kind: 'projectile', must: false });
|
||||
assert.deepEqual(f.game.battle?.preparation, { kind: 'prescience', owner: f.atreides, beneficiary: f.atreides });
|
||||
} else {
|
||||
assert.ok(f.declinedVoice);
|
||||
assert.deepEqual(f.declinedVoice.steps, [{ actor: f.bg, action: { type: 'declineBattlePower' } }]);
|
||||
assert.equal(f.game.battle?.voice, undefined);
|
||||
assert.deepEqual(f.game.battle?.prescience, { player: f.atreides, field: 'weapon' });
|
||||
assert.equal(Object.hasOwn(f.game.battle!.prescience!, 'value'), false);
|
||||
assert.deepEqual(f.game.battle?.preparation, { kind: 'prescienceAnswer', owner: f.bg, beneficiary: f.atreides });
|
||||
}
|
||||
assert.equal(f.sourceActionCount, f.actions.length); assert.equal(f.originalInventory.length, 45);
|
||||
assert.deepEqual(inventory(f.game), f.originalInventory);
|
||||
}
|
||||
function open(f: SemutaCombatDeclarationsFixture) {
|
||||
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
|
||||
assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaCombatDiscard');
|
||||
assert.equal(pending.reaction?.stage, 'offer'); assert.equal(pending.batch.cause, `karama:${f.kind}`);
|
||||
assert.deepEqual(pending.batch.entries.map(entry => ({ card: entry.card.id, by: entry.discardedBy, face: entry.publicFace })),
|
||||
[{ card: f.cost, by: f.spender, face: true }]);
|
||||
if (pending.continuation.kind !== 'karamaCombatDiscard') throw Error('Use the original combat-declaration cost');
|
||||
assert.equal(pending.continuation.player, f.spender); assert.equal(pending.continuation.card, f.cost);
|
||||
assert.deepEqual(pending.continuation.use.response, f.originalResponse);
|
||||
assert.deepEqual(pending.continuation.resume.response, f.originalResponse);
|
||||
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
|
||||
assert.deepEqual(game.battle, f.originalBattle); assert.deepEqual(physical(game), physical(f.game));
|
||||
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
|
||||
assert.equal(game.discard.filter(card => card.id === f.cost).length, 1);
|
||||
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
|
||||
assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||
}
|
||||
function prevented(game: Game, f: SemutaCombatDeclarationsFixture, claimed: boolean) {
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingKarama ?? null, null);
|
||||
assert.equal(game.response ?? null, null);
|
||||
assert.deepEqual(physical(game), physical(f.game));
|
||||
const battle = structuredClone(f.originalBattle), patch = f.expected.patches;
|
||||
if (f.expected.powerChecks !== undefined) battle.powerChecks = structuredClone(f.expected.powerChecks);
|
||||
if (patch.deleteVoice) delete battle.voice;
|
||||
if (patch.deletePrescience) delete battle.prescience;
|
||||
if (patch.deletePreparation) delete battle.preparation;
|
||||
if (patch.fullPlanOffered) battle.fullPlanOffered = true;
|
||||
assert.deepEqual(game.battle, battle);
|
||||
assert.deepEqual(game.decision ?? null, f.expected.decision);
|
||||
if (f.kind === 'voice') {
|
||||
assert.equal(game.battle?.voice, undefined);
|
||||
assert.deepEqual(game.battle?.preparation, f.originalBattle.preparation, 'The original next Prescience belongs to Atreides');
|
||||
} else {
|
||||
assert.equal(game.battle?.prescience, undefined); assert.equal(game.battle?.preparation, undefined);
|
||||
}
|
||||
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
|
||||
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.cost).length, Number(claimed));
|
||||
assert.equal(game.discard.filter(card => card.id === f.cost).length, Number(!claimed));
|
||||
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.semuta).length, Number(!claimed));
|
||||
assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimed));
|
||||
assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||
}
|
||||
function revealOriginalBattle(game: Game, f: SemutaCombatDeclarationsFixture, preventedPower: boolean) {
|
||||
const steps: { actor: string; action: Action }[] = [];
|
||||
game = finish(game, steps);
|
||||
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.battle?.preparation, undefined);
|
||||
assert.equal(game.battle?.event, f.originalBattle.event); assert.equal(game.battle?.revealed, false);
|
||||
assert.deepEqual(game.battle?.plans, {}); assert.equal(game.battle?.preLeader?.closed, true);
|
||||
assert.deepEqual(physical(game), physical(f.game));
|
||||
if (f.kind === 'prescience' && !preventedPower) {
|
||||
assert.ok(steps.some(step => step.actor === f.bg && step.action.type === 'prescienceAnswer' && step.action.value === null));
|
||||
assert.equal(game.battle?.prescience?.value, null);
|
||||
} else assert.equal(steps.some(step => step.action.type === 'prescienceAnswer'), false);
|
||||
if (f.kind === 'voice') assert.equal(game.battle?.voice !== undefined, !preventedPower);
|
||||
const first = f.originalBattle.attacker, second = f.originalBattle.defender;
|
||||
game = act(game, first, { type: 'battlePlan', ...f.plans[first] });
|
||||
assert.equal(game.battle?.event, f.originalBattle.event); assert.equal(game.battle?.revealed, false);
|
||||
assert.deepEqual(game.battle?.plans[first], f.plans[first]);
|
||||
assert.equal(game.battle?.plans[second], undefined);
|
||||
game = act(game, second, { type: 'battlePlan', ...f.plans[second] });
|
||||
assert.equal(game.battle?.event, f.originalBattle.event); assert.equal(game.battle?.revealed, true);
|
||||
assert.deepEqual(game.battle?.plans[first], f.plans[first]); assert.deepEqual(game.battle?.plans[second], f.plans[second]);
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null);
|
||||
assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||
}
|
||||
|
||||
for (const variant of variants) for (const claimed of [false, true]) void test(
|
||||
`${variant.advanced ? 'Advanced' : 'Basic'} native ${variant.kind} used cancellation cost ${claimed ? 'claim' : 'pass'} preserves the original battle and resumes preparation once`, () => {
|
||||
const f = fixture(variant); nativeSource(f);
|
||||
const offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
|
||||
const game = prevented(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
|
||||
reject(game, f.holder, { type: 'semutaCommit', event }); reject(game, f.holder, { type: 'semutaPass', event });
|
||||
revealOriginalBattle(game, f, true);
|
||||
});
|
||||
|
||||
void test('native Voice and ordinary Prescience allowances retain the actual declaration and its next original control', () => {
|
||||
for (const variant of variants) {
|
||||
const f = fixture(variant); nativeSource(f);
|
||||
const game = allow(structuredClone(f.game));
|
||||
assert.equal(game.response ?? null, null); assert.equal(game.pendingTreacheryDiscard ?? null, null);
|
||||
assert.deepEqual(game.battle, f.originalBattle); assert.deepEqual(physical(game), physical(f.game));
|
||||
assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost));
|
||||
assert.ok(player(game, f.holder).hand.some(card => card.id === f.semuta));
|
||||
revealOriginalBattle(game, f, false);
|
||||
}
|
||||
});
|
||||
|
||||
void test('fresh combat costs reject stale and underlying answers while both original preparation cursors stay parked', () => {
|
||||
for (const variant of variants) {
|
||||
const f = fixture(variant), game = open(f), event = game.pendingTreacheryDiscard!.batch.event;
|
||||
reject(game, f.holder, { type: 'semutaCommit', event: `${event}:stale` });
|
||||
reject(game, f.spender, { type: 'semutaCommit', event });
|
||||
reject(game, f.spender, f.action); reject(game, f.owner, { type: 'passResponse' });
|
||||
reject(game, f.owner, f.nativeAction);
|
||||
reject(game, f.atreides, { type: 'battlePlan', ...f.plans[f.atreides] });
|
||||
reject(game, f.kind === 'voice' ? f.atreides : f.bg, f.kind === 'voice'
|
||||
? { type: 'declineBattlePower' } : { type: 'prescienceAnswer', value: null });
|
||||
assert.deepEqual(game.battle, f.originalBattle); assert.deepEqual(physical(game), physical(f.game));
|
||||
prevented(pass(game), f, false);
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal policies answer the actual Basic and Advanced combat-declaration costs legally', () => {
|
||||
for (const variant of variants) {
|
||||
const f = fixture(variant), offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const own = viewGame(offered, f.holder); own.players.find(player => player.id === f.holder)!.bot = difficulty;
|
||||
const before = structuredClone(own), action = botActions(own)[0]; assert.ok(action, difficulty);
|
||||
assert.deepEqual(own, before);
|
||||
assert.ok(action.type === 'semutaCommit' || action.type === 'semutaPass'); assert.equal(action.event, event);
|
||||
let game = act(structuredClone(offered), f.holder, action);
|
||||
const claimed = action.type === 'semutaCommit';
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') {
|
||||
const choosing = viewGame(game, f.holder); choosing.players.find(player => player.id === f.holder)!.bot = difficulty;
|
||||
const selected = botActions(choosing)[0]; assert.ok(selected);
|
||||
assert.equal(selected.type, 'semutaSelect'); assert.equal(selected.card, f.cost);
|
||||
game = act(game, f.holder, selected);
|
||||
} else if (game.pendingTreacheryDiscard) game = pass(game);
|
||||
prevented(game, f, claimed);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import {
|
||||
createSemutaCombatForcesFixture as fixture, actSemutaCombatForces as act,
|
||||
passSemutaCombatForces as pass, claimSemutaCombatForces as claim,
|
||||
allowSemutaCombatForces as allow, finishSemutaCombatForces as finish,
|
||||
semutaCombatForcesPlayer as player, semutaCombatForcesInventory as inventory,
|
||||
semutaCombatForcesPhysical as physical, type SemutaCombatForcesFixture,
|
||||
} from './fixture-semuta-combat-forces';
|
||||
|
||||
const variants = [
|
||||
{ kind: 'eliteStrength' as const, eliteOwner: 'fremen' as const },
|
||||
{ kind: 'eliteStrength' as const, eliteOwner: 'emperor' as const },
|
||||
{ kind: 'fremenSupport' as const },
|
||||
{ kind: 'choamBattleAid' as const },
|
||||
];
|
||||
const clock = (game: Game) => ({ turn: game.turn, phase: game.phase, active: game.active,
|
||||
order: game.order, ready: game.ready, storm: game.storm, spiceDeck: game.spiceDeck,
|
||||
spiceDiscard: game.spiceDiscard, movementRemaining: game.movementRemaining });
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
function nativeSource(f: SemutaCombatForcesFixture) {
|
||||
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, true); assert.equal(f.setup.semutaPreview, true);
|
||||
assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0));
|
||||
assert.deepEqual(f.game.players.map(p => p.faction), ['richese', 'choam', 'fremen', 'emperor', 'guild']);
|
||||
assert.deepEqual(f.afterSetup.players.flatMap(p => p.hand.map(card => card.id)), f.deal);
|
||||
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama'));
|
||||
assert.ok(player(f.afterSetup, f.emperor).hand.some(card => card.id === f.independentKarama && card.effect === 'karama'));
|
||||
assert.notEqual(f.cost, f.independentKarama);
|
||||
assert.equal(player(f.afterSetup, f.fremen).forces['sietch_tabr:14'], 10);
|
||||
assert.equal(player(f.afterSetup, f.fremen).elites?.reserves, 3);
|
||||
assert.equal(player(f.afterSetup, f.emperor).elites?.reserves, 5);
|
||||
assert.equal(Object.keys(player(f.afterSetup, f.richese).forces).length, 0,
|
||||
'The opposing Richese garrison must be earned by its own actual shipment');
|
||||
assert.equal(f.acquisition.bid.actor, f.holder); assert.equal(f.acquisition.bid.action.amount, 1);
|
||||
assert.equal(player(f.acquisition.before, f.holder).spice - player(f.acquired, f.holder).spice, 1);
|
||||
assert.equal(player(f.acquired, f.richese).spice - player(f.acquisition.before, f.richese).spice, 1);
|
||||
assert.equal(f.shipments.length, 2);
|
||||
for (const shipment of f.shipments) {
|
||||
assert.ok(shipment.steps.some(step => step.actor === shipment.actor && step.action.type === 'ship' &&
|
||||
step.action.amount === 1 && step.action.elite === shipment.elite));
|
||||
assert.equal(player(shipment.before, shipment.actor).spice - player(shipment.after, shipment.actor).spice, shipment.cost);
|
||||
assert.equal(player(shipment.before, shipment.actor).reserves - player(shipment.after, shipment.actor).reserves, 1);
|
||||
assert.equal(player(shipment.after, shipment.actor).forces[f.location], 1);
|
||||
}
|
||||
assert.equal(player(f.game, f.combatant).elites?.forces[f.location], 1);
|
||||
assert.equal(player(f.game, f.opponent).forces[f.location], 1);
|
||||
assert.equal(player(f.game, f.fremen).forces['sietch_tabr:14'], 10);
|
||||
assert.equal(f.game.phase, 6); assert.equal(f.game.turn, f.turn);
|
||||
assert.equal(f.game.battle?.revealed, false); assert.deepEqual(f.game.battle?.plans, {});
|
||||
assert.deepEqual(f.game.battle, f.originalBattle); assert.deepEqual(f.game.response, f.originalResponse);
|
||||
assert.equal(f.game.response?.kind, f.kind); assert.equal(f.game.response?.owner, f.owner);
|
||||
assert.equal(f.game.phaseOpening ?? null, null); assert.equal(f.game.pendingTreacheryDiscard ?? null, null);
|
||||
assert.equal(f.game.decision ?? null, null);
|
||||
assert.equal(player(f.game, f.spender).hand.filter(card => card.id === f.cost).length, 1);
|
||||
assert.equal(player(f.game, f.holder).hand.filter(card => card.id === f.semuta).length, 1);
|
||||
if (f.combatant === f.emperor) assert.notEqual(f.opponent, f.fremen,
|
||||
'Sardaukar never earn their native double-strength check against Fremen');
|
||||
if (f.kind === 'fremenSupport') {
|
||||
assert.equal(f.game.battle?.eliteBlocked?.includes(f.combatant) ?? false, false);
|
||||
assert.ok(f.earlierControls.steps.some(step => step.action.type === 'passResponse'));
|
||||
}
|
||||
if (f.kind === 'choamBattleAid') {
|
||||
assert.equal(f.turn, 2); assert.ok(f.alliance); assert.equal(f.alliance.steps.length, 2);
|
||||
assert.ok(f.alliance.steps.every(step => step.action.type === 'alliance'));
|
||||
assert.equal(player(f.game, f.holder).ally, f.emperor); assert.equal(player(f.game, f.emperor).ally, f.holder);
|
||||
assert.deepEqual(f.game.aid[f.holder], { recipient: f.emperor, amount: 1 });
|
||||
assert.ok(f.earlierControls.steps.some(step => step.actor === f.holder && step.action.type === 'decision' && step.action.amount === 1));
|
||||
assert.ok(f.earlierControls.steps.some(step => step.action.type === 'passResponse'));
|
||||
assert.equal(f.game.battle?.eliteBlocked?.includes(f.emperor) ?? false, false);
|
||||
} else assert.equal(f.alliance, null);
|
||||
assert.equal(f.originalInventory.length, 45); assert.deepEqual(inventory(f.game), f.originalInventory);
|
||||
assert.equal(f.sourceActionCount, f.actions.length);
|
||||
}
|
||||
function open(f: SemutaCombatForcesFixture) {
|
||||
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
|
||||
assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaCombatDiscard');
|
||||
if (pending.continuation.kind !== 'karamaCombatDiscard') throw Error('Expected the original combat-power cost');
|
||||
assert.equal(pending.batch.cause, `karama:${f.kind}`); assert.equal(pending.reaction?.stage, 'offer');
|
||||
assert.deepEqual(pending.batch.entries.map(entry => ({ card: entry.card.id, by: entry.discardedBy, face: entry.publicFace })),
|
||||
[{ card: f.cost, by: f.spender, face: true }]);
|
||||
assert.deepEqual(pending.continuation.use.response, f.originalResponse);
|
||||
assert.deepEqual(pending.continuation.resume.response, f.originalResponse);
|
||||
assert.deepEqual(game.battle, f.originalBattle); assert.deepEqual(physical(game), f.originalPhysical);
|
||||
assert.deepEqual(clock(game), clock(f.game)); assert.equal(game.response ?? null, null);
|
||||
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
|
||||
assert.equal(game.discard.filter(card => card.id === f.cost).length, 1);
|
||||
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
|
||||
assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||
}
|
||||
function beforePlans(state: Game, f: SemutaCombatForcesFixture, canceled: boolean) {
|
||||
assert.equal(state.pendingTreacheryDiscard ?? null, null);
|
||||
assert.deepEqual(physical(state), f.originalPhysical);
|
||||
assert.deepEqual(state.battle?.plans, {}); assert.equal(state.battle?.revealed, false);
|
||||
if (canceled) {
|
||||
assert.deepEqual(state.battle?.powerChecks, f.expected.powerChecks);
|
||||
assert.deepEqual(state.response, f.expected.response);
|
||||
if (f.kind === 'eliteStrength') assert.deepEqual(state.battle?.eliteBlocked, [f.combatant]);
|
||||
if (f.kind === 'fremenSupport') assert.equal(state.battle?.fremenSupportBlocked, true);
|
||||
if (f.kind === 'choamBattleAid') assert.equal(state.battle?.choamAidBlocked, true);
|
||||
}
|
||||
const steps: { actor: string; action: Action }[] = [], game = finish(state, steps);
|
||||
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
|
||||
assert.equal(game.phase, 6); assert.equal(game.turn, f.turn); assert.equal(game.battle?.event, f.originalBattle.event);
|
||||
assert.equal(game.battle?.revealed, false); assert.deepEqual(game.battle?.plans, {});
|
||||
assert.deepEqual(game.battle?.powerChecks, []); assert.deepEqual(physical(game), f.originalPhysical);
|
||||
assert.deepEqual(clock(game), clock(f.game));
|
||||
if (f.kind === 'eliteStrength') assert.equal(game.battle?.eliteBlocked?.includes(f.combatant) ?? false, canceled);
|
||||
if (f.kind === 'fremenSupport') assert.equal(game.battle?.fremenSupportBlocked, canceled);
|
||||
if (f.kind === 'choamBattleAid') assert.equal(game.battle?.choamAidBlocked, canceled);
|
||||
if (f.kind === 'eliteStrength' && f.combatant === f.fremen) {
|
||||
assert.equal(game.battle?.fremenSupportBlocked, false);
|
||||
assert.ok(steps.some(step => step.action.type === 'passResponse'), 'The independent support window really receives a pass');
|
||||
}
|
||||
assert.ok(steps.every(step => step.action.type !== 'battlePlan' && step.action.type !== 'card'));
|
||||
if (f.kind === 'choamBattleAid') {
|
||||
assert.equal(viewGame(game, f.combatant).aid.available, canceled ? 0 : 1);
|
||||
assert.deepEqual(game.aid, f.originalAid, 'The prior real pledge remains escrowed before any plan payment');
|
||||
}
|
||||
if (canceled) reject(game, f.combatant, f.allowedPlan);
|
||||
const plan = canceled ? f.canceledPlan : f.allowedPlan;
|
||||
const sealed = act(structuredClone(game), f.combatant, plan);
|
||||
assert.equal(sealed.battle?.plans[f.combatant]?.dial, plan.dial);
|
||||
assert.equal(sealed.battle?.plans[f.combatant]?.support, plan.support);
|
||||
assert.equal(sealed.battle?.plans[f.combatant]?.allyPayment ?? 0, plan.allyPayment);
|
||||
assert.equal(sealed.battle?.revealed, false); assert.deepEqual(physical(sealed), f.originalPhysical);
|
||||
assert.deepEqual(inventory(sealed), f.originalInventory); return game;
|
||||
}
|
||||
function denied(state: Game, f: SemutaCombatForcesFixture, claimed: boolean) {
|
||||
const game = beforePlans(state, f, true);
|
||||
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
|
||||
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.cost).length, Number(claimed));
|
||||
assert.equal(game.discard.filter(card => card.id === f.cost).length, Number(!claimed));
|
||||
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.semuta).length, Number(!claimed));
|
||||
assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimed));
|
||||
assert.ok(player(game, f.emperor).hand.some(card => card.id === f.independentKarama));
|
||||
reject(game, f.spender, f.action); assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||
}
|
||||
|
||||
for (const variant of variants) for (const claimed of [false, true]) void test(
|
||||
`Advanced native ${variant.kind === 'eliteStrength' ? `${variant.eliteOwner} ` : ''}${variant.kind} cost Semuta ${claimed ? 'claim' : 'pass'} resumes one combat check before plans`, () => {
|
||||
const f = fixture(variant); nativeSource(f); const offered = open(f);
|
||||
denied(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
|
||||
},
|
||||
);
|
||||
|
||||
void test('native force-power allowance preserves positive Fedaykin Sardaukar free support and pledged CHOAM aid plans', () => {
|
||||
for (const variant of variants) {
|
||||
const f = fixture(variant); nativeSource(f);
|
||||
const game = beforePlans(allow(structuredClone(f.game)), f, false);
|
||||
assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost));
|
||||
assert.ok(player(game, f.holder).hand.some(card => card.id === f.semuta));
|
||||
}
|
||||
});
|
||||
|
||||
void test('native force-power cost reactions reject stale foreign and underlying declarations before flags or plans change', () => {
|
||||
for (const variant of variants) {
|
||||
const f = fixture(variant), game = open(f), event = game.pendingTreacheryDiscard!.batch.event;
|
||||
reject(game, f.holder, { type: 'semutaCommit', event: `${event}:stale` });
|
||||
reject(game, f.spender, { type: 'semutaCommit', event }); reject(game, f.spender, f.action);
|
||||
reject(game, f.owner, { type: 'passResponse' }); reject(game, f.combatant, f.allowedPlan);
|
||||
reject(game, f.opponent, f.opponentPlan); reject(game, f.owner, { type: 'ready' });
|
||||
const done = denied(pass(game), f, false); reject(done, f.holder, { type: 'semutaCommit', event });
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal profiles legally claim or pass native elite support and CHOAM battle-aid costs', () => {
|
||||
for (const variant of variants) {
|
||||
const f = fixture(variant), offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const own = viewGame(offered, f.holder); own.players.find(p => p.id === f.holder)!.bot = difficulty;
|
||||
const proposed = botActions(own)[0]; assert.ok(proposed, difficulty);
|
||||
assert.ok(proposed.type === 'semutaPass' || proposed.type === 'semutaCommit'); assert.equal(proposed.event, event);
|
||||
let game = act(structuredClone(offered), f.holder, proposed);
|
||||
const claimed = proposed.type === 'semutaCommit';
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') {
|
||||
const selecting = viewGame(game, f.holder); selecting.players.find(p => p.id === f.holder)!.bot = difficulty;
|
||||
const selected = botActions(selecting)[0]; assert.ok(selected);
|
||||
assert.equal(selected.type, 'semutaSelect'); assert.equal(selected.card, f.cost);
|
||||
game = act(game, f.holder, selected);
|
||||
} else if (game.pendingTreacheryDiscard) game = pass(game);
|
||||
denied(game, f, claimed);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user