Preserve native BG conversion through Semuta physical costs and counters
This commit is contained in:
1 parent
8a2df97be8
commit
f587374923
13 files changed
+693
-63
No files matched your search
@@ -3,6 +3,7 @@ import { botActions } from '../game/bots';
|
||||
import { takeKullCard } from './fixture-choam-kull';
|
||||
import { orderNexusSpice } from './fixture-nexus-cards';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
|
||||
export function createNexusChoamKullFixture(options: {
|
||||
advanced?: boolean; bg?: boolean; seatIds?: string[]; nexusFace?: 'choam' | 'richese';
|
||||
@@ -79,6 +80,22 @@ export function createNexusChoamKullFixture(options: {
|
||||
original: original.id, fuel: fuel.id, counter: counter?.id ?? null };
|
||||
}
|
||||
|
||||
/** Follow the real pre-inspection queue; never clear native preparation by hand. */
|
||||
export function passNexusKullBorrowedVoice(state: Game): Game {
|
||||
let game = state;
|
||||
for (let n = 0; game.battle && n < 30 &&
|
||||
((game.battle.preLeader && !game.battle.preLeader.closed) ||
|
||||
game.battle.preparation?.kind === 'nexusVoice'); n++) {
|
||||
const next = nextStrongholdFactionsNativeStep(game);
|
||||
if (!next) throw new Error('The original pre-Voice preparation needs a legal native action.');
|
||||
game = applyAction(game, next.actor, next.action);
|
||||
}
|
||||
if ((game.battle?.preLeader && !game.battle.preLeader.closed) ||
|
||||
game.battle?.preparation?.kind === 'nexusVoice')
|
||||
throw new Error('The original pre-leader and borrowed Voice queues must actually finish.');
|
||||
return game;
|
||||
}
|
||||
|
||||
/** A native declaration is produced by actions before its Karama/Kull parent
|
||||
* is suspended. The final faction roster is fixed before genuine setup. */
|
||||
export function createNexusKullNativeParent(kind: 'inspection' | 'sardaukar' | 'advisors' | 'guild',
|
||||
@@ -116,8 +133,7 @@ export function createNexusKullNativeParent(kind: 'inspection' | 'sardaukar' | '
|
||||
game.active = choice.chooser;
|
||||
game = applyAction(game, choice.chooser, { type: 'chooseBattle', territory: choice.territory,
|
||||
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker });
|
||||
for (const id of [f.actor, f.other!]) if (game.battle!.preLeader && !game.battle!.preLeader.closed)
|
||||
game = applyAction(game, id, { type: 'battlePreparationReady', event: game.battle!.event });
|
||||
game = passNexusKullBorrowedVoice(game);
|
||||
if (kind === 'inspection') {
|
||||
if (game.response) game = allowNativeParent(game);
|
||||
game = applyAction(game, f.actor, { type: 'prescience', field: 'dial' });
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer,
|
||||
type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { TERRITORIES } from '../game/board';
|
||||
import { presenceAt } from '../game/force-presence';
|
||||
import { nextSemutaGuildIncomeNativeStep } from './fixture-semuta-guild-income';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
import { moveSemutaEmperorCard } from './fixture-semuta-emperor-share';
|
||||
|
||||
export type SemutaBgUse = 'shipment' | 'cancel' | 'purchase' | 'auctionPayment';
|
||||
export type SemutaBgStep = { actor: string; action: Action };
|
||||
const player = (game: Game, actor: string) => game.players.find(p => p.id === actor)!;
|
||||
const clean = (game: Game) => !game.response && !game.decision && !game.phaseOpening && !game.pendingTreacheryDiscard;
|
||||
|
||||
/** Original native BG/Richese/classic setup, actual paid Semuta, Karama and
|
||||
* Worthless auctions. Only original undealt lotteries/deck order are selected;
|
||||
* no played hand/army/wallet/phase/response is staged. */
|
||||
export function createOriginalSemutaBg(use: SemutaBgUse = 'shipment', homeworlds = false) {
|
||||
let game = createGame('SEMUTABGCONVERSION', newPlayer('r', 'Richese', 'richese'), true, ['choam']);
|
||||
for (const [id, name, faction] of [['b', 'Bene Gesserit', 'beneGesserit'], ['a', 'Atreides', 'atreides'],
|
||||
['e', 'Emperor', 'emperor'], ['g', 'Guild', 'guild']] as const) joinGame(game, newPlayer(id, name, faction));
|
||||
if (homeworlds) game = applyAction(game, 'r', { type: 'homeworlds', enabled: true });
|
||||
for (const p of game.players) game = applyAction(game, p.id, { type: 'ready' });
|
||||
game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game));
|
||||
const actions: SemutaBgStep[] = [];
|
||||
const act = (actor: string, action: Action) => {
|
||||
actions.push({ actor, action }); game = applyAction(game, actor, action);
|
||||
};
|
||||
const reach = (until: (state: Game) => boolean) => {
|
||||
for (let n = 0; !until(game) && n < 1800; n++) {
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const next = nextSemutaGuildIncomeNativeStep(game); act(next.actor, next.action);
|
||||
}
|
||||
assert.ok(until(game), `Original BG source stalled at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
};
|
||||
reach(state => state.status === 'playing');
|
||||
const setup = structuredClone(game);
|
||||
const ki = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(ki >= 0);
|
||||
const karama = game.deck.splice(ki, 1)[0];
|
||||
const wi = game.deck.findIndex(card => card.kind === 'worthless'); assert.ok(wi >= 0);
|
||||
const worthless = game.deck.splice(wi, 1)[0]; game.deck.unshift(karama, worthless);
|
||||
for (const position of [0, 1]) {
|
||||
const at = game.spiceDeck.findIndex((card, i) => i >= position && 'territory' in card);
|
||||
assert.ok(at >= position); game.spiceDeck.splice(position, 0, game.spiceDeck.splice(at, 1)[0]);
|
||||
}
|
||||
reach(state => state.decision?.kind === 'richeseCache');
|
||||
act('r', { type: 'decision', event: game.richeseBidding!.event, card: 'richese-semuta-drug',
|
||||
method: 'onceAround', direction: 'counterclockwise' });
|
||||
reach(state => !!state.richeseAuction && !state.richeseAuction.outcome &&
|
||||
state.richeseAuction.active === 'a' && clean(state));
|
||||
act('a', { type: 'richeseBid', event: game.richeseAuction!.event, amount: 1 });
|
||||
reach(state => player(state, 'a').hand.some(card => card.id === 'richese-semuta-drug') && clean(state));
|
||||
const boughtSemuta = structuredClone(game);
|
||||
reach(state => !!state.auction && state.auction.index === 0 && state.auction.active === 'e' && clean(state));
|
||||
act('e', { type: 'bid', amount: 1 });
|
||||
reach(state => player(state, 'e').hand.some(card => card.id === karama.id) && clean(state));
|
||||
const boughtKarama = structuredClone(game);
|
||||
reach(state => !!state.auction && state.auction.index === 1 && state.auction.active === 'b' && clean(state));
|
||||
act('b', { type: 'bid', amount: 1 });
|
||||
reach(state => player(state, 'b').hand.some(card => card.id === worthless.id) && clean(state));
|
||||
const boughtWorthless = structuredClone(game);
|
||||
let action: Action, lot: string | undefined, originalResponse: Game['response'] = null;
|
||||
let target: string | undefined;
|
||||
if (use === 'purchase' || use === 'auctionPayment') {
|
||||
reach(state => !!state.auction && state.auction.index === 2 && state.auction.active === 'b' && clean(state));
|
||||
lot = game.auction!.cards[game.auction!.index].id;
|
||||
if (use === 'purchase') action = { type: 'card', card: worthless.id, mode: 'purchase' };
|
||||
else {
|
||||
act('b', { type: 'bid', amount: player(game, 'b').spice + 1 });
|
||||
reach(state => state.decision?.kind === 'auctionPayment' && state.decision.player === 'b' && !state.response && !state.phaseOpening);
|
||||
action = { type: 'decision', karama: true, card: worthless.id };
|
||||
}
|
||||
} else {
|
||||
reach(state => state.phase === 5 && state.active === (use === 'cancel' ? 'g' : 'a') && clean(state));
|
||||
if (use === 'shipment') action = { type: 'card', card: worthless.id, mode: 'shipment', target: 'a' };
|
||||
else {
|
||||
const destination = TERRITORIES.find(t => t.type === 'sand' && t.sectors.some(s => s !== game.storm) &&
|
||||
game.players.every(p => presenceAt(p, t.id) === 0));
|
||||
assert.ok(destination); const sector = destination.sectors.find(s => s !== game.storm)!;
|
||||
target = `${destination.id}:${sector}`;
|
||||
act('g', { type: 'ship', territory: destination.id, sector, amount: 2, elite: 0, allyPayment: 0 });
|
||||
if (game.decision?.kind === 'guildShipment') act('g', { type: 'decision', allow: true });
|
||||
assert.equal(game.response?.kind, 'guildRate'); originalResponse = structuredClone(game.response);
|
||||
action = { type: 'card', card: worthless.id, mode: 'cancel' };
|
||||
}
|
||||
}
|
||||
const beforeCost = structuredClone(game);
|
||||
act('b', action);
|
||||
assert.equal(game.pendingTreacheryDiscard?.batch.cause, 'karama:conversion');
|
||||
assert.equal(game.pendingKarama, null);
|
||||
return { setup, boughtSemuta, boughtKarama, boughtWorthless, actions, beforeCost,
|
||||
game, action, use, homeworlds, lot, target, originalResponse,
|
||||
owner: 'b', holder: 'a', counter: 'e', beneficiary: 'a', guild: 'g',
|
||||
worthless, karama, semuta: 'richese-semuta-drug',
|
||||
provenance: [
|
||||
'Original five-faction Semuta setup and native BG choices; only original undealt entropy/deck order selected.',
|
||||
'Atreides pays1 for public Semuta; Emperor pays1 for normal Karama; BG pays1 for the actual normal Worthless.',
|
||||
'Real native phase/lot progression and optional native Guild declaration or actual BG overbid; no played state staged.',
|
||||
] };
|
||||
}
|
||||
|
||||
export function passSemutaBgResponse(state: Game, kind = 'worthlessKarama') {
|
||||
let game = state;
|
||||
for (let n = 0; game.response?.kind === kind && n < 30; n++) {
|
||||
const actor = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(actor);
|
||||
game = applyAction(game, actor.id, { type: 'passResponse' });
|
||||
}
|
||||
assert.notEqual(game.response?.kind, kind);
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Only native already-earned arrival/inspection continuations, never another
|
||||
* shipment, bid, card spend or invented controller. */
|
||||
export function settleSemutaBgNative(state: Game) {
|
||||
let game = state;
|
||||
for (let n = 0; n < 80; n++) {
|
||||
if (!game.response && !game.decision && !game.phaseOpening && !game.pendingTreacheryDiscard) return game;
|
||||
if (game.pendingTreacheryDiscard) throw Error('Resolve the actual fresh Semuta event first.');
|
||||
if (game.response) {
|
||||
const actor = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(actor);
|
||||
game = applyAction(game, actor.id, { type: 'passResponse' });
|
||||
} else if (game.decision?.kind === 'advisor' || game.decision?.kind === 'intrusion')
|
||||
game = applyAction(game, game.decision.player, { type: 'decision', accept: false });
|
||||
else if (game.decision?.kind === 'guildShipment') game = applyAction(game, game.decision.player, { type: 'decision', allow: true });
|
||||
else if (game.decision?.kind === 'homeworldShipmentGuild') game = applyAction(game, game.decision.player,
|
||||
{ type: 'decision', event: game.decision.event, allow: true });
|
||||
else throw Error(`Finish only the original native suffix: ${game.decision?.kind ?? 'phase opening'}`);
|
||||
}
|
||||
throw Error('Original BG native suffix did not settle.');
|
||||
}
|
||||
|
||||
/** CONTROLLED acquisition/phase fixture for a real subsequent CHOAM rediscard,
|
||||
* not a synthetic discard or an original-action acquisition claim. Native
|
||||
* roles/setup/stocks/wallets are genuine and are never replaced after setup. */
|
||||
export function createControlledBgClaimedPriceRefund() {
|
||||
let game = createGame('BGCLAIMEDCASHOUT', newPlayer('r', 'Richese', 'richese'), true, ['choam']);
|
||||
for (const [id, faction] of [['b', 'beneGesserit'], ['c', 'choam'], ['g', 'guild'], ['a', 'atreides']] as const)
|
||||
joinGame(game, newPlayer(id, faction, faction));
|
||||
for (const p of game.players) game = applyAction(game, p.id, { type: 'ready' });
|
||||
game = initializeSemutaGameForAudit(game);
|
||||
for (let n = 0; game.status === 'setup' && n < 300; n++) {
|
||||
const next = nextSemutaGuildIncomeNativeStep(game); game = applyAction(game, next.actor, next.action);
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
for (const p of game.players) game.deck.push(...p.hand.splice(0));
|
||||
const worthless = game.deck.find(card => card.kind === 'worthless'); assert.ok(worthless);
|
||||
const karamas = game.deck.filter(card => card.effect === 'karama').slice(0, 2);
|
||||
assert.equal(karamas.length, 2);
|
||||
moveSemutaEmperorCard(game, worthless.id, 'b');
|
||||
moveSemutaEmperorCard(game, karamas[0].id, 'c');
|
||||
moveSemutaEmperorCard(game, karamas[1].id, 'g');
|
||||
moveSemutaEmperorCard(game, 'richese-semuta-drug', 'c');
|
||||
game.phase = 5; game.active = 'a'; game.order = ['a', 'b', 'c', 'g', 'r'];
|
||||
game.movementRemaining = ['a', 'b', 'c', 'g', 'r']; game.ready = [];
|
||||
game.response = null; game.decision = null; game.phaseOpening = null;
|
||||
return { game, worthless, cashout: karamas[0], stop: karamas[1], holder: 'c',
|
||||
owner: 'b', beneficiary: 'a', guild: 'g', semuta: 'richese-semuta-drug',
|
||||
provenance: ['CONTROLLED conserved later acquisition and phase after genuine native BG/CHOAM/Richese/classic setup.',
|
||||
'Original native armies/wallets retained; actual BG conversion, Semuta recovery, CHOAM cashout and Guild stop thereafter.'] };
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, initializeNexusKullGameForAudit, joinGame, newPlayer, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { createNexusChoamKullFixture, createNexusKullNativeParent, createNexusKullNestedParent,
|
||||
createNexusKullAuctionParent } from './fixture-nexus-choam-kull';
|
||||
createNexusKullAuctionParent, passNexusKullBorrowedVoice } from './fixture-nexus-choam-kull';
|
||||
import { choamKullGame, kullShipmentAttempt, takeKullCard } from './fixture-choam-kull';
|
||||
import { spiceDeck } from '../game/cards';
|
||||
import { createNexusCardPhase } from '../game/nexus-card-phase';
|
||||
@@ -243,22 +243,15 @@ void test('printed Kull discovers a suspended Nexus parent through direct and BG
|
||||
});
|
||||
|
||||
void test('Nexus fuel needed by a live own Truthtrance battle promise cannot be accepted', () => {
|
||||
const f = createNexusChoamKullFixture({ fuel: 'weapon' });
|
||||
const f = createNexusChoamKullFixture({ fuel: 'weapon', actorFaction: 'atreides' });
|
||||
let game = applyAction(f.game, f.choam, { type: 'kullDecision', event: f.event, decline: true });
|
||||
Object.assign(game, { phase: 6, active: f.choam, karamaShipping: null });
|
||||
for (const p of game.players) { p.forces = {}; p.reserves = 20; }
|
||||
for (const p of game.players.slice(0, 2)) { p.forces = { 'arrakeen:10': 3 }; p.reserves = 17; }
|
||||
game = applyAction(game, f.choam, { type: 'chooseBattle', territory: 'arrakeen', target: f.actor });
|
||||
for (let step = 0; game.response && step < 20; step++) {
|
||||
const p = game.players.find(p => {
|
||||
const controls = viewGame(game, p.id).responseControls;
|
||||
return controls && !controls.hasPassed && controls.cancelCards.length > 0;
|
||||
});
|
||||
game = p ? applyAction(game, p.id, { type: 'passResponse' }) : normalizeAutomaticGame(game);
|
||||
}
|
||||
game.battle!.preparation = undefined;
|
||||
game = passNexusKullBorrowedVoice(game);
|
||||
game.battle!.truthPromises = [{ player: f.choam, asker: f.other!, claim: { kind: 'weapon', name: 'Hunter Seeker' }, answer: true }];
|
||||
game.response = { kind: 'eliteStrength', owner: f.actor, passed: [] };
|
||||
game = applyAction(game, f.actor, { type: 'prescience', field: 'dial' });
|
||||
const offered = applyAction(game, f.other!, { type: 'card', mode: 'cancel', card: f.counter! });
|
||||
assert.equal(viewGame(offered, f.choam).kullReaction!.plays.some(play => play.card.id === f.fuel), false);
|
||||
reject(offered, f.choam, { type: 'kullDecision', event: offered.pendingKull!.event, source: 'nexus', card: f.fuel });
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { TERRITORIES } from '../game/board';
|
||||
import { presenceAt } from '../game/force-presence';
|
||||
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
|
||||
import {
|
||||
createOriginalSemutaBg, createControlledBgClaimedPriceRefund,
|
||||
passSemutaBgResponse, settleSemutaBgNative, type SemutaBgUse,
|
||||
} from './fixture-semuta-bg-karama';
|
||||
import { reactSemutaSpecial } from './fixture-semuta-special-karama';
|
||||
import { passSemutaSpecialDiscards } from './fixture-semuta-emperor-share';
|
||||
|
||||
const player = (game: Game, actor: string) => game.players.find(p => p.id === actor)!;
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
function ordinaryPriceShipment(game: Game): Action {
|
||||
const destination = TERRITORIES.find(t => t.type === 'sand' && t.sectors.some(s => s !== game.storm) &&
|
||||
game.players.every(p => presenceAt(p, t.id) === 0));
|
||||
assert.ok(destination);
|
||||
return { type: 'ship', territory: destination.id, sector: destination.sectors.find(s => s !== game.storm)!,
|
||||
amount: 3, elite: 0, allyPayment: 0 };
|
||||
}
|
||||
|
||||
for (const use of ['shipment', 'cancel', 'purchase', 'auctionPayment'] as SemutaBgUse[])
|
||||
void test(`original BG ${use} keeps both fresh costs and its original native allowance or denial`, () => {
|
||||
for (const claim of [false, true]) for (const canceled of [false, true]) {
|
||||
const f = createOriginalSemutaBg(use), before = structuredClone(f.beforeCost);
|
||||
let game = f.game;
|
||||
assert.equal(game.pendingTreacheryDiscard?.batch.cause, 'karama:conversion');
|
||||
assert.equal(game.pendingKarama, null);
|
||||
if (use === 'shipment') assert.equal(game.karamaShipping, null);
|
||||
reject(game, f.owner, f.action);
|
||||
game = reactSemutaSpecial(game, f.holder, claim);
|
||||
assert.equal(game.response?.kind, 'worthlessKarama');
|
||||
assert.equal(player(game, f.owner).hand.some(c => c.id === f.worthless.id), false);
|
||||
assert.ok(viewGame(game, f.counter).responseControls!.cancelCards.includes(f.karama.id));
|
||||
if (canceled) {
|
||||
game = applyAction(game, f.counter, { type: 'card', mode: 'cancel', card: f.karama.id });
|
||||
assert.equal(game.pendingTreacheryDiscard?.batch.cause, 'karama:worthlessKarama');
|
||||
game = reactSemutaSpecial(game, f.holder, !claim);
|
||||
assert.equal(player(game, f.holder).hand.some(c => c.id === f.karama.id), !claim);
|
||||
} else game = passSemutaBgResponse(game);
|
||||
assert.equal(game.pendingKarama, null);
|
||||
const recovered = player(game, f.holder).hand.find(c => c.id === f.worthless.id);
|
||||
assert.equal(!!recovered, claim);
|
||||
if (recovered) assert.deepEqual(recovered, f.worthless);
|
||||
assert.equal(game.discard.filter(c => c.id === f.semuta).length, claim || canceled ? 1 : 0);
|
||||
if (use === 'shipment') {
|
||||
if (canceled) {
|
||||
assert.equal(game.karamaShipping, null);
|
||||
assert.equal(player(game, f.beneficiary).spice, player(before, f.beneficiary).spice);
|
||||
assert.equal(player(game, f.beneficiary).reserves, player(before, f.beneficiary).reserves);
|
||||
assert.equal(player(game, f.beneficiary).shipped, false);
|
||||
} else {
|
||||
assert.equal(viewGame(game, f.beneficiary).karamaShipping!.owner, f.owner);
|
||||
const shipment = ordinaryPriceShipment(game);
|
||||
game = settleSemutaBgNative(applyAction(game, f.beneficiary, shipment));
|
||||
assert.equal(player(game, f.beneficiary).spice, player(before, f.beneficiary).spice - 3);
|
||||
assert.equal(player(game, f.beneficiary).reserves, player(before, f.beneficiary).reserves - 3);
|
||||
assert.equal(player(game, f.guild).spice, player(before, f.guild).spice);
|
||||
assert.equal(player(game, f.beneficiary).forces[`${String(shipment.territory)}:${Number(shipment.sector)}`], 3);
|
||||
assert.equal(game.karamaShipping, null);
|
||||
}
|
||||
} else if (use === 'cancel') {
|
||||
// Original already-passed seats stay passed: native normalization may
|
||||
// finish this restored response automatically. Assert its actual price.
|
||||
if (game.response?.kind === 'guildRate') game = passSemutaBgResponse(game, 'guildRate');
|
||||
game = settleSemutaBgNative(game);
|
||||
assert.equal(player(game, f.guild).spice, player(before, f.guild).spice - (canceled ? 2 : 4));
|
||||
assert.equal(player(game, f.guild).reserves, player(before, f.guild).reserves - 2);
|
||||
assert.equal(player(game, f.guild).forces[f.target!], 2);
|
||||
assert.equal(player(game, f.guild).shipped, true);
|
||||
assert.equal(player(game, f.guild).moved, 0);
|
||||
} else {
|
||||
assert.equal(player(game, f.owner).hand.filter(c => c.id === f.lot).length, canceled ? 0 : 1);
|
||||
assert.equal(player(game, f.owner).spice, player(before, f.owner).spice);
|
||||
assert.equal(player(game, f.owner).hand.some(c => c.id === f.worthless.id), false);
|
||||
if (canceled) assert.equal(game.auction!.cards[game.auction!.index].id, f.lot);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('an original converted price retains exact Caladan withdrawal and bank-only world payment after cost recovery', () => {
|
||||
for (const claim of [false, true]) {
|
||||
const f = createOriginalSemutaBg('shipment', true);
|
||||
const before = structuredClone(f.beforeCost);
|
||||
let game = passSemutaBgResponse(reactSemutaSpecial(f.game, f.holder, claim));
|
||||
const quote = homeworldShipmentChoice(viewGame(game, f.beneficiary), 'homeworld:emperor',
|
||||
{ 'homeworld:atreides': { normal: 3, elite: 0 } });
|
||||
assert.ok(quote.action, quote.blocked ?? undefined); assert.equal(quote.cost, 2);
|
||||
game = settleSemutaBgNative(applyAction(game, f.beneficiary, quote.action));
|
||||
assert.equal(player(game, f.beneficiary).spice, player(before, f.beneficiary).spice - 2);
|
||||
assert.equal(player(game, f.beneficiary).reserves, player(before, f.beneficiary).reserves - 3);
|
||||
assert.equal(player(game, f.guild).spice, player(before, f.guild).spice);
|
||||
assert.deepEqual(game.homeworlds!.custody!.visitors['homeworld:emperor'][f.beneficiary], { normal: 3, elite: 0 });
|
||||
assert.equal(player(game, f.holder).hand.some(c => c.id === f.worthless.id), claim);
|
||||
assert.equal(game.karamaShipping, null);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a controlled real CHOAM rediscard cannot revive refund eligibility for a recovered converted price cost', () => {
|
||||
const f = createControlledBgClaimedPriceRefund();
|
||||
let game = applyAction(f.game, f.owner, { type: 'card', card: f.worthless.id,
|
||||
mode: 'shipment', target: f.beneficiary });
|
||||
game = passSemutaBgResponse(reactSemutaSpecial(game, f.holder, true));
|
||||
const cashoutSpice = player(game, f.holder).spice;
|
||||
game = applyAction(game, f.holder, { type: 'card', mode: 'special', card: f.cashout.id,
|
||||
cards: [f.worthless.id] });
|
||||
game = passSemutaSpecialDiscards(game);
|
||||
assert.equal(player(game, f.holder).spice, cashoutSpice + 3);
|
||||
assert.equal(game.discard.filter(c => c.id === f.worthless.id).length, 1);
|
||||
const before = structuredClone(player(game, f.beneficiary));
|
||||
game = applyAction(game, f.beneficiary, ordinaryPriceShipment(game));
|
||||
game = applyAction(game, f.guild, { type: 'card', mode: 'special', card: f.stop.id });
|
||||
game = passSemutaSpecialDiscards(game);
|
||||
assert.equal(player(game, f.owner).hand.some(c => c.id === f.worthless.id), false);
|
||||
assert.equal(game.discard.filter(c => c.id === f.worthless.id).length, 1);
|
||||
assert.equal(player(game, f.beneficiary).spice, before.spice);
|
||||
assert.equal(player(game, f.beneficiary).reserves, before.reserves);
|
||||
assert.equal(player(game, f.beneficiary).shipped, true);
|
||||
assert.equal(player(game, f.beneficiary).moved, 0);
|
||||
assert.equal(game.karamaShipping, null);
|
||||
});
|
||||
|
||||
void test('recovering a Worthless does not grant its new faction the native BG conversion role', () => {
|
||||
const f = createOriginalSemutaBg();
|
||||
let game = reactSemutaSpecial(f.game, f.holder, true);
|
||||
game = applyAction(game, f.counter, { type: 'card', card: f.karama.id, mode: 'cancel' });
|
||||
game = reactSemutaSpecial(game, f.holder, false);
|
||||
assert.equal(game.karamaShipping, null);
|
||||
assert.equal(game.active, f.beneficiary);
|
||||
assert.equal(player(game, f.beneficiary).shipped, false);
|
||||
assert.deepEqual(player(game, f.holder).hand.find(c => c.id === f.worthless.id), f.worthless);
|
||||
reject(game, f.holder, { type: 'card', card: f.worthless.id, mode: 'shipment', target: f.beneficiary });
|
||||
});
|
||||
|
||||
void test('changed source and stale conversion/counter events cannot redirect the original physical cost or power', () => {
|
||||
const f = createOriginalSemutaBg();
|
||||
const event = f.game.pendingTreacheryDiscard!.batch.event;
|
||||
reject(f.game, f.owner, { type: 'semutaCommit', event });
|
||||
const changed = structuredClone(f.game);
|
||||
const continuation = changed.pendingTreacheryDiscard!.continuation;
|
||||
assert.ok(continuation?.kind === 'bgKaramaCostDiscard' && continuation.use.kind === 'shipment');
|
||||
continuation.use.recipient = f.guild;
|
||||
reject(changed, f.holder, { type: 'semutaCommit', event });
|
||||
let game = reactSemutaSpecial(f.game, f.holder, false);
|
||||
game = applyAction(game, f.counter, { type: 'card', card: f.karama.id, mode: 'cancel' });
|
||||
reject(game, f.holder, { type: 'semutaCommit', event });
|
||||
game = reactSemutaSpecial(game, f.holder, true);
|
||||
assert.equal(game.karamaShipping, null);
|
||||
assert.equal(player(game, f.holder).hand.filter(c => c.id === f.karama.id).length, 1);
|
||||
assert.equal(game.discard.filter(c => c.id === f.worthless.id).length, 1);
|
||||
});
|
||||
Reference in new issue
Block a user