Offer Semuta on native market and allied revival cancellation costs
This commit is contained in:
1 parent
56d55a1565
commit
40c31a46e4
11 files changed
+1367
-11
No files matched your search
@@ -0,0 +1,197 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { quoteFremenRevivalGrant, validateFremenRevivalGrant } from '../game/fremen-revival-grant';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
import {
|
||||
actSemutaDisasterWorm, nextSemutaDisasterWorm, passSemutaDisasterWorm, claimSemutaDisasterWorm,
|
||||
semutaDisasterWormPlayer, semutaDisasterWormInventory,
|
||||
} from './fixture-semuta-disaster-worm';
|
||||
|
||||
export type SemutaFremenGrantCostStep = { actor: string; action: Action };
|
||||
export const actSemutaFremenGrantCost = actSemutaDisasterWorm;
|
||||
export const passSemutaFremenGrantCost = passSemutaDisasterWorm;
|
||||
export const claimSemutaFremenGrantCost = claimSemutaDisasterWorm;
|
||||
export const semutaFremenGrantCostPlayer = semutaDisasterWormPlayer;
|
||||
export const semutaFremenGrantCostInventory = semutaDisasterWormInventory;
|
||||
export const semutaFremenGrantCostClean = (game: Game) => !game.response && !game.phaseOpening && !game.decision &&
|
||||
!game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingRevival;
|
||||
export const semutaFremenGrantCostPhysical = (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, leaders: p.leaders, ally: p.ally,
|
||||
revived: p.revived, freeForcesRevived: p.freeForcesRevived, leaderRevived: p.leaderRevived,
|
||||
shipped: p.shipped, moved: p.moved })),
|
||||
spice: game.spice, revivalFreeIncome: game.revivalFreeIncome, revivalRequests: game.revivalRequests,
|
||||
});
|
||||
|
||||
/** Native setup/readiness, real positive first-Storm dials and declined
|
||||
* ordinary optional controls. No grant, revival or fresh group is chosen. */
|
||||
export function nextSemutaFremenGrantCost(game: Game): SemutaFremenGrantCostStep | null {
|
||||
if (game.pendingTreacheryDiscard || game.truthtrance) return null;
|
||||
if (!game.response && !game.phaseOpening &&
|
||||
(game.decision?.kind === 'revivalStop' || game.decision?.kind === 'choamFreeRevival'))
|
||||
return { actor: game.decision.player, action: { type: 'decision', decline: true } };
|
||||
const step = nextSemutaDisasterWorm(game);
|
||||
if (step?.action.type === 'stormDial' && game.turn === 1)
|
||||
return { actor: step.actor, action: { ...step.action, amount: step.actor === game.stormDialers[0] ? 11 : 0 } };
|
||||
return step;
|
||||
}
|
||||
export const nextSemutaFremenGrantCostStep = nextSemutaFremenGrantCost;
|
||||
export function advanceSemutaFremenGrantCost(game: Game, until: (state: Game) => boolean,
|
||||
actions: SemutaFremenGrantCostStep[] = []): Game {
|
||||
for (let count = 0; count < 2000; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const step = nextSemutaFremenGrantCost(game);
|
||||
assert.ok(step, `Choose the original fresh/private control at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
game = actSemutaFremenGrantCost(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The native Fremen grant source did not reach its finite boundary');
|
||||
}
|
||||
export function allowSemutaFremenGrantCost(game: Game, actions: SemutaFremenGrantCostStep[] = []): Game {
|
||||
assert.equal(game.response?.kind, 'fremenRevivalGrant');
|
||||
for (let count = 0; game.response?.kind === 'fremenRevivalGrant' && count < game.players.length; count++) {
|
||||
const actor = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(actor);
|
||||
game = actSemutaFremenGrantCost(game, actor.id, { type: 'passResponse' }, actions);
|
||||
}
|
||||
assert.notEqual(game.response?.kind, 'fremenRevivalGrant'); return game;
|
||||
}
|
||||
/** Settle only this original grant or an already requested ordinary return;
|
||||
* never ready the phase, resubmit a request or bypass a fresh cost. */
|
||||
export function finishSemutaFremenGrantCost(game: Game, actions: SemutaFremenGrantCostStep[] = []): Game {
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null, 'Choose the original fresh cost first');
|
||||
return advanceSemutaFremenGrantCost(game, semutaFremenGrantCostClean, actions);
|
||||
}
|
||||
|
||||
/** Genuine cache1 Semuta, original printed cost, exposed native casualties,
|
||||
* real Nexus pact and completed intrinsic free return before the grant. */
|
||||
export function createSemutaFremenGrantCostFixture(options: { advanced?: boolean } = {}) {
|
||||
const advanced = options.advanced ?? false, seller = 'richese', holder = 'choam', owner = 'fremen';
|
||||
const recipient = 'emperor', spender = 'guild', territory = 'hagga_basin', sector = 13, location = `${territory}:${sector}`;
|
||||
let game = createGame('SEMUTAFREMENGRANTCOST', newPlayer(seller, 'Richese', 'richese'), advanced, ['choam']);
|
||||
for (const faction of ['choam', 'fremen', 'emperor', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction));
|
||||
const initial = structuredClone(game), actions: SemutaFremenGrantCostStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actSemutaFremenGrantCost(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaFremenGrantCost(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), 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 = semutaFremenGrantCostInventory(game); assert.equal(originalInventory.length, 45);
|
||||
const printed = game.deck.find(card => card.effect === 'karama'), shield = game.deck.find(card => card.kind === 'shield');
|
||||
const ordinary = game.deck.filter(card => card.kind === 'projectile').slice(0, 3); assert.equal(ordinary.length, 3);
|
||||
assert.ok(printed && shield);
|
||||
const selected = [...ordinary, shield, printed], dealOrder = selected.map((card, position) => {
|
||||
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { position, from, card: card.id };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game); reach(state => state.status === 'playing');
|
||||
const afterSetup = structuredClone(game), cost = printed.id;
|
||||
assert.deepEqual(game.players.flatMap(p => p.hand.map(card => card.id)), selected.map(card => card.id));
|
||||
assert.equal(semutaFremenGrantCostPlayer(game, owner).forces['sietch_tabr:14'], 10);
|
||||
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 the original still-unopened ${face} Spice Card`);
|
||||
const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card);
|
||||
spiceOrder.push({ turn: game.turn, position, from, card: structuredClone(card) });
|
||||
}
|
||||
};
|
||||
orderSpice(advanced ? ['funeral_plain', 'broken_land'] : ['funeral_plain']);
|
||||
let beforeBid: Game | undefined, bid: SemutaFremenGrantCostStep | undefined, bidAt = -1;
|
||||
for (let count = 0; count < 900 && !semutaFremenGrantCostPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
|
||||
const step = nextSemutaFremenGrantCost(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(semutaFremenGrantCostPlayer(beforeBid, holder).spice - semutaFremenGrantCostPlayer(game, holder).spice, 1);
|
||||
reach(state => state.turn === 1 && state.phase === 5 && state.active === recipient && semutaFremenGrantCostClean(state));
|
||||
assert.equal(game.storm, 12);
|
||||
const shipment = transition(() => {
|
||||
act(recipient, { type: 'ship', territory, sector, amount: 3, elite: 0, allyPayment: 0 });
|
||||
reach(semutaFremenGrantCostClean); act(recipient, { type: 'endMovement' });
|
||||
});
|
||||
assert.equal(semutaFremenGrantCostPlayer(shipment.before, recipient).spice - semutaFremenGrantCostPlayer(game, recipient).spice, 6);
|
||||
assert.equal(semutaFremenGrantCostPlayer(game, recipient).forces[location], 3);
|
||||
reach(state => state.turn === 2 && state.phase === 0 && semutaFremenGrantCostClean(state));
|
||||
const storm = transition(() => {
|
||||
reach(state => state.turn === 2 && state.phase === 1 && semutaFremenGrantCostClean(state) &&
|
||||
!state.spiceSequence && !state.spiceResolution && !state.spiceWindow && !state.nexus && state.ready.length === 0);
|
||||
});
|
||||
assert.equal(semutaFremenGrantCostPlayer(game, recipient).tanks, 3);
|
||||
assert.equal(semutaFremenGrantCostPlayer(game, recipient).forces[location] ?? 0, 0);
|
||||
orderSpice(['worm', 'the_great_flat', ...(advanced ? ['hagga_basin'] : [])]);
|
||||
reach(state => state.turn === 2 && state.phase === 1 && state.nexus && !state.spiceWindow &&
|
||||
!state.spiceResolution && semutaFremenGrantCostClean(state));
|
||||
const alliance = transition(() => {
|
||||
act(owner, { type: 'alliance', target: recipient }); act(recipient, { type: 'alliance', target: owner });
|
||||
});
|
||||
assert.equal(semutaFremenGrantCostPlayer(game, owner).ally, recipient);
|
||||
assert.equal(semutaFremenGrantCostPlayer(game, recipient).ally, owner);
|
||||
reach(state => state.turn === 2 && state.phase === 4 && semutaFremenGrantCostClean(state));
|
||||
const beforePriorRevival = structuredClone(game), intrinsicBefore = structuredClone(viewGame(game, recipient).revival);
|
||||
assert.equal(intrinsicBefore.freeRemaining, 1); assert.equal(intrinsicBefore.forcesRemaining, 3);
|
||||
const priorRevivalAction: Action = { type: 'revive', amount: 1, elite: 0 };
|
||||
const priorRevival = transition(() => {
|
||||
act(recipient, priorRevivalAction); game = finishSemutaFremenGrantCost(game, actions);
|
||||
});
|
||||
assert.equal(semutaFremenGrantCostPlayer(game, recipient).revived, 1);
|
||||
assert.equal(semutaFremenGrantCostPlayer(game, recipient).freeForcesRevived, 1);
|
||||
assert.equal(semutaFremenGrantCostPlayer(game, recipient).tanks, 2);
|
||||
assert.equal(semutaFremenGrantCostPlayer(game, recipient).spice, 4);
|
||||
const beforeDeclaration = structuredClone(game), intrinsic = structuredClone(viewGame(game, recipient).revival);
|
||||
assert.equal(intrinsic.freeRemaining, 0); assert.equal(intrinsic.forcesRemaining, 2);
|
||||
const nativeAction: Action = { type: 'grantRevival' };
|
||||
const declaration = transition(() => { act(owner, nativeAction); });
|
||||
assert.ok(game.response?.fremenGrant); assert.equal(game.response.kind, 'fremenRevivalGrant');
|
||||
assert.equal(game.response.owner, owner); assert.equal(game.response.recipient, recipient);
|
||||
validateFremenRevivalGrant(game, game.response.fremenGrant);
|
||||
assert.deepEqual(game.response.fremenGrant, quoteFremenRevivalGrant(game, owner));
|
||||
assert.ok(game.revivalRules);
|
||||
assert.equal(game.pendingRevival ?? null, null); assert.equal(game.decision ?? null, null);
|
||||
assert.deepEqual(semutaFremenGrantCostPhysical(game), semutaFremenGrantCostPhysical(beforeDeclaration));
|
||||
const source = structuredClone(game), originalResponse = structuredClone(game.response), fremenGrant = structuredClone(game.response.fremenGrant);
|
||||
const action: Action = { type: 'card', card: cost, mode: 'cancel' };
|
||||
const replacementAction: Action = { type: 'revive', amount: 2, elite: 0 };
|
||||
assert.deepEqual(semutaFremenGrantCostInventory(game), originalInventory);
|
||||
return { game, source, beforeCost: source, advanced, kind: 'fremenRevivalGrant' as const, seller, richese: seller,
|
||||
holder, owner, fremen: owner, recipient, ally: recipient, spender, guild: spender, cost, karama: cost,
|
||||
semuta: SEMUTA_DRUG_ID, action, nativeAction, grantAction: nativeAction, replacementAction,
|
||||
ordinaryRevivalAction: replacementAction, priorRevivalAction, originalResponse, fremenGrant,
|
||||
sourceFremenGrant: fremenGrant, originalRules: structuredClone(game.revivalRules),
|
||||
originalFreeRevival: [...game.freeRevival], originalPhysical: structuredClone(semutaFremenGrantCostPhysical(game)),
|
||||
intrinsicBefore, intrinsic, replacementAmount: 2, deniedCost: 4, allowedCost: 0,
|
||||
territory, sector, location, initial, lobby, setup, initialization, beforeStartingDeal, afterSetup,
|
||||
deal: selected.map(card => card.id), dealOrder, originalInventory, originalSpice, spiceOrder,
|
||||
acquisition, acquired, shipment, storm, alliance, beforePriorRevival, priorRevival, beforeDeclaration, declaration,
|
||||
actions, steps: actions, sourceActions: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
advanced ? 'Original Advanced Semuta initializer on the genuinely ready undealt R/C/F/E/G lobby, main35/cache10, no modules.' : 'Actual public Basic R/C/F/E/G ready/start, original main35/cache10 and no modules.',
|
||||
'Only unopened original starting-card/Spice positions and scalar lotteries are selected. Guild really receives printed Karama; CHOAM really pays cache1 for Semuta. Fremen actually places native ten at Tabr. Actual first-Storm dials11+0 end at12.',
|
||||
'Emperor really pays six for three original normal reserves at exposed Hagga13. The original next Storm crosses13 and kills all three; native Fremen forecast/CHOAM/Guild controls are passed without spending the cancellation card. No casualties or weather cursor are installed.',
|
||||
'An actual original turn2 worm on the empty first Funeral pile earns Nexus. Fremen proposes and Emperor accepts their actual mutual pact. At real Revival, Emperor completes its intrinsic free1, earning revived1/freeUsed1 with two Tanks and four spice left, before Fremen declares the original grant.',
|
||||
'Stop at the typed native fremenRevivalGrant response with no pendingRevival. No played hand, wallet, force, alliance, clock, free rate, return, rules or pending frame is installed. The same later ordinary2 action costs four if denied and is free if allowed; all prior return/usage remains native.',
|
||||
],
|
||||
};
|
||||
}
|
||||
export type SemutaFremenGrantCostFixture = ReturnType<typeof createSemutaFremenGrantCostFixture>;
|
||||
@@ -0,0 +1,191 @@
|
||||
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 { actTruthMovement } from './fixture-truth-movement';
|
||||
import {
|
||||
nextSemutaIxianAuctionStep, semutaIxianAuctionPlayer,
|
||||
semutaIxianAuctionInventory, semutaIxianAuctionClean,
|
||||
passSemutaIxianAuction, claimSemutaIxianAuction,
|
||||
} from './fixture-semuta-ixian-auction';
|
||||
|
||||
export type SemutaIxAllyCostStep = { actor: string; action: Action };
|
||||
export const actSemutaIxAllyCost = actTruthMovement;
|
||||
export const semutaIxAllyCostPlayer = semutaIxianAuctionPlayer;
|
||||
export const semutaIxAllyCostInventory = semutaIxianAuctionInventory;
|
||||
export const semutaIxAllyCostClean = semutaIxianAuctionClean;
|
||||
export const passSemutaIxAllyCost = passSemutaIxianAuction;
|
||||
export const claimSemutaIxAllyCost = claimSemutaIxianAuction;
|
||||
|
||||
/** Consume only live controls. The real ally replacement request and paid bid
|
||||
* remain source-owned actions; fresh physical discards receive actual passes. */
|
||||
export function nextSemutaIxAllyCost(game: Game): SemutaIxAllyCostStep | null {
|
||||
const pending = game.pendingTreacheryDiscard, reaction = pending?.reaction;
|
||||
if (pending) {
|
||||
if (reaction?.stage !== 'offer') return null;
|
||||
const actor = game.players.find(p => !reaction.passed.includes(p.id)); assert.ok(actor);
|
||||
return { actor: actor.id, action: { type: 'semutaPass', event: pending.batch.event } };
|
||||
}
|
||||
if (!game.response && !game.phaseOpening) {
|
||||
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 d = game.decision;
|
||||
if (d?.kind === 'ixAllyCard') return null;
|
||||
if (d?.kind === 'wormRide' || d?.kind === 'wormPlacement')
|
||||
return { actor: d.player, action: { type: 'decision', accept: false } };
|
||||
if (d?.kind === 'wormProtection') return { actor: d.player, action: { type: 'decision', accept: true } };
|
||||
}
|
||||
return nextSemutaIxianAuctionStep(game);
|
||||
}
|
||||
export const nextSemutaIxAllyCostStep = nextSemutaIxAllyCost;
|
||||
export function advanceSemutaIxAllyCost(game: Game, until: (state: Game) => boolean,
|
||||
actions: SemutaIxAllyCostStep[] = []): Game {
|
||||
for (let count = 0; count < 2600; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const next = nextSemutaIxAllyCost(game);
|
||||
assert.ok(next, `Supply the actual Ixian ally purchase choice at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
game = actSemutaIxAllyCost(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The original paid Ixian ally source did not reach its finite boundary');
|
||||
}
|
||||
export function allowSemutaIxAllyCost(game: Game, actions: SemutaIxAllyCostStep[] = []): Game {
|
||||
assert.equal(game.response?.kind, 'ixAllyCard');
|
||||
for (let count = 0; game.response?.kind === 'ixAllyCard' && count < game.players.length; count++) {
|
||||
const actor = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(actor);
|
||||
game = actSemutaIxAllyCost(game, actor.id, { type: 'passResponse' }, actions);
|
||||
}
|
||||
assert.notEqual(game.response?.kind, 'ixAllyCard'); return game;
|
||||
}
|
||||
/** Finish this paid sale only, including a real replacement-card discard
|
||||
* reaction if allowance opened one. Stop before the next actual bid. */
|
||||
export function finishSemutaIxAllyCost(game: Game, actions: SemutaIxAllyCostStep[] = []): Game {
|
||||
return advanceSemutaIxAllyCost(game, state => !state.currentAuctionSale &&
|
||||
!state.pendingIxAlly && semutaIxAllyCostClean(state), actions);
|
||||
}
|
||||
|
||||
/** Original R/C/I/F/G setup, cache1, actual worm Nexus pact and ordinary paid
|
||||
* auction. Neither the delivered card nor a replacement is installed. */
|
||||
export function createSemutaIxAllyCostFixture(options: { advanced?: boolean } = {}) {
|
||||
const advanced = options.advanced ?? false;
|
||||
const richese = 'richese', holder = 'choam', owner = 'ixians', buyer = 'fremen', spender = 'guild';
|
||||
let game = createGame('SEMUTAIXALLYCOST', newPlayer(richese, 'Richese', 'richese'), advanced, ['choam', 'ix']);
|
||||
for (const faction of ['choam', 'ixians', 'fremen', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction));
|
||||
const initial = structuredClone(game), actions: SemutaIxAllyCostStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actSemutaIxAllyCost(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaIxAllyCost(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));
|
||||
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
|
||||
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
|
||||
const originalInventory = semutaIxAllyCostInventory(game); assert.equal(originalInventory.length, 57);
|
||||
const printed = game.deck.find(card => card.effect === 'karama' && !card.id.startsWith('ix-'));
|
||||
const shield = game.deck.find(card => card.kind === 'shield');
|
||||
const ordinary = game.deck.filter(card => card.kind === 'projectile' && !card.effect).slice(0, 3);
|
||||
assert.ok(printed && shield); assert.equal(ordinary.length, 3);
|
||||
const selected = [ordinary[0], ordinary[1], shield, ordinary[2], printed];
|
||||
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 = printed.id;
|
||||
const setupChoices = transition(() => { reach(state => state.status === 'playing'); });
|
||||
const afterSetup = structuredClone(game);
|
||||
assert.deepEqual(game.players.flatMap(p => p.hand.map(card => card.id)), selected.map(card => card.id));
|
||||
assert.ok(semutaIxAllyCostPlayer(game, spender).hand.some(card => card.id === cost));
|
||||
assert.ok(semutaIxAllyCostPlayer(game, owner).hand.some(card => card.id === shield.id));
|
||||
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, `Select 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(advanced ? ['funeral_plain', 'hagga_basin'] : ['funeral_plain']);
|
||||
let beforeBid: Game | undefined, bid: SemutaIxAllyCostStep | undefined, bidAt = -1;
|
||||
for (let count = 0; count < 1000 && !semutaIxAllyCostPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
|
||||
const next = nextSemutaIxAllyCost(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(semutaIxAllyCostPlayer(beforeBid, holder).spice - semutaIxAllyCostPlayer(game, holder).spice, 1);
|
||||
reach(state => state.turn === 2 && state.phase === 1 && semutaIxAllyCostClean(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 && semutaIxAllyCostClean(state));
|
||||
const alliance = transition(() => {
|
||||
act(owner, { type: 'alliance', target: buyer }); act(buyer, { type: 'alliance', target: owner });
|
||||
});
|
||||
assert.equal(semutaIxAllyCostPlayer(game, owner).ally, buyer);
|
||||
assert.equal(semutaIxAllyCostPlayer(game, buyer).ally, owner);
|
||||
const beforeBidding = structuredClone(game), biddingAt = actions.length;
|
||||
reach(state => state.turn === 2 && state.phase === 3 && !!state.auction &&
|
||||
state.auction.index === 0 && state.auction.active === buyer && semutaIxAllyCostClean(state));
|
||||
const pool = { before: beforeBidding, steps: structuredClone(actions.slice(biddingAt)), after: structuredClone(game) };
|
||||
let paymentBefore: Game | null = null, paymentAt = -1;
|
||||
const beforePurchase = structuredClone(game), purchaseAt = actions.length;
|
||||
const awaitingReplacement = (state: Game) => state.decision?.kind === 'ixAllyCard';
|
||||
act(buyer, { type: 'bid', amount: 1, allyPayment: 0 });
|
||||
for (let count = 0; !awaitingReplacement(game) && count < 100; count++) {
|
||||
const before = structuredClone(game), at = actions.length;
|
||||
if (!game.response && !game.phaseOpening && game.decision?.kind === 'auctionPayment') {
|
||||
assert.equal(game.decision.player, buyer); act(buyer, { type: 'decision', karama: false });
|
||||
} else {
|
||||
const next = nextSemutaIxAllyCost(game); assert.ok(next); act(next.actor, next.action);
|
||||
}
|
||||
if (awaitingReplacement(game)) { paymentBefore = before; paymentAt = at; }
|
||||
}
|
||||
assert.equal(game.decision?.kind, 'ixAllyCard');
|
||||
const purchase = { before: beforePurchase, steps: structuredClone(actions.slice(purchaseAt)), after: structuredClone(game) };
|
||||
assert.ok(paymentBefore && paymentAt >= 0);
|
||||
const payment = { before: paymentBefore, steps: structuredClone(actions.slice(paymentAt)), after: structuredClone(game) };
|
||||
assert.equal(semutaIxAllyCostPlayer(payment.before, buyer).spice - semutaIxAllyCostPlayer(game, buyer).spice, 1);
|
||||
assert.ok(game.pendingIxAlly && game.currentAuctionSale && game.auction);
|
||||
const deliveredCard = semutaIxAllyCostPlayer(game, buyer).hand.find(card => card.id === game.pendingIxAlly!.card);
|
||||
assert.ok(deliveredCard); assert.equal(game.currentAuctionSale.origin, 'normal');
|
||||
const requestAction: Action = { type: 'decision', accept: true };
|
||||
const request = transition(() => { act(buyer, requestAction); });
|
||||
assert.ok(game.response?.kind === 'ixAllyCard'); assert.equal(game.response.owner, owner);
|
||||
assert.equal(game.response.recipient, buyer); assert.equal(game.decision ?? null, null);
|
||||
assert.ok(game.deck[0]); assert.deepEqual(semutaIxAllyCostInventory(game), originalInventory);
|
||||
return { game, kind: 'ixAllyCard' as const, advanced, blackMarket: false as const,
|
||||
owner, holder, spender, buyer, recipient: buyer, richese, cost, karama: cost, semuta: SEMUTA_DRUG_ID,
|
||||
action: { type: 'card', card: cost, mode: 'cancel' } as Action,
|
||||
originalResponse: structuredClone(game.response), originalPending: structuredClone(game.pendingIxAlly!),
|
||||
originalSale: structuredClone(game.currentAuctionSale!), originalAuction: structuredClone(game.auction!),
|
||||
originalDeck: structuredClone(game.deck), deliveredCard: structuredClone(deliveredCard), deliveredCardId: deliveredCard.id,
|
||||
replacementTop: structuredClone(game.deck[0]), requestAction, initial, lobby, setup, beforeStartingDeal,
|
||||
afterSetup, setupChoices, dealOrder, deal: selected.map(card => card.id), originalInventory, originalSpice, spiceOrder,
|
||||
acquisition, acquired, cache: acquisition, alliance, pact: alliance, pool, purchase, payment, request,
|
||||
actions, steps: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
'Original public Basic start or original undealt Advanced initializer for R/C/I/F/G with native CHOAM/Ix decks. Only unopened deal/Spice positions and scalar lotteries are selected. Native Ix chooses its inspected Shield; Guild receives the printed Karama; CHOAM actually pays cache1 for Semuta.',
|
||||
'Original first-turn movement is unused. A real turn2 worm over the empty Funeral pile opens Nexus; Ix and Fremen actually propose and accept their pact. Native HMS, pool-return, optional Technology decline and other original controls remain real actions.',
|
||||
'Fremen bids and pays one own spice for an actual ordinary auction card. There is no Emperor seat, so the printed payment goes to the Bank. The card is delivered into the buyer hand and native pendingIxAlly/currentAuctionSale remain; Fremen really requests replacement before the cancellation cost.',
|
||||
'No played hand, wallet, card, ally, force, clock, pool, purchase, pending control or replacement is installed. Cache replacement and absent-Ix Secret Ally are outside these ordinary native sources.',
|
||||
] };
|
||||
}
|
||||
export type SemutaIxAllyCostFixture = ReturnType<typeof createSemutaIxAllyCostFixture>;
|
||||
@@ -0,0 +1,198 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createGame, initializeSemutaGameForAudit, joinGame, newPlayer, type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { quoteChoamSaleCancellation } from '../game/choam-sale-cancellation';
|
||||
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 SemutaNativeMarketsKind = 'choamSale' | 'richeseBlackMarket';
|
||||
export type SemutaNativeMarketsStep = { actor: string; action: Action };
|
||||
export const actSemutaNativeMarkets = actSemutaRicheseCacheKarama;
|
||||
export const passSemutaNativeMarkets = passSemutaFremenGuildMovement;
|
||||
export const claimSemutaNativeMarkets = claimSemutaFremenGuildMovement;
|
||||
export const semutaNativeMarketsPlayer = semutaRicheseCacheKaramaPlayer;
|
||||
export const semutaNativeMarketsInventory = semutaRicheseCacheKaramaInventory;
|
||||
export const semutaNativeMarketsClean = semutaRicheseCacheKaramaClean;
|
||||
|
||||
/** Native no-spending programme; the selected market response/fresh batch stays
|
||||
* explicit. Original early markets are actually closed before cache acquisition. */
|
||||
export function nextSemutaNativeMarkets(game: Game): SemutaNativeMarketsStep | null {
|
||||
if (game.pendingTreacheryDiscard || game.truthtrance ||
|
||||
['choamSale', 'richeseBlackMarket'].includes(game.response?.kind ?? '')) 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 advanceSemutaNativeMarkets(game: Game, until: (state: Game) => boolean,
|
||||
actions: SemutaNativeMarketsStep[] = []): Game {
|
||||
for (let count = 0; count < 1800; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const step = nextSemutaNativeMarkets(game); assert.ok(step, 'Choose the actual native market or fresh-discard response');
|
||||
game = actSemutaNativeMarkets(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The genuine native-market programme did not reach its finite boundary');
|
||||
}
|
||||
export function allowSemutaNativeMarkets(game: Game, actions: SemutaNativeMarketsStep[] = []): Game {
|
||||
const kind = game.response?.kind; assert.ok(kind === 'choamSale' || kind === 'richeseBlackMarket');
|
||||
for (let count = 0; count < game.players.length && game.response?.kind === kind; count++) {
|
||||
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
|
||||
game = actSemutaNativeMarkets(game, actor.id, { type: 'passResponse' }, actions);
|
||||
}
|
||||
assert.notEqual(game.response?.kind, kind); return game;
|
||||
}
|
||||
/** Finish only a positive sale's actual fresh discard, or the accepted original
|
||||
* Black Market's real zero bids. Keep the next CHOAM/compulsory-cache chooser. */
|
||||
export function finishSemutaNativeMarkets(game: Game, kind: SemutaNativeMarketsKind,
|
||||
actions: SemutaNativeMarketsStep[] = []): Game {
|
||||
if (kind === 'choamSale' && game.pendingTreacheryDiscard) {
|
||||
assert.equal(game.pendingTreacheryDiscard.continuation.kind, 'choamSaleDiscard');
|
||||
assert.equal(game.pendingTreacheryDiscard.batch.cause, 'choamSale');
|
||||
game = passSemutaNativeMarkets(game, actions);
|
||||
}
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null, 'Resolve the original cancellation cost first');
|
||||
assert.notEqual(game.response?.kind, kind);
|
||||
if (kind === 'richeseBlackMarket') {
|
||||
const event = game.richeseAuction?.event;
|
||||
for (let count = 0; event && game.richeseAuction?.event === event && count < 12; count++) {
|
||||
const lot = game.richeseAuction;
|
||||
assert.equal(lot.source, 'blackMarket'); assert.equal(game.response ?? null, null);
|
||||
assert.equal(game.decision ?? null, null); assert.ok(lot.active); assert.equal(lot.bid, 0);
|
||||
game = actSemutaNativeMarkets(game, lot.active, { type: 'richeseBid', event, amount: null }, actions);
|
||||
}
|
||||
assert.equal(game.richeseAuction ?? null, null); assert.equal(game.decision?.kind, 'richeseDeclaration');
|
||||
} else {
|
||||
assert.equal(game.decision?.kind, 'choamMarket'); assert.equal(game.choamMarket?.sale, undefined);
|
||||
}
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Public Basic/original Advanced R/C/E source. Duplicate sales obtain their
|
||||
* witness by an actual paid normal auction, not a played hand assignment. */
|
||||
export function createSemutaNativeMarketsFixture(options: {
|
||||
kind: SemutaNativeMarketsKind; advanced?: boolean; worthless?: boolean;
|
||||
}) {
|
||||
const kind = options.kind, advanced = options.advanced ?? kind === 'richeseBlackMarket';
|
||||
const worthless = options.worthless ?? false;
|
||||
assert.ok(kind !== 'richeseBlackMarket' || advanced && !worthless);
|
||||
const richese = 'richese', holder = 'choam', spender = 'emperor', owner = kind === 'choamSale' ? holder : richese;
|
||||
let game = createGame('SEMUTANATIVEMARKETS', newPlayer(richese, 'Richese', 'richese'), advanced, ['choam']);
|
||||
for (const faction of [holder, spender] as const) joinGame(game, newPlayer(faction, faction, faction));
|
||||
const initial = structuredClone(game), actions: SemutaNativeMarketsStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actSemutaNativeMarkets(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaNativeMarkets(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 = semutaNativeMarketsInventory(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 duplicate = game.deck.find(card => card.kind === 'shield' && !card.effect &&
|
||||
game.deck.some(other => other.id !== card.id && other.name === card.name)); assert.ok(duplicate);
|
||||
const twin = game.deck.find(card => card.id !== duplicate.id && card.name === duplicate.name); assert.ok(twin);
|
||||
const filler = game.deck.find(card => card.kind === 'projectile' && !card.effect); assert.ok(filler);
|
||||
const junk = worthless ? game.deck.find(card => card.kind === 'worthless') : null;
|
||||
if (worthless) assert.ok(junk);
|
||||
const saleCard = kind === 'choamSale' ? worthless ? junk!.id : duplicate.id : duplicate.id;
|
||||
const witness = kind === 'choamSale' && !worthless ? twin.id : undefined;
|
||||
const selected = kind === 'richeseBlackMarket' ? [duplicate.id, filler.id, printed.id]
|
||||
: [filler.id, saleCard, printed.id, ...(witness ? [witness] : [])];
|
||||
const dealOrder = selected.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), cost = printed.id;
|
||||
reach(state => state.status === 'playing'); const afterSetup = structuredClone(game);
|
||||
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), selected.slice(0, 3));
|
||||
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 === 'richeseBlackMarket' ? 4 : 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: SemutaNativeMarketsStep | undefined, bidAt = -1;
|
||||
for (let count = 0; count < 900 && !semutaNativeMarketsPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
|
||||
const step = nextSemutaNativeMarkets(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(semutaNativeMarketsPlayer(beforeBid, holder).spice - semutaNativeMarketsPlayer(game, holder).spice, 1);
|
||||
assert.equal(semutaNativeMarketsPlayer(game, richese).spice - semutaNativeMarketsPlayer(beforeBid, richese).spice, 1);
|
||||
let normalPurchase: ReturnType<typeof transition> | null = null;
|
||||
if (witness) {
|
||||
reach(state => state.phase === 3 && semutaNativeMarketsClean(state) && !!state.auction &&
|
||||
state.auction.index === 0 && state.auction.active === holder && state.auction.bid === 0);
|
||||
assert.equal(game.auction!.cards[0].id, witness);
|
||||
normalPurchase = transition(() => {
|
||||
act(holder, { type: 'bid', amount: 1, allyPayment: 0 });
|
||||
reach(state => semutaNativeMarketsClean(state) && semutaNativeMarketsPlayer(state, holder).hand.some(card => card.id === witness));
|
||||
});
|
||||
assert.equal(semutaNativeMarketsPlayer(normalPurchase.before, holder).spice - semutaNativeMarketsPlayer(game, holder).spice, 1);
|
||||
assert.equal(semutaNativeMarketsPlayer(game, holder).hand.filter(card => card.id === witness).length, 1);
|
||||
}
|
||||
let nativeAction: Action;
|
||||
if (kind === 'choamSale') {
|
||||
reach(state => state.turn === 1 && state.phase === 3 && !state.response && !state.phaseOpening && state.decision?.kind === 'choamMarket');
|
||||
nativeAction = { type: 'decision', mode: 'sell', card: saleCard, ...(witness ? { witness } : {}) };
|
||||
} else {
|
||||
reach(state => state.turn === 2 && state.phase === 3 && !state.response && !state.phaseOpening && state.decision?.kind === 'richeseBlackMarket');
|
||||
nativeAction = { type: 'decision', event: game.richeseBidding!.event, card: saleCard,
|
||||
method: 'normal', direction: 'clockwise' };
|
||||
}
|
||||
const beforeDeclaration = structuredClone(game), declaration = transition(() => { act(owner, nativeAction); });
|
||||
assert.equal(game.response?.kind, kind); assert.equal(game.response?.owner, owner);
|
||||
assert.equal(game.decision ?? null, null); assert.equal(game.phaseOpening ?? null, null);
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null);
|
||||
assert.deepEqual(semutaNativeMarketsPlayer(game, owner), semutaNativeMarketsPlayer(beforeDeclaration, owner));
|
||||
assert.ok(semutaNativeMarketsPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
|
||||
assert.ok(semutaNativeMarketsPlayer(game, spender).hand.some(card => card.id === cost));
|
||||
const source = structuredClone(game), originalResponse = structuredClone(game.response!);
|
||||
const originalMarket = structuredClone(game.choamMarket ?? null), originalLot = structuredClone(game.richeseAuction ?? null);
|
||||
const originalRound = structuredClone(game.richeseBidding ?? null), originalAuction = structuredClone(game.auction ?? null);
|
||||
const originalDeck = game.deck.map(card => card.id), originalCache = game.richeseCache!.map(card => card.id);
|
||||
const sale = structuredClone(game.choamMarket?.sale ?? null), price = kind === 'choamSale' ? worthless ? 2 : 3 : 0;
|
||||
const expected = kind === 'choamSale' ? quoteChoamSaleCancellation(game, game.response!) : null;
|
||||
const action: Action = { type: 'card', card: cost, mode: 'cancel' };
|
||||
assert.deepEqual(semutaNativeMarketsInventory(game), originalInventory);
|
||||
return { game, source, beforeCost: source, kind, advanced, worthless, richese, holder, spender, owner,
|
||||
cost, semuta: SEMUTA_DRUG_ID, action, nativeAction, originalResponse,
|
||||
saleCard, witness, price, sale, originalMarket, originalLot, originalRound, originalAuction, originalDeck, originalCache, expected,
|
||||
initial, lobby, setup, initialization, beforeStartingDeal, afterSetup, selected, dealOrder, originalSpice, spiceOrder,
|
||||
acquired, acquisition, normalPurchase, beforeDeclaration, declaration,
|
||||
originalInventory, actions, sourceActions: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
'Public Basic start or original Advanced Semuta initializer on a genuinely ready undealt R/C/E lobby, exact CHOAM and no optional modules. Only unopened original starting/next-normal-card/Spice positions and scalar lotteries are selected; original main35/cache10 remain physical. Emperor receives printed Karama and CHOAM buys original cache Semuta for1.',
|
||||
kind === 'richeseBlackMarket' ? 'Original first-turn phases finish through actual no-spending controls. At the genuine turn2 Advanced Black Market, Richese offers its actually dealt Shield using normal bidding. Stop at the original response with card still held, no bids/payment, and compulsory-cache accounting unresolved.' :
|
||||
witness ? 'CHOAM holds its actually dealt Shield. Its first real normal bid1 buys a second exact printed-name physical copy after cache Semuta. At the original closing Bidding market it declares the first card for3 with that genuinely acquired witness; neither card has moved or produced revenue.' :
|
||||
'CHOAM retains its actually dealt Worthless through earlier native markets. After real cache Semuta and original normal Bidding, it declares that original Worthless for2 at the closing market, with no witness or pre-applied discard/payout.',
|
||||
'Stop before the separate original printed cancellation cost. Denial preserves original held sale/Black Market card and revenue0, then resumes the next native market/declaration. Positive CHOAM sale honors its existing actual fresh-discard window; accepted Black Market finishes only real zero bids, not a fabricated outcome or purchase.',
|
||||
] };
|
||||
}
|
||||
export type SemutaNativeMarketsFixture = ReturnType<typeof createSemutaNativeMarketsFixture>;
|
||||
@@ -0,0 +1,167 @@
|
||||
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 { quoteFremenRevivalGrant, validateFremenRevivalGrant } from '../game/fremen-revival-grant';
|
||||
import {
|
||||
createSemutaFremenGrantCostFixture as fixture, actSemutaFremenGrantCost as act,
|
||||
passSemutaFremenGrantCost as pass, claimSemutaFremenGrantCost as claim,
|
||||
allowSemutaFremenGrantCost as allow, finishSemutaFremenGrantCost as finish,
|
||||
semutaFremenGrantCostPlayer as player, semutaFremenGrantCostInventory as inventory,
|
||||
semutaFremenGrantCostPhysical as physical, type SemutaFremenGrantCostFixture,
|
||||
} from './fixture-semuta-fremen-grant-cost';
|
||||
|
||||
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: SemutaFremenGrantCostFixture) {
|
||||
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(player(f.afterSetup, f.owner).forces['sietch_tabr:14'], 10);
|
||||
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.seller).spice - player(f.acquisition.before, f.seller).spice, 1);
|
||||
assert.equal(f.shipment.before.storm, 12);
|
||||
assert.equal(player(f.shipment.before, f.recipient).spice - player(f.shipment.after, f.recipient).spice, 6);
|
||||
assert.ok(f.shipment.steps.some(step => step.actor === f.recipient && step.action.type === 'ship' && step.action.amount === 3 && step.action.elite === 0));
|
||||
assert.equal(player(f.shipment.after, f.recipient).forces[f.location], 3);
|
||||
assert.equal(f.storm.before.storm, 12); assert.ok(f.storm.after.storm >= 13 && f.storm.after.storm <= 18);
|
||||
assert.equal(player(f.storm.before, f.recipient).tanks, 0); assert.equal(player(f.storm.after, f.recipient).tanks, 3);
|
||||
assert.equal(player(f.storm.after, f.recipient).forces[f.location] ?? 0, 0);
|
||||
assert.equal(f.alliance.steps.length, 2); assert.ok(f.alliance.steps.every(step => step.action.type === 'alliance'));
|
||||
assert.equal(f.alliance.before.nexus, true); assert.equal(player(f.game, f.owner).ally, f.recipient);
|
||||
assert.equal(player(f.game, f.recipient).ally, f.owner);
|
||||
assert.equal(f.intrinsicBefore.freeRemaining, 1); assert.equal(f.intrinsicBefore.forcesRemaining, 3);
|
||||
assert.equal(f.priorRevival.steps[0].actor, f.recipient); assert.deepEqual(f.priorRevival.steps[0].action, f.priorRevivalAction);
|
||||
assert.equal(player(f.priorRevival.before, f.recipient).spice, player(f.priorRevival.after, f.recipient).spice);
|
||||
assert.equal(player(f.priorRevival.after, f.recipient).revived, 1);
|
||||
assert.equal(player(f.priorRevival.after, f.recipient).freeForcesRevived, 1);
|
||||
assert.equal(player(f.game, f.recipient).tanks, 2); assert.equal(player(f.game, f.recipient).reserves, 18);
|
||||
assert.equal(player(f.game, f.recipient).spice, 4); assert.equal(player(f.game, f.recipient).revived, 1);
|
||||
assert.equal(player(f.game, f.recipient).freeForcesRevived, 1);
|
||||
assert.equal(f.intrinsic.freeRemaining, 0); assert.equal(f.intrinsic.forcesRemaining, 2);
|
||||
assert.equal(f.game.phase, 4); assert.equal(f.game.turn, 2); assert.equal(f.game.pendingRevival ?? null, null);
|
||||
assert.equal(f.game.decision ?? null, null); assert.equal(f.game.phaseOpening ?? null, null);
|
||||
assert.equal(f.game.response?.kind, 'fremenRevivalGrant'); assert.deepEqual(f.game.response, f.originalResponse);
|
||||
assert.deepEqual(f.fremenGrant, { turn: 2, owner: f.owner, recipient: f.recipient });
|
||||
assert.deepEqual(quoteFremenRevivalGrant(f.game, f.owner), f.fremenGrant); validateFremenRevivalGrant(f.game, f.fremenGrant);
|
||||
assert.deepEqual(f.game.revivalRules, f.originalRules); assert.deepEqual(f.game.freeRevival, f.originalFreeRevival);
|
||||
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));
|
||||
assert.equal(f.originalInventory.length, 45); assert.deepEqual(inventory(f.game), f.originalInventory);
|
||||
assert.equal(f.sourceActionCount, f.actions.length);
|
||||
}
|
||||
function open(f: SemutaFremenGrantCostFixture) {
|
||||
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
|
||||
assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaFremenGrantDiscard');
|
||||
if (pending.continuation.kind !== 'karamaFremenGrantDiscard') throw Error('Expected the original allied grant cost');
|
||||
assert.equal(pending.batch.cause, 'karama:fremenRevivalGrant'); 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.equal(game.response ?? null, null); assert.equal(game.pendingRevival ?? null, null);
|
||||
assert.deepEqual(physical(game), f.originalPhysical); assert.deepEqual(clock(game), clock(f.game));
|
||||
assert.deepEqual(game.revivalRules, f.originalRules); assert.deepEqual(game.freeRevival, f.originalFreeRevival);
|
||||
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 nativeOutcome(state: Game, f: SemutaFremenGrantCostFixture, canceled: boolean) {
|
||||
const game = finish(state);
|
||||
assert.equal(game.response ?? null, null); assert.equal(game.pendingRevival ?? null, null);
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.decision ?? null, null);
|
||||
assert.deepEqual(physical(game), f.originalPhysical); assert.deepEqual(clock(game), clock(f.game));
|
||||
if (canceled) {
|
||||
assert.deepEqual(game.freeRevival, f.originalFreeRevival);
|
||||
assert.deepEqual(game.revivalRules, { ...f.originalRules,
|
||||
fremenGrantBlocked: [...(f.originalRules?.fremenGrantBlocked ?? []), f.recipient] });
|
||||
} else {
|
||||
assert.deepEqual(game.freeRevival, [...f.originalFreeRevival, f.recipient]);
|
||||
assert.deepEqual(game.revivalRules, f.originalRules);
|
||||
}
|
||||
const current = viewGame(game, f.recipient).revival;
|
||||
assert.equal(current.freeRemaining, canceled ? 0 : 2); assert.equal(current.forcesRemaining, 2);
|
||||
assert.equal(current.limit, f.intrinsic.limit);
|
||||
reject(game, f.owner, f.nativeAction);
|
||||
const beforeReturn = structuredClone(game), returned = finish(act(structuredClone(game), f.recipient, f.replacementAction));
|
||||
assert.equal(returned.response ?? null, null); assert.equal(returned.pendingRevival ?? null, null);
|
||||
assert.equal(returned.phase, 4); assert.equal(returned.turn, 2);
|
||||
assert.equal(player(returned, f.recipient).reserves, 20); assert.equal(player(returned, f.recipient).tanks, 0);
|
||||
assert.equal(player(returned, f.recipient).revived, 3);
|
||||
assert.equal(player(returned, f.recipient).freeForcesRevived, canceled ? 1 : 3);
|
||||
assert.equal(player(returned, f.recipient).spice, player(beforeReturn, f.recipient).spice - (canceled ? f.deniedCost : f.allowedCost));
|
||||
assert.deepEqual(player(returned, f.recipient).forces, player(f.game, f.recipient).forces);
|
||||
assert.equal(player(returned, f.recipient).elites?.reserves, player(f.game, f.recipient).elites?.reserves);
|
||||
assert.equal(player(returned, f.recipient).elites?.tanks ?? 0, player(f.game, f.recipient).elites?.tanks ?? 0);
|
||||
for (const p of game.players.filter(p => p.id !== f.recipient)) {
|
||||
assert.equal(player(returned, p.id).spice, p.spice); assert.equal(player(returned, p.id).reserves, p.reserves);
|
||||
assert.equal(player(returned, p.id).tanks, p.tanks); assert.deepEqual(player(returned, p.id).forces, p.forces);
|
||||
}
|
||||
assert.deepEqual(returned.revivalRules, game.revivalRules); assert.deepEqual(returned.freeRevival, game.freeRevival);
|
||||
assert.equal(viewGame(returned, f.recipient).revival.forcesRemaining, 0);
|
||||
reject(returned, f.recipient, f.replacementAction); reject(returned, f.owner, f.nativeAction);
|
||||
assert.deepEqual(inventory(returned), f.originalInventory); return game;
|
||||
}
|
||||
function denied(state: Game, f: SemutaFremenGrantCostFixture, claimed: boolean) {
|
||||
const game = nativeOutcome(state, f, true);
|
||||
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
|
||||
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.cost).length, Number(claimed));
|
||||
assert.equal(game.discard.filter(card => card.id === f.cost).length, Number(!claimed));
|
||||
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.semuta).length, Number(!claimed));
|
||||
assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimed));
|
||||
reject(game, f.spender, f.action); return game;
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) for (const claimed of [false, true]) void test(
|
||||
`${advanced ? 'Advanced' : 'Basic'} native Fremen grant cost Semuta ${claimed ? 'claim' : 'pass'} preserves prior free1 and leaves ordinary paid2 legal`, () => {
|
||||
const f = fixture({ advanced }); nativeSource(f); const offered = open(f);
|
||||
denied(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
|
||||
},
|
||||
);
|
||||
|
||||
void test('native Fremen grant allowance retains prior usage and makes only the two remaining ordinary returns free', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const f = fixture({ advanced }); nativeSource(f); const game = nativeOutcome(allow(structuredClone(f.game)), f, false);
|
||||
assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost));
|
||||
assert.ok(player(game, f.holder).hand.some(card => card.id === f.semuta));
|
||||
}
|
||||
});
|
||||
|
||||
void test('native Fremen grant cost rejects stale foreign and underlying grant or revival replies before permission changes', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const f = fixture({ advanced }), 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 Fremen allied revival-grant costs', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const f = fixture({ advanced }), 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
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 {
|
||||
createSemutaIxAllyCostFixture as fixture, actSemutaIxAllyCost as act,
|
||||
passSemutaIxAllyCost as pass, claimSemutaIxAllyCost as claim,
|
||||
allowSemutaIxAllyCost as allow, finishSemutaIxAllyCost as finish,
|
||||
semutaIxAllyCostPlayer as player, semutaIxAllyCostInventory as inventory,
|
||||
type SemutaIxAllyCostFixture,
|
||||
} from './fixture-semuta-ix-ally-cost';
|
||||
|
||||
const variants = [{ advanced: false }, { advanced: true }];
|
||||
const physical = (game: Game) => ({
|
||||
turn: game.turn, phase: game.phase, storm: game.storm, order: game.order,
|
||||
spice: game.spice, spiceDeck: game.spiceDeck, spiceDiscard: game.spiceDiscard,
|
||||
mobile: game.mobileStronghold, aid: game.aid, offers: game.allianceOffers,
|
||||
players: game.players.map(p => ({ id: p.id, spice: p.spice, forces: p.forces, reserves: p.reserves,
|
||||
tanks: p.tanks, elites: p.elites, leaders: p.leaders, ally: p.ally, allySinceTurn: p.allySinceTurn,
|
||||
moved: p.moved, shipped: p.shipped })),
|
||||
});
|
||||
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: SemutaIxAllyCostFixture) {
|
||||
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', 'ixians', 'fremen', 'guild']);
|
||||
assert.deepEqual(f.afterSetup.players.flatMap(p => p.hand.map(card => card.id)), f.deal);
|
||||
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama'));
|
||||
assert.ok(f.setupChoices.steps.some(step => step.actor === f.owner && step.action.type === 'decision' &&
|
||||
player(f.afterSetup, f.owner).hand.some(card => card.id === step.action.card && card.kind === 'shield')));
|
||||
assert.equal(player(f.afterSetup, f.buyer).forces['sietch_tabr:14'], 10);
|
||||
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.pact.before.turn, 2); assert.equal(f.pact.before.phase, 1); assert.equal(f.pact.before.nexus, true);
|
||||
assert.equal(f.pact.steps.length, 2); assert.ok(f.pact.steps.every(step => step.action.type === 'alliance'));
|
||||
assert.equal(player(f.game, f.owner).ally, f.buyer); assert.equal(player(f.game, f.buyer).ally, f.owner);
|
||||
assert.ok(f.spiceOrder.some(row => row.turn === 2 && 'worm' in row.card));
|
||||
assert.ok(f.pool.steps.some(step => step.actor === f.owner && step.action.type === 'decision' && step.action.position === 'bottom'),
|
||||
'The actual native Ixian pool return precedes the ordinary auction');
|
||||
assert.ok(f.purchase.steps.some(step => step.actor === f.buyer && step.action.type === 'bid' && step.action.amount === 1));
|
||||
assert.equal(player(f.payment.before, f.buyer).spice - player(f.payment.after, f.buyer).spice, 1);
|
||||
assert.ok(f.payment.steps.length > 0);
|
||||
assert.equal(f.originalSale.origin, 'normal'); assert.equal(f.originalSale.winner, f.buyer);
|
||||
assert.equal(f.originalSale.amount, 1); assert.equal(f.originalSale.free, false);
|
||||
assert.equal(f.originalPending.player, f.buyer); assert.equal(f.originalPending.card, f.deliveredCard.id);
|
||||
assert.equal(f.originalPending.free, false);
|
||||
assert.equal(player(f.payment.before, f.buyer).hand.some(card => card.id === f.deliveredCard.id), false);
|
||||
assert.equal(player(f.payment.after, f.buyer).hand.filter(card => card.id === f.deliveredCard.id).length, 1);
|
||||
assert.deepEqual(f.request.steps, [{ actor: f.buyer, action: f.requestAction }]);
|
||||
assert.equal(f.game.response?.kind, 'ixAllyCard'); assert.equal(f.game.response?.owner, f.owner);
|
||||
assert.equal(f.game.response?.recipient, f.buyer); assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 3);
|
||||
assert.equal(f.game.decision ?? null, null); assert.equal(f.game.pendingTreacheryDiscard ?? null, null);
|
||||
assert.deepEqual(f.game.pendingIxAlly, f.originalPending); assert.deepEqual(f.game.currentAuctionSale, f.originalSale);
|
||||
assert.deepEqual(f.game.deck, f.originalDeck); assert.equal(f.originalInventory.length, 57);
|
||||
assert.deepEqual(inventory(f.game), f.originalInventory); assert.equal(f.sourceActionCount, f.actions.length);
|
||||
}
|
||||
function open(f: SemutaIxAllyCostFixture) {
|
||||
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
|
||||
assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaMarketDiscard');
|
||||
if (pending.continuation.kind !== 'karamaMarketDiscard') throw Error('Use the original paid ally replacement cost');
|
||||
assert.equal(pending.batch.cause, 'karama:ixAllyCard'); 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.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
|
||||
assert.deepEqual(game.pendingIxAlly, f.originalPending); assert.deepEqual(game.currentAuctionSale, f.originalSale);
|
||||
assert.deepEqual(game.auction, f.originalAuction); assert.deepEqual(game.deck, f.originalDeck);
|
||||
assert.equal(player(game, f.buyer).hand.filter(card => card.id === f.deliveredCard.id).length, 1);
|
||||
assert.deepEqual(physical(game), physical(f.game));
|
||||
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
|
||||
assert.equal(game.discard.filter(card => card.id === f.cost).length, 1);
|
||||
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
|
||||
assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||
}
|
||||
function canceled(state: Game, f: SemutaIxAllyCostFixture, claimed: boolean) {
|
||||
assert.equal(state.pendingTreacheryDiscard ?? null, null); assert.equal(state.pendingIxAlly ?? null, null);
|
||||
const steps: { actor: string; action: Action }[] = [], game = finish(state, steps);
|
||||
assert.equal(game.currentAuctionSale ?? null, null); assert.equal(game.response ?? null, null);
|
||||
assert.equal(game.pendingIxAlly ?? null, null); assert.equal(game.pendingTreacheryDiscard ?? null, null);
|
||||
assert.equal(game.auction?.index, f.originalAuction.index + 1);
|
||||
assert.deepEqual(game.deck, f.originalDeck, 'Prevention never draws a replacement or replays the auction pool');
|
||||
assert.equal(player(game, f.buyer).hand.filter(card => card.id === f.deliveredCard.id).length, 1);
|
||||
assert.equal(game.discard.some(card => card.id === f.deliveredCard.id), false);
|
||||
assert.deepEqual(player(game, f.buyer).hand, player(f.game, f.buyer).hand);
|
||||
assert.deepEqual(physical(game), physical(f.game), 'The original bank payment and physical board stay committed');
|
||||
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.cost).length, Number(claimed));
|
||||
assert.equal(game.discard.filter(card => card.id === f.cost).length, Number(!claimed));
|
||||
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.semuta).length, Number(!claimed));
|
||||
assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimed));
|
||||
assert.ok(steps.every(step => !['bid', 'richeseBid', 'ship', 'move', 'card'].includes(step.action.type)));
|
||||
reject(game, f.buyer, f.requestAction); reject(game, f.spender, f.action);
|
||||
assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||
}
|
||||
|
||||
for (const variant of variants) for (const claimed of [false, true]) void test(
|
||||
`${variant.advanced ? 'Advanced' : 'Basic'} native Ixian ally paid ordinary replacement cost Semuta ${claimed ? 'claim' : 'pass'} keeps the delivered card and finishes the paid sale once`, () => {
|
||||
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 });
|
||||
},
|
||||
);
|
||||
|
||||
void test('native Ixian ally allowance actually discards the delivered card then draws one replacement after its real fresh-discard passes', () => {
|
||||
for (const variant of variants) {
|
||||
const f = fixture(variant); nativeSource(f); let game = allow(structuredClone(f.game));
|
||||
assert.equal(game.pendingIxAlly ?? null, null);
|
||||
assert.equal(player(game, f.buyer).hand.some(card => card.id === f.deliveredCard.id), false);
|
||||
assert.equal(game.discard.filter(card => card.id === f.deliveredCard.id).length, 1);
|
||||
assert.equal(game.pendingTreacheryDiscard?.continuation.kind, 'ixAllyCard');
|
||||
assert.equal(game.pendingTreacheryDiscard?.batch.cause, 'ixAllyCard');
|
||||
assert.deepEqual(game.currentAuctionSale, f.originalSale); assert.deepEqual(game.deck, f.originalDeck);
|
||||
assert.deepEqual(physical(game), physical(f.game));
|
||||
const event = game.pendingTreacheryDiscard!.batch.event, steps: { actor: string; action: Action }[] = [];
|
||||
game = finish(game, steps);
|
||||
assert.ok(steps.some(step => step.action.type === 'semutaPass' && step.action.event === event));
|
||||
assert.ok(steps.every(step => !['bid', 'richeseBid', 'card', 'ship', 'move'].includes(step.action.type)));
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.currentAuctionSale ?? null, null);
|
||||
assert.equal(game.auction?.index, f.originalAuction.index + 1);
|
||||
assert.equal(player(game, f.buyer).hand.filter(card => card.id === f.replacementTop.id).length, 1);
|
||||
assert.equal(player(game, f.buyer).hand.some(card => card.id === f.deliveredCard.id), false);
|
||||
assert.equal(game.discard.filter(card => card.id === f.deliveredCard.id).length, 1);
|
||||
assert.deepEqual(game.deck, f.originalDeck.slice(1)); assert.deepEqual(physical(game), physical(f.game));
|
||||
assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost));
|
||||
assert.ok(player(game, f.holder).hand.some(card => card.id === f.semuta));
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
reject(game, f.buyer, f.requestAction); reject(game, f.holder, { type: 'semutaCommit', event });
|
||||
}
|
||||
});
|
||||
|
||||
void test('fresh Ixian ally costs reject stale and underlying purchase answers while the paid original and pending replacement stay parked', () => {
|
||||
for (const variant of variants) {
|
||||
const f = fixture(variant), game = open(f), event = game.pendingTreacheryDiscard!.batch.event;
|
||||
reject(game, f.holder, { type: 'semutaCommit', event: `${event}:stale` });
|
||||
reject(game, f.spender, { type: 'semutaCommit', event }); reject(game, f.spender, f.action);
|
||||
reject(game, f.holder, { type: 'semutaSelect', event, card: f.deliveredCard.id });
|
||||
reject(game, f.owner, { type: 'passResponse' }); reject(game, f.buyer, f.requestAction);
|
||||
reject(game, f.buyer, { type: 'decision', karama: false });
|
||||
reject(game, f.buyer, { type: 'bid', amount: 2 }); reject(game, f.buyer, { type: 'passBid' });
|
||||
assert.deepEqual(game.currentAuctionSale, f.originalSale); assert.deepEqual(game.pendingIxAlly, f.originalPending);
|
||||
assert.deepEqual(game.deck, f.originalDeck); assert.deepEqual(physical(game), physical(f.game));
|
||||
canceled(pass(game), f, false);
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal profiles legally answer native Basic and Advanced Ixian ally replacement 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);
|
||||
canceled(game, f, claimed);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
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 {
|
||||
createSemutaNativeMarketsFixture as fixture, actSemutaNativeMarkets as act,
|
||||
passSemutaNativeMarkets as pass, claimSemutaNativeMarkets as claim,
|
||||
allowSemutaNativeMarkets as allow, finishSemutaNativeMarkets as finish,
|
||||
semutaNativeMarketsPlayer as player, semutaNativeMarketsInventory as inventory,
|
||||
type SemutaNativeMarketsFixture,
|
||||
} from './fixture-semuta-native-markets';
|
||||
|
||||
const variants = [
|
||||
{ kind: 'choamSale' as const, advanced: false },
|
||||
{ kind: 'choamSale' as const, advanced: true },
|
||||
{ kind: 'choamSale' as const, advanced: false, worthless: true },
|
||||
{ kind: 'richeseBlackMarket' as const, advanced: true },
|
||||
];
|
||||
const resources = (game: Game) => ({ turn: game.turn, phase: game.phase, storm: game.storm,
|
||||
order: game.order, ready: game.ready, movementRemaining: game.movementRemaining,
|
||||
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, 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: SemutaNativeMarketsFixture) {
|
||||
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, f.advanced); assert.equal(f.setup.semutaPreview, true);
|
||||
assert.ok(f.beforeStartingDeal.players.every(player => player.hand.length === 0));
|
||||
assert.deepEqual(f.game.players.map(player => player.faction), ['richese', 'choam', 'emperor']);
|
||||
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama'));
|
||||
assert.ok(player(f.afterSetup, f.owner).hand.some(card => card.id === f.saleCard));
|
||||
assert.notEqual(f.saleCard, f.semuta); assert.notEqual(f.saleCard, f.cost);
|
||||
assert.equal(f.acquisition.price, 1);
|
||||
assert.ok(f.acquisition.steps.some(step => step.actor === f.holder && step.action.type === 'richeseBid' && step.action.amount === 1));
|
||||
assert.equal(player(f.acquisition.before, f.holder).spice - player(f.acquisition.after, f.holder).spice, 1);
|
||||
assert.equal(player(f.acquisition.after, f.richese).spice - player(f.acquisition.before, f.richese).spice, 1);
|
||||
assert.equal(f.game.phase, 3); assert.equal(f.game.response?.kind, f.kind); 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);
|
||||
assert.deepEqual(player(f.game, f.owner), player(f.beforeDeclaration, f.owner));
|
||||
assert.equal(player(f.game, f.owner).hand.filter(card => card.id === f.saleCard).length, 1);
|
||||
assert.equal(f.game.discard.some(card => card.id === f.saleCard), false);
|
||||
assert.deepEqual(f.declaration.steps, [{ actor: f.owner, action: f.nativeAction }]);
|
||||
if (f.kind === 'choamSale') {
|
||||
assert.equal(f.game.turn, 1); assert.ok(f.originalMarket && f.sale && f.expected);
|
||||
assert.equal(f.originalMarket.owner, f.holder); assert.equal(f.originalMarket.resume, 'phase');
|
||||
assert.deepEqual(f.sale, { card: f.saleCard, witness: f.witness, price: f.price });
|
||||
assert.equal(f.price, f.worthless ? 2 : 3);
|
||||
if (f.witness) {
|
||||
assert.ok(f.normalPurchase);
|
||||
assert.ok(f.normalPurchase.steps.some(step => step.actor === f.holder && step.action.type === 'bid' && step.action.amount === 1));
|
||||
assert.equal(player(f.normalPurchase.before, f.holder).spice - player(f.normalPurchase.after, f.holder).spice, 1);
|
||||
const sold = player(f.game, f.owner).hand.find(card => card.id === f.saleCard);
|
||||
const witness = player(f.game, f.owner).hand.find(card => card.id === f.witness);
|
||||
assert.ok(sold && witness); assert.notEqual(sold.id, witness.id); assert.equal(sold.name, witness.name);
|
||||
} else {
|
||||
assert.equal(f.normalPurchase, null);
|
||||
assert.equal(player(f.game, f.owner).hand.find(card => card.id === f.saleCard)?.kind, 'worthless');
|
||||
}
|
||||
} else {
|
||||
assert.equal(f.game.turn, 2); assert.equal(f.game.advanced, true); assert.ok(f.originalLot && f.originalRound);
|
||||
assert.equal(f.originalLot.source, 'blackMarket'); assert.equal(f.originalLot.cardId, f.saleCard);
|
||||
assert.equal(f.originalLot.bid, 0); assert.equal(f.originalLot.bidder, null);
|
||||
assert.equal(f.originalLot.outcome ?? null, null); assert.equal(f.originalRound.stage, 'lot');
|
||||
assert.equal(f.originalLot.method, 'normal'); assert.equal(f.originalCache.length, 9);
|
||||
assert.equal(f.game.currentAuctionSale ?? null, null);
|
||||
}
|
||||
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.sourceActionCount, f.actions.length); assert.equal(f.originalInventory.length, 45);
|
||||
assert.deepEqual(inventory(f.game), f.originalInventory);
|
||||
}
|
||||
function open(f: SemutaNativeMarketsFixture) {
|
||||
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
|
||||
assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaMarketDiscard');
|
||||
assert.equal(pending.reaction?.stage, 'offer'); assert.equal(pending.batch.cause, `karama:${f.kind}`);
|
||||
assert.deepEqual(pending.batch.entries.map(entry => ({ card: entry.card.id, by: entry.discardedBy, face: entry.publicFace })),
|
||||
[{ card: f.cost, by: f.spender, face: true }]);
|
||||
assert.notEqual(pending.batch.entries[0].card.id, f.saleCard);
|
||||
if (pending.continuation.kind !== 'karamaMarketDiscard') throw Error('Use the original native market 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.choamMarket ?? null, f.originalMarket);
|
||||
assert.deepEqual(game.richeseAuction ?? null, f.originalLot); assert.deepEqual(game.richeseBidding ?? null, f.originalRound);
|
||||
assert.deepEqual(game.auction ?? null, f.originalAuction);
|
||||
assert.deepEqual(game.deck.map(card => card.id), f.originalDeck);
|
||||
assert.equal(player(game, f.owner).hand.filter(card => card.id === f.saleCard).length, 1);
|
||||
if (f.witness) assert.equal(player(game, f.owner).hand.filter(card => card.id === f.witness).length, 1);
|
||||
assert.equal(game.discard.some(card => card.id === f.saleCard), false);
|
||||
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
|
||||
assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||
}
|
||||
function originalChooser(game: Game, f: SemutaNativeMarketsFixture) {
|
||||
assert.equal(game.phase, 3); assert.equal(game.turn, f.game.turn);
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.notEqual(game.response?.kind, f.kind);
|
||||
if (f.kind === 'choamSale') {
|
||||
assert.equal(game.decision?.kind, 'choamMarket'); assert.equal(game.decision?.player, f.owner);
|
||||
assert.equal(game.choamMarket?.resume, f.originalMarket!.resume);
|
||||
assert.equal(game.choamMarket?.sale, undefined);
|
||||
} else {
|
||||
assert.equal(game.decision?.kind, 'richeseDeclaration'); assert.equal(game.decision?.player, f.richese);
|
||||
assert.equal(game.richeseAuction ?? null, null); assert.equal(game.currentAuctionSale ?? null, null);
|
||||
assert.equal(game.richeseBidding?.blackMarketSold, false); assert.equal(game.richeseBidding?.cacheCanceled, false);
|
||||
assert.equal(game.richeseBidding?.normalCountBeforeCache, 3); assert.equal(game.richeseBidding?.normalCount, 2);
|
||||
}
|
||||
assert.deepEqual(game.richeseCache?.map(card => card.id), f.originalCache);
|
||||
assert.deepEqual(game.deck.map(card => card.id), f.originalDeck);
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
}
|
||||
function prevented(game: Game, f: SemutaNativeMarketsFixture, claimed: boolean) {
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingKarama ?? null, null);
|
||||
assert.deepEqual(resources(game), resources(f.game));
|
||||
assert.equal(player(game, f.owner).hand.filter(card => card.id === f.saleCard).length, 1);
|
||||
assert.equal(game.discard.some(card => card.id === f.saleCard), false);
|
||||
if (f.witness) assert.equal(player(game, f.owner).hand.filter(card => card.id === f.witness).length, 1);
|
||||
if (f.kind === 'choamSale') {
|
||||
assert.ok(f.expected); assert.deepEqual(game.choamMarket, f.expected.market);
|
||||
assert.deepEqual(game.decision, f.expected.decision);
|
||||
assert.equal(game.choamMarket!.blocked.filter(card => card === f.saleCard).length, 1);
|
||||
}
|
||||
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));
|
||||
const steps: { actor: string; action: Action }[] = [];
|
||||
game = finish(game, f.kind, steps); assert.deepEqual(steps, []);
|
||||
originalChooser(game, f);
|
||||
assert.deepEqual(finish(game, f.kind), game);
|
||||
return game;
|
||||
}
|
||||
|
||||
for (const variant of variants) for (const claimed of [false, true]) void test(
|
||||
`${variant.advanced ? 'Advanced' : 'Basic'} native ${variant.kind}${variant.kind === 'choamSale' ? 'worthless' in variant ? ' Worthless2' : ' duplicate3' : ''} cost ${claimed ? 'claim' : 'pass'} retains the original offered card and resumes its native chooser 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.owner, f.nativeAction);
|
||||
});
|
||||
|
||||
void test('native CHOAM sale and Black Market allowance controls complete only the actual sale/discard or real unbid lot', () => {
|
||||
for (const variant of variants) {
|
||||
const f = fixture(variant); nativeSource(f);
|
||||
let game = allow(structuredClone(f.game));
|
||||
if (f.kind === 'choamSale') {
|
||||
assert.equal(player(game, f.owner).spice, player(f.game, f.owner).spice + f.price);
|
||||
assert.equal(player(game, f.owner).hand.some(card => card.id === f.saleCard), false);
|
||||
assert.equal(game.discard.filter(card => card.id === f.saleCard).length, 1);
|
||||
if (f.witness) assert.equal(player(game, f.owner).hand.filter(card => card.id === f.witness).length, 1);
|
||||
if (game.pendingTreacheryDiscard) {
|
||||
assert.equal(game.pendingTreacheryDiscard.continuation.kind, 'choamSaleDiscard');
|
||||
assert.deepEqual(game.pendingTreacheryDiscard.batch.entries.map(entry => entry.card.id), [f.saleCard]);
|
||||
}
|
||||
} else {
|
||||
assert.equal(game.response ?? null, null); assert.ok(game.richeseAuction);
|
||||
assert.equal(game.richeseAuction.source, 'blackMarket'); assert.equal(game.richeseAuction.outcome ?? null, null);
|
||||
assert.equal(game.richeseAuction.bid, 0); assert.deepEqual(resources(game), resources(f.game));
|
||||
}
|
||||
const steps: { actor: string; action: Action }[] = [];
|
||||
game = finish(game, f.kind, steps); originalChooser(game, f);
|
||||
if (f.kind === 'choamSale') {
|
||||
assert.ok(steps.every(step => step.action.type === 'semutaPass'));
|
||||
assert.equal(player(game, f.owner).spice, player(f.game, f.owner).spice + f.price);
|
||||
assert.equal(game.discard.filter(card => card.id === f.saleCard).length, 1);
|
||||
} else {
|
||||
assert.ok(steps.length > 0); assert.ok(steps.every(step => step.action.type === 'richeseBid' && step.action.amount === null));
|
||||
assert.deepEqual(resources(game), resources(f.game));
|
||||
assert.equal(player(game, f.owner).hand.filter(card => card.id === f.saleCard).length, 1);
|
||||
}
|
||||
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 native-market costs reject stale and underlying replies without paying or disposing the original offered cards', () => {
|
||||
for (const variant of variants) {
|
||||
const f = fixture(variant), game = open(f), event = game.pendingTreacheryDiscard!.batch.event;
|
||||
reject(game, f.holder, { type: 'semutaCommit', event: `${event}:stale` });
|
||||
reject(game, f.spender, { type: 'semutaCommit', event });
|
||||
reject(game, f.spender, f.action); reject(game, f.owner, { type: 'passResponse' });
|
||||
reject(game, f.owner, f.nativeAction);
|
||||
reject(game, f.kind === 'choamSale' ? f.owner : f.originalLot!.active!, f.kind === 'choamSale'
|
||||
? { type: 'decision', done: true } : { type: 'richeseBid', event: f.originalLot!.event, amount: null });
|
||||
assert.deepEqual(resources(game), resources(f.game));
|
||||
prevented(pass(game), f, false);
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal profiles answer the actual native sale and Black Market cancellation costs legally', () => {
|
||||
for (const variant of variants) {
|
||||
const f = fixture(variant), offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const own = viewGame(offered, f.holder); own.players.find(player => player.id === f.holder)!.bot = difficulty;
|
||||
const before = structuredClone(own), action = botActions(own)[0]; assert.ok(action, difficulty);
|
||||
assert.deepEqual(own, before);
|
||||
assert.ok(action.type === 'semutaCommit' || action.type === 'semutaPass'); assert.equal(action.event, event);
|
||||
let game = act(structuredClone(offered), f.holder, action);
|
||||
const claimed = action.type === 'semutaCommit';
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') {
|
||||
const choosing = viewGame(game, f.holder); choosing.players.find(player => player.id === f.holder)!.bot = difficulty;
|
||||
const selected = botActions(choosing)[0]; assert.ok(selected);
|
||||
assert.equal(selected.type, 'semutaSelect'); assert.equal(selected.card, f.cost);
|
||||
game = act(game, f.holder, selected);
|
||||
} else if (game.pendingTreacheryDiscard) game = pass(game);
|
||||
prevented(game, f, claimed);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user