Offer Semuta on Richese cancellation costs
This commit is contained in:
1 parent
8e6602633d
commit
0d868a3838
12 files changed
+957
-22
No files matched your search
@@ -4,6 +4,7 @@ import { botActions } from '../game/bots';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import type { Difficulty } from '../game/bot-profiles';
|
||||
import type { Card } from '../game/cards';
|
||||
import { finishSemutaBattleOutcome } from './fixture-semuta-homeworld-choam';
|
||||
|
||||
export type SemutaMoritaniStep = { actor: string; action: Action };
|
||||
export type SemutaMoritaniOptions = { winnerMandatory?: boolean; karamaHolder?: 'r' | 'm' | 'w' | 'l'; semutaHolder?: 'r' | 'l' };
|
||||
@@ -115,7 +116,7 @@ export function resolveSemutaMoritaniBattle(f: SemutaMoritaniFixture, actions?:
|
||||
game = semutaMoritaniAct(game, { actor: 'l', action: { type: 'battlePlan', dial: 0, support: 0,
|
||||
leader: f.loserLeader, weapon: f.weapon, defense: f.defense } }, actions);
|
||||
for (const actor of ['w', 'l']) game = semutaMoritaniAct(game, { actor, action: { type: 'traitorCall', call: false } }, actions);
|
||||
return game;
|
||||
return finishSemutaBattleOutcome(game, actions);
|
||||
}
|
||||
export function passSemutaMoritaniDiscard(game: Game, actions?: SemutaMoritaniStep[]): Game {
|
||||
const event = game.pendingTreacheryDiscard!.batch.event;
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
finishSemutaMoritaniWinner, chooseSemutaMoritaniRetention,
|
||||
type SemutaMoritaniFixture,
|
||||
} from './fixture-semuta-moritani-retention';
|
||||
import { finishSemutaBattleOutcome } from './fixture-semuta-homeworld-choam';
|
||||
|
||||
export type SemutaNativeKaramaStep = { actor: string; action: Action };
|
||||
export type SemutaNativeKaramaFixture = {
|
||||
@@ -101,6 +102,7 @@ export function semutaNativeKaramaPreCost(f: SemutaNativeKaramaFixture, actions?
|
||||
game = semutaNativeKaramaAct(game, { actor: ix.winner, action: { type: 'decision', activate: true } }, actions);
|
||||
for (const actor of [ix.winner, ix.loser])
|
||||
game = semutaNativeKaramaAct(game, { actor, action: { type: 'traitorCall', call: false } }, actions);
|
||||
game = finishSemutaBattleOutcome(game, actions);
|
||||
assert.deepEqual(game.pendingTreacheryDiscard?.batch.entries.map(entry => entry.card.id), [ix.snooper]);
|
||||
game = semutaNativeKaramaReact(game, f, false, actions);
|
||||
assert.equal(game.decision?.kind, 'ixSubstitution');
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createGame, initializeSemutaGameForAudit, joinGame, newPlayer, type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { actTruthMovement } from './fixture-truth-movement';
|
||||
import { nextTruthMovementNativeEarlyStep } from './fixture-truth-movement-native-early';
|
||||
|
||||
export type SemutaRicheseCacheKaramaStep = { actor: string; action: Action };
|
||||
export const actSemutaRicheseCacheKarama = actTruthMovement;
|
||||
export const semutaRicheseCacheKaramaPlayer = (game: Game, id: string) => {
|
||||
const player = game.players.find(player => player.id === id); assert.ok(player); return player;
|
||||
};
|
||||
export const semutaRicheseCacheKaramaClean = (game: Game) => !game.phaseOpening && !game.response &&
|
||||
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard;
|
||||
export function semutaRicheseCacheKaramaInventory(game: Game): string[] {
|
||||
const ids = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
|
||||
...game.players.flatMap(player => player.hand),
|
||||
...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? [])]
|
||||
.map(card => card.id).sort();
|
||||
assert.equal(new Set(ids).size, ids.length, 'Every original main/cache card has one physical location');
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** Real native decisions only. Turn one buys the actual Semuta for one;
|
||||
* subsequent original lots receive passes rather than installed outcomes. */
|
||||
export function nextSemutaRicheseCacheKarama(game: Game): SemutaRicheseCacheKaramaStep | null {
|
||||
if (game.truthtrance) return null;
|
||||
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
|
||||
if (game.decision?.kind === 'choamMarket' || game.decision?.kind === 'choamMentat')
|
||||
return { actor: game.decision.player, action: { type: 'decision', done: true } };
|
||||
if (!game.decision && game.biddingEnd) {
|
||||
const end = game.biddingEnd, actor = end.owners.find(id => !end.ready.includes(id));
|
||||
if (actor) return { actor, action: { type: 'biddingEnd', event: end.event, mode: 'ready' } };
|
||||
}
|
||||
if (!game.decision && game.phase === 5) return { actor: game.active!, action: { type: 'endMovement' } };
|
||||
}
|
||||
const lot = game.richeseAuction;
|
||||
if (semutaRicheseCacheKaramaClean(game) && game.turn === 1 && lot?.cardId === SEMUTA_DRUG_ID &&
|
||||
!lot.outcome && lot.active === 'choam' && lot.bid === 0)
|
||||
return { actor: 'choam', action: { type: 'richeseBid', event: lot.event, amount: 1, allyPayment: 0 } };
|
||||
return nextTruthMovementNativeEarlyStep(game, {
|
||||
position: 'first', method: 'onceAround', direction: 'clockwise',
|
||||
card: game.richeseCache?.find(card => card.id === SEMUTA_DRUG_ID)?.id ?? game.richeseCache?.[0]?.id,
|
||||
keepUnbid: false,
|
||||
});
|
||||
}
|
||||
export function advanceSemutaRicheseCacheKarama(game: Game, until: (state: Game) => boolean,
|
||||
actions: SemutaRicheseCacheKaramaStep[] = []): Game {
|
||||
for (let count = 0; count < 1800; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The native cache-cancellation source must reach its next control');
|
||||
const next = nextSemutaRicheseCacheKarama(game);
|
||||
assert.ok(next, `Supply the original owned control at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
game = actSemutaRicheseCacheKarama(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The original Semuta/cache programme did not reach its finite boundary');
|
||||
}
|
||||
|
||||
/** Public bare Basic start or the original Advanced Semuta preview. Only
|
||||
* unopened main/Spice positions and scoped scalar lotteries are selected.
|
||||
* Semuta is acquired through a real earlier paid cache1 auction. */
|
||||
export function createSemutaRicheseCacheKaramaFixture(options: {
|
||||
advanced?: boolean; position?: 'first' | 'last'; blackMarket?: boolean;
|
||||
} = {}) {
|
||||
const advanced = options.advanced ?? false, position = options.position ?? 'first';
|
||||
assert.ok(!options.blackMarket || advanced, 'Only the original Advanced rules offer Black Market');
|
||||
const owner = 'richese', holder = 'choam', spender = 'atreides', emperor = 'emperor';
|
||||
let game = createGame('SEMUTARICHESECACHEKARAMA', newPlayer(owner, 'Richese', 'richese'), advanced, ['choam']);
|
||||
for (const faction of [holder, spender, emperor] as const) joinGame(game, newPlayer(faction, faction, faction));
|
||||
const initial = structuredClone(game), actions: SemutaRicheseCacheKaramaStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actSemutaRicheseCacheKarama(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaRicheseCacheKarama(game, until, actions); };
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game);
|
||||
if (advanced) game = initializeSemutaGameForAudit(game);
|
||||
else act(game.host, { type: 'start' });
|
||||
const setup = structuredClone(game), initialization = { before: lobby, after: setup, advanced };
|
||||
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 = semutaRicheseCacheKaramaInventory(game);
|
||||
assert.equal(originalInventory.length, 45); assert.equal(game.deck.length, 35); assert.equal(game.richeseCache!.length, 10);
|
||||
const printed = game.deck.find(card => card.effect === 'karama'); assert.ok(printed);
|
||||
const ordinary = game.deck.filter(card => card.kind === 'projectile').slice(0, 3);
|
||||
assert.equal(ordinary.length, 3);
|
||||
const selected = [ordinary[0], ordinary[1], printed, ordinary[2]];
|
||||
const dealOrder = selected.map((card, position) => {
|
||||
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { position, from, card: card.id };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game);
|
||||
reach(state => state.status === 'playing');
|
||||
const afterSetup = structuredClone(game), cost = printed.id;
|
||||
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), selected.map(card => card.id));
|
||||
assert.ok(semutaRicheseCacheKaramaPlayer(game, spender).hand.some(card => card.id === cost));
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const front: Game['spiceDeck'] = [], spiceOrder: { position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
|
||||
for (let position = 0; position < (advanced ? 4 : 2); position++) {
|
||||
const from = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(from >= 0);
|
||||
const card = game.spiceDeck.splice(from, 1)[0]; front.push(card);
|
||||
spiceOrder.push({ position, from, card: structuredClone(card) });
|
||||
}
|
||||
game.spiceDeck.unshift(...front);
|
||||
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
|
||||
let beforeBid: Game | undefined, bidStep: SemutaRicheseCacheKaramaStep | undefined, bidAt = -1;
|
||||
for (let count = 0; count < 900 && !semutaRicheseCacheKaramaPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
|
||||
const next = nextSemutaRicheseCacheKarama(game); assert.ok(next);
|
||||
if (next.actor === holder && next.action.type === 'richeseBid' && next.action.amount === 1) {
|
||||
beforeBid = structuredClone(game); bidStep = structuredClone(next); bidAt = actions.length;
|
||||
}
|
||||
act(next.actor, next.action);
|
||||
}
|
||||
assert.ok(beforeBid && bidStep && bidAt >= 0, 'The holder must buy Semuta through the original cache auction');
|
||||
const acquired = structuredClone(game), acquisition = {
|
||||
before: beforeBid, bid: bidStep, steps: structuredClone(actions.slice(bidAt)), after: acquired,
|
||||
};
|
||||
assert.equal(semutaRicheseCacheKaramaPlayer(beforeBid, holder).spice - semutaRicheseCacheKaramaPlayer(game, holder).spice, 1);
|
||||
assert.equal(semutaRicheseCacheKaramaPlayer(game, owner).spice - semutaRicheseCacheKaramaPlayer(beforeBid, owner).spice, 1);
|
||||
assert.equal(game.richeseCache!.length, 9);
|
||||
let blackMarketSale: { before: Game; steps: SemutaRicheseCacheKaramaStep[]; after: Game; card: string } | null = null;
|
||||
if (options.blackMarket) {
|
||||
reach(state => state.turn === 2 && state.phase === 3 && !state.response && !state.phaseOpening &&
|
||||
state.decision?.kind === 'richeseBlackMarket');
|
||||
const before = structuredClone(game), start = actions.length;
|
||||
const offered = semutaRicheseCacheKaramaPlayer(game, owner).hand.find(card => card.kind === 'projectile'); assert.ok(offered);
|
||||
act(owner, { type: 'decision', event: game.richeseBidding!.event, card: offered.id,
|
||||
method: 'onceAround', direction: 'clockwise' });
|
||||
for (let count = 0; game.decision?.kind !== 'richeseDeclaration' && count < 40; count++) {
|
||||
const lot = game.richeseAuction;
|
||||
const next = semutaRicheseCacheKaramaClean(game) && lot?.source === 'blackMarket' &&
|
||||
!lot.outcome && lot.active === emperor && lot.bid === 0
|
||||
? { actor: emperor, action: { type: 'richeseBid', event: lot.event, amount: 1, allyPayment: 0 } as Action }
|
||||
: nextSemutaRicheseCacheKarama(game);
|
||||
assert.ok(next); act(next.actor, next.action);
|
||||
}
|
||||
assert.equal(game.decision?.kind, 'richeseDeclaration');
|
||||
assert.ok(semutaRicheseCacheKaramaPlayer(game, emperor).hand.some(card => card.id === offered.id));
|
||||
assert.equal(semutaRicheseCacheKaramaPlayer(before, emperor).spice - semutaRicheseCacheKaramaPlayer(game, emperor).spice, 1);
|
||||
assert.equal(semutaRicheseCacheKaramaPlayer(game, owner).spice - semutaRicheseCacheKaramaPlayer(before, owner).spice, 1);
|
||||
blackMarketSale = { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game), card: offered.id };
|
||||
}
|
||||
reach(state => state.turn === 2 && state.phase === 3 && !state.response && !state.phaseOpening &&
|
||||
state.decision?.kind === 'richeseDeclaration');
|
||||
const beforeDeclaration = structuredClone(game), declarationStep: SemutaRicheseCacheKaramaStep = {
|
||||
actor: owner, action: { type: 'decision', event: game.richeseBidding!.event, position },
|
||||
};
|
||||
act(declarationStep.actor, declarationStep.action);
|
||||
assert.equal(game.response?.kind, 'richeseAuction'); assert.equal(game.response?.owner, owner);
|
||||
assert.equal(game.richeseBidding!.position, position);
|
||||
assert.equal(game.richeseBidding!.cacheCanceled, false);
|
||||
assert.equal(game.richeseBidding!.blackMarketSold, !!blackMarketSale);
|
||||
assert.equal(game.richeseBidding!.normalCountBeforeCache, 4 - Number(!!blackMarketSale));
|
||||
assert.equal(game.richeseBidding!.normalCount, 3 - Number(!!blackMarketSale));
|
||||
assert.ok(semutaRicheseCacheKaramaPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
|
||||
assert.ok(semutaRicheseCacheKaramaPlayer(game, spender).hand.some(card => card.id === cost));
|
||||
assert.deepEqual(semutaRicheseCacheKaramaInventory(game), originalInventory);
|
||||
const source = structuredClone(game), cache = game.richeseCache!.map(card => card.id);
|
||||
const action: Action = { type: 'card', card: cost, mode: 'cancel' };
|
||||
return {
|
||||
game, source, gameBeforeCost: source, action, owner, richese: owner, holder, spender, emperor,
|
||||
cost, karama: cost, semuta: SEMUTA_DRUG_ID, advanced, position,
|
||||
event: game.richeseBidding!.event, beforeCount: game.richeseBidding!.normalCountBeforeCache!,
|
||||
beforeNormalCount: game.richeseBidding!.normalCount!, cache, beforeCache: cache,
|
||||
initial, lobby, setup, initialization, beforeStartingDeal, afterSetup, dealOrder, spiceOrder,
|
||||
beforeDeclaration, declaration: { before: beforeDeclaration, step: declarationStep, after: source },
|
||||
acquired, acquisition, blackMarketSale, originalInventory, inventory: originalInventory, originalSpice,
|
||||
actions, sourceActions: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
advanced
|
||||
? 'Original Advanced Semuta preview initializer acts only on the ready undealt Richese/CHOAM/Atreides/Emperor lobby, with original main35/cache10 and no optional modules. It is not a newly admitted public Advanced mode.'
|
||||
: 'Actual public bare Basic Richese ready/start initializes its existing Semuta preview before original setup; original main35/cache10 and no optional modules.',
|
||||
'Select four original unopened starting-card positions, including the printed main-deck Karama actually dealt to Atreides. Position only the first two Basic/four Advanced still-unplayed original land Spice Cards and scope scalar lotteries; no played card custody, wallet, force, phase, allowance or response is assigned.',
|
||||
'CHOAM actually buys the original Semuta in the first native Once Around cache auction for one spice paid once to Richese. Other original bids/pass responses, normal auctions, Revival and combined turns finish through native controls. First-turn Advanced Black Market is actually declined, never marked sold or cleared by assignment.',
|
||||
blackMarketSale
|
||||
? 'At the real second-turn Advanced Black Market, Richese sells its original dealt projectile to Emperor for one through actual Once Around bids and native seller credit. That completed sale independently reduces the frozen ordinary count by one.'
|
||||
: 'The second-turn Advanced Black Market, when present, is also actually declined before declaration.',
|
||||
`At the real turn2 Bidding declaration, Richese chooses ${position}. Stop at its actual compulsory-cache response before Atreides spends the genuinely dealt printed Karama. The frozen before-cache count is ${4 - Number(!!blackMarketSale)}, the reduced ordinary count ${3 - Number(!!blackMarketSale)} and the original cache stock nine.`,
|
||||
],
|
||||
};
|
||||
}
|
||||
export type SemutaRicheseCacheKaramaFixture = ReturnType<typeof createSemutaRicheseCacheKaramaFixture>;
|
||||
|
||||
export function claimSemutaRicheseCacheKarama(game: Game, fixture: Pick<SemutaRicheseCacheKaramaFixture, 'holder' | 'cost'>,
|
||||
actions: SemutaRicheseCacheKaramaStep[] = []): Game {
|
||||
const pending = game.pendingTreacheryDiscard; assert.ok(pending && pending.reaction?.stage === 'offer');
|
||||
const event = pending.batch.event;
|
||||
assert.deepEqual(pending.batch.entries.map(entry => entry.card.id), [fixture.cost]);
|
||||
game = actSemutaRicheseCacheKarama(game, fixture.holder, { type: 'semutaCommit', event }, actions);
|
||||
if (game.pendingTreacheryDiscard?.batch.event === event && game.pendingTreacheryDiscard.reaction?.stage === 'select')
|
||||
game = actSemutaRicheseCacheKarama(game, fixture.holder, { type: 'semutaSelect', event, card: fixture.cost }, actions);
|
||||
return game;
|
||||
}
|
||||
export function passSemutaRicheseCacheKarama(game: Game, actions: SemutaRicheseCacheKaramaStep[] = []): Game {
|
||||
const pending = game.pendingTreacheryDiscard; assert.ok(pending && pending.reaction?.stage === 'offer');
|
||||
const event = pending.batch.event;
|
||||
for (let count = 0; game.pendingTreacheryDiscard?.batch.event === event && count < game.players.length + 1; count++) {
|
||||
const reaction = game.pendingTreacheryDiscard.reaction; assert.ok(reaction?.stage === 'offer');
|
||||
const player = game.players.find(player => !reaction.passed.includes(player.id)); assert.ok(player);
|
||||
game = actSemutaRicheseCacheKarama(game, player.id, { type: 'semutaPass', event }, actions);
|
||||
}
|
||||
assert.notEqual(game.pendingTreacheryDiscard?.batch.event, event);
|
||||
return game;
|
||||
}
|
||||
export function finishSemutaRicheseCacheKarama(game: Game, actions: SemutaRicheseCacheKaramaStep[] = []): Game {
|
||||
return advanceSemutaRicheseCacheKarama(game, state => state.phase === 3 &&
|
||||
state.richeseBidding?.stage === 'normal' && !!state.auction && semutaRicheseCacheKaramaClean(state), actions);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
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 { actTruthMovement } from './fixture-truth-movement';
|
||||
import { nextTruthMovementNativeEarlyStep } from './fixture-truth-movement-native-early';
|
||||
import { richeseNormalBetrayalInventory } from './fixture-richese-normal-betrayal';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
|
||||
export type SemutaRicheseGiftKaramaStep = { actor: string; action: Action };
|
||||
export const actSemutaRicheseGiftKarama = actTruthMovement;
|
||||
export const semutaRicheseGiftKaramaInventory = richeseNormalBetrayalInventory;
|
||||
export const semutaRicheseGiftKaramaPlayer = (game: Game, id: string) => {
|
||||
const player = game.players.find(p => p.id === id); assert.ok(player); return player;
|
||||
};
|
||||
export const semutaRicheseGiftKaramaClean = (game: Game) => !game.phaseOpening && !game.response &&
|
||||
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingRicheseGift;
|
||||
const GIFT = 'richese-distrans', DONOR = 'richese';
|
||||
|
||||
/** Original bidding and readiness controls. Fresh disposal, the gift itself
|
||||
* and the selected Karama cancellation remain explicit caller actions. */
|
||||
export function nextSemutaRicheseGiftKaramaStep(game: Game, fullHand = false): SemutaRicheseGiftKaramaStep | null {
|
||||
if (game.pendingTreacheryDiscard || game.pendingRicheseGift || game.truthtrance) return null;
|
||||
if (!game.phaseOpening && !game.response) {
|
||||
const d = game.decision;
|
||||
if (d?.kind === 'richeseCache') {
|
||||
const selected = game.turn === 1 ? SEMUTA_DRUG_ID : GIFT;
|
||||
const card = game.richeseCache!.find(card => card.id === selected);
|
||||
assert.ok(card, `Original gift source needs ${selected}: ${JSON.stringify({ turn: game.turn, phase: game.phase,
|
||||
decision: d.kind, cache: game.richeseCache!.map(card => card.id),
|
||||
holders: game.players.map(p => ({ id: p.id, cards: p.hand.map(card => card.id) })) })}`);
|
||||
return { actor: d.player, action: { type: 'decision', event: game.richeseBidding!.event,
|
||||
card: card.id, method: 'silent' } };
|
||||
}
|
||||
if (d?.kind === 'auctionPayment') return { actor: d.player, action: { type: 'decision', karama: false } };
|
||||
if (!d && game.status === 'playing' && game.phase === 5)
|
||||
return { actor: game.active!, action: { type: 'endMovement' } };
|
||||
}
|
||||
if (semutaRicheseGiftKaramaClean(game)) {
|
||||
const lot = game.richeseAuction;
|
||||
if (lot && !lot.outcome) {
|
||||
// Once Around closes unbid before its owner acts after every rival passes.
|
||||
// Silent gives Richese an actual paid1 self-purchase while all rivals
|
||||
// submit their original zero bids, without a fictitious prior rival bid.
|
||||
assert.equal(lot.method, 'silent');
|
||||
const actor = lot.eligible.find(id => !Object.hasOwn(lot.sealed, id)); assert.ok(actor);
|
||||
return { actor, action: { type: 'richeseBid', event: lot.event, amount: actor === DONOR ? 1 : 0, allyPayment: 0 } };
|
||||
}
|
||||
if (fullHand && game.turn === 1 && game.auction?.active === DONOR && game.auction.bid === 0 &&
|
||||
semutaRicheseGiftKaramaPlayer(game, DONOR).hand.length < 3)
|
||||
return { actor: DONOR, action: { type: 'bid', amount: 1, allyPayment: 0 } };
|
||||
}
|
||||
return nextTruthMovementNativeEarlyStep(game, {
|
||||
position: 'first', method: 'silent', keepUnbid: false,
|
||||
card: game.richeseCache?.find(card => card.id === (game.turn === 1 ? SEMUTA_DRUG_ID : GIFT))?.id,
|
||||
});
|
||||
}
|
||||
export function advanceSemutaRicheseGiftKarama(game: Game, until: (state: Game) => boolean,
|
||||
actions: SemutaRicheseGiftKaramaStep[] = [], fullHand = false): Game {
|
||||
for (let count = 0; count < 1800; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const step = nextSemutaRicheseGiftKaramaStep(game, fullHand);
|
||||
assert.ok(step, 'Resolve the actual fresh discard or gift before continuing');
|
||||
game = actSemutaRicheseGiftKarama(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The original Semuta/Richese programme did not reach its finite boundary');
|
||||
}
|
||||
|
||||
/** Pass only this current fresh batch; preserve any restored response parent. */
|
||||
export function passSemutaRicheseGiftKarama(game: Game, actions: SemutaRicheseGiftKaramaStep[] = []): Game {
|
||||
const event = game.pendingTreacheryDiscard?.batch.event; assert.ok(event);
|
||||
for (let count = 0; game.pendingTreacheryDiscard?.batch.event === event && count < game.players.length; count++) {
|
||||
const pending: NonNullable<Game['pendingTreacheryDiscard']> = game.pendingTreacheryDiscard!;
|
||||
const reaction = pending.reaction; assert.ok(reaction?.stage === 'offer');
|
||||
const actor = game.players.find(p => !reaction.passed.includes(p.id)); assert.ok(actor);
|
||||
game = actSemutaRicheseGiftKarama(game, actor.id, { type: 'semutaPass', event }, actions);
|
||||
}
|
||||
assert.notEqual(game.pendingTreacheryDiscard?.batch.event, event); return game;
|
||||
}
|
||||
export function claimSemutaRicheseGiftKarama(game: Game, holder: string,
|
||||
actions: SemutaRicheseGiftKaramaStep[] = []): Game {
|
||||
const pending = game.pendingTreacheryDiscard; assert.ok(pending && pending.batch.entries.length === 1);
|
||||
const event = pending.batch.event, card = pending.batch.entries[0].card.id;
|
||||
game = actSemutaRicheseGiftKarama(game, holder, { type: 'semutaCommit', event }, actions);
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
|
||||
game = actSemutaRicheseGiftKarama(game, holder, { type: 'semutaSelect', event, card }, actions);
|
||||
return game;
|
||||
}
|
||||
/** The used-cost continuation completes the prevention itself. Do not allow
|
||||
* the gift or spend a restored parent response in this helper. */
|
||||
export function finishSemutaRicheseGiftKarama(game: Game): Game {
|
||||
assert.ok(!game.pendingTreacheryDiscard, 'Choose the original fresh-discard reaction first');
|
||||
assert.ok(!game.pendingRicheseGift, 'The original gift prevention must finish before its parent resumes');
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Genuine native acquisitions/alliance. The only controls over physical stock
|
||||
* are unopened starting-card/main-deck/Spice positions and original lotteries.
|
||||
* No played hand, balance, army, alliance, phase or paused receipt is assigned. */
|
||||
export function createSemutaRicheseGiftKaramaFixture(options: {
|
||||
advanced?: boolean; parent?: 'quiet' | 'emperorIncome'; fullHand?: boolean;
|
||||
} = {}) {
|
||||
const advanced = options.advanced ?? false, parent = options.parent ?? 'quiet', fullHand = options.fullHand ?? false;
|
||||
const donor = DONOR, holder = donor, recipient = 'emperor', spender = 'atreides', observer = 'choam';
|
||||
let game = createGame('SEMUTARICHESEGIFTKARAMA', newPlayer(donor, 'Richese', 'richese'), advanced, ['choam']);
|
||||
for (const faction of ['choam', 'atreides', 'emperor'] as const) joinGame(game, newPlayer(faction, faction, faction));
|
||||
const actions: SemutaRicheseGiftKaramaStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actSemutaRicheseGiftKarama(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaRicheseGiftKarama(game, until, actions, fullHand); };
|
||||
const transition = (run: () => void) => {
|
||||
const before = structuredClone(game), start = actions.length; run();
|
||||
return { before, steps: structuredClone(actions.slice(start)), 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 ? 'explicit-original-semuta-initializer' : '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));
|
||||
const originalInventory = semutaRicheseGiftKaramaInventory(game); assert.equal(originalInventory.length, 45);
|
||||
const karamas = game.deck.filter(card => card.effect === 'karama'); assert.equal(karamas.length, 2);
|
||||
const ordinary = game.deck.find(card => card.kind === 'projectile'), shield = game.deck.find(card => card.kind === 'shield');
|
||||
assert.ok(ordinary && shield);
|
||||
const deal = [ordinary.id, karamas[0].id, karamas[1].id, shield.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 { position, from, card };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game), cost = karamas[1].id, parentCounter = karamas[0].id;
|
||||
reach(state => state.status === 'playing');
|
||||
const afterSetup = structuredClone(game);
|
||||
assert.deepEqual(game.players.flatMap(p => p.hand.map(card => card.id)), deal);
|
||||
const spiceOrder: { turn: number; position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
|
||||
const order = (worm: boolean, position: number) => {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && (worm
|
||||
? 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper
|
||||
: 'territory' in card && !card.discovery)); assert.ok(from >= position);
|
||||
const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card);
|
||||
spiceOrder.push({ turn: game.turn, position, from, card: structuredClone(card) });
|
||||
};
|
||||
order(false, 0); if (advanced) order(false, 1);
|
||||
reach(state => state.turn === 1 && state.phase === 2 && semutaRicheseGiftKaramaClean(state));
|
||||
let normalOrder: { card: string; from: number; before: Game; after: Game } | null = null;
|
||||
if (fullHand) {
|
||||
const before = structuredClone(game), from = game.deck.findIndex(card => card.kind === 'shield'); assert.ok(from >= 0);
|
||||
const card = game.deck.splice(from, 1)[0]; game.deck.unshift(card);
|
||||
normalOrder = { card: card.id, from, before, after: structuredClone(game) };
|
||||
}
|
||||
const buy = (card: string) => {
|
||||
const before = structuredClone(game), start = actions.length;
|
||||
reach(state => semutaRicheseGiftKaramaPlayer(state, donor).hand.some(candidate => candidate.id === card));
|
||||
assert.ok(actions.slice(start).some(step => step.actor === donor && step.action.type === 'richeseBid' && step.action.amount === 1));
|
||||
assert.equal(semutaRicheseGiftKaramaPlayer(before, donor).spice - semutaRicheseGiftKaramaPlayer(game, donor).spice, 1);
|
||||
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game), card, price: 1 };
|
||||
};
|
||||
const semutaPurchase = buy(SEMUTA_DRUG_ID);
|
||||
reach(state => state.turn === 1 && state.phase === 4 && semutaRicheseGiftKaramaClean(state));
|
||||
const afterFirstBidding = structuredClone(game);
|
||||
assert.equal(semutaRicheseGiftKaramaPlayer(game, donor).hand.length, fullHand ? 3 : 2);
|
||||
if (normalOrder) assert.ok(semutaRicheseGiftKaramaPlayer(game, donor).hand.some(card => card.id === normalOrder.card));
|
||||
reach(state => state.turn === 2 && state.phase === 1 && semutaRicheseGiftKaramaClean(state) &&
|
||||
!state.nexus && !state.spiceWindow && !state.spiceResolution && !state.spiceSequence && !state.ready.length);
|
||||
const beforeWorm = structuredClone(game); order(true, 0); order(false, 1); if (advanced) order(false, 2);
|
||||
reach(state => state.nexus && semutaRicheseGiftKaramaClean(state) && !state.spiceWindow && !state.spiceResolution);
|
||||
const alliance = transition(() => {
|
||||
act(donor, { type: 'alliance', target: recipient }); act(recipient, { type: 'alliance', target: donor });
|
||||
});
|
||||
assert.equal(semutaRicheseGiftKaramaPlayer(game, donor).ally, recipient);
|
||||
assert.equal(semutaRicheseGiftKaramaPlayer(game, recipient).ally, donor);
|
||||
reach(state => state.turn === 2 && state.phase === 2 && semutaRicheseGiftKaramaClean(state));
|
||||
const giftPurchase = buy(GIFT);
|
||||
assert.equal(game.response?.kind, 'emperorIncome');
|
||||
assert.equal(game.currentAuctionSale?.amount ?? game.auction?.bid, 1);
|
||||
if (parent === 'quiet') reach(semutaRicheseGiftKaramaClean);
|
||||
assert.equal(semutaRicheseGiftKaramaPlayer(game, holder).hand.length, fullHand ? 4 : 3);
|
||||
assert.ok(semutaRicheseGiftKaramaPlayer(game, spender).hand.some(card => card.id === cost));
|
||||
assert.ok(semutaRicheseGiftKaramaPlayer(game, observer).hand.some(card => card.id === parentCounter));
|
||||
assert.ok(viewGame(game, donor).richeseGift?.cards.some(card => card.id === GIFT));
|
||||
const beforeGift = structuredClone(game), originalParent = { response: structuredClone(game.response),
|
||||
decision: structuredClone(game.decision), phaseOpening: structuredClone(game.phaseOpening), pendingKarama: structuredClone(game.pendingKarama) };
|
||||
const giftAction: Action = { type: 'richeseGift', card: GIFT }, giftDeclaration = transition(() => act(donor, giftAction));
|
||||
assert.equal(game.response?.kind, 'richeseGift'); assert.equal(game.response?.recipient, recipient);
|
||||
assert.equal(game.pendingRicheseGift?.intent.cardId, GIFT);
|
||||
assert.deepEqual(game.pendingRicheseGift?.resume, originalParent);
|
||||
assert.deepEqual(semutaRicheseGiftKaramaInventory(game), originalInventory);
|
||||
const action: Action = { type: 'card', card: cost, mode: 'cancel' }, source = structuredClone(game);
|
||||
return { game, source, beforeCost: source, beforeGift, advanced, parent, fullHand,
|
||||
holder, donor, owner: donor, recipient, spender, observer, parentCounter,
|
||||
semuta: SEMUTA_DRUG_ID, cost, karama: cost, gift: GIFT, card: cost, action, giftAction,
|
||||
lobby, setup, initialization, beforeStartingDeal, afterSetup, dealOrder, spiceOrder,
|
||||
semutaPurchase, normalOrder, afterFirstBidding, beforeWorm, alliance, giftPurchase, giftDeclaration, originalParent,
|
||||
originalInventory, originalCards: originalInventory, actions, steps: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
'Basic uses the real public bare Richese/CHOAM/Atreides/Emperor start. Advanced uses the existing explicit Semuta initializer on the original ready undealt lobby. Both retain original main35/cache10; no mode is converted after start.',
|
||||
'Only unopened original starting-card positions (one actual printed Karama each to Atreides/CHOAM), original land/Worm Spice positions and scalar lotteries are selected. Each actual Silent cache auction has a real Richese bid1 and original zero submissions from all other eligible seats: Richese pays1 for Semuta on turn1, forms a mutual Emperor alliance at the actual turn2 Worm, and separately pays1 for Distrans before declaring its original allied gift. This avoids native Once Around’s all-rivals-pass free-retention boundary.',
|
||||
fullHand ? 'The full4 branch selects one still-unopened normal Shield before turn1 Bidding and really bids/pays normal1 for it. Starting ordinary + actual Semuta + actual Shield + actual Distrans fill the native hand before the fresh cost; Semuta exchange frees its own slot.' : 'The ordinary source retains exactly its original starting card plus the two actual paid cache purchases.',
|
||||
parent === 'emperorIncome' ? 'The original paid-cache EmperorIncome1 remains pending when Richese declares the gift. The distinct originally dealt CHOAM Karama keeps that native parent cancellable after Atreides spends its own original printed Karama.' : 'The quiet branch actually passes the prior original cache-purchase income response before declaring the gift.',
|
||||
'Stop before the actual Atreides cancellation. No played card custody, wallet, force, alliance, clock, response, receipt or prevention outcome is installed; caller owns the printed cost and neutral fresh-discard claim/pass.',
|
||||
] };
|
||||
}
|
||||
export type SemutaRicheseGiftKaramaFixture = ReturnType<typeof createSemutaRicheseGiftKaramaFixture>;
|
||||
@@ -0,0 +1,158 @@
|
||||
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 {
|
||||
createSemutaRicheseCacheKaramaFixture as fixture,
|
||||
actSemutaRicheseCacheKarama as act, claimSemutaRicheseCacheKarama as claim,
|
||||
passSemutaRicheseCacheKarama as pass, finishSemutaRicheseCacheKarama as finish,
|
||||
semutaRicheseCacheKaramaInventory as inventory, semutaRicheseCacheKaramaPlayer as player,
|
||||
type SemutaRicheseCacheKaramaFixture,
|
||||
} from './fixture-semuta-richese-cache-karama';
|
||||
|
||||
const resources = (game: Game) => game.players.map(player => ({
|
||||
id: player.id, spice: player.spice, reserves: player.reserves, tanks: player.tanks,
|
||||
forces: player.forces, elites: player.elites, shipped: player.shipped, moved: player.moved,
|
||||
revived: player.revived, freeForcesRevived: player.freeForcesRevived, ally: player.ally,
|
||||
}));
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
function nativeSource(f: SemutaRicheseCacheKaramaFixture) {
|
||||
assert.equal(f.initial.status, 'lobby');
|
||||
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.semutaPreview, true);
|
||||
assert.equal(f.setup.advanced, f.advanced);
|
||||
assert.ok(f.beforeStartingDeal.players.every(player => player.hand.length === 0));
|
||||
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost));
|
||||
assert.equal(player(f.afterSetup, f.spender).hand.find(card => card.id === f.cost)!.effect, 'karama');
|
||||
assert.ok(f.acquisition.steps.some(step => step.actor === f.holder && step.action.type === 'richeseBid' && step.action.amount === 1));
|
||||
assert.ok(player(f.acquisition.after, f.holder).hand.some(card => card.id === f.semuta));
|
||||
assert.equal(player(f.acquisition.before, f.holder).spice - player(f.acquisition.after, f.holder).spice, 1);
|
||||
assert.equal(player(f.acquisition.after, f.owner).spice - player(f.acquisition.before, f.owner).spice, 1);
|
||||
assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 3);
|
||||
assert.equal(f.game.response?.kind, 'richeseAuction'); assert.equal(f.game.response?.owner, f.owner);
|
||||
assert.equal(f.game.richeseBidding!.position, f.position);
|
||||
assert.equal(f.game.richeseBidding!.normalCountBeforeCache, f.beforeCount);
|
||||
assert.equal(f.game.richeseBidding!.normalCount, f.beforeCount - 1);
|
||||
assert.equal(f.game.richeseBidding!.cacheCanceled, false);
|
||||
assert.equal(f.game.richeseBidding!.blackMarketSold, !!f.blackMarketSale);
|
||||
assert.equal(f.cache.length, 9);
|
||||
assert.equal(f.sourceActionCount, f.actions.length);
|
||||
assert.deepEqual(inventory(f.game), f.originalInventory);
|
||||
}
|
||||
function open(f: SemutaRicheseCacheKaramaFixture) {
|
||||
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
|
||||
assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaRicheseAuctionDiscard');
|
||||
assert.equal(pending.reaction?.stage, 'offer'); assert.equal(pending.batch.cause, 'karama:richeseAuction');
|
||||
assert.deepEqual(pending.batch.entries.map(entry => ({ card: entry.card.id, by: entry.discardedBy, public: entry.publicFace })),
|
||||
[{ card: f.cost, by: f.spender, public: true }]);
|
||||
assert.equal(game.discard.filter(card => card.id === f.cost).length, 1);
|
||||
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
|
||||
assert.equal(game.response, null, 'The original cache response is parked while its cost is fresh');
|
||||
if (pending.continuation.kind !== 'karamaRicheseAuctionDiscard') throw Error('Use the exact cache cancellation continuation');
|
||||
assert.equal(pending.continuation.player, f.spender);
|
||||
assert.equal(pending.continuation.card, f.cost);
|
||||
assert.equal(pending.continuation.use.kind, 'cancel');
|
||||
assert.equal(pending.continuation.use.response.kind, 'richeseAuction');
|
||||
assert.equal(pending.continuation.use.response.owner, f.owner);
|
||||
assert.deepEqual(pending.continuation.resume.response, f.game.response);
|
||||
assert.equal(game.richeseBidding!.cacheCanceled, false, 'Prevention waits for the committed cost reaction');
|
||||
assert.equal(game.richeseBidding!.normalCount, f.beforeNormalCount);
|
||||
assert.equal(game.richeseBidding!.normalCountBeforeCache, f.beforeCount);
|
||||
assert.deepEqual(game.richeseCache!.map(card => card.id), f.cache);
|
||||
assert.deepEqual(resources(game), resources(f.game));
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
|
||||
return game;
|
||||
}
|
||||
function canceled(state: Game, f: SemutaRicheseCacheKaramaFixture, claimed: boolean) {
|
||||
const game = finish(state);
|
||||
assert.ok(!game.pendingTreacheryDiscard);
|
||||
assert.equal(game.turn, f.game.turn); assert.equal(game.phase, 3);
|
||||
assert.equal(game.richeseBidding!.event, f.event);
|
||||
assert.equal(game.richeseBidding!.stage, 'normal');
|
||||
assert.equal(game.richeseBidding!.position, f.position);
|
||||
assert.equal(game.richeseBidding!.cacheCanceled, true);
|
||||
assert.equal(game.richeseBidding!.blackMarketSold, !!f.blackMarketSale);
|
||||
assert.equal(game.richeseBidding!.normalCountBeforeCache, f.beforeCount);
|
||||
assert.equal(game.richeseBidding!.normalCount, f.beforeCount);
|
||||
assert.equal(game.richeseAuction, null);
|
||||
assert.ok(game.auction); assert.equal(game.auction.index, 0);
|
||||
assert.equal(game.auction.cards.length, f.beforeCount);
|
||||
assert.equal(game.auction.bid, 0); assert.equal(game.auction.bidder, null);
|
||||
assert.equal(game.currentAuctionSale, null);
|
||||
assert.equal(game.response, null); assert.equal(game.decision, null);
|
||||
assert.deepEqual(game.richeseCache!.map(card => card.id), f.cache);
|
||||
assert.deepEqual(resources(game), resources(f.game));
|
||||
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
|
||||
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.cost).length, Number(claimed));
|
||||
assert.equal(game.discard.filter(card => card.id === f.cost).length, Number(!claimed));
|
||||
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.semuta).length, Number(!claimed));
|
||||
assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimed));
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
return game;
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) for (const position of ['first', 'last'] as const) for (const claimed of [false, true]) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} genuine paid Semuta ${claimed ? 'claims' : 'passes'} the cache-${position} printed Karama before original prevention completes`, () => {
|
||||
const f = fixture({ advanced, position }); nativeSource(f);
|
||||
const pending = open(f), event = pending.pendingTreacheryDiscard!.batch.event;
|
||||
const game = canceled(claimed ? claim(pending, f) : pass(pending), f, claimed);
|
||||
reject(game, f.holder, { type: 'semutaCommit', event });
|
||||
reject(game, f.holder, { type: 'semutaPass', event });
|
||||
});
|
||||
}
|
||||
|
||||
for (const claimed of [false, true]) void test(`Advanced actual Black Market reduction survives ${claimed ? 'claimed' : 'passed'} cache-cancel cost`, () => {
|
||||
const f = fixture({ advanced: true, position: 'last', blackMarket: true }); nativeSource(f);
|
||||
assert.ok(f.blackMarketSale);
|
||||
assert.equal(f.beforeCount, 3); assert.equal(f.beforeNormalCount, 2);
|
||||
assert.ok(player(f.blackMarketSale.after, f.emperor).hand.some(card => card.id === f.blackMarketSale!.card));
|
||||
assert.equal(player(f.blackMarketSale.before, f.emperor).spice - player(f.blackMarketSale.after, f.emperor).spice, 1);
|
||||
assert.equal(player(f.blackMarketSale.after, f.owner).spice - player(f.blackMarketSale.before, f.owner).spice, 1);
|
||||
const pending = open(f), game = canceled(claimed ? claim(pending, f) : pass(pending), f, claimed);
|
||||
assert.equal(game.auction!.cards.length, 3, 'Cache prevention restores one lot without undoing the real Black Market reduction');
|
||||
assert.ok(player(game, f.emperor).hand.some(card => card.id === f.blackMarketSale!.card));
|
||||
});
|
||||
|
||||
void test('parked cost and stale source controls cannot repeat the cache cancellation or change its frozen count', () => {
|
||||
const f = fixture({ position: 'last' }); nativeSource(f);
|
||||
const pending = open(f), event = pending.pendingTreacheryDiscard!.batch.event;
|
||||
reject(pending, f.spender, f.action);
|
||||
reject(pending, f.spender, { type: 'passResponse' });
|
||||
reject(pending, f.owner, f.declaration.step.action);
|
||||
const game = canceled(claim(pending, f), f, true);
|
||||
reject(game, f.owner, f.declaration.step.action);
|
||||
reject(game, f.holder, { type: 'semutaCommit', event });
|
||||
assert.equal(game.richeseBidding!.cacheCanceled, true);
|
||||
assert.equal(game.richeseBidding!.stage, 'normal');
|
||||
assert.equal(game.richeseBidding!.normalCount, f.beforeCount);
|
||||
// A future real response may independently accept a held Karama; no global
|
||||
// ban is inferred from retirement of this original cache opportunity.
|
||||
});
|
||||
|
||||
void test('all four minimal policies resolve the actual fresh cache-cancel cost and native ordinary suffix legally', () => {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const f = fixture(), pending = open(f), event = pending.pendingTreacheryDiscard!.batch.event;
|
||||
let game = pending;
|
||||
for (let count = 0; game.pendingTreacheryDiscard?.batch.event === event && count < 32; count++) {
|
||||
let proposal: { actor: string; action: Action } | undefined;
|
||||
for (const seat of game.players) {
|
||||
const view = viewGame(game, seat.id);
|
||||
view.players.find(player => player.id === seat.id)!.bot = difficulty;
|
||||
const before = structuredClone(view), action = botActions(view)[0];
|
||||
assert.deepEqual(view, before);
|
||||
if (action) { proposal = { actor: seat.id, action }; break; }
|
||||
}
|
||||
assert.ok(proposal);
|
||||
assert.ok(['semutaPass', 'semutaCommit', 'semutaSelect'].includes(proposal.action.type));
|
||||
game = act(game, proposal.actor, proposal.action);
|
||||
}
|
||||
assert.notEqual(game.pendingTreacheryDiscard?.batch.event, event);
|
||||
const claimed = !player(game, f.holder).hand.some(card => card.id === f.semuta);
|
||||
canceled(game, f, claimed);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
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 {
|
||||
createSemutaRicheseGiftKaramaFixture as fixture,
|
||||
actSemutaRicheseGiftKarama as act, passSemutaRicheseGiftKarama as pass,
|
||||
claimSemutaRicheseGiftKarama as claim, finishSemutaRicheseGiftKarama as finish,
|
||||
advanceSemutaRicheseGiftKarama as advance, semutaRicheseGiftKaramaClean as clean,
|
||||
semutaRicheseGiftKaramaInventory as inventory, semutaRicheseGiftKaramaPlayer as player,
|
||||
type SemutaRicheseGiftKaramaFixture,
|
||||
} from './fixture-semuta-richese-gift-karama';
|
||||
|
||||
const resources = (game: Game) => ({ players: game.players.map(p => ({ id: p.id, spice: p.spice,
|
||||
reserves: p.reserves, tanks: p.tanks, forces: p.forces, elites: p.elites, moved: p.moved, shipped: p.shipped,
|
||||
ally: p.ally, revived: p.revived })), order: game.order, active: game.active, ready: game.ready,
|
||||
turn: game.turn, phase: game.phase, movementRemaining: game.movementRemaining });
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
function nativeSource(f: SemutaRicheseGiftKaramaFixture) {
|
||||
assert.equal(f.game.advanced, f.advanced); assert.equal(f.setup.advanced, f.advanced);
|
||||
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.semutaPreview, true);
|
||||
assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0));
|
||||
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost));
|
||||
assert.ok(player(f.afterSetup, f.observer).hand.some(card => card.id === f.parentCounter));
|
||||
assert.notEqual(f.cost, f.parentCounter);
|
||||
for (const acquisition of [f.semutaPurchase, f.giftPurchase]) {
|
||||
assert.equal(acquisition.price, 1);
|
||||
const bids = acquisition.steps.filter(step => step.action.type === 'richeseBid');
|
||||
assert.equal(bids.length, f.game.players.length);
|
||||
assert.equal(bids.filter(step => step.actor === f.donor && step.action.amount === 1).length, 1);
|
||||
assert.ok(bids.filter(step => step.actor !== f.donor).every(step => step.action.amount === 0));
|
||||
assert.equal(player(acquisition.before, f.donor).spice - player(acquisition.after, f.donor).spice, 1);
|
||||
assert.ok(player(acquisition.after, f.donor).hand.some(card => card.id === acquisition.card));
|
||||
}
|
||||
assert.equal(f.alliance.before.nexus, true);
|
||||
assert.deepEqual(f.alliance.steps, [
|
||||
{ actor: f.donor, action: { type: 'alliance', target: f.recipient } },
|
||||
{ actor: f.recipient, action: { type: 'alliance', target: f.donor } },
|
||||
]);
|
||||
assert.equal(player(f.game, f.donor).ally, f.recipient); assert.equal(player(f.game, f.recipient).ally, f.donor);
|
||||
assert.equal(f.game.response?.kind, 'richeseGift'); assert.equal(f.game.response?.recipient, f.recipient);
|
||||
assert.equal(f.game.pendingRicheseGift?.intent.cardId, f.gift);
|
||||
assert.deepEqual(f.game.pendingRicheseGift?.resume, f.originalParent);
|
||||
assert.equal(f.game.discard.some(card => card.id === f.cost), false);
|
||||
assert.ok(player(f.game, f.holder).hand.some(card => card.id === f.semuta));
|
||||
assert.ok(player(f.game, f.holder).hand.some(card => card.id === f.gift));
|
||||
assert.equal(player(f.game, f.recipient).hand.some(card => card.id === f.gift), false);
|
||||
assert.equal(f.sourceActionCount, f.actions.length); assert.deepEqual(inventory(f.game), f.originalInventory);
|
||||
}
|
||||
function open(f: SemutaRicheseGiftKaramaFixture) {
|
||||
const offered = act(structuredClone(f.game), f.spender, f.action), pending = offered.pendingTreacheryDiscard;
|
||||
assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaRicheseGiftDiscard');
|
||||
assert.deepEqual(pending.batch.entries.map(entry => entry.card.id), [f.cost]);
|
||||
assert.deepEqual(pending.batch.entries.map(entry => entry.discardedBy), [f.spender]);
|
||||
assert.equal(offered.discard.filter(card => card.id === f.cost).length, 1);
|
||||
assert.equal(player(offered, f.spender).hand.some(card => card.id === f.cost), false);
|
||||
assert.deepEqual(resources(offered), resources(f.game)); assert.deepEqual(inventory(offered), f.originalInventory);
|
||||
assert.ok(offered.pendingRicheseGift, 'Original prevention waits for the used physical cost reaction');
|
||||
assert.deepEqual(offered.pendingRicheseGift.intent, f.game.pendingRicheseGift!.intent);
|
||||
assert.deepEqual(offered.pendingRicheseGift.resume, f.originalParent);
|
||||
assert.equal(viewGame(offered, f.holder).semutaReaction?.canCommit, true);
|
||||
assert.equal(offered.richeseGiftBlocked?.cards.includes(f.gift) ?? false, false);
|
||||
return offered;
|
||||
}
|
||||
function prevented(game: Game, f: SemutaRicheseGiftKaramaFixture, claimed: boolean) {
|
||||
const done = finish(game);
|
||||
assert.ok(!done.pendingTreacheryDiscard); assert.ok(!done.pendingRicheseGift);
|
||||
assert.deepEqual(resources(done), resources(f.game)); assert.deepEqual(inventory(done), f.originalInventory);
|
||||
assert.equal(player(done, f.donor).hand.filter(card => card.id === f.gift).length, 1);
|
||||
assert.equal(player(done, f.recipient).hand.some(card => card.id === f.gift), false);
|
||||
assert.equal(player(done, f.spender).hand.some(card => card.id === f.cost), false);
|
||||
assert.equal(player(done, f.holder).hand.filter(card => card.id === f.cost).length, Number(claimed));
|
||||
assert.equal(done.discard.filter(card => card.id === f.cost).length, Number(!claimed));
|
||||
assert.equal(player(done, f.holder).hand.filter(card => card.id === f.semuta).length, Number(!claimed));
|
||||
assert.equal(done.discard.filter(card => card.id === f.semuta).length, Number(claimed));
|
||||
assert.ok(player(done, f.observer).hand.some(card => card.id === f.parentCounter));
|
||||
assert.deepEqual(done.richeseGiftBlocked, { turn: f.game.turn, phase: f.game.phase, cards: [f.gift] });
|
||||
assert.deepEqual(done.response, f.originalParent.response); assert.deepEqual(done.decision, f.originalParent.decision);
|
||||
assert.deepEqual(done.phaseOpening, f.originalParent.phaseOpening);
|
||||
assert.deepEqual(done.pendingKarama, f.originalParent.pendingKarama);
|
||||
reject(done, f.donor, f.giftAction);
|
||||
return done;
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) for (const claimed of [false, true]) void test(
|
||||
`${advanced ? 'Advanced' : 'Basic'} genuine paid Semuta ${claimed ? 'claims' : 'passes'} the original printed Karama before allied Richese gift prevention completes`, () => {
|
||||
const f = fixture({ advanced }); nativeSource(f);
|
||||
const offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
|
||||
const done = prevented(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
|
||||
assert.equal(player(done, f.holder).hand.length, 3);
|
||||
reject(done, f.holder, { type: 'semutaCommit', event });
|
||||
reject(done, f.spender, f.action);
|
||||
},
|
||||
);
|
||||
|
||||
for (const advanced of [false, true]) void test(
|
||||
`${advanced ? 'Advanced' : 'Basic'} native normal-one purchase fills Richese to four before the Semuta singleton exchange`, () => {
|
||||
const f = fixture({ advanced, fullHand: true }); nativeSource(f);
|
||||
const normalOrder = f.normalOrder; assert.ok(normalOrder);
|
||||
assert.ok(f.actions.some(step => step.actor === f.donor && step.action.type === 'bid' && step.action.amount === 1));
|
||||
assert.ok(player(f.afterFirstBidding, f.holder).hand.some(card => card.id === normalOrder.card));
|
||||
assert.equal(player(f.game, f.holder).hand.length, 4);
|
||||
const done = prevented(claim(open(f), f.holder), f, true);
|
||||
assert.equal(player(done, f.holder).hand.length, 4);
|
||||
assert.ok(player(done, f.holder).hand.some(card => card.id === normalOrder.card));
|
||||
},
|
||||
);
|
||||
|
||||
for (const claimed of [false, true]) void test(
|
||||
`native pending cache-purchase Emperor income survives gift-cost Semuta ${claimed ? 'claim' : 'pass'} then settles once`, () => {
|
||||
const f = fixture({ advanced: true, parent: 'emperorIncome' }); nativeSource(f);
|
||||
assert.equal(f.originalParent.response?.kind, 'emperorIncome');
|
||||
assert.equal(f.beforeGift.currentAuctionSale?.amount ?? f.beforeGift.auction?.bid, 1);
|
||||
const offered = open(f), done = prevented(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
|
||||
assert.equal(done.response?.kind, 'emperorIncome');
|
||||
const before = player(done, f.recipient).spice, remaining = advance(done, clean);
|
||||
assert.equal(player(remaining, f.recipient).spice, before + 1);
|
||||
assert.ok(!remaining.pendingRicheseGift); assert.ok(!remaining.pendingTreacheryDiscard);
|
||||
assert.equal(player(remaining, f.donor).hand.filter(card => card.id === f.gift).length, 1);
|
||||
assert.equal(player(remaining, f.recipient).hand.some(card => card.id === f.gift), false);
|
||||
assert.deepEqual(remaining.richeseGiftBlocked, done.richeseGiftBlocked);
|
||||
assert.deepEqual(inventory(remaining), f.originalInventory);
|
||||
},
|
||||
);
|
||||
|
||||
void test('all four minimal profiles legally answer the exact original Richese-gift Karama discard in both rule bands', () => {
|
||||
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 action = botActions(own)[0]; assert.ok(action, difficulty);
|
||||
assert.ok(action.type === 'semutaCommit' || action.type === 'semutaPass'); assert.equal(action.event, event);
|
||||
let done = act(structuredClone(offered), f.holder, action);
|
||||
const claimed = action.type === 'semutaCommit';
|
||||
if (done.pendingTreacheryDiscard?.reaction?.stage === 'select') {
|
||||
const selecting = viewGame(done, 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);
|
||||
done = act(done, f.holder, selected);
|
||||
} else if (done.pendingTreacheryDiscard) done = pass(done);
|
||||
prevented(done, f, claimed);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('the original fresh cost rejects stale, foreign and underlying gift replies without consuming another card', () => {
|
||||
const f = fixture(), offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
|
||||
reject(offered, f.holder, { type: 'semutaCommit', event: `${event}:stale` });
|
||||
reject(offered, f.spender, { type: 'semutaCommit', event });
|
||||
reject(offered, f.spender, f.action); reject(offered, f.donor, f.giftAction);
|
||||
const done = prevented(pass(offered), f, false);
|
||||
assert.equal(done.discard.filter(card => card.id === f.cost).length, 1);
|
||||
assert.equal(done.richeseGiftBlocked!.cards.filter(card => card === f.gift).length, 1);
|
||||
assert.ok(player(done, f.holder).hand.some(card => card.id === f.semuta));
|
||||
});
|
||||
Reference in new issue
Block a user