Offer Semuta on bonus No-Field and allied Traitor cancellation costs
This commit is contained in:
1 parent
3a01c8f1c0
commit
12e3a448c6
11 files changed
+1659
-12
No files matched your search
@@ -0,0 +1,215 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createGame, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame,
|
||||
type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
import {
|
||||
actSemutaCombatCapabilities, nextSemutaCombatCapabilities,
|
||||
semutaCombatCapabilitiesPlayer, semutaCombatCapabilitiesInventory, semutaCombatCapabilitiesClean,
|
||||
passSemutaCombatCapabilities, claimSemutaCombatCapabilities,
|
||||
} from './fixture-semuta-combat-capabilities';
|
||||
|
||||
export type SemutaAlliedTraitorStep = { actor: string; action: Action };
|
||||
export const actSemutaAlliedTraitor = actSemutaCombatCapabilities;
|
||||
export const semutaAlliedTraitorPlayer = semutaCombatCapabilitiesPlayer;
|
||||
export const semutaAlliedTraitorInventory = semutaCombatCapabilitiesInventory;
|
||||
export const semutaAlliedTraitorClean = semutaCombatCapabilitiesClean;
|
||||
export const passSemutaAlliedTraitor = passSemutaCombatCapabilities;
|
||||
export const claimSemutaAlliedTraitor = claimSemutaCombatCapabilities;
|
||||
|
||||
/** Native controls and explicit declines only. Sealing plans and the allied
|
||||
* positive Traitor declaration remain caller-owned source acts. */
|
||||
export function nextSemutaAlliedTraitor(game: Game): SemutaAlliedTraitorStep | null {
|
||||
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
|
||||
const fremen = game.players.find(p => p.faction === 'fremen');
|
||||
if (game.status === 'setup' && game.setupStage === 'forces' && fremen?.reserves === 20 &&
|
||||
viewGame(game, fremen.id).setupPending.includes(fremen.id))
|
||||
return { actor: fremen.id, action: { type: 'fremenSetup', placements: { 'sietch_tabr:14': 10 } } };
|
||||
const decision = game.decision;
|
||||
if (decision?.kind === 'wormRide' || decision?.kind === 'wormPlacement')
|
||||
return { actor: decision.player, action: { type: 'decision', accept: false } };
|
||||
if (decision?.kind === 'wormProtection')
|
||||
return { actor: decision.player, action: { type: 'decision', accept: true } };
|
||||
if (decision?.kind === 'choamBattleFunding')
|
||||
return { actor: decision.player, action: { type: 'decision', amount: 0 } };
|
||||
}
|
||||
return nextSemutaCombatCapabilities(game);
|
||||
}
|
||||
export const nextSemutaAlliedTraitorStep = nextSemutaAlliedTraitor;
|
||||
export function advanceSemutaAlliedTraitor(game: Game, until: (state: Game) => boolean,
|
||||
actions: SemutaAlliedTraitorStep[] = []): Game {
|
||||
for (let count = 0; count < 2600; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const next = nextSemutaAlliedTraitor(game);
|
||||
assert.ok(next, `Supply the actual owned allied-Traitor control at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
game = actSemutaAlliedTraitor(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The native allied-Traitor programme did not reach its finite boundary');
|
||||
}
|
||||
export function allowSemutaAlliedTraitor(game: Game, actions: SemutaAlliedTraitorStep[] = []): Game {
|
||||
assert.equal(game.response?.kind, 'harkonnenTraitor');
|
||||
for (let count = 0; game.response?.kind === 'harkonnenTraitor' && count < game.players.length; count++) {
|
||||
const actor = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(actor);
|
||||
game = actSemutaAlliedTraitor(game, actor.id, { type: 'passResponse' }, actions);
|
||||
}
|
||||
assert.notEqual(game.response?.kind, 'harkonnenTraitor'); return game;
|
||||
}
|
||||
/** Finish only this original battle: missing voters, the shared final window
|
||||
* and actual cleanup decisions. No new battle, return or card play is made. */
|
||||
export function finishSemutaAlliedTraitor(game: Game, actions: SemutaAlliedTraitorStep[] = []): Game {
|
||||
assert.ok(game.battle && !game.pendingTreacheryDiscard);
|
||||
return advanceSemutaAlliedTraitor(game, state => !state.battle &&
|
||||
!state.pendingTreacheryDiscard && !state.response && !state.decision, actions);
|
||||
}
|
||||
|
||||
/** Original R/C/H/E/F setup and paid cache1. A real turn2 worm earns the
|
||||
* Harkonnen/Emperor alliance; both combat cohorts actually ship. The opposing
|
||||
* leader is selected from Harkonnen's original four physical Traitor cards. */
|
||||
export function createSemutaAlliedTraitorFixture(options: { advanced?: boolean; last?: boolean } = {}) {
|
||||
const advanced = options.advanced ?? false, last = options.last ?? false;
|
||||
const richese = 'richese', holder = 'choam', owner = 'harkonnen', beneficiary = 'emperor', spender = beneficiary;
|
||||
const fremen = 'fremen', territory = 'the_great_flat', sector = 15, location = `${territory}:${sector}`;
|
||||
let game = createGame('SEMUTAALLIEDTRAITOR', newPlayer(richese, 'Richese', 'richese'), advanced, ['choam']);
|
||||
for (const faction of ['choam', 'harkonnen', 'emperor', 'fremen'] as const)
|
||||
joinGame(game, newPlayer(faction, faction, faction));
|
||||
const initial = structuredClone(game), actions: SemutaAlliedTraitorStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actSemutaAlliedTraitor(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaAlliedTraitor(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);
|
||||
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 === 0));
|
||||
const originalInventory = semutaAlliedTraitorInventory(game); assert.equal(originalInventory.length, 45);
|
||||
const costCard = game.deck.find(card => card.effect === 'karama'); assert.ok(costCard);
|
||||
const ordinary = game.deck.filter(card => card.kind === 'projectile' || card.kind === 'poison').slice(0, 5);
|
||||
assert.equal(ordinary.length, 5);
|
||||
const selected = [ordinary[0], ordinary[1], ordinary[2], ordinary[3], costCard, ordinary[4]];
|
||||
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 { card: card.id, position, from };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game), cost = costCard.id;
|
||||
reach(state => state.status === 'playing'); const afterSetup = structuredClone(game);
|
||||
assert.ok(semutaAlliedTraitorPlayer(game, spender).hand.some(card => card.id === cost));
|
||||
assert.equal(semutaAlliedTraitorPlayer(game, owner).traitors.length, 4);
|
||||
const candidates = game.players.filter(p => ![owner, beneficiary].includes(p.id)).flatMap(p =>
|
||||
p.leaders.filter(leader => semutaAlliedTraitorPlayer(game, owner).traitors.includes(leader.id))
|
||||
.map(leader => ({ player: p.id, leader }))).sort((a, b) => b.leader.strength - a.leader.strength);
|
||||
const target = candidates[0]; assert.ok(target, 'Use an opposing leader Harkonnen actually received as a Traitor');
|
||||
const opponent = target.player, traitorId = target.leader.id;
|
||||
const beneficiaryLeader = semutaAlliedTraitorPlayer(game, beneficiary).leaders
|
||||
.toSorted((a, b) => a.strength - b.strength)[0]; assert.ok(beneficiaryLeader);
|
||||
assert.ok(target.leader.strength > beneficiaryLeader.strength,
|
||||
'Without the allied Traitor, the actual unarmed opposing plan wins normally');
|
||||
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], 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 an original 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: SemutaAlliedTraitorStep | undefined, bidAt = -1;
|
||||
for (let count = 0; count < 1000 && !semutaAlliedTraitorPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
|
||||
const next = nextSemutaAlliedTraitor(game); assert.ok(next);
|
||||
if (next.actor === holder && next.action.type === 'richeseBid' && next.action.amount === 1) {
|
||||
beforeBid = structuredClone(game); bid = structuredClone(next); bidAt = actions.length;
|
||||
}
|
||||
act(next.actor, next.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(semutaAlliedTraitorPlayer(beforeBid, holder).spice - semutaAlliedTraitorPlayer(game, holder).spice, 1);
|
||||
reach(state => state.turn === 2 && state.phase === 1 && semutaAlliedTraitorClean(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 && semutaAlliedTraitorClean(state));
|
||||
const alliance = transition(() => {
|
||||
act(owner, { type: 'alliance', target: beneficiary }); act(beneficiary, { type: 'alliance', target: owner });
|
||||
});
|
||||
assert.equal(semutaAlliedTraitorPlayer(game, owner).ally, beneficiary);
|
||||
assert.equal(semutaAlliedTraitorPlayer(game, beneficiary).ally, owner);
|
||||
reach(state => state.turn === 2 && state.phase === 5 && semutaAlliedTraitorClean(state));
|
||||
const shipments: { actor: string; cost: number; before: Game; steps: SemutaAlliedTraitorStep[]; after: Game }[] = [];
|
||||
for (let count = 0; game.turn === 2 && game.phase === 5 && count < 200; count++) {
|
||||
const actor = game.active;
|
||||
if (semutaAlliedTraitorClean(game) && actor && [beneficiary, opponent].includes(actor) &&
|
||||
!semutaAlliedTraitorPlayer(game, actor).shipped) {
|
||||
const shipment = transition(() => {
|
||||
act(actor, { type: 'ship', territory, sector, amount: 1, elite: 0, allyPayment: 0 });
|
||||
reach(semutaAlliedTraitorClean);
|
||||
});
|
||||
const paid = actor === fremen ? 0 : 2;
|
||||
assert.equal(semutaAlliedTraitorPlayer(shipment.before, actor).spice - semutaAlliedTraitorPlayer(game, actor).spice, paid);
|
||||
shipments.push({ actor, cost: paid, ...shipment });
|
||||
} else {
|
||||
const next = nextSemutaAlliedTraitor(game); assert.ok(next); act(next.actor, next.action);
|
||||
}
|
||||
}
|
||||
assert.equal(shipments.length, 2);
|
||||
assert.equal(semutaAlliedTraitorPlayer(game, beneficiary).forces[location], 1);
|
||||
assert.equal(semutaAlliedTraitorPlayer(game, opponent).forces[location], 1);
|
||||
reach(state => state.phase === 6 && semutaAlliedTraitorClean(state) && !!state.active);
|
||||
const beforeBattle = structuredClone(game), chooser = game.active!;
|
||||
const choice = viewGame(game, chooser).battleChoices.find(row => row.territory === territory &&
|
||||
[row.attacker, row.defender].includes(beneficiary) && [row.attacker, row.defender].includes(opponent));
|
||||
assert.ok(choice);
|
||||
const battleAction: Action = { type: 'chooseBattle', territory,
|
||||
target: choice.attacker === chooser ? choice.defender : choice.attacker };
|
||||
act(chooser, battleAction);
|
||||
reach(state => !!state.battle && semutaAlliedTraitorClean(state) && !state.battle.preparation &&
|
||||
state.battle.preLeader?.closed !== false);
|
||||
assert.ok(game.battle && !game.battle.revealed && ![game.battle.attacker, game.battle.defender].includes(owner));
|
||||
const plans: Record<string, Action> = {
|
||||
[beneficiary]: { type: 'battlePlan', leader: beneficiaryLeader.id, dial: 0, support: 0, weapon: null, defense: null },
|
||||
[opponent]: { type: 'battlePlan', leader: traitorId, dial: 0, support: 0, weapon: null, defense: null },
|
||||
};
|
||||
const reveal = transition(() => {
|
||||
for (const actor of [game.battle!.attacker, game.battle!.defender]) act(actor, plans[actor]);
|
||||
reach(state => !!state.battle?.revealed && semutaAlliedTraitorClean(state));
|
||||
});
|
||||
const voters = viewGame(game, owner).battle!.traitorVoters;
|
||||
assert.ok(voters.includes(owner)); assert.equal(voters.length, 3);
|
||||
const votes = transition(() => {
|
||||
if (last) for (const voter of voters.filter(id => id !== owner)) act(voter, { type: 'traitorCall', call: false });
|
||||
act(owner, { type: 'traitorCall', call: true });
|
||||
});
|
||||
assert.ok(game.response?.kind === 'harkonnenTraitor'); assert.equal(game.response.owner, owner);
|
||||
assert.equal(game.battle?.traitorCalls[owner], true); assert.ok(!game.battle?.outcomeWindow);
|
||||
assert.ok(semutaAlliedTraitorPlayer(game, spender).hand.some(card => card.id === cost));
|
||||
assert.ok(semutaAlliedTraitorPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
|
||||
assert.deepEqual(semutaAlliedTraitorInventory(game), originalInventory);
|
||||
return { game, kind: 'harkonnenTraitor' as const, advanced, last, holder, spender, owner, beneficiary, opponent,
|
||||
combatant: beneficiary, richese, fremen, cost, karama: cost, semuta: SEMUTA_DRUG_ID, traitorId,
|
||||
action: { type: 'card', card: cost, mode: 'cancel' } as Action,
|
||||
originalBattle: structuredClone(game.battle!), originalResponse: structuredClone(game.response!),
|
||||
plans, territory, sector, location, voters, initial, lobby, setup, beforeStartingDeal, afterSetup,
|
||||
dealOrder, deal: selected.map(card => card.id), originalInventory, originalSpice, spiceOrder,
|
||||
acquisition, acquired, cache: acquisition, alliance, shipments, beforeBattle, chooser, battleAction, reveal, votes,
|
||||
actions, steps: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
'Original R/C/H/E/F undealt setup, unopened deals/Spice and scoped native scalar lotteries only. Emperor receives the printed Karama; Harkonnen retains its four original Traitors; CHOAM pays actual cache1 for Semuta.',
|
||||
'Original turn1 movement is unused. A real turn2 worm at the empty Funeral pile opens Nexus, where Harkonnen and Emperor genuinely propose/accept alliance. Both competing normal cohorts actually ship to Great Flat15.',
|
||||
'The opposing leader is selected from Harkonnen’s actual dealt identities. Real shared preparation and unarmed plans reveal before Harkonnen’s actual spectator-ally Traitor declaration. Early/last variants preserve which other voters truly answered.',
|
||||
'No played card, force, wallet, clock, ally, Traitor, plan, vote, battle or pending control is installed. Stop before Emperor spends the actual cancellation cost.',
|
||||
] };
|
||||
}
|
||||
export type SemutaAlliedTraitorFixture = ReturnType<typeof createSemutaAlliedTraitorFixture>;
|
||||
@@ -0,0 +1,289 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer, type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { AMBASSADOR_EFFECTS } from '../game/ecaz-ambassadors';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
import {
|
||||
actSemutaRicheseCacheKarama, nextSemutaRicheseCacheKarama,
|
||||
semutaRicheseCacheKaramaClean, semutaRicheseCacheKaramaInventory, semutaRicheseCacheKaramaPlayer,
|
||||
} from './fixture-semuta-richese-cache-karama';
|
||||
import { passSemutaFremenGuildMovement, claimSemutaFremenGuildMovement } from './fixture-semuta-fremen-guild-movement';
|
||||
|
||||
export type SemutaHarkonnenBonusKind = 'normal' | 'blackMarket' | 'ambassador';
|
||||
export type SemutaHarkonnenBonusStep = { actor: string; action: Action };
|
||||
export const actSemutaHarkonnenBonus = actSemutaRicheseCacheKarama;
|
||||
export const passSemutaHarkonnenBonus = passSemutaFremenGuildMovement;
|
||||
export const claimSemutaHarkonnenBonus = claimSemutaFremenGuildMovement;
|
||||
export const semutaHarkonnenBonusPlayer = semutaRicheseCacheKaramaPlayer;
|
||||
export const semutaHarkonnenBonusInventory = semutaRicheseCacheKaramaInventory;
|
||||
export const semutaHarkonnenBonusClean = semutaRicheseCacheKaramaClean;
|
||||
|
||||
/** Bonus/entry choices stay explicit; normal setup and no-spending native
|
||||
* controls retain their existing owners and priorities. */
|
||||
export function nextSemutaHarkonnenBonus(game: Game): SemutaHarkonnenBonusStep | null {
|
||||
if (game.pendingTreacheryDiscard || game.truthtrance || game.response?.kind === 'harkonnenBonus') return null;
|
||||
if (!game.response && !game.phaseOpening && game.decision?.kind === 'ecazAmbassador') return null;
|
||||
const step = nextSemutaRicheseCacheKarama(game);
|
||||
if (step?.action.type === 'stormDial' && game.turn === 1)
|
||||
return { actor: step.actor, action: { ...step.action, amount: 0 } };
|
||||
return step;
|
||||
}
|
||||
export function advanceSemutaHarkonnenBonus(game: Game, until: (state: Game) => boolean,
|
||||
actions: SemutaHarkonnenBonusStep[] = []): Game {
|
||||
for (let count = 0; count < 2000; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const step = nextSemutaHarkonnenBonus(game); assert.ok(step, 'Choose the actual paid bonus, Ambassador or fresh-cost control');
|
||||
game = actSemutaHarkonnenBonus(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The genuine Harkonnen bonus source did not reach its finite boundary');
|
||||
}
|
||||
/** Allow the original bonus and its one native suffix, never buy another card. */
|
||||
export function allowSemutaHarkonnenBonus(game: Game, actions: SemutaHarkonnenBonusStep[] = []): Game {
|
||||
assert.equal(game.response?.kind, 'harkonnenBonus');
|
||||
for (let count = 0; count < game.players.length && game.response?.kind === 'harkonnenBonus'; count++) {
|
||||
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
|
||||
game = actSemutaHarkonnenBonus(game, actor.id, { type: 'passResponse' }, actions);
|
||||
}
|
||||
assert.notEqual(game.response?.kind, 'harkonnenBonus'); return game;
|
||||
}
|
||||
/** The native bonus callback already retires its sale/entry. Preserve the next
|
||||
* normal lot, compulsory Richese declaration or entrant's unfinished turn. */
|
||||
export function finishSemutaHarkonnenBonus(game: Game, kind: SemutaHarkonnenBonusKind,
|
||||
_actions: SemutaHarkonnenBonusStep[] = []): Game {
|
||||
void _actions; // This boundary deliberately submits no additional action.
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null);
|
||||
assert.notEqual(game.response?.kind, 'harkonnenBonus');
|
||||
assert.equal(game.currentAuctionSale ?? null, null);
|
||||
if (kind === 'normal') {
|
||||
assert.equal(game.phase, 3); assert.ok(game.auction); assert.equal(game.auction.index, 1);
|
||||
} else if (kind === 'blackMarket') {
|
||||
assert.equal(game.phase, 3); assert.equal(game.richeseAuction ?? null, null);
|
||||
assert.equal(game.decision?.kind, 'richeseDeclaration');
|
||||
} else {
|
||||
assert.equal(game.phase, 5); assert.equal(game.pendingAmbassador ?? null, null);
|
||||
assert.equal(game.pendingArrivalReaction?.length ?? 0, 0);
|
||||
assert.equal(game.active, 'emperor');
|
||||
}
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Select only the nine native Fisher–Yates packet draws at original force
|
||||
* initialization. The Richese marker is actually dealt, never installed. */
|
||||
function withOriginalRichesePacket<T>(run: () => T): T {
|
||||
const pool = AMBASSADOR_EFFECTS.filter(effect => effect !== 'ecaz');
|
||||
const desired = ['richese', ...pool.filter(effect => effect !== 'richese')], current: string[] = [...pool];
|
||||
const rolls: number[] = [];
|
||||
for (let index = current.length - 1; index > 0; index--) {
|
||||
const selected = current.indexOf(desired[index]); assert.ok(selected >= 0 && selected <= index);
|
||||
rolls.push(Math.floor((selected + 0.5) * 0x100000000 / (index + 1)));
|
||||
[current[index], current[selected]] = [current[selected], current[index]];
|
||||
}
|
||||
assert.deepEqual(current, desired);
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const native = crypto.getRandomValues.bind(crypto); let cursor = 0;
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (array instanceof Uint32Array && array.length === 1) array[0] = rolls[cursor++] ?? 0xffffffff;
|
||||
else Reflect.apply(native, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try { return run(); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
|
||||
/** Real starting Harkonnen two-card deal and paid acquisition. Basic Ambassador
|
||||
* uses actual Ecaz alliance consent before an independent entrant triggers a
|
||||
* Richese purchase for allied Harkonnen; Advanced E3 admission stays closed. */
|
||||
export function createSemutaHarkonnenBonusFixture(options: {
|
||||
kind: SemutaHarkonnenBonusKind; advanced?: boolean;
|
||||
}) {
|
||||
const kind = options.kind, advanced = options.advanced ?? kind === 'blackMarket';
|
||||
assert.ok(kind !== 'blackMarket' || advanced, 'The native Black Market is Advanced');
|
||||
assert.ok(kind !== 'ambassador' || !advanced, 'Advanced Ecaz Semuta starts remain outside the original preview gate');
|
||||
const richese = 'richese', holder = 'choam', owner = 'harkonnen', spender = 'emperor', ecaz = 'ecaz';
|
||||
let game = createGame('SEMUTAHARKONNENBONUS', newPlayer(richese, 'Richese', 'richese'), advanced,
|
||||
kind === 'ambassador' ? ['choam', 'ecaz'] : ['choam']);
|
||||
const roster = kind === 'ambassador' ? [holder, ecaz, owner, spender] as const : [holder, owner, spender] as const;
|
||||
for (const faction of roster) joinGame(game, newPlayer(faction, faction, faction));
|
||||
const initial = structuredClone(game), actions: SemutaHarkonnenBonusStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actSemutaHarkonnenBonus(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaHarkonnenBonus(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 = semutaHarkonnenBonusInventory(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 projectile = game.deck.find(card => card.kind === 'projectile' && !card.effect); assert.ok(projectile);
|
||||
const ordinary = [projectile, ...game.deck.filter(card => card.id !== projectile.id && !card.effect &&
|
||||
['projectile', 'poison', 'shield', 'snooper'].includes(card.kind))].slice(0, game.players.length);
|
||||
assert.equal(ordinary.length, game.players.length);
|
||||
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);
|
||||
let packetCreation: ReturnType<typeof transition> | null = null;
|
||||
for (let count = 0; game.status === 'setup' && count < 100; count++) {
|
||||
const step = nextSemutaHarkonnenBonus(game); assert.ok(step);
|
||||
const lastTraitor = kind === 'ambassador' && game.setupStage === 'traitors' && step.action.type === 'traitor' &&
|
||||
game.players.filter(player => player.traitorChoices.length > 0).length === 1;
|
||||
if (lastTraitor) packetCreation = transition(() => {
|
||||
game = withOriginalRichesePacket(() => applyAction(game, step.actor, step.action));
|
||||
actions.push(structuredClone(step));
|
||||
});
|
||||
else act(step.actor, step.action);
|
||||
}
|
||||
assert.equal(game.status, 'playing'); const afterSetup = structuredClone(game);
|
||||
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), deal);
|
||||
assert.equal(semutaHarkonnenBonusPlayer(game, owner).hand.length, 2);
|
||||
assert.ok(semutaHarkonnenBonusPlayer(game, spender).hand.some(card => card.id === cost));
|
||||
if (kind === 'ambassador') {
|
||||
assert.ok(packetCreation);
|
||||
assert.equal(game.ecazAmbassadors?.tokens.find(token => token.effect === 'richese')?.zone, 'supply');
|
||||
}
|
||||
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 && kind === 'blackMarket' ? 4 : 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: SemutaHarkonnenBonusStep | undefined, bidAt = -1;
|
||||
for (let count = 0; count < 900 && !semutaHarkonnenBonusPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
|
||||
const step = nextSemutaHarkonnenBonus(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(semutaHarkonnenBonusPlayer(beforeBid, holder).spice - semutaHarkonnenBonusPlayer(game, holder).spice, 1);
|
||||
assert.equal(semutaHarkonnenBonusPlayer(game, richese).spice - semutaHarkonnenBonusPlayer(beforeBid, richese).spice, 1);
|
||||
let blackMarket: ReturnType<typeof transition> | null = null;
|
||||
let placements: ReturnType<typeof transition> | null = null;
|
||||
let alliance: ReturnType<typeof transition> | null = null;
|
||||
let arrival: ReturnType<typeof transition> | null = null;
|
||||
let nativeAction: Action, purchase: ReturnType<typeof transition>, purchased: string;
|
||||
if (kind === 'ambassador') {
|
||||
reach(state => state.phase === 4 && !state.response && !state.phaseOpening && state.decision?.kind === 'ecazPlacement');
|
||||
placements = transition(() => {
|
||||
for (const [effect, territory] of [['ecaz', 'sietch_tabr'], ['richese', 'habbanya_ridge_sietch']] as const) {
|
||||
const token = game.ecazAmbassadors!.tokens.find(token => token.effect === effect && token.zone === 'supply'); assert.ok(token);
|
||||
act(ecaz, { type: 'decision', token: token.id, territory });
|
||||
assert.equal(game.response?.kind, 'ecazPlacement');
|
||||
const quotedCost = game.pendingEcazPlacement?.cost; assert.ok(quotedCost !== undefined);
|
||||
const balance = semutaHarkonnenBonusPlayer(game, ecaz).spice;
|
||||
for (let count = 0; game.response?.kind === 'ecazPlacement' && count < game.players.length; count++) {
|
||||
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
|
||||
act(actor.id, { type: 'passResponse' });
|
||||
}
|
||||
assert.equal(game.response ?? null, null);
|
||||
assert.equal(game.decision?.kind, 'ecazPlacement');
|
||||
assert.equal(semutaHarkonnenBonusPlayer(game, ecaz).spice, balance - quotedCost);
|
||||
assert.equal(game.ecazAmbassadors!.tokens.find(candidate => candidate.id === token.id)?.location, territory);
|
||||
}
|
||||
act(ecaz, { type: 'decision', decline: true });
|
||||
});
|
||||
reach(state => state.phase === 5 && state.active === owner && semutaHarkonnenBonusClean(state));
|
||||
alliance = transition(() => {
|
||||
act(owner, { type: 'ship', territory: 'sietch_tabr', sector: 14, amount: 1, elite: 0, allyPayment: 0 });
|
||||
assert.equal(game.pendingAmbassador?.stage, 'offer');
|
||||
act(ecaz, { type: 'decision', event: game.pendingAmbassador!.event, trigger: true, choice: 'alliance', beneficiary: ecaz });
|
||||
assert.equal(game.pendingAmbassador?.stage, 'allianceReply');
|
||||
act(owner, { type: 'decision', event: game.pendingAmbassador!.event, accept: true });
|
||||
if (game.pendingAmbassador?.stage === 'loan')
|
||||
act(ecaz, { type: 'decision', event: game.pendingAmbassador.event, loan: false });
|
||||
assert.equal(game.pendingAmbassador ?? null, null);
|
||||
});
|
||||
assert.equal(semutaHarkonnenBonusPlayer(game, owner).ally, ecaz);
|
||||
assert.equal(semutaHarkonnenBonusPlayer(game, ecaz).ally, owner);
|
||||
act(owner, { type: 'endMovement' });
|
||||
reach(state => state.phase === 5 && state.active === spender && semutaHarkonnenBonusClean(state));
|
||||
arrival = transition(() => { act(spender, { type: 'ship', territory: 'habbanya_ridge_sietch', sector: 17,
|
||||
amount: 1, elite: 0, allyPayment: 0 }); });
|
||||
assert.equal(game.pendingAmbassador?.stage, 'offer');
|
||||
purchased = game.deck[0].id;
|
||||
nativeAction = { type: 'decision', event: game.pendingAmbassador!.event, trigger: true, beneficiary: owner };
|
||||
purchase = transition(() => {
|
||||
act(ecaz, nativeAction);
|
||||
reach(state => state.response?.kind === 'harkonnenBonus');
|
||||
});
|
||||
} else {
|
||||
if (kind === 'blackMarket') {
|
||||
reach(state => state.turn === 2 && state.phase === 3 && !state.response && !state.phaseOpening &&
|
||||
state.decision?.kind === 'richeseBlackMarket');
|
||||
const card = semutaHarkonnenBonusPlayer(game, richese).hand.find(card => card.kind === 'projectile'); assert.ok(card);
|
||||
blackMarket = transition(() => { act(richese, { type: 'decision', event: game.richeseBidding!.event,
|
||||
card: card.id, method: 'normal', direction: 'clockwise' }); });
|
||||
reach(state => semutaHarkonnenBonusClean(state) && state.richeseAuction?.source === 'blackMarket' &&
|
||||
state.richeseAuction.active === owner && state.richeseAuction.bid === 0);
|
||||
purchased = game.richeseAuction!.cardId;
|
||||
nativeAction = { type: 'richeseBid', event: game.richeseAuction!.event, amount: 1, allyPayment: 0 };
|
||||
} else {
|
||||
reach(state => semutaHarkonnenBonusClean(state) && !!state.auction && state.auction.active === owner && state.auction.bid === 0);
|
||||
assert.equal(game.auction!.index, 0); assert.ok(game.auction!.cards.length >= 2);
|
||||
purchased = game.auction!.cards[0].id;
|
||||
nativeAction = { type: 'bid', amount: 1, allyPayment: 0 };
|
||||
}
|
||||
purchase = transition(() => {
|
||||
act(owner, nativeAction);
|
||||
reach(state => state.response?.kind === 'harkonnenBonus');
|
||||
});
|
||||
}
|
||||
const paid = kind === 'ambassador' ? 3 : 1;
|
||||
assert.equal(game.response?.kind, 'harkonnenBonus'); 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.equal(semutaHarkonnenBonusPlayer(purchase.before, owner).spice - semutaHarkonnenBonusPlayer(game, owner).spice, paid);
|
||||
assert.equal(semutaHarkonnenBonusPlayer(game, owner).hand.length, semutaHarkonnenBonusPlayer(purchase.before, owner).hand.length + 1);
|
||||
assert.equal(semutaHarkonnenBonusPlayer(game, owner).hand.filter(card => card.id === purchased).length, 1);
|
||||
assert.notEqual(purchased, cost);
|
||||
const purchasedCard = structuredClone(semutaHarkonnenBonusPlayer(game, owner).hand.find(card => card.id === purchased)!);
|
||||
const source = structuredClone(game), originalResponse = structuredClone(game.response!);
|
||||
const originalSale = structuredClone(game.currentAuctionSale ?? null), originalAuction = structuredClone(game.auction ?? null);
|
||||
const originalEntry = structuredClone(game.pendingAmbassador ?? null), originalLot = structuredClone(game.richeseAuction ?? null);
|
||||
const originalRound = structuredClone(game.richeseBidding ?? null), originalDeck = game.deck.map(card => card.id);
|
||||
const bonus = game.deck[0]; assert.ok(bonus);
|
||||
assert.ok(semutaHarkonnenBonusPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
|
||||
assert.ok(semutaHarkonnenBonusPlayer(game, spender).hand.some(card => card.id === cost));
|
||||
assert.deepEqual(semutaHarkonnenBonusInventory(game), originalInventory);
|
||||
const action: Action = { type: 'card', card: cost, mode: 'cancel' };
|
||||
return { game, source, beforeCost: source, kind, advanced, richese, holder, spender, owner, buyer: owner, ecaz,
|
||||
cost, semuta: SEMUTA_DRUG_ID, action, nativeAction, originalResponse,
|
||||
paid, purchased, purchasedCard, bonus: structuredClone(bonus), originalDeck,
|
||||
originalSale, originalAuction, originalEntry, originalLot, originalRound,
|
||||
initial, lobby, setup, initialization, beforeStartingDeal, afterSetup, deal, dealOrder, packetCreation,
|
||||
originalSpice, spiceOrder, acquired, acquisition, blackMarket, placements, alliance, arrival, purchase,
|
||||
originalInventory, actions, sourceActions: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
'Public Basic start or original Advanced Semuta initializer on a ready undealt R/C/H/E lobby; Basic Ambassador additionally seats Ecaz under its existing native gate. Original main35/cache10 remain physical. Only unopened original deal/Spice positions and scalar lotteries are selected. Harkonnen really receives two starting cards, Emperor printed Karama, and CHOAM buys cache Semuta for1.',
|
||||
kind === 'normal' ? 'Harkonnen makes a real positive bid1 on the first native normal lot. Other seats pass; native delivery/debit and Emperor income settle before the original Harkonnen bonus response. The bonus has not drawn.' :
|
||||
kind === 'blackMarket' ? 'After actual first-turn completion, Richese offers its original dealt projectile at the real turn2 Advanced Black Market. Harkonnen bids1; the native seller/payment/income continuations finish before bonus. No purchase or auction frame is installed.' :
|
||||
'The original nine-roll Ambassador packet genuinely includes Richese. At first Revival Ecaz declares reusable Ecaz at Tabr14, actually passes its native counter, pays1, and regains its placement chooser. It declares Richese at Habbanya17, actually passes that counter and pays the next native price2 before finishing placement. Harkonnen ships1 to Tabr, actually accepts Ecaz’s printed alliance and declines any real optional Duke loan. Independent non-ally Emperor next ships1 to Habbanya; Ecaz triggers Richese for allied Harkonnen. Harkonnen pays3 and draws its paid card; original Emperor income settles before bonus. Advanced E3 Semuta admission remains unchanged.',
|
||||
'Stop before the separate original printed cancellation cost. The purchased card and native wallets already committed, and the exact next deck card is still undrawn. Claim/pass resumes denial plus the original sale/entry tail once; normal allowance alone draws its one physical bonus.',
|
||||
] };
|
||||
}
|
||||
export type SemutaHarkonnenBonusFixture = ReturnType<typeof createSemutaHarkonnenBonusFixture>;
|
||||
@@ -0,0 +1,275 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { AMBASSADOR_EFFECTS, type AmbassadorEffect } from '../game/ecaz-ambassadors';
|
||||
import { quoteNoFieldCancellation } from '../game/karama-no-field-cancellation';
|
||||
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 SemutaNoFieldCostKind = 'own' | 'allied' | 'ambassador';
|
||||
export type SemutaNoFieldCostStep = { actor: string; action: Action };
|
||||
export const actSemutaNoFieldCost = actSemutaDisasterWorm;
|
||||
export const passSemutaNoFieldCost = passSemutaDisasterWorm;
|
||||
export const claimSemutaNoFieldCost = claimSemutaDisasterWorm;
|
||||
export const semutaNoFieldCostPlayer = semutaDisasterWormPlayer;
|
||||
export const semutaNoFieldCostInventory = semutaDisasterWormInventory;
|
||||
export const semutaNoFieldCostClean = semutaDisasterWormClean;
|
||||
export const semutaNoFieldCostPhysical = (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,
|
||||
noField: p.noField, noFieldEvent: p.noFieldEvent })),
|
||||
aid: game.aid, spice: game.spice,
|
||||
});
|
||||
|
||||
/** Fresh groups and the independent Ambassador chooser remain caller-owned. */
|
||||
export function nextSemutaNoFieldCost(game: Game): SemutaNoFieldCostStep | null {
|
||||
if (game.pendingTreacheryDiscard || game.truthtrance) return null;
|
||||
if (!game.response && !game.phaseOpening) {
|
||||
const ecaz = game.players.find(p => p.faction === 'ecaz');
|
||||
if (game.status === 'setup' && game.setupStage === 'forces' && ecaz?.reserves === 20 &&
|
||||
viewGame(game, ecaz.id).setupPending.includes(ecaz.id))
|
||||
return { actor: ecaz.id, action: { type: 'ecazSetup', placements: { 'imperial_basin:9': 6 } } };
|
||||
if (game.decision?.kind === 'ecazAmbassador') return null;
|
||||
if (game.decision?.kind === 'ecazPlacement')
|
||||
return { actor: game.decision.player, action: { type: 'decision', decline: true } };
|
||||
}
|
||||
return nextSemutaDisasterWorm(game);
|
||||
}
|
||||
export const nextSemutaNoFieldCostStep = nextSemutaNoFieldCost;
|
||||
export function advanceSemutaNoFieldCost(game: Game, until: (state: Game) => boolean,
|
||||
actions: SemutaNoFieldCostStep[] = []): Game {
|
||||
for (let count = 0; count < 2000; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const step = nextSemutaNoFieldCost(game);
|
||||
assert.ok(step, `Choose the native fresh/Ambassador control at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
game = actSemutaNoFieldCost(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The native No-Field cost source did not reach its finite boundary');
|
||||
}
|
||||
/** Allow this exact No-Field response only, then leave original shipment
|
||||
* permission/rate responses and independent arrival to finish. */
|
||||
export function allowSemutaNoFieldCost(game: Game, actions: SemutaNoFieldCostStep[] = []): Game {
|
||||
assert.equal(game.response?.kind, 'richeseNoField');
|
||||
for (let count = 0; game.response?.kind === 'richeseNoField' && count < game.players.length; count++) {
|
||||
const actor = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(actor);
|
||||
game = actSemutaNoFieldCost(game, actor.id, { type: 'passResponse' }, actions);
|
||||
}
|
||||
assert.notEqual(game.response?.kind, 'richeseNoField'); return game;
|
||||
}
|
||||
/** Drains only native original responses/permissions. A denied independent
|
||||
* grant stops at its restored chooser without choosing the reserve fallback. */
|
||||
export function finishSemutaNoFieldCost(game: Game, actions: SemutaNoFieldCostStep[] = []): Game {
|
||||
return advanceSemutaNoFieldCost(game, state => semutaNoFieldCostClean(state) ||
|
||||
!state.response && !state.phaseOpening && !state.pendingTreacheryDiscard &&
|
||||
state.decision?.kind === 'ecazAmbassador', actions);
|
||||
}
|
||||
|
||||
function originalGuildPacket() {
|
||||
const pool = AMBASSADOR_EFFECTS.filter(effect => effect !== 'ecaz');
|
||||
const supply: Exclude<AmbassadorEffect, 'ecaz'>[] = ['guild', 'atreides', 'emperor', 'fremen', 'harkonnen'];
|
||||
const desired = [...supply, ...pool.filter(effect => !supply.includes(effect))], current = [...pool], rolls: number[] = [];
|
||||
for (let i = current.length - 1; i > 0; i--) {
|
||||
const j = current.indexOf(desired[i]); assert.ok(j >= 0 && j <= i);
|
||||
rolls.push(Math.floor((j + 0.5) * 0x100000000 / (i + 1)));
|
||||
[current[i], current[j]] = [current[j], current[i]];
|
||||
}
|
||||
assert.deepEqual(current, desired); assert.equal(rolls.length, 9); return { supply, rolls };
|
||||
}
|
||||
function createOriginalGuildPacket(game: Game, step: SemutaNoFieldCostStep, rolls: number[], actions: SemutaNoFieldCostStep[]) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const original = crypto.getRandomValues.bind(crypto); let cursor = 0;
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (array instanceof Uint32Array && array.length === 1)
|
||||
array[0] = cursor < rolls.length ? rolls[cursor++] : 0xffffffff;
|
||||
else Reflect.apply(original, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try {
|
||||
const next = applyAction(game, step.actor, step.action);
|
||||
assert.equal(cursor, rolls.length); actions.push(structuredClone(step)); return next;
|
||||
} finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
|
||||
/** Genuine own/allied Basic+Advanced and Basic independent Guild grant.
|
||||
* Advanced Ambassador substitution retains its existing Basic-only gate. */
|
||||
export function createSemutaNoFieldCostFixture(options: {
|
||||
kind?: SemutaNoFieldCostKind; advanced?: boolean;
|
||||
} = {}) {
|
||||
const kind = options.kind ?? 'own', advanced = options.advanced ?? false;
|
||||
assert.ok(kind !== 'ambassador' || !advanced,
|
||||
'Native Guild Ambassador No-Field substitution requires the existing bare Basic profile');
|
||||
const owner = 'richese', holder = 'choam', emperor = 'emperor', spender = 'guild', ecaz = 'ecaz';
|
||||
const recipient = kind === 'allied' ? emperor : owner, territory = kind === 'ambassador' ? 'sietch_tabr' : 'imperial_basin';
|
||||
const sector = kind === 'ambassador' ? 14 : 9, location = `${territory}:${sector}`;
|
||||
let game = createGame('SEMUTANOFIELDCOST', newPlayer(owner, 'Richese', 'richese'), advanced,
|
||||
kind === 'ambassador' ? ['choam', 'ecaz'] : ['choam']);
|
||||
for (const faction of ['choam', 'emperor', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction));
|
||||
if (kind === 'ambassador') joinGame(game, newPlayer(ecaz, 'Ecaz', 'ecaz'));
|
||||
const initial = structuredClone(game), actions: SemutaNoFieldCostStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actSemutaNoFieldCost(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaNoFieldCost(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) };
|
||||
};
|
||||
const circles = kind === 'ambassador' ? transition(() => {
|
||||
act(holder, { type: 'seatPosition', position: 6 });
|
||||
act(owner, { type: 'seatPosition', position: 2 });
|
||||
act(holder, { type: 'seatPosition', position: 1 });
|
||||
}) : null;
|
||||
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), initialization = { before: lobby, after: setup,
|
||||
entry: advanced ? 'original-undealt-advanced-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(p => p.hand.length === 0));
|
||||
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
|
||||
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
|
||||
const originalInventory = semutaNoFieldCostInventory(game); assert.equal(originalInventory.length, 45);
|
||||
const printed = game.deck.find(card => card.effect === 'karama'); assert.ok(printed);
|
||||
const ordinary = game.deck.filter(card => card.kind === 'projectile').slice(0, 3); assert.equal(ordinary.length, 3);
|
||||
const shield = game.deck.find(card => card.kind === 'shield'); assert.ok(shield);
|
||||
const selected = [ordinary[0], ordinary[1], ordinary[2], printed, ...(kind === 'ambassador' ? [shield] : [])];
|
||||
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), packet = kind === 'ambassador' ? originalGuildPacket() : null;
|
||||
let packetCreation: { before: Game; steps: SemutaNoFieldCostStep[]; after: Game } | null = null;
|
||||
for (let count = 0; game.status === 'setup' && count < 100; count++) {
|
||||
const step = nextSemutaNoFieldCost(game); assert.ok(step);
|
||||
if (packet && game.setupStage === 'traitors' && step.action.type === 'traitor' &&
|
||||
game.players.filter(p => p.traitorChoices.length).length === 1) {
|
||||
const before = structuredClone(game);
|
||||
game = createOriginalGuildPacket(game, step, packet.rolls, actions);
|
||||
packetCreation = { before, steps: [structuredClone(step)], after: structuredClone(game) };
|
||||
} else act(step.actor, step.action);
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
const afterSetup = structuredClone(game), cost = printed.id;
|
||||
assert.deepEqual(game.players.flatMap(p => p.hand.map(card => card.id)), selected.map(card => card.id));
|
||||
const originalNoField = structuredClone(semutaNoFieldCostPlayer(game, owner).noField); assert.ok(originalNoField);
|
||||
const token = originalNoField.tokens.find(token => token.value === 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], 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, `The original unopened Spice stock needs ${face}`);
|
||||
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(advanced ? ['funeral_plain', 'hagga_basin'] : ['funeral_plain']);
|
||||
let beforeBid: Game | undefined, bid: SemutaNoFieldCostStep | undefined, bidAt = -1;
|
||||
for (let count = 0; count < 900 && !semutaNoFieldCostPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
|
||||
const step = nextSemutaNoFieldCost(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(semutaNoFieldCostPlayer(beforeBid, holder).spice - semutaNoFieldCostPlayer(game, holder).spice, 1);
|
||||
let placement: { before: Game; steps: SemutaNoFieldCostStep[]; after: Game } | null = null;
|
||||
let guildToken: string | null = null;
|
||||
if (kind === 'ambassador') {
|
||||
assert.ok(packetCreation);
|
||||
guildToken = game.ecazAmbassadors!.tokens.find(token => token.effect === 'guild')!.id;
|
||||
reach(state => state.decision?.kind === 'ecazPlacement' && !state.response && !state.phaseOpening);
|
||||
placement = transition(() => {
|
||||
act(ecaz, { type: 'decision', token: guildToken!, territory: 'carthag' });
|
||||
reach(state => state.decision?.kind === 'ecazPlacement' && !state.response && !state.phaseOpening);
|
||||
act(ecaz, { type: 'decision', decline: true });
|
||||
});
|
||||
assert.equal(semutaNoFieldCostPlayer(placement.before, ecaz).spice - semutaNoFieldCostPlayer(game, ecaz).spice, 1);
|
||||
}
|
||||
let alliance: { before: Game; steps: SemutaNoFieldCostStep[]; after: Game } | null = null;
|
||||
if (kind !== 'own') {
|
||||
reach(state => state.turn === 2 && state.phase === 1 && semutaNoFieldCostClean(state) &&
|
||||
!state.spiceSequence && !state.spiceResolution && !state.spiceWindow && !state.nexus && state.ready.length === 0);
|
||||
orderSpice(['worm', 'the_great_flat', ...(advanced ? ['broken_land'] : [])]);
|
||||
reach(state => state.turn === 2 && state.phase === 1 && state.nexus && !state.spiceWindow &&
|
||||
!state.spiceResolution && semutaNoFieldCostClean(state));
|
||||
const ally = kind === 'ambassador' ? ecaz : emperor;
|
||||
alliance = transition(() => {
|
||||
act(owner, { type: 'alliance', target: ally }); act(ally, { type: 'alliance', target: owner });
|
||||
});
|
||||
assert.equal(semutaNoFieldCostPlayer(game, owner).ally, ally); assert.equal(semutaNoFieldCostPlayer(game, ally).ally, owner);
|
||||
}
|
||||
const turn = kind === 'own' ? 1 : 2;
|
||||
let priorShipment: { before: Game; steps: SemutaNoFieldCostStep[]; after: Game } | null = null;
|
||||
let entrantShipment: { before: Game; steps: SemutaNoFieldCostStep[]; after: Game } | null = null;
|
||||
if (kind === 'ambassador') {
|
||||
reach(state => state.turn === turn && state.phase === 5 && state.active === owner && semutaNoFieldCostClean(state));
|
||||
priorShipment = transition(() => {
|
||||
act(owner, { type: 'ship', amount: 1, elite: 0, allyPayment: 0, territory: 'polar_sink', sector: 0 });
|
||||
reach(semutaNoFieldCostClean); act(owner, { type: 'endMovement' });
|
||||
});
|
||||
reach(state => state.turn === turn && state.phase === 5 && state.active === emperor && semutaNoFieldCostClean(state));
|
||||
entrantShipment = transition(() => {
|
||||
act(emperor, { type: 'ship', amount: 1, elite: 0, allyPayment: 0, territory: 'carthag', sector: 11 });
|
||||
reach(state => !state.response && state.pendingAmbassador?.stage === 'offer');
|
||||
});
|
||||
act(ecaz, { type: 'decision', event: game.pendingAmbassador!.event, trigger: true, beneficiary: owner });
|
||||
assert.equal(game.pendingAmbassador?.stage, 'ship'); assert.equal(game.pendingAmbassador.effect, 'guild');
|
||||
} else reach(state => state.turn === turn && state.phase === 5 && state.active === recipient &&
|
||||
(semutaNoFieldCostClean(state) || kind === 'allied' && !state.response && !state.phaseOpening &&
|
||||
!state.pendingTreacheryDiscard && state.decision?.kind === 'richeseAllyOpportunity' && state.decision.player === owner));
|
||||
const beforeDeclaration = structuredClone(game), tokenEvent = semutaNoFieldCostPlayer(game, owner).noFieldEvent!;
|
||||
const entry = structuredClone(game.pendingAmbassador ?? null);
|
||||
const nativeAction: Action = kind === 'own'
|
||||
? { type: 'ship', noField: token.id, event: tokenEvent, territory, sector, allyPayment: 0 }
|
||||
: kind === 'allied'
|
||||
? { type: 'offerRicheseNoField', token: token.id, event: tokenEvent, payer: recipient, territory, sector }
|
||||
: { type: 'decision', event: entry!.event, amount: 1, noField: token.id, noFieldEvent: tokenEvent, territory, sector };
|
||||
const declaration = transition(() => {
|
||||
act(owner, nativeAction);
|
||||
if (kind === 'allied') act(recipient, { type: 'decision', event: game.richeseAllyOffer!.event, accept: true, elite: 0 });
|
||||
});
|
||||
assert.ok(game.response && game.pendingShipment);
|
||||
assert.equal(game.response.kind, 'richeseNoField'); assert.equal(game.response.owner, owner);
|
||||
assert.equal(game.response.source ?? null, kind === 'ambassador' ? 'ambassadorNoField' : null);
|
||||
assert.deepEqual(semutaNoFieldCostPhysical(game), semutaNoFieldCostPhysical(beforeDeclaration));
|
||||
assert.deepEqual(semutaNoFieldCostPlayer(game, owner).noField, originalNoField);
|
||||
const expected = kind === 'ambassador' ? null : quoteNoFieldCancellation(game, game.response);
|
||||
const source = structuredClone(game), originalResponse = structuredClone(game.response), originalShipment = structuredClone(game.pendingShipment);
|
||||
const action: Action = { type: 'card', card: cost, mode: 'cancel' };
|
||||
const replacementAction: Action = kind === 'ambassador'
|
||||
? { type: 'decision', event: entry!.event, amount: 1, elite: 0, territory, sector }
|
||||
: { type: 'ship', amount: 1, elite: 0, allyPayment: 0, territory, sector };
|
||||
assert.ok(semutaNoFieldCostPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
|
||||
assert.ok(semutaNoFieldCostPlayer(game, spender).hand.some(card => card.id === cost));
|
||||
assert.deepEqual(semutaNoFieldCostInventory(game), originalInventory);
|
||||
return { game, source, beforeCost: source, kind, advanced, owner, holder, spender, recipient, emperor, guild: spender,
|
||||
ecaz: kind === 'ambassador' ? ecaz : null, cost, karama: cost, semuta: SEMUTA_DRUG_ID, action, nativeAction,
|
||||
replacementAction, fallbackAction: replacementAction, replacementActor: recipient, token, tokenEvent, entry,
|
||||
originalResponse, originalShipment, originalPhysical: structuredClone(semutaNoFieldCostPhysical(game)), expected,
|
||||
territory, sector, location, turn, shipmentCost: kind === 'ambassador' ? 0 : 2, amount: kind === 'allied' ? 5 : 1,
|
||||
initial, lobby, setup, initialization, beforeStartingDeal, afterSetup, deal: selected.map(card => card.id), dealOrder,
|
||||
originalInventory, originalNoField, originalSpice, spiceOrder, acquisition, acquired, packetCreation, circles,
|
||||
guildToken, placement, alliance, priorShipment, entrantShipment, beforeDeclaration, declaration,
|
||||
actions, steps: actions, sourceActions: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
advanced ? 'Original Advanced Semuta initializer on a genuinely ready undealt R/C/E/G lobby, main35/cache10, no modules.' : 'Actual public Basic R/C/E/G ready/start, adding original Ecaz/E3 only for the independent Ambassador source; main35/cache10 and no modules.',
|
||||
'Only original unopened starting-card/Spice positions and scalar lotteries are selected. Guild really receives printed Karama; CHOAM really pays one for cache Semuta. Richese retains its original three token identities, physical reserves and selection event before the declaration.',
|
||||
kind === 'allied' ? 'A real turn2 natural worm on the empty first Funeral pile creates Nexus. Richese and Emperor actually propose/accept alliance, then Richese privately offers its original five-marker during Emperor’s unused turn; Emperor really consents to five normals and its own two-spice fee.' : kind === 'ambassador' ? 'Basic-only native route: three public lobby circle choices put Richese before Emperor after the actual second Storm. Select the original nine-roll Guild packet, pay for its Carthag token, earn real Richese/Ecaz Nexus alliance. Richese really pays one for ordinary Polar entry and ends its turn. Emperor really pays one into Carthag, Ecaz really triggers Guild for allied Richese, then Richese really declares its original concealed marker for the independent free grant. Existing Advanced Ambassador/No-Field gate is preserved.' : 'At the original unused first movement turn, Richese really declares its original five-marker to Basin9 for the native one-marker two-spice fee.',
|
||||
'Stop at the native unpaid No-Field response before Guild spends its originally dealt printed Karama. No played hand, force, wallet, clock, alliance, token, shipment, Ambassador entry or response is assigned. Native denial runs without an allowed budget/capacity trial; actual ordinary/independent physical fallback remains caller-owned.',
|
||||
],
|
||||
};
|
||||
}
|
||||
export type SemutaNoFieldCostFixture = ReturnType<typeof createSemutaNoFieldCostFixture>;
|
||||
@@ -0,0 +1,195 @@
|
||||
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 { quoteStrongholdFactionsBattle } from './fixture-stronghold-factions';
|
||||
import {
|
||||
createSemutaAlliedTraitorFixture as fixture, actSemutaAlliedTraitor as act,
|
||||
passSemutaAlliedTraitor as pass, claimSemutaAlliedTraitor as claim,
|
||||
allowSemutaAlliedTraitor as allow, finishSemutaAlliedTraitor as finish,
|
||||
advanceSemutaAlliedTraitor as advance, semutaAlliedTraitorPlayer as player,
|
||||
semutaAlliedTraitorInventory as inventory, semutaAlliedTraitorClean as clean,
|
||||
type SemutaAlliedTraitorFixture,
|
||||
} from './fixture-semuta-allied-traitor';
|
||||
|
||||
const variants = [
|
||||
{ advanced: false, last: false }, { advanced: false, last: true },
|
||||
{ advanced: true, last: false }, { advanced: true, last: true },
|
||||
];
|
||||
const physical = (game: Game) => ({
|
||||
turn: game.turn, phase: game.phase, storm: game.storm, active: game.active, order: game.order,
|
||||
ready: game.ready, spice: game.spice, spiceDeck: game.spiceDeck, spiceDiscard: game.spiceDiscard,
|
||||
movementRemaining: game.movementRemaining, aid: game.aid,
|
||||
players: game.players.map(p => ({ id: p.id, spice: p.spice, forces: p.forces, elites: p.elites,
|
||||
reserves: p.reserves, tanks: p.tanks, leaders: p.leaders, traitors: p.traitors,
|
||||
revealedTraitors: p.revealedTraitors, ally: p.ally, shipped: p.shipped, moved: p.moved, battleLosses: p.battleLosses })),
|
||||
});
|
||||
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: SemutaAlliedTraitorFixture) {
|
||||
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', 'harkonnen', 'emperor', 'fremen']);
|
||||
assert.deepEqual(f.afterSetup.players.flatMap(p => p.hand.map(card => card.id)), f.deal);
|
||||
assert.equal(player(f.afterSetup, f.owner).hand.length, 2);
|
||||
assert.equal(player(f.afterSetup, f.owner).traitors.length, 4);
|
||||
assert.ok(player(f.afterSetup, f.owner).traitors.includes(f.traitorId));
|
||||
assert.ok(player(f.afterSetup, f.opponent).leaders.some(leader => leader.id === f.traitorId));
|
||||
assert.deepEqual(player(f.game, f.owner).traitors, player(f.afterSetup, f.owner).traitors);
|
||||
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama'));
|
||||
assert.equal(Object.keys(player(f.afterSetup, f.richese).forces).length, 0,
|
||||
'Richese never supplies a manufactured starting garrison');
|
||||
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.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.alliance.before.turn, 2); assert.equal(f.alliance.before.phase, 1); assert.equal(f.alliance.before.nexus, true);
|
||||
assert.equal(f.alliance.steps.length, 2); assert.ok(f.alliance.steps.every(step => step.action.type === 'alliance'));
|
||||
assert.equal(player(f.game, f.owner).ally, f.beneficiary); assert.equal(player(f.game, f.beneficiary).ally, f.owner);
|
||||
assert.ok(f.spiceOrder.some(row => row.turn === 2 && 'worm' in row.card));
|
||||
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 === 0));
|
||||
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(f.reveal.steps.filter(step => step.action.type === 'battlePlan').length, 2);
|
||||
assert.equal(f.game.phase, 6); assert.equal(f.game.turn, 2); assert.equal(f.game.battle?.revealed, true);
|
||||
assert.equal(f.game.battle?.plans[f.opponent].leader, f.traitorId);
|
||||
assert.equal([f.game.battle?.attacker, f.game.battle?.defender].includes(f.owner), false);
|
||||
assert.deepEqual(new Set(f.voters), new Set([f.beneficiary, f.opponent, f.owner]));
|
||||
assert.equal(f.votes.steps.length, f.last ? 3 : 1);
|
||||
assert.deepEqual(f.votes.steps.at(-1), { actor: f.owner, action: { type: 'traitorCall', call: true } });
|
||||
assert.equal(f.game.battle?.traitorCalls[f.owner], true);
|
||||
assert.equal(Object.keys(f.game.battle!.traitorCalls).length, f.last ? 3 : 1);
|
||||
assert.equal(f.game.battle?.outcomeWindow, undefined); assert.equal(f.game.lastBattleContext, undefined);
|
||||
assert.ok(f.game.response?.kind === 'harkonnenTraitor'); assert.equal(f.game.response.owner, f.owner);
|
||||
assert.deepEqual(f.game.battle, f.originalBattle); assert.deepEqual(f.game.response, f.originalResponse);
|
||||
assert.equal(f.game.pendingTreacheryDiscard ?? null, null); assert.equal(f.game.decision ?? null, null);
|
||||
assert.equal(f.originalInventory.length, 45); assert.deepEqual(inventory(f.game), f.originalInventory);
|
||||
assert.equal(f.sourceActionCount, f.actions.length);
|
||||
}
|
||||
function open(f: SemutaAlliedTraitorFixture) {
|
||||
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
|
||||
assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaAlliedTraitorDiscard');
|
||||
if (pending.continuation.kind !== 'karamaAlliedTraitorDiscard') throw Error('Use the actual allied Traitor cost');
|
||||
assert.equal(pending.batch.cause, 'karama:harkonnenTraitor'); 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.battle?.traitorCalls[f.owner], true); assert.equal(game.response ?? null, null);
|
||||
assert.deepEqual(viewGame(game, f.holder).battle?.traitorSubmitted, viewGame(f.game, f.holder).battle?.traitorSubmitted);
|
||||
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 canceled(state: Game, f: SemutaAlliedTraitorFixture, claimed: boolean) {
|
||||
assert.equal(state.pendingTreacheryDiscard ?? null, null); assert.equal(state.response ?? null, null);
|
||||
assert.deepEqual(physical(state), physical(f.game)); assert.equal(state.battle?.event, f.originalBattle.event);
|
||||
assert.deepEqual(state.battle?.plans, f.originalBattle.plans);
|
||||
assert.deepEqual(state.battle?.traitorCalls, { ...f.originalBattle.traitorCalls, [f.owner]: false });
|
||||
assert.equal(!!state.battle?.outcomeWindow, f.last,
|
||||
'Only the all-voters branch advances to the real final battle window');
|
||||
assert.equal(state.lastBattleContext, undefined);
|
||||
assert.equal(player(state, f.spender).hand.some(card => card.id === f.cost), false);
|
||||
assert.equal(player(state, f.holder).hand.filter(card => card.id === f.cost).length, Number(claimed));
|
||||
assert.equal(state.discard.filter(card => card.id === f.cost).length, Number(!claimed));
|
||||
assert.equal(player(state, f.holder).hand.filter(card => card.id === f.semuta).length, Number(!claimed));
|
||||
assert.equal(state.discard.filter(card => card.id === f.semuta).length, Number(claimed));
|
||||
assert.deepEqual(player(state, f.owner).traitors, player(f.game, f.owner).traitors);
|
||||
reject(state, f.spender, f.action);
|
||||
assert.deepEqual(inventory(state), f.originalInventory); return state;
|
||||
}
|
||||
function votesThenOutcome(state: Game, f: SemutaAlliedTraitorFixture, prevented: boolean) {
|
||||
const votes: { actor: string; action: Action }[] = [];
|
||||
const ready = advance(state, game => !!game.battle?.outcomeWindow && clean(game), votes);
|
||||
assert.equal(ready.battle?.event, f.originalBattle.event); assert.equal(ready.battle?.revealed, true);
|
||||
assert.deepEqual(ready.battle?.plans, f.originalBattle.plans);
|
||||
assert.equal(ready.battle?.traitorCalls[f.owner], !prevented);
|
||||
assert.equal(votes.filter(step => step.action.type === 'traitorCall').length, f.last ? 0 : 2);
|
||||
assert.ok(votes.every(step => step.action.type === 'traitorCall' && step.action.call === false && step.actor !== f.owner));
|
||||
assert.deepEqual(physical(ready), physical(f.game));
|
||||
const quote = quoteStrongholdFactionsBattle(ready);
|
||||
assert.equal(quote.result, prevented ? 'normal' : 'traitor');
|
||||
assert.equal(quote.winner, prevented ? f.opponent : f.beneficiary);
|
||||
assert.deepEqual(quote.revelations, prevented ? [] : [{ player: f.owner, identity: f.traitorId }]);
|
||||
const steps: { actor: string; action: Action }[] = [], game = finish(ready, steps);
|
||||
assert.equal(game.battle ?? null, null); assert.equal(game.pendingTreacheryDiscard ?? null, null);
|
||||
assert.equal(game.lastBattleContext?.event, f.originalBattle.event);
|
||||
assert.equal(game.lastBattleContext?.result, quote.result); assert.equal(game.lastBattleContext?.winner, quote.winner);
|
||||
assert.equal(steps.filter(step => step.action.type === 'battleOutcomeReady').length, 2);
|
||||
assert.ok(steps.every(step => !['battlePlan', 'traitorCall', 'ship', 'move', 'chooseBattle', 'card'].includes(step.action.type)));
|
||||
const winner = quote.winner!, loser = winner === f.opponent ? f.beneficiary : f.opponent;
|
||||
assert.equal(player(game, winner).forces[f.location], 1); assert.equal(player(game, loser).forces[f.location] ?? 0, 0);
|
||||
assert.equal(player(game, loser).tanks - player(f.game, loser).tanks, 1);
|
||||
assert.equal(player(game, f.opponent).leaders.find(leader => leader.id === f.traitorId)?.dead, !prevented);
|
||||
assert.deepEqual(player(game, f.owner).traitors, player(f.game, f.owner).traitors);
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
reject(game, f.owner, { type: 'traitorCall', call: true });
|
||||
reject(game, f.beneficiary, { type: 'battleOutcomeReady', event: f.originalBattle.event });
|
||||
return game;
|
||||
}
|
||||
|
||||
for (const variant of variants) for (const claimed of [false, true]) void test(
|
||||
`${variant.advanced ? 'Advanced' : 'Basic'} native allied Harkonnen ${variant.last ? 'last' : 'early'} call cost Semuta ${claimed ? 'claim' : 'pass'} preserves revealed plans and changes only that effective vote`, () => {
|
||||
const f = fixture(variant); nativeSource(f); const offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
|
||||
const game = canceled(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
|
||||
reject(game, f.holder, { type: 'semutaCommit', event }); reject(game, f.holder, { type: 'semutaPass', event });
|
||||
votesThenOutcome(game, f, true);
|
||||
},
|
||||
);
|
||||
|
||||
void test('native allied Traitor allowance waits missing voters then settles the real held identity as a Traitor win', () => {
|
||||
for (const variant of variants) {
|
||||
const f = fixture(variant); nativeSource(f); const game = allow(structuredClone(f.game));
|
||||
assert.deepEqual(physical(game), physical(f.game)); assert.equal(game.battle?.traitorCalls[f.owner], true);
|
||||
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));
|
||||
votesThenOutcome(game, f, false);
|
||||
}
|
||||
});
|
||||
|
||||
void test('fresh allied-call costs reject stale and underlying controls while the original declaration remains true', () => {
|
||||
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.holder, { type: 'semutaSelect', event, card: f.traitorId });
|
||||
reject(game, f.owner, { type: 'traitorCall', call: false }); reject(game, f.owner, { type: 'passResponse' });
|
||||
reject(game, f.beneficiary, f.plans[f.beneficiary]); reject(game, f.opponent, f.plans[f.opponent]);
|
||||
for (const voter of f.voters) reject(game, voter, { type: 'traitorCall', call: false });
|
||||
reject(game, f.beneficiary, { type: 'battleOutcomeReady', event: f.originalBattle.event });
|
||||
assert.deepEqual(game.battle, f.originalBattle); assert.deepEqual(physical(game), physical(f.game));
|
||||
canceled(pass(game), f, false);
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal profiles legally answer original Basic and Advanced allied-Traitor used 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 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);
|
||||
const ready = canceled(game, f, claimed);
|
||||
const final = advance(ready, state => !!state.battle?.outcomeWindow && clean(state));
|
||||
assert.equal(quoteStrongholdFactionsBattle(final).result, 'normal');
|
||||
assert.equal(final.battle?.traitorCalls[f.owner], false);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
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 {
|
||||
createSemutaHarkonnenBonusFixture as fixture, actSemutaHarkonnenBonus as act,
|
||||
passSemutaHarkonnenBonus as pass, claimSemutaHarkonnenBonus as claim,
|
||||
allowSemutaHarkonnenBonus as allow, finishSemutaHarkonnenBonus as finish,
|
||||
semutaHarkonnenBonusPlayer as player, semutaHarkonnenBonusInventory as inventory,
|
||||
type SemutaHarkonnenBonusFixture,
|
||||
} from './fixture-semuta-harkonnen-bonus';
|
||||
|
||||
const variants = [
|
||||
{ kind: 'normal' as const, advanced: false },
|
||||
{ kind: 'normal' as const, advanced: true },
|
||||
{ kind: 'blackMarket' as const, advanced: true },
|
||||
{ kind: 'ambassador' as const, advanced: false },
|
||||
];
|
||||
const resources = (game: Game) => ({ turn: game.turn, phase: game.phase, storm: game.storm,
|
||||
spice: game.spice, spiceDeck: game.spiceDeck, spiceDiscard: game.spiceDiscard,
|
||||
players: game.players.map(player => ({ id: player.id, spice: player.spice, bribes: player.bribes,
|
||||
forces: player.forces, reserves: player.reserves, tanks: player.tanks, elites: player.elites,
|
||||
leaders: player.leaders, ally: player.ally, allySinceTurn: player.allySinceTurn,
|
||||
shipped: player.shipped, moved: player.moved, revived: player.revived })) });
|
||||
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: SemutaHarkonnenBonusFixture) {
|
||||
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.semutaPreview, true); assert.equal(f.game.advanced, f.advanced);
|
||||
assert.ok(f.beforeStartingDeal.players.every(player => player.hand.length === 0));
|
||||
assert.equal(player(f.afterSetup, f.owner).hand.length, 2);
|
||||
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(player(f.purchase.before, f.owner).spice - player(f.game, f.owner).spice, f.paid);
|
||||
assert.equal(player(f.game, f.owner).hand.length, player(f.purchase.before, f.owner).hand.length + 1);
|
||||
assert.equal(player(f.game, f.owner).hand.filter(card => card.id === f.purchased).length, 1);
|
||||
assert.equal(f.game.deck[0].id, f.bonus.id); assert.equal(player(f.game, f.owner).hand.some(card => card.id === f.bonus.id), false);
|
||||
assert.notEqual(f.purchased, f.cost); assert.notEqual(f.bonus.id, f.cost);
|
||||
assert.ok(player(f.game, f.holder).hand.some(card => card.id === f.semuta));
|
||||
assert.ok(player(f.game, f.spender).hand.some(card => card.id === f.cost));
|
||||
assert.equal(f.game.response?.kind, 'harkonnenBonus'); assert.equal(f.game.response?.owner, f.owner);
|
||||
assert.equal(f.game.decision ?? null, null); assert.equal(f.game.phaseOpening ?? null, null);
|
||||
assert.equal(f.game.pendingTreacheryDiscard ?? null, null);
|
||||
if (f.kind === 'ambassador') {
|
||||
assert.equal(f.game.turn, 1); assert.equal(f.game.phase, 5); assert.equal(f.paid, 3);
|
||||
assert.ok(f.packetCreation && f.placements && f.alliance && f.arrival && f.originalEntry);
|
||||
assert.equal(f.placements.steps.filter(step => step.actor === f.ecaz && step.action.type === 'decision' &&
|
||||
typeof step.action.token === 'string').length, 2);
|
||||
assert.ok(f.placements.steps.some(step => step.action.type === 'passResponse'));
|
||||
assert.equal(player(f.placements.before, f.ecaz).spice - player(f.placements.after, f.ecaz).spice, 3,
|
||||
'Two actually allowed placements charge their successive native prices1+2');
|
||||
assert.equal(f.placements.after.ecazAmbassadors?.tokens.find(token => token.effect === 'ecaz')?.location, 'sietch_tabr');
|
||||
assert.equal(f.placements.after.ecazAmbassadors?.tokens.find(token => token.effect === 'richese')?.location, 'habbanya_ridge_sietch');
|
||||
assert.equal(f.originalSale, null); assert.equal(f.originalAuction, null);
|
||||
assert.equal(f.originalEntry.effect, 'richese'); assert.equal(f.originalEntry.entrant, f.spender);
|
||||
assert.equal(f.originalEntry.owner, f.ecaz); assert.equal(f.originalEntry.beneficiary, f.owner);
|
||||
assert.equal(f.originalEntry.stage, 'bonus');
|
||||
assert.deepEqual(f.originalEntry.purchaseReceipt, { buyer: f.owner, card: f.purchased,
|
||||
amount: 3, emperor: f.spender, stage: 'bonus' });
|
||||
assert.equal(f.game.response?.source, 'ambassador'); assert.equal(f.game.response?.intent, f.originalEntry.event);
|
||||
assert.equal(f.game.active, f.spender); assert.equal(player(f.game, f.spender).shipped, true);
|
||||
assert.equal(player(f.game, f.spender).moved, 0);
|
||||
assert.ok(f.alliance.steps.some(step => step.actor === f.owner && step.action.type === 'ship' && step.action.territory === 'sietch_tabr'));
|
||||
assert.ok(f.alliance.steps.some(step => step.actor === f.ecaz && step.action.type === 'decision' && step.action.choice === 'alliance'));
|
||||
assert.ok(f.alliance.steps.some(step => step.actor === f.owner && step.action.type === 'decision' && step.action.accept === true));
|
||||
assert.equal(player(f.game, f.owner).ally, f.ecaz); assert.equal(player(f.game, f.ecaz).ally, f.owner);
|
||||
assert.equal(player(f.game, f.spender).ally, null);
|
||||
assert.equal(player(f.game, f.owner).forces['sietch_tabr:14'], 1);
|
||||
assert.equal(player(f.game, f.spender).forces['habbanya_ridge_sietch:17'], 1);
|
||||
assert.equal(player(f.purchase.before, f.spender).spice + 3, player(f.game, f.spender).spice,
|
||||
'The separate original Emperor income has already completed');
|
||||
} else {
|
||||
assert.equal(f.game.phase, 3); assert.equal(f.paid, 1); assert.equal(f.originalEntry, null);
|
||||
assert.ok(f.originalSale); assert.equal(f.originalSale.winner, f.owner);
|
||||
assert.equal(f.originalSale.amount, 1); assert.equal(f.originalSale.free, false);
|
||||
assert.equal(f.originalSale.origin, f.kind); assert.notEqual(f.game.response?.source, 'ambassador');
|
||||
if (f.kind === 'normal') {
|
||||
assert.equal(f.game.turn, 1); assert.equal(f.blackMarket, null); assert.ok(f.originalAuction);
|
||||
assert.equal(f.originalAuction.index, 0); assert.equal(f.originalAuction.cards[0].id, f.purchased);
|
||||
assert.ok(f.purchase.steps.some(step => step.actor === f.owner && step.action.type === 'bid' && step.action.amount === 1));
|
||||
} else {
|
||||
assert.equal(f.game.turn, 2); assert.equal(f.game.advanced, true); assert.ok(f.blackMarket && f.originalLot);
|
||||
assert.equal(f.originalLot.source, 'blackMarket'); assert.equal(f.originalLot.cardId, f.purchased);
|
||||
assert.ok(f.blackMarket.steps.some(step => step.actor === f.richese && step.action.type === 'decision' && step.action.card === f.purchased));
|
||||
assert.ok(player(f.blackMarket.before, f.richese).hand.some(card => card.id === f.purchased));
|
||||
assert.equal(player(f.game, f.richese).hand.some(card => card.id === f.purchased), false);
|
||||
assert.ok(f.purchase.steps.some(step => step.actor === f.owner && step.action.type === 'richeseBid' && step.action.amount === 1));
|
||||
}
|
||||
}
|
||||
assert.equal(f.sourceActionCount, f.actions.length); assert.equal(f.originalInventory.length, 45);
|
||||
assert.deepEqual(inventory(f.game), f.originalInventory);
|
||||
}
|
||||
function open(f: SemutaHarkonnenBonusFixture) {
|
||||
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
|
||||
assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaBonusDiscard');
|
||||
assert.equal(pending.reaction?.stage, 'offer'); assert.equal(pending.batch.cause, 'karama:harkonnenBonus');
|
||||
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.notEqual(pending.batch.entries[0].card.id, f.purchased);
|
||||
if (pending.continuation.kind !== 'karamaBonusDiscard') throw Error('Use the separate original bonus cancellation 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(resources(game), resources(f.game));
|
||||
assert.deepEqual(game.currentAuctionSale ?? null, f.originalSale);
|
||||
assert.deepEqual(game.auction ?? null, f.originalAuction); assert.deepEqual(game.richeseAuction ?? null, f.originalLot);
|
||||
assert.deepEqual(game.richeseBidding ?? null, f.originalRound); assert.deepEqual(game.pendingAmbassador ?? null, f.originalEntry);
|
||||
assert.deepEqual(game.deck.map(card => card.id), f.originalDeck);
|
||||
assert.equal(player(game, f.owner).hand.filter(card => card.id === f.purchased).length, 1);
|
||||
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 originalTail(game: Game, f: SemutaHarkonnenBonusFixture, bonus: boolean) {
|
||||
game = finish(game, f.kind);
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingKarama ?? null, null);
|
||||
assert.notEqual(game.response?.kind, 'harkonnenBonus');
|
||||
assert.deepEqual(resources(game), resources(f.game));
|
||||
assert.deepEqual(game.deck.map(card => card.id), bonus ? f.originalDeck.slice(1) : f.originalDeck);
|
||||
assert.equal(player(game, f.owner).hand.filter(card => card.id === f.purchased).length, 1);
|
||||
assert.equal(player(game, f.owner).hand.length, player(f.game, f.owner).hand.length + Number(bonus));
|
||||
assert.equal(player(game, f.owner).hand.filter(card => card.id === f.bonus.id).length, Number(bonus));
|
||||
assert.equal(game.discard.some(card => card.id === f.purchased), false);
|
||||
if (f.kind === 'normal') {
|
||||
assert.equal(game.auction?.index, f.originalAuction!.index + 1);
|
||||
assert.deepEqual(game.auction?.cards.map(card => card.id), f.originalAuction!.cards.map(card => card.id));
|
||||
} else if (f.kind === 'blackMarket') {
|
||||
assert.equal(game.decision?.kind, 'richeseDeclaration'); assert.equal(game.decision?.player, f.richese);
|
||||
assert.equal(game.richeseBidding?.blackMarketSold, true);
|
||||
} else {
|
||||
assert.equal(game.pendingAmbassador ?? null, null); assert.equal(game.active, f.spender);
|
||||
assert.deepEqual(game.movementRemaining, f.game.movementRemaining);
|
||||
assert.deepEqual(game.ecazAmbassadors, f.game.ecazAmbassadors);
|
||||
}
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
const snapshot = structuredClone(game); assert.deepEqual(finish(game, f.kind), snapshot);
|
||||
return game;
|
||||
}
|
||||
function prevented(game: Game, f: SemutaHarkonnenBonusFixture, claimed: boolean) {
|
||||
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));
|
||||
return originalTail(game, f, false);
|
||||
}
|
||||
|
||||
for (const variant of variants) for (const claimed of [false, true]) void test(
|
||||
`${variant.advanced ? 'Advanced' : 'Basic'} native ${variant.kind} Harkonnen bonus cost ${claimed ? 'claim' : 'pass'} preserves the paid acquisition and resumes its original tail 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 });
|
||||
reject(game, f.spender, f.action);
|
||||
});
|
||||
|
||||
void test('native normal, Black Market and Ambassador bonus allowances draw exactly the original next card after payment', () => {
|
||||
for (const variant of variants) {
|
||||
const f = fixture(variant); nativeSource(f);
|
||||
const game = originalTail(allow(structuredClone(f.game)), f, true);
|
||||
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 bonus costs reject stale, foreign and underlying actions without redrawing, refunding or retiring the source early', () => {
|
||||
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.kind === 'ambassador' ? f.ecaz : f.owner, f.nativeAction);
|
||||
assert.deepEqual(game.pendingAmbassador ?? null, f.originalEntry);
|
||||
assert.deepEqual(game.currentAuctionSale ?? null, f.originalSale);
|
||||
assert.deepEqual(game.deck.map(card => card.id), f.originalDeck);
|
||||
prevented(pass(game), f, false);
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal policies answer every original paid Harkonnen bonus cancellation source 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,213 @@
|
||||
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 {
|
||||
createSemutaNoFieldCostFixture as fixture, actSemutaNoFieldCost as act,
|
||||
passSemutaNoFieldCost as pass, claimSemutaNoFieldCost as claim,
|
||||
allowSemutaNoFieldCost as allow, finishSemutaNoFieldCost as finish,
|
||||
semutaNoFieldCostPlayer as player, semutaNoFieldCostInventory as inventory,
|
||||
semutaNoFieldCostPhysical as physical, type SemutaNoFieldCostFixture,
|
||||
} from './fixture-semuta-no-field-cost';
|
||||
|
||||
const variants = [
|
||||
{ kind: 'own' as const, advanced: false }, { kind: 'own' as const, advanced: true },
|
||||
{ kind: 'allied' as const, advanced: false }, { kind: 'allied' as const, advanced: true },
|
||||
{ kind: 'ambassador' as const, advanced: false },
|
||||
];
|
||||
const clock = (game: Game) => ({ turn: game.turn, phase: game.phase, active: game.active, ready: game.ready,
|
||||
order: game.order, movementRemaining: game.movementRemaining, storm: game.storm,
|
||||
spiceDeck: game.spiceDeck, spiceDiscard: game.spiceDiscard });
|
||||
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: SemutaNoFieldCostFixture) {
|
||||
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.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.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.owner).spice - player(f.acquisition.before, f.owner).spice, 1);
|
||||
assert.deepEqual(player(f.game, f.owner).noField, f.originalNoField);
|
||||
assert.deepEqual(f.originalNoField.tokens.map(token => token.value).sort((a, b) => a - b), [0, 3, 5]);
|
||||
assert.equal(f.token.value, 5); assert.equal(f.originalNoField.lastShipped, null); assert.equal(f.originalNoField.deployed, null);
|
||||
assert.equal(f.game.response?.kind, 'richeseNoField'); assert.equal(f.game.response?.owner, f.owner);
|
||||
assert.deepEqual(f.game.response, f.originalResponse); assert.deepEqual(f.game.pendingShipment, f.originalShipment);
|
||||
assert.equal(f.game.phase, 5); assert.equal(f.game.turn, f.turn); assert.equal(f.game.phaseOpening ?? null, null);
|
||||
assert.equal(f.game.decision ?? null, null); assert.equal(f.game.pendingTreacheryDiscard ?? null, null);
|
||||
assert.deepEqual(physical(f.game), physical(f.beforeDeclaration));
|
||||
assert.ok(player(f.game, f.holder).hand.some(card => card.id === f.semuta));
|
||||
assert.ok(player(f.game, f.spender).hand.some(card => card.id === f.cost));
|
||||
if (f.kind === 'own') {
|
||||
assert.equal(f.turn, 1); assert.equal(f.alliance, null); assert.equal(f.entry, null);
|
||||
assert.equal(f.originalShipment.noField?.tokenId, f.token.id); assert.equal(f.originalShipment.cost, 2);
|
||||
assert.equal(player(f.game, f.owner).reserves, 20); assert.equal(player(f.game, f.owner).shipped, false);
|
||||
} else {
|
||||
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'));
|
||||
const ally = f.kind === 'ambassador' ? f.ecaz! : f.recipient;
|
||||
assert.equal(player(f.game, f.owner).ally, ally); assert.equal(player(f.game, ally).ally, f.owner);
|
||||
}
|
||||
if (f.kind === 'allied') {
|
||||
assert.equal(f.declaration.steps.length, 2); assert.equal(f.declaration.steps[0].action.type, 'offerRicheseNoField');
|
||||
assert.equal(f.declaration.steps[1].actor, f.recipient); assert.equal(f.declaration.steps[1].action.accept, true);
|
||||
assert.equal(f.originalShipment.alliedNoField?.tokenId, f.token.id); assert.equal(f.originalShipment.amount, 5);
|
||||
assert.equal(f.originalShipment.elite, 0); assert.equal(f.originalShipment.cost, 2);
|
||||
assert.equal(player(f.game, f.recipient).reserves, 20); assert.equal(player(f.game, f.recipient).shipped, false);
|
||||
}
|
||||
if (f.kind === 'ambassador') {
|
||||
assert.equal(f.advanced, false); assert.ok(f.ecaz && f.packetCreation && f.placement && f.entry);
|
||||
assert.equal(f.placement.steps[0].actor, f.ecaz); assert.equal(f.placement.steps[0].action.token, f.guildToken);
|
||||
assert.equal(player(f.placement.before, f.ecaz).spice - player(f.placement.after, f.ecaz).spice, 1);
|
||||
assert.ok(f.priorShipment && f.entrantShipment);
|
||||
assert.ok(f.priorShipment.steps.some(step => step.actor === f.owner && step.action.type === 'ship' && step.action.amount === 1));
|
||||
assert.equal(player(f.priorShipment.before, f.owner).spice - player(f.priorShipment.after, f.owner).spice, 2);
|
||||
assert.equal(player(f.entrantShipment.before, f.emperor).spice - player(f.entrantShipment.after, f.emperor).spice, 1);
|
||||
assert.equal(player(f.game, f.owner).forces['polar_sink:0'], 1); assert.equal(player(f.game, f.owner).reserves, 19);
|
||||
assert.equal(player(f.game, f.owner).shipped, true); assert.equal(player(f.game, f.emperor).forces['carthag:11'], 1);
|
||||
assert.equal(f.game.active, f.emperor); assert.equal(f.entry.effect, 'guild'); assert.equal(f.entry.stage, 'ship');
|
||||
assert.equal(f.originalResponse.source, 'ambassadorNoField'); assert.equal(f.originalShipment.source, 'ambassador');
|
||||
assert.equal(f.originalShipment.ambassadorMarker?.mode, 'own'); assert.equal(f.originalShipment.cost, 0);
|
||||
assert.equal(f.originalShipment.ambassadorEvent, f.entry.event); assert.equal(f.originalShipment.ambassadorMarker?.tokenId, f.token.id);
|
||||
}
|
||||
assert.equal(f.originalInventory.length, 45); assert.deepEqual(inventory(f.game), f.originalInventory);
|
||||
assert.equal(f.sourceActionCount, f.actions.length);
|
||||
}
|
||||
function open(f: SemutaNoFieldCostFixture) {
|
||||
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
|
||||
assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaNoFieldDiscard');
|
||||
if (pending.continuation.kind !== 'karamaNoFieldDiscard') throw Error('Expected the original unpaid No-Field cost');
|
||||
assert.equal(pending.batch.cause, 'karama:richeseNoField'); 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.pendingShipment, f.originalShipment); assert.deepEqual(game.pendingAmbassador ?? null, f.entry);
|
||||
assert.deepEqual(physical(game), f.originalPhysical); assert.deepEqual(clock(game), clock(f.game));
|
||||
assert.equal(player(game, f.owner).noFieldBlockedTurn ?? null, player(f.game, f.owner).noFieldBlockedTurn ?? null);
|
||||
assert.deepEqual(game.richeseAllyBlocked ?? null, f.game.richeseAllyBlocked ?? null);
|
||||
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? 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 denied(state: Game, f: SemutaNoFieldCostFixture, claimed: boolean) {
|
||||
const game = finish(state);
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingShipment ?? null, null);
|
||||
assert.equal(game.response ?? null, null); assert.deepEqual(physical(game), f.originalPhysical);
|
||||
assert.deepEqual(clock(game), clock(f.game)); assert.deepEqual(player(game, f.owner).noField, f.originalNoField);
|
||||
assert.equal(player(game, f.owner).noFieldEvent, f.tokenEvent);
|
||||
if (f.kind === 'own') {
|
||||
assert.equal(player(game, f.owner).noFieldBlockedTurn, game.turn); assert.equal(game.decision ?? null, null);
|
||||
} else if (f.kind === 'allied') {
|
||||
assert.deepEqual(game.richeseAllyBlocked, { turn: game.turn, recipient: f.recipient });
|
||||
assert.equal(game.decision ?? null, null);
|
||||
} else {
|
||||
assert.equal(game.pendingAmbassador?.event, f.entry!.event); assert.equal(game.pendingAmbassador?.stage, 'ship');
|
||||
assert.equal(game.pendingAmbassador?.guildMarkerBlocked, true); assert.equal(game.pendingAmbassador?.guildMarkerRequest, undefined);
|
||||
assert.equal(game.decision?.kind, 'ecazAmbassador'); assert.equal(game.decision?.player, f.owner);
|
||||
assert.equal(player(game, f.owner).noFieldBlockedTurn ?? null, player(f.game, f.owner).noFieldBlockedTurn ?? null);
|
||||
}
|
||||
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));
|
||||
reject(game, f.owner, f.nativeAction); reject(game, f.spender, f.action);
|
||||
const replacement = finish(act(structuredClone(game), f.replacementActor, f.replacementAction));
|
||||
assert.equal(replacement.pendingShipment ?? null, null); assert.equal(replacement.pendingAmbassador ?? null, null);
|
||||
assert.equal(replacement.response ?? null, null); assert.equal(replacement.decision ?? null, null);
|
||||
assert.equal(replacement.phase, 5); assert.equal(replacement.active, f.game.active);
|
||||
assert.equal(player(replacement, f.recipient).forces[f.location], 1);
|
||||
assert.equal(player(replacement, f.recipient).reserves, player(f.game, f.recipient).reserves - 1);
|
||||
assert.equal(player(replacement, f.recipient).elites?.reserves, player(f.game, f.recipient).elites?.reserves);
|
||||
assert.equal(player(replacement, f.recipient).spice, player(f.game, f.recipient).spice - f.shipmentCost);
|
||||
assert.equal(player(replacement, f.guild).spice, player(f.game, f.guild).spice + f.shipmentCost);
|
||||
assert.equal(player(replacement, f.recipient).moved, player(f.game, f.recipient).moved);
|
||||
assert.equal(player(replacement, f.recipient).shipped, true);
|
||||
assert.deepEqual(player(replacement, f.owner).noField, f.originalNoField);
|
||||
if (f.kind === 'ambassador') {
|
||||
assert.equal(player(replacement, f.emperor).forces['carthag:11'], 1);
|
||||
assert.equal(player(replacement, f.emperor).spice, player(f.game, f.emperor).spice);
|
||||
assert.equal(player(replacement, f.owner).forces['polar_sink:0'], 1);
|
||||
}
|
||||
reject(replacement, f.replacementActor, f.replacementAction);
|
||||
assert.deepEqual(inventory(replacement), f.originalInventory); return game;
|
||||
}
|
||||
|
||||
for (const variant of variants) for (const claimed of [false, true]) void test(
|
||||
`${variant.advanced ? 'Advanced' : 'Basic'} native ${variant.kind} No-Field cost Semuta ${claimed ? 'claim' : 'pass'} preserves unpaid stock and earns a real replacement`, () => {
|
||||
const f = fixture(variant); nativeSource(f); const offered = open(f);
|
||||
denied(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
|
||||
},
|
||||
);
|
||||
|
||||
void test('native own allied and independent No-Field allowances commit the original token forces and payment exactly once', () => {
|
||||
for (const variant of variants) {
|
||||
const f = fixture(variant); nativeSource(f); let game = finish(allow(structuredClone(f.game)));
|
||||
assert.equal(game.pendingShipment ?? null, null); assert.equal(game.pendingAmbassador ?? null, null);
|
||||
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
|
||||
assert.equal(game.phase, 5); assert.equal(game.active, f.game.active);
|
||||
assert.equal(player(game, f.recipient).spice, player(f.game, f.recipient).spice - f.shipmentCost);
|
||||
assert.equal(player(game, f.guild).spice, player(f.game, f.guild).spice + f.shipmentCost);
|
||||
assert.equal(player(game, f.owner).noField!.lastShipped, f.token.id);
|
||||
assert.notEqual(player(game, f.owner).noFieldEvent, f.tokenEvent);
|
||||
assert.equal(player(game, f.recipient).moved, player(f.game, f.recipient).moved);
|
||||
if (f.kind === 'allied') {
|
||||
assert.equal(player(game, f.owner).noField!.deployed, null);
|
||||
assert.equal(player(game, f.recipient).forces[f.location], 5);
|
||||
assert.equal(player(game, f.recipient).reserves, player(f.game, f.recipient).reserves - 5);
|
||||
assert.equal(player(game, f.owner).reserves, player(f.game, f.owner).reserves);
|
||||
assert.equal(player(game, f.recipient).shipped, true);
|
||||
} else {
|
||||
assert.deepEqual(player(game, f.owner).noField!.deployed, { tokenId: f.token.id, controller: f.owner,
|
||||
location: { territory: f.territory, sector: f.sector } });
|
||||
assert.deepEqual(player(game, f.owner).forces, player(f.game, f.owner).forces);
|
||||
assert.equal(player(game, f.owner).reserves, player(f.game, f.owner).reserves);
|
||||
assert.equal(player(game, f.owner).shipped, true);
|
||||
if (f.kind === 'own') {
|
||||
game = act(game, f.owner, { type: 'revealNoField', event: player(game, f.owner).noFieldEvent, token: f.token.id });
|
||||
assert.equal(player(game, f.owner).forces[f.location], 5);
|
||||
assert.equal(player(game, f.owner).reserves, player(f.game, f.owner).reserves - 5);
|
||||
assert.equal(player(game, f.owner).spice, player(f.game, f.owner).spice - 2);
|
||||
}
|
||||
}
|
||||
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));
|
||||
reject(game, f.owner, f.nativeAction); assert.deepEqual(inventory(game), f.originalInventory);
|
||||
}
|
||||
});
|
||||
|
||||
void test('native No-Field cost reactions reject stale foreign and underlying replies without selecting or consuming a marker', () => {
|
||||
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, f.nativeAction); reject(game, f.recipient, f.replacementAction);
|
||||
reject(game, f.owner, { type: 'passResponse' }); 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 own allied and independent No-Field cancellation 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