Resume printed native specials through Semuta cost and cashout stages
This commit is contained in:
1 parent
44c2bf0da4
commit
8a2df97be8
14 files changed
+656
-35
No files matched your search
@@ -7,7 +7,7 @@ import type { Card } from '../game/cards';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { nextRicheseSkillsTechNativeStep } from './fixture-richese-skills-tech';
|
||||
import {
|
||||
moveSemutaEmperorCard, semutaEmperorCardId, semutaEmperorPlayer,
|
||||
moveSemutaEmperorCard, semutaEmperorCardId, semutaEmperorPlayer, passSemutaSpecialDiscards,
|
||||
} from './fixture-semuta-emperor-share';
|
||||
|
||||
export type SemutaEmperorIncomeKind = 'emperorIncome' | 'richesePurchaseIncome';
|
||||
@@ -138,6 +138,7 @@ export function createSemutaEmperorIncomeFixture(
|
||||
const purchase: Action = { type: 'card', mode: 'special', card: special!, acquire: acquired };
|
||||
sourceActions.push({ actor: 'r', action: purchase });
|
||||
game = applyAction(game, 'r', purchase);
|
||||
game = passSemutaSpecialDiscards(game, sourceActions);
|
||||
} else {
|
||||
if (!options.acquiredKarama) {
|
||||
const first = game.deck.find(card => card.effect !== 'karama');
|
||||
|
||||
@@ -127,3 +127,23 @@ export function semutaEmperorSharePhysical(game: Game) {
|
||||
export function semutaEmperorShareViews(game: Game): void {
|
||||
for (const player of game.players) viewGame(game, player.id);
|
||||
}
|
||||
|
||||
/** Pass only original special costs/results; never skip a committed private selection. */
|
||||
export function passSemutaSpecialDiscards(
|
||||
state: Game, actions?: { actor: string; action: Action }[],
|
||||
): Game {
|
||||
let game = state;
|
||||
for (let n = 0; game.pendingTreacheryDiscard?.batch.cause.startsWith('specialKarama:') && n < 20; n++) {
|
||||
const pending = game.pendingTreacheryDiscard;
|
||||
const reaction = pending.reaction; assert.ok(reaction?.stage === 'offer');
|
||||
const event = pending.batch.event;
|
||||
for (const seat of game.players.filter(p => !reaction.passed.includes(p.id))) {
|
||||
const action: Action = { type: 'semutaPass', event };
|
||||
actions?.push({ actor: seat.id, action });
|
||||
game = applyAction(game, seat.id, action);
|
||||
}
|
||||
}
|
||||
assert.ok(!game.pendingTreacheryDiscard?.batch.cause.startsWith('specialKarama:'),
|
||||
'The native special discard continuation must actually retire.');
|
||||
return game;
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { nextRicheseSkillsTechNativeStep } from './fixture-richese-skills-tech';
|
||||
import { semutaEmperorIncomeCards } from './fixture-semuta-emperor-income';
|
||||
import {
|
||||
moveSemutaEmperorCard, semutaEmperorCardId, semutaEmperorPlayer,
|
||||
moveSemutaEmperorCard, semutaEmperorCardId, semutaEmperorPlayer, passSemutaSpecialDiscards,
|
||||
} from './fixture-semuta-emperor-share';
|
||||
|
||||
export type SemutaHomeworldEmperorIncomeKind = 'emperorIncome' | 'richesePurchaseIncome';
|
||||
@@ -208,6 +208,7 @@ export function createSemutaHomeworldEmperorIncomeFixture(
|
||||
beforePayment = structuredClone(game);
|
||||
acquired = game.richeseCache![0].id;
|
||||
act({ actor: 'r', action: { type: 'card', mode: 'special', card: special!, acquire: acquired } }, sourceActions);
|
||||
game = passSemutaSpecialDiscards(game, sourceActions);
|
||||
} else {
|
||||
acquired = options.acquiredKarama ? karama : game.deck.find(card => card.effect !== 'karama')!.id;
|
||||
nextOriginalDraw(game, acquired);
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer,
|
||||
viewGame, type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import { TERRITORIES, MOBILE_LOCATION, mobileRoutes } from '../game/board';
|
||||
import { presenceAt } from '../game/force-presence';
|
||||
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
|
||||
import { createSemutaGuildTransport } from './fixture-semuta-guild-transport';
|
||||
import { nextSemutaGuildIncomeNativeStep } from './fixture-semuta-guild-income';
|
||||
import { moveSemutaEmperorCard, passSemutaSpecialDiscards } from './fixture-semuta-emperor-share';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
|
||||
export type SemutaSpecialStep = { 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;
|
||||
|
||||
/** Two original paid auctions deliberately give Semuta to the shipper and the
|
||||
* printed Karama to native Guild. No played hand, wallet, force or phase is assigned. */
|
||||
export function createOriginalSemutaSpecialGuild(homeworlds = false) {
|
||||
const original = createSemutaGuildTransport({ route: homeworlds ? 'world' : 'reserve' });
|
||||
let game = structuredClone(original.setup);
|
||||
const actions: SemutaSpecialStep[] = [];
|
||||
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 < 1600; n++) {
|
||||
const next = nextSemutaGuildIncomeNativeStep(game); act(next.actor, next.action);
|
||||
}
|
||||
assert.ok(until(game));
|
||||
};
|
||||
const at = game.deck.findIndex(c => c.effect === 'karama'); assert.ok(at >= 0);
|
||||
const card = game.deck.splice(at, 1)[0]; game.deck.unshift(card);
|
||||
for (const position of [0, 1]) {
|
||||
const index = game.spiceDeck.findIndex((c, i) => i >= position && 'territory' in c);
|
||||
assert.ok(index >= position); game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
|
||||
}
|
||||
reach(state => state.decision?.kind === 'richeseCache');
|
||||
act('r', { type: 'decision', event: game.richeseBidding!.event, card: SEMUTA_DRUG_ID,
|
||||
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(c => c.id === SEMUTA_DRUG_ID) && clean(state));
|
||||
const boughtSemuta = structuredClone(game);
|
||||
reach(state => !!state.auction && state.auction.index === 0 && state.auction.active === 'g' && clean(state));
|
||||
act('g', { type: 'bid', amount: 1 });
|
||||
reach(state => player(state, 'g').hand.some(c => c.id === card.id) && clean(state));
|
||||
const boughtKarama = structuredClone(game);
|
||||
reach(state => state.phase === 5 && state.active === 'a' && clean(state));
|
||||
const beforeShipment = structuredClone(game);
|
||||
let shipment: Action, target: string;
|
||||
if (homeworlds) {
|
||||
const quote = homeworldShipmentChoice(viewGame(game, 'a'), 'homeworld:emperor',
|
||||
{ 'homeworld:atreides': { normal: 2, elite: 0 } });
|
||||
assert.ok(quote.action, quote.blocked ?? undefined); shipment = quote.action; target = 'homeworld:emperor';
|
||||
} 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)!;
|
||||
shipment = { type: 'ship', territory: destination.id, sector, amount: 2, elite: 0, allyPayment: 0 };
|
||||
target = `${destination.id}:${sector}`;
|
||||
}
|
||||
act('a', shipment);
|
||||
assert.equal(game.decision?.kind, homeworlds ? 'homeworldShipmentGuild' : 'guildShipment');
|
||||
return { original, actions, beforeShipment, beforeCost: structuredClone(game), game,
|
||||
boughtSemuta, boughtKarama, homeworlds, shipment, target,
|
||||
owner: 'g', holder: 'a', shipper: 'a', karama: card.id, semuta: SEMUTA_DRUG_ID,
|
||||
action: { type: 'card', mode: 'special', card: card.id } as Action,
|
||||
provenance: [
|
||||
'Original printed Semuta setup; only original undealt lottery and conserved unplayed deck order selected.',
|
||||
'Atreides pays1 for the actual public cache Semuta; Guild pays1 for the actual normal Karama auction.',
|
||||
'Original Atreides shipment and actual native interception; no played cards/forces/wallets/phase staged.',
|
||||
] };
|
||||
}
|
||||
|
||||
export function reactSemutaSpecial(game: Game, holder: string, claim: boolean, card?: string): Game {
|
||||
const pending = game.pendingTreacheryDiscard; assert.ok(pending);
|
||||
assert.ok(pending.reaction?.stage === 'offer'); const event = pending.batch.event;
|
||||
if (!claim) {
|
||||
const reaction = pending.reaction;
|
||||
for (const seat of game.players.filter(p => !reaction.passed.includes(p.id)))
|
||||
game = applyAction(game, seat.id, { type: 'semutaPass', event });
|
||||
return game;
|
||||
}
|
||||
game = applyAction(game, holder, { type: 'semutaCommit', event });
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') {
|
||||
assert.ok(card, 'Private multi-card recovery needs the caller’s actual selection.');
|
||||
assert.ok(viewGame(game, holder).semutaReaction!.candidates.some(c => c.id === card));
|
||||
game = applyAction(game, holder, { type: 'semutaSelect', event, card });
|
||||
}
|
||||
return game;
|
||||
}
|
||||
|
||||
export type ControlledSemutaSpecialKind = 'emperorForces' | 'emperorLeader' | 'choam' |
|
||||
'richese' | 'ixians' | 'tleilaxu' | 'fremen' | 'atreides' | 'harkonnen';
|
||||
|
||||
/** CONTROLLED native-effect fixture: genuine native Semuta setup, then conserved
|
||||
* later hands/armies/wallets and phase are staged to isolate each printed boundary.
|
||||
* These are not original-action acquisition or full-game claims. */
|
||||
export function createControlledSemutaSpecial(kind: ControlledSemutaSpecialKind) {
|
||||
const faction: FactionId = kind === 'emperorForces' || kind === 'emperorLeader' ? 'emperor' : kind;
|
||||
let game = createGame('CONTROLLEDSPECIALSEMUTA', newPlayer('p', 'Owner', faction), true,
|
||||
['choam', ...(['ixians', 'tleilaxu'].includes(faction) ? ['ix' as const] : [])]);
|
||||
joinGame(game, newPlayer('r', faction === 'richese' ? 'Guild' : 'Richese', faction === 'richese' ? 'guild' : 'richese'));
|
||||
joinGame(game, newPlayer('h', 'Holder', faction === 'atreides' ? 'guild' : 'atreides'));
|
||||
for (const seat of game.players) game = applyAction(game, seat.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));
|
||||
p.forces = {}; p.reserves = 20; p.tanks = 0; p.spice = 20; p.shipped = false; p.moved = 0;
|
||||
if (p.elites) {
|
||||
p.elites.reserves += p.elites.tanks + Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0);
|
||||
p.elites.forces = {}; p.elites.tanks = 0; p.elites.revived = 0;
|
||||
}
|
||||
}
|
||||
const karama = game.deck.find(c => c.effect === 'karama'); assert.ok(karama);
|
||||
moveSemutaEmperorCard(game, karama.id, 'p'); moveSemutaEmperorCard(game, SEMUTA_DRUG_ID, 'h');
|
||||
game.phase = 4; game.active = 'p'; game.order = ['p', 'r', 'h'];
|
||||
game.movementRemaining = ['p', 'r', 'h']; game.ready = [];
|
||||
game.response = null; game.decision = null; game.phaseOpening = null;
|
||||
game.storm = 18; game.spiceWindow = null; game.spiceResolution = null; game.spice = {};
|
||||
const owner = player(game, 'p'), holder = player(game, 'h');
|
||||
const action: Action = { type: 'card', mode: 'special', card: karama.id };
|
||||
let selected: string[] = [], acquired: string | undefined, target: string | undefined;
|
||||
if (kind === 'emperorForces') {
|
||||
owner.reserves = 17; owner.tanks = 3; action.amount = 2; action.elite = 0;
|
||||
} else if (kind === 'emperorLeader') {
|
||||
owner.leaders[0].dead = true; action.leader = owner.leaders[0].id;
|
||||
} else if (kind === 'choam') {
|
||||
game.phase = 3;
|
||||
const cards = game.deck.filter(c => c.effect !== 'karama' && c.id !== SEMUTA_DRUG_ID).slice(0, 2);
|
||||
for (const card of cards) moveSemutaEmperorCard(game, card.id, 'p');
|
||||
selected = cards.map(c => c.id); action.cards = selected;
|
||||
} else if (kind === 'richese') {
|
||||
game.phase = 3; acquired = game.richeseCache![0].id; action.acquire = acquired;
|
||||
} else if (kind === 'ixians') {
|
||||
game.phase = 5; game.mobileStronghold = { location: 'polar_sink:0' };
|
||||
owner.forces = { [MOBILE_LOCATION]: 1 }; owner.reserves = 19;
|
||||
action.route = mobileRoutes(game, 2)[0]; action.collect = false;
|
||||
} else if (kind === 'tleilaxu') {
|
||||
holder.reserves = 18; holder.tanks = 2; game.active = 'h';
|
||||
game = applyAction(game, 'h', { type: 'revive', amount: 2, elite: 0 });
|
||||
assert.equal(game.decision?.kind, 'revivalStop');
|
||||
} else if (kind === 'fremen') {
|
||||
game.phase = 1;
|
||||
const destination = TERRITORIES.find(t => t.type === 'sand' && t.sectors.some(s => s !== game.storm));
|
||||
assert.ok(destination); const sector = destination.sectors.find(s => s !== game.storm)!;
|
||||
target = `${destination.id}:${sector}`; holder.forces[target] = 2; holder.reserves = 18;
|
||||
action.territory = destination.id;
|
||||
} else if (kind === 'atreides') {
|
||||
game.phase = 6; owner.forces = { 'arrakeen:10': 2 }; owner.reserves = 18;
|
||||
holder.forces = { 'arrakeen:10': 2 }; holder.reserves = 18;
|
||||
game = applyAction(game, 'p', { type: 'chooseBattle', territory: 'arrakeen', target: 'h' });
|
||||
for (let n = 0; n < 40 && (game.response || game.battle?.preparation); n++) {
|
||||
if (game.response) game = applyAction(game,
|
||||
game.players.find(p => !game.response!.passed.includes(p.id))!.id, { type: 'passResponse' });
|
||||
else game = applyAction(game, game.battle!.preparation!.owner, { type: 'declineBattlePower' });
|
||||
}
|
||||
assert.equal(game.decision?.kind, 'fullPlanOffer'); action.target = 'h';
|
||||
} else {
|
||||
game.phase = 3; target = 'h'; action.target = target; action.amount = 1;
|
||||
const cards = game.deck.filter(c => c.effect !== 'karama' && c.id !== SEMUTA_DRUG_ID).slice(0, 2);
|
||||
assert.equal(cards.length, 2);
|
||||
moveSemutaEmperorCard(game, cards[0].id, 'p'); selected = [cards[0].id];
|
||||
moveSemutaEmperorCard(game, cards[1].id, 'h');
|
||||
}
|
||||
return { kind, beforeCost: structuredClone(game), game, action, owner: 'p', holder: 'h',
|
||||
karama: karama.id, semuta: SEMUTA_DRUG_ID, selected, acquired, target,
|
||||
provenance: ['CONTROLLED genuine native Semuta setup, then conserved later hands/armies/wallets/phase.',
|
||||
'Actual native battle/revival declarations where needed; actual printed activation and Semuta reactions thereafter.'] };
|
||||
}
|
||||
|
||||
export { passSemutaSpecialDiscards };
|
||||
@@ -270,7 +270,7 @@ void test('Guild and Tleilaxu intentions bind the actual declaration, not just i
|
||||
}
|
||||
});
|
||||
|
||||
void test('Harkonnen preparation does not expose or select unseen cards; only commitment draws them', (t) => {
|
||||
void test('Harkonnen preparation withholds unseen identities until the actual hand transfer', () => {
|
||||
const g = fixture('harkonnen');
|
||||
g.phase = 3;
|
||||
const foreign = [hold(g, 'q', 'Baliset'), hold(g, 'q', 'Snooper')];
|
||||
@@ -280,9 +280,7 @@ void test('Harkonnen preparation does not expose or select unseen cards; only co
|
||||
action(g, { target: 'q', amount: 1 }),
|
||||
);
|
||||
assert.ok(foreign.every((id) => !JSON.stringify(intent).includes(id)));
|
||||
const draw = t.mock.method(crypto, 'getRandomValues');
|
||||
executeSpecialKaramaIntent(g, intent);
|
||||
assert.ok(draw.mock.callCount() > 0);
|
||||
assert.equal(g.players[1].hand.length, 1);
|
||||
assert.equal(g.players[0].hand.length, 1);
|
||||
assert.equal(g.decision?.kind, 'handExchange');
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
createSemutaShipmentPrice, reactSemutaShipmentPrice,
|
||||
semutaPurchasedShipment, allowSemutaPurchasedShipment,
|
||||
} from './fixture-semuta-shipment-price';
|
||||
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) {
|
||||
@@ -54,6 +55,8 @@ for (const homeworlds of [false, true])
|
||||
const shipment = semutaPurchasedShipment(game, homeworlds);
|
||||
game = applyAction(game, f.beneficiary, shipment.action);
|
||||
game = applyAction(game, f.guild, { type: 'card', card: f.karama, mode: 'special' });
|
||||
assert.equal(player(game, f.beneficiary).shipped, false);
|
||||
game = passSemutaSpecialDiscards(game);
|
||||
const shipper = player(game, f.beneficiary);
|
||||
assert.equal(shipper.spice, original.spice);
|
||||
assert.equal(shipper.reserves, original.reserves);
|
||||
@@ -83,6 +86,7 @@ void test('declining the price cost retains its existing exact-card refund when
|
||||
const shipment = semutaPurchasedShipment(game, homeworlds);
|
||||
game = applyAction(game, f.beneficiary, shipment.action);
|
||||
game = applyAction(game, f.guild, { type: 'card', card: stop.id, mode: 'special' });
|
||||
game = passSemutaSpecialDiscards(game);
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import {
|
||||
createOriginalSemutaSpecialGuild, createControlledSemutaSpecial,
|
||||
reactSemutaSpecial, type ControlledSemutaSpecialKind,
|
||||
} from './fixture-semuta-special-karama';
|
||||
|
||||
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 exactCost(game: Game, id: string) {
|
||||
assert.equal(game.discard.filter(c => c.id === id).length + game.deck.filter(c => c.id === id).length +
|
||||
game.players.reduce((sum, p) => sum + p.hand.filter(c => c.id === id).length, 0), 1);
|
||||
}
|
||||
|
||||
for (const homeworlds of [false, true])
|
||||
void test(`original ${homeworlds ? 'Homeworld' : 'reserve'} Guild stop waits for cost recovery and then prevents the same unpaid shipment`, () => {
|
||||
for (const claim of [false, true]) {
|
||||
const f = createOriginalSemutaSpecialGuild(homeworlds);
|
||||
let game = applyAction(f.beforeCost, f.owner, f.action);
|
||||
assert.equal(game.pendingTreacheryDiscard?.batch.cause,
|
||||
homeworlds ? 'specialKarama:guildHomeworld' : 'specialKarama:guild');
|
||||
assert.equal(player(game, f.shipper).shipped, false);
|
||||
assert.equal(player(game, f.shipper).spice, 9);
|
||||
assert.equal(player(game, f.shipper).reserves, 10);
|
||||
assert.equal(player(game, f.owner).specialKaramaUsed, undefined);
|
||||
reject(game, f.shipper, { type: 'endMovement' });
|
||||
game = reactSemutaSpecial(game, f.holder, claim);
|
||||
assert.equal(player(game, f.shipper).shipped, true);
|
||||
assert.equal(player(game, f.shipper).spice, 9);
|
||||
assert.equal(player(game, f.shipper).reserves, 10);
|
||||
assert.equal(player(game, f.shipper).moved, 0);
|
||||
assert.equal(player(game, f.owner).specialKaramaUsed, true);
|
||||
assert.equal(player(game, f.holder).hand.some(c => c.id === f.karama), claim);
|
||||
assert.equal(game.discard.filter(c => c.id === f.semuta).length, claim ? 1 : 0);
|
||||
if (homeworlds) assert.equal(game.homeworlds!.custody!.visitors[f.target]?.[f.shipper], undefined);
|
||||
else assert.equal(player(game, f.shipper).forces[f.target] ?? 0, 0);
|
||||
exactCost(game, f.karama);
|
||||
reject(game, f.owner, f.action);
|
||||
game = applyAction(game, f.shipper, { type: 'endMovement' });
|
||||
assert.notEqual(game.active, f.shipper);
|
||||
}
|
||||
});
|
||||
|
||||
for (const kind of ['emperorForces', 'emperorLeader', 'choam', 'richese', 'ixians',
|
||||
'tleilaxu', 'fremen', 'atreides', 'harkonnen'] as ControlledSemutaSpecialKind[])
|
||||
void test(`controlled native ${kind} preserves its own cost/effect order across Semuta claim or decline`, () => {
|
||||
for (const claim of [false, true]) {
|
||||
const f = createControlledSemutaSpecial(kind);
|
||||
const before = structuredClone(f.beforeCost);
|
||||
let game = applyAction(f.beforeCost, f.owner, f.action);
|
||||
assert.equal(game.pendingTreacheryDiscard?.batch.cause, `specialKarama:${kind}`);
|
||||
if (kind === 'emperorForces') {
|
||||
assert.equal(player(game, f.owner).reserves, 19);
|
||||
assert.equal(player(game, f.owner).tanks, 1);
|
||||
assert.equal(player(game, f.owner).specialKaramaUsed, true);
|
||||
} else if (kind === 'emperorLeader') {
|
||||
assert.equal(player(game, f.owner).leaders[0].dead, false);
|
||||
assert.equal(player(game, f.owner).specialKaramaUsed, true);
|
||||
} else {
|
||||
assert.equal(player(game, f.owner).specialKaramaUsed, undefined);
|
||||
assert.equal(player(game, f.owner).spice, player(before, f.owner).spice);
|
||||
assert.equal(player(game, f.owner).reserves, player(before, f.owner).reserves);
|
||||
}
|
||||
if (kind === 'richese') {
|
||||
assert.ok(game.richeseCache!.some(c => c.id === f.acquired));
|
||||
assert.equal(player(game, f.owner).hand.some(c => c.id === f.acquired), false);
|
||||
}
|
||||
if (kind === 'fremen') assert.equal(player(game, f.holder).tanks, 0);
|
||||
game = reactSemutaSpecial(game, f.holder, claim);
|
||||
if (kind === 'choam') {
|
||||
assert.equal(player(game, f.owner).spice, 26);
|
||||
assert.equal(game.pendingTreacheryDiscard?.batch.cause, 'specialKarama:choamCashout');
|
||||
game = reactSemutaSpecial(game, f.holder, !claim, !claim ? f.selected[0] : undefined);
|
||||
assert.equal(player(game, f.owner).spice, 26);
|
||||
if (!claim) assert.ok(player(game, f.holder).hand.some(c => c.id === f.selected[0]));
|
||||
} else if (kind === 'emperorForces') {
|
||||
assert.equal(player(game, f.owner).reserves, 19);
|
||||
assert.equal(player(game, f.owner).tanks, 1);
|
||||
assert.equal(player(game, f.owner).elites!.revived, 0);
|
||||
} else if (kind === 'emperorLeader') {
|
||||
assert.equal(player(game, f.owner).leaders[0].dead, false);
|
||||
} else if (kind === 'richese') {
|
||||
assert.equal(player(game, f.owner).spice, 17);
|
||||
assert.equal(player(game, f.owner).hand.filter(c => c.id === f.acquired).length, 1);
|
||||
assert.equal(game.richeseCache!.some(c => c.id === f.acquired), false);
|
||||
} else if (kind === 'ixians') {
|
||||
assert.equal(game.mobileStronghold!.location, (f.action.route as string[]).at(-1));
|
||||
assert.equal(player(game, f.owner).reserves, 19);
|
||||
assert.equal(player(game, f.owner).moved, 0);
|
||||
} else if (kind === 'tleilaxu') {
|
||||
assert.equal(game.revivalPrevention!.player, f.holder);
|
||||
assert.equal(player(game, f.holder).tanks, 2);
|
||||
assert.equal(player(game, f.holder).reserves, 18);
|
||||
reject(game, f.holder, { type: 'revive', amount: 2, elite: 0 });
|
||||
} else if (kind === 'fremen') {
|
||||
assert.equal(player(game, f.holder).tanks, 2);
|
||||
assert.equal(player(game, f.holder).forces[f.target!] ?? 0, 0);
|
||||
assert.equal(player(game, f.owner).shipped, false);
|
||||
} else if (kind === 'atreides') {
|
||||
assert.deepEqual(game.battle!.fullPlan, { owner: f.owner, target: f.holder });
|
||||
assert.equal(player(game, f.owner).forces['arrakeen:10'], 2);
|
||||
assert.equal(player(game, f.holder).forces['arrakeen:10'], 2);
|
||||
} else {
|
||||
assert.equal(game.decision?.kind, 'handExchange');
|
||||
assert.equal(player(game, f.owner).hand.length, 2);
|
||||
const returned = f.selected[0];
|
||||
game = applyAction(game, f.owner, { type: 'decision', returnCards: [returned] });
|
||||
assert.equal(player(game, f.owner).hand.length, 1);
|
||||
assert.equal(player(game, f.holder).hand.length, 2);
|
||||
}
|
||||
assert.equal(player(game, f.owner).specialKaramaUsed, true);
|
||||
exactCost(game, f.karama);
|
||||
reject(game, f.owner, f.action);
|
||||
}
|
||||
});
|
||||
|
||||
void test('CHOAM cashout is a separate private simultaneous batch with one earned credit and only fresh selectable cards', () => {
|
||||
const f = createControlledSemutaSpecial('choam');
|
||||
let game = reactSemutaSpecial(applyAction(f.beforeCost, f.owner, f.action), f.holder, false);
|
||||
assert.equal(player(game, f.owner).spice, 26);
|
||||
const event = game.pendingTreacheryDiscard!.batch.event;
|
||||
for (const seat of game.players)
|
||||
assert.deepEqual(viewGame(game, seat.id).semutaReaction!.candidates, []);
|
||||
game = applyAction(game, f.holder, { type: 'semutaCommit', event });
|
||||
assert.deepEqual(new Set(viewGame(game, f.holder).semutaReaction!.candidates.map(c => c.id)), new Set(f.selected));
|
||||
reject(game, f.holder, { type: 'semutaSelect', event, card: f.karama });
|
||||
const chosen = f.selected[1];
|
||||
game = applyAction(game, f.holder, { type: 'semutaSelect', event, card: chosen });
|
||||
assert.equal(player(game, f.owner).spice, 26);
|
||||
assert.ok(player(game, f.holder).hand.some(c => c.id === chosen));
|
||||
assert.ok(game.discard.some(c => c.id === f.selected[0]));
|
||||
assert.equal(game.discard.filter(c => c.id === f.karama).length, 1);
|
||||
assert.equal(game.discard.filter(c => c.id === f.semuta).length, 1);
|
||||
});
|
||||
|
||||
void test('a malformed saved native intent cannot spend a second effect or reward through a fresh-card claim', () => {
|
||||
const f = createOriginalSemutaSpecialGuild();
|
||||
const game = applyAction(f.beforeCost, f.owner, f.action);
|
||||
const event = game.pendingTreacheryDiscard!.batch.event;
|
||||
reject(game, f.owner, { type: 'semutaCommit', event });
|
||||
const changed = structuredClone(game);
|
||||
const continuation = changed.pendingTreacheryDiscard!.continuation;
|
||||
assert.ok(continuation?.kind === 'specialKaramaDiscard');
|
||||
continuation.intent.turn++;
|
||||
reject(changed, f.holder, { type: 'semutaCommit', event });
|
||||
const done = reactSemutaSpecial(game, f.holder, true);
|
||||
assert.equal(player(done, f.shipper).spice, 9);
|
||||
assert.equal(player(done, f.shipper).shipped, true);
|
||||
});
|
||||
Reference in new issue
Block a user