Files
Dune/tests/homeworld-occupied-producers-engine.test.ts

567 lines
35 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, normalizeAutomaticGame, RuleError, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { freshBiddingGame, biddingPlayer, biddingPhysicalIds, stageBiddingHandSize, stageBiddingKarama } from './fixture-homeworld-occupied-bidding';
import { qualifyDefensePosition, clearDefenseNative, removeDefenseVisitor, recordDefensePosition, assertDefenseInventory } from './fixture-homeworld-occupied-defenses';
import { occupiedIncomeMovementFixture } from './fixture-homeworld-occupied-income';
import { advanceOriginal, buyOriginalNormalLot, cash, freshSouthernProducerGame, freshGuildAdvisorProducerGame } from './fixture-homeworld-occupied-producers';
import { validateAmbassadors } from '../game/ecaz-ambassadors';
import { DIFFICULTIES } from '../game/bot-profiles';
import { nativeShipmentSources } from '../game/homeworld-options';
import { distance } from '../game/board';
function normalBidding(game: Game): Game {
return advanceOriginal(game, game => game.phase === 3 && !!game.auction && !game.phaseOpening && !game.response && !game.decision);
}
void test('original paid Kaitain invoice conserves cash/cards and immediately allocates its original occupied share after JSON reload', () => {
let game = freshBiddingGame('emperor', 'harkonnen');
qualifyDefensePosition(game, 'homeworld:emperor');
biddingPlayer(game, 'occupier').ally = 'ally';
biddingPlayer(game, 'ally').ally = 'occupier';
game = normalBidding(game);
const original = biddingPhysicalIds(game), before = cash(game), bought = game.auction!.cards[game.auction!.index].id;
game = buyOriginalNormalLot(game, 'occupier', 5);
game = advanceOriginal(game, game => game.decision?.kind === 'homeworldOccupiedPercentage');
assert.equal(game.currentAuctionSale!.winner, 'occupier');
assert.equal(game.currentAuctionSale!.amount, 5);
assert.equal(biddingPlayer(game, 'occupier').spice, before.occupier - 5);
assert.equal(biddingPlayer(game, 'native').spice, before.native + 3);
assert.ok(biddingPlayer(game, 'occupier').hand.some(card => card.id === bought));
const offer = viewGame(game, 'occupier').homeworldOccupiedPercentage!;
assert.equal(offer.amount, 2);
assert.equal(offer.world, 'homeworld:emperor');
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, 'occupier');
view.players.find(player => player.id === 'occupier')!.bot = difficulty;
const choice = botActions(view)[0];
assert.ok(choice, `${difficulty} can settle the actual original paid source`);
const result = applyAction(JSON.parse(JSON.stringify(game)), 'occupier', choice);
assert.equal(result.homeworldOccupiedPercentageLedger!.receipts[0].status, 'settled');
assert.equal(Object.values(cash(result)).reduce((sum, n) => sum + n, 0), Object.values(before).reduce((sum, n) => sum + n, 0));
assert.deepEqual(biddingPhysicalIds(result), original);
}
const action = { type: 'decision', event: offer.event, ownAmount: 1 };
for (const [actor, invalid] of [
['native', action], ['occupier', { ...action, ownAmount: 3 }],
['occupier', { ...action, event: `${offer.event}:stale` }],
] as const) {
const frozen = structuredClone(game);
assert.throws(() => applyAction(game, actor, invalid), RuleError);
assert.deepEqual(game, frozen, 'rejection leaves cash, ledger and exact purchase suffix untouched');
}
const changed: Game = structuredClone(game);
changed.currentAuctionSale!.amount++;
const frozen = structuredClone(changed);
assert.throws(() => applyAction(changed, 'occupier', action), RuleError);
assert.deepEqual(changed, frozen);
game = applyAction(JSON.parse(JSON.stringify(game)), 'occupier', action);
assert.equal(biddingPlayer(game, 'native').spice, before.native + 3);
assert.equal(biddingPlayer(game, 'occupier').spice, before.occupier - 4);
assert.equal(biddingPlayer(game, 'ally').spice, before.ally + 1);
assert.equal(Object.values(cash(game)).reduce((sum, n) => sum + n, 0), Object.values(before).reduce((sum, n) => sum + n, 0));
assert.deepEqual(biddingPhysicalIds(game), original);
assert.equal(game.homeworldOccupiedPercentageLedger!.receipts.filter(row => row.source.kind === 'emperor-treachery').length, 1);
assert.throws(() => applyAction(game, 'occupier', action), RuleError);
});
void test('actual native Karama denial removes only the native Kaitain leg, not the printed occupied allocation', () => {
let game = freshBiddingGame('emperor', 'harkonnen');
qualifyDefensePosition(game, 'homeworld:emperor');
const counter = stageBiddingKarama(game, 'observer');
game = normalBidding(game);
const before = cash(game), original = biddingPhysicalIds(game);
game = buyOriginalNormalLot(game, 'occupier', 5);
assert.equal(game.response?.kind, 'emperorIncome');
game = applyAction(game, 'observer', { type: 'card', card: counter.id, mode: 'cancel' });
game = advanceOriginal(game, game => game.decision?.kind === 'homeworldOccupiedPercentage');
const offer = viewGame(game, 'occupier').homeworldOccupiedPercentage!;
game = applyAction(game, 'occupier', { type: 'decision', event: offer.event, ownAmount: 2 });
assert.equal(biddingPlayer(game, 'native').spice, before.native);
assert.equal(biddingPlayer(game, 'occupier').spice, before.occupier - 3);
assert.deepEqual(biddingPhysicalIds(game), original);
const receipt = game.homeworldOccupiedPercentageLedger!.receipts.find(row => row.source.kind === 'emperor-treachery')!;
assert.equal(receipt.nativeRetained, 0);
assert.equal(receipt.occupiedAmount, 2);
assert.equal(receipt.bankRetained, 3);
});
void test('original Caladan lot inspection shares one actual private face with native plus original occupier, never their ally', () => {
let game = freshBiddingGame('atreides');
qualifyDefensePosition(game, 'homeworld:atreides');
biddingPlayer(game, 'occupier').ally = 'ally';
biddingPlayer(game, 'ally').ally = 'occupier';
game = normalBidding(game);
const actual = game.auction!.cards[game.auction!.index];
assert.deepEqual(viewGame(game, 'native').auction!.card, actual);
assert.deepEqual(viewGame(game, 'occupier').auction!.card, actual);
assert.equal(viewGame(game, 'ally').auction!.card, null);
assert.equal(viewGame(game, 'observer').auction!.card, null);
assert.deepEqual(viewGame(JSON.parse(JSON.stringify(game)), 'occupier').auction!.card, actual);
});
void test('original Ix draw has one displaced decision and private native-stock pool, with stale source/actor atomic rejection', () => {
let game = freshBiddingGame('ixians');
qualifyDefensePosition(game, 'homeworld:ixians');
const original = biddingPhysicalIds(game);
game = advanceOriginal(game, game => game.decision?.kind === 'ixAuction');
assert.equal(game.phaseOpening, null, 'the original Amal window settles before the displaced Ix action');
assert.equal(game.decision!.player, 'occupier');
assert.ok(viewGame(game, 'occupier').ixTechnology!.pool!.length > 1);
assert.equal(viewGame(game, 'native').ixTechnology!.pool, null);
assert.equal(viewGame(game, 'observer').ixTechnology, null);
const returned = game.ixAuction!.cards[0];
const action = { type: 'decision', card: returned.id, position: 'top' };
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, 'occupier');
view.players.find(player => player.id === 'occupier')!.bot = difficulty;
const choice = botActions(view)[0];
assert.ok(choice);
const result = applyAction(JSON.parse(JSON.stringify(game)), 'occupier', choice);
assert.equal(result.ixAuction, null);
assert.equal(result.auction!.cards.length, game.ixAuction!.cards.length - 1);
assert.deepEqual(viewGame(result, 'native').ixTechnology!.known, []);
assert.deepEqual(biddingPhysicalIds(result), original);
}
const frozen = structuredClone(game);
assert.throws(() => applyAction(game, 'native', action), RuleError);
assert.deepEqual(game, frozen);
const departed = structuredClone(game);
removeDefenseVisitor(departed, 'homeworld:ixians');
recordDefensePosition(departed, 'pending-original-pool-departure');
const before = structuredClone(departed);
assert.equal(viewGame(departed, 'occupier').ixTechnology!.pool, null);
assert.notEqual(viewGame(departed, 'occupier').ixTechnology!.blocked, null);
assert.equal(viewGame(departed, 'native').ixTechnology!.pool, null, 'a stale source cannot silently reveal the old pool to its native provider');
assert.throws(() => applyAction(departed, 'occupier', action), RuleError);
assert.deepEqual(departed, before);
const rest = game.ixAuction!.cards.slice(1).map(card => card.id).sort();
game = applyAction(JSON.parse(JSON.stringify(game)), 'occupier', action);
assert.equal(game.deck[0].id, returned.id);
assert.deepEqual(game.auction!.cards.map(card => card.id).sort(), rest);
assert.deepEqual(viewGame(game, 'occupier').ixTechnology!.known.map(card => card.id).sort(), rest);
assert.deepEqual(viewGame(game, 'native').ixTechnology!.known, []);
assert.deepEqual(biddingPhysicalIds(game), original);
assert.throws(() => applyAction(game, 'occupier', action), RuleError);
});
void test('Richese original seller sets terms; displaced chooser selects physical cache card; actual seller net is split without replacing invoice', () => {
let game = freshBiddingGame('richese');
qualifyDefensePosition(game, 'homeworld:richese');
game = advanceOriginal(game, game => game.decision?.kind === 'richeseCacheTerms');
assert.equal(game.decision!.player, 'native');
const termsEvent = game.richeseBidding!.event;
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, 'native');
view.players.find(player => player.id === 'native')!.bot = difficulty;
const terms = botActions(view)[0];
assert.ok(terms);
let result = applyAction(JSON.parse(JSON.stringify(game)), 'native', terms);
const choiceView = viewGame(result, 'occupier');
choiceView.players.find(player => player.id === 'occupier')!.bot = difficulty;
const choice = botActions(choiceView)[0];
assert.ok(choice);
result = applyAction(result, 'occupier', choice);
assert.equal(result.richeseAuction!.owner, 'native');
assert.equal(result.richeseAuction!.method, result.richeseBidding!.cacheTerms!.method);
assert.ok(game.richeseCache!.some(card => card.id === result.richeseAuction!.cardId));
assert.deepEqual(biddingPhysicalIds(result), biddingPhysicalIds(game));
}
game = applyAction(game, 'native', { type: 'decision', event: termsEvent, method: 'onceAround', direction: 'clockwise' });
assert.equal(game.decision?.kind, 'richeseCache');
assert.equal(game.decision!.player, 'occupier');
const card = game.richeseCache![0], original = biddingPhysicalIds(game), before = cash(game);
const event = game.richeseBidding!.event;
const frozen = structuredClone(game);
assert.throws(() => applyAction(game, 'native', { type: 'decision', event, card: card.id }), RuleError);
assert.throws(() => applyAction(game, 'occupier', { type: 'decision', event, card: card.id, method: 'silent' }), RuleError);
assert.deepEqual(game, frozen);
game = applyAction(JSON.parse(JSON.stringify(game)), 'occupier', { type: 'decision', event, card: card.id });
game = advanceOriginal(game, game => !!game.richeseAuction && !game.response && !game.decision);
assert.equal(game.richeseAuction!.owner, 'native');
assert.equal(game.richeseAuction!.method, 'onceAround');
const lotEvent = game.richeseAuction!.event;
for (let step = 0; !game.currentAuctionSale && step < game.players.length + 1; step++) {
const active = game.richeseAuction!.active!;
game = applyAction(game, active, { type: 'richeseBid', event: lotEvent, amount: active === 'occupier' ? 5 : null, allyPayment: 0 });
}
assert.equal(game.decision?.kind, 'homeworldOccupiedPercentage');
assert.equal(game.currentAuctionSale!.seller, 'native');
assert.equal(game.currentAuctionSale!.winner, 'occupier');
assert.equal(biddingPlayer(game, 'native').spice, before.native + 3);
const offer = viewGame(game, 'occupier').homeworldOccupiedPercentage!;
game = applyAction(game, 'occupier', { type: 'decision', event: offer.event, ownAmount: 2 });
assert.equal(biddingPlayer(game, 'occupier').spice, before.occupier - 3);
assert.ok(biddingPlayer(game, 'occupier').hand.some(held => held.id === card.id));
assert.deepEqual(biddingPhysicalIds(game), original);
});
void test('Giedi original purchase gives one actual occupied bonus to reciprocal ally even when native buyer fills its hand', () => {
let game = freshBiddingGame('harkonnen');
qualifyDefensePosition(game, 'homeworld:harkonnen');
biddingPlayer(game, 'occupier').ally = 'ally';
biddingPlayer(game, 'ally').ally = 'occupier';
stageBiddingHandSize(game, 'native', 7);
stageBiddingHandSize(game, 'occupier', 4);
stageBiddingHandSize(game, 'ally', 3);
game = normalBidding(game);
// Original Storm/card choices can spend a staged card. Freeze the tested
// receiving capacities at the settled original purchase boundary.
stageBiddingHandSize(game, 'native', 7);
stageBiddingHandSize(game, 'occupier', 4);
stageBiddingHandSize(game, 'ally', 3);
const original = biddingPhysicalIds(game), before = cash(game), allyCards = biddingPlayer(game, 'ally').hand.length;
game = buyOriginalNormalLot(game, 'native', 5);
game = advanceOriginal(game, game => game.decision?.kind === 'homeworldOccupiedBonus');
assert.equal(biddingPlayer(game, 'native').hand.length, 8);
const offer = viewGame(game, 'occupier').homeworldOccupiedBonus!;
assert.deepEqual(offer.recipients.map(recipient => recipient.player), ['ally']);
const filled = structuredClone(game);
stageBiddingHandSize(filled, 'ally', 4);
const exhaustedStock = filled.deck.length + filled.discard.length;
const continued = normalizeAutomaticGame(JSON.parse(JSON.stringify(filled)));
assert.equal(continued.pendingHomeworldOccupiedBonus, null, 'a now-impossible real receiver consumes the original suffix without awarding a card');
assert.equal(biddingPlayer(continued, 'native').hand.length, 8);
assert.equal(biddingPlayer(continued, 'occupier').hand.length, 4);
assert.equal(biddingPlayer(continued, 'ally').hand.length, 4);
assert.equal(continued.deck.length + continued.discard.length, exhaustedStock);
assert.deepEqual(biddingPhysicalIds(continued), original);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, 'occupier');
view.players.find(player => player.id === 'occupier')!.bot = difficulty;
const choice = botActions(view)[0];
assert.ok(choice);
const result = applyAction(JSON.parse(JSON.stringify(game)), 'occupier', choice);
assert.equal(biddingPlayer(result, 'ally').hand.length, allyCards + 1);
assert.equal(biddingPlayer(result, 'native').hand.length, 8);
assert.equal(biddingPlayer(result, 'occupier').hand.length, 4);
assert.deepEqual(biddingPhysicalIds(result), original);
}
const action = { type: 'decision', event: offer.event, recipient: 'ally' };
for (const [actor, choice] of [['native', action], ['occupier', { ...action, recipient: 'native' }]] as const) {
const frozen = structuredClone(game);
assert.throws(() => applyAction(game, actor, choice), RuleError);
assert.deepEqual(game, frozen);
}
const corrupted = structuredClone(game);
corrupted.currentAuctionSale!.winner = 'occupier';
const frozen = structuredClone(corrupted);
assert.throws(() => applyAction(corrupted, 'occupier', action), RuleError);
assert.deepEqual(corrupted, frozen);
game = applyAction(JSON.parse(JSON.stringify(game)), 'occupier', action);
assert.equal(biddingPlayer(game, 'native').hand.length, 8);
assert.equal(biddingPlayer(game, 'occupier').hand.length, 4);
assert.equal(biddingPlayer(game, 'ally').hand.length, allyCards + 1);
assert.equal(biddingPlayer(game, 'native').spice, before.native - 5);
assert.deepEqual(biddingPhysicalIds(game), original);
assert.throws(() => applyAction(game, 'occupier', action), RuleError);
});
void test('Junction original shipment debits payer once, then allocates actual eligible native payment without changing movement', () => {
let game = occupiedIncomeMovementFixture('junction', true, true).game;
game = advanceOriginal(game, game => game.phase === 5 && game.active === 'owner' && !game.phaseOpening && !game.response && !game.decision);
const view = viewGame(game, 'owner');
view.players.find(seat => seat.id === 'owner')!.bot = 'Easy';
const shipment = botActions(view).find(action => action.type === 'ship');
assert.ok(shipment, 'original legal shipment control must offer a native-to-Arrakis purchase');
const homeworldSources = nativeShipmentSources(view, 3, 0);
assert.ok(homeworldSources, 'three original normal native reserve counters must be available');
assert.deepEqual(homeworldSources, { 'homeworld:atreides': { normal: 3, elite: 0 } });
const before = cash(game), cards = biddingPhysicalIds(game), reserves = biddingPlayer(game, 'owner').reserves;
assert.ok(typeof shipment.territory === 'string' && typeof shipment.sector === 'number');
const destination = `${shipment.territory}:${shipment.sector}`;
const forces = biddingPlayer(game, 'owner').forces[destination] ?? 0;
const mismatched = structuredClone(game), frozen = structuredClone(mismatched);
assert.throws(() => applyAction(mismatched, 'owner',
{ ...shipment, amount: 2, elite: 0, allyPayment: 0, homeworldSources }), RuleError);
assert.deepEqual(mismatched, frozen, 'a changed amount cannot reuse the original three-force source allocation');
game = applyAction(JSON.parse(JSON.stringify(game)), 'owner',
{ ...shipment, amount: 3, elite: 0, allyPayment: 0, homeworldSources });
assert.equal(game.decision?.kind, 'guildShipment');
game = applyAction(game, game.decision!.player, { type: 'decision', allow: true });
game = advanceOriginal(game, game => game.decision?.kind === 'homeworldOccupiedPercentage');
const source = game.pendingHomeworldOccupiedPercentage!.source;
assert.equal(source.kind, 'guild-shipping');
const native = game.players.find(player => player.id === 'native')!, owner = game.players.find(player => player.id === 'owner')!;
assert.equal(native.spice - before.native, Math.ceil(source.amount / 2));
assert.equal(owner.spice - before.owner, -source.amount);
assert.equal(owner.shipped, true);
assert.equal(owner.reserves, reserves - 3);
assert.equal(owner.forces[destination], forces + 3);
assertDefenseInventory(game);
const movement = structuredClone(owner.forces), offer = viewGame(game, 'owner').homeworldOccupiedPercentage!;
game = applyAction(JSON.parse(JSON.stringify(game)), 'owner', { type: 'decision', event: offer.event, ownAmount: 0 });
assert.deepEqual(game.players.find(player => player.id === 'owner')!.forces, movement);
assert.equal(game.players.find(player => player.id === 'ally')!.spice - before.ally, Math.floor(source.amount / 2));
assert.deepEqual(biddingPhysicalIds(game), cards);
});
void test('native Caladan cancellation keeps its actual face hidden from native while the separate printed occupier inspection settles', () => {
let game = freshBiddingGame('atreides');
qualifyDefensePosition(game, 'homeworld:atreides');
const counter = stageBiddingKarama(game, 'observer');
game = advanceOriginal(game, game => game.response?.kind === 'atreidesAuction');
const actual = game.auction!.cards[game.auction!.index], original = biddingPhysicalIds(game);
assert.equal(viewGame(game, 'native').auction!.card, null);
assert.equal(viewGame(game, 'occupier').auction!.card, null, 'printed sharing does not inspect before the original opportunity resolves');
game = applyAction(game, 'observer', { type: 'card', card: counter.id, mode: 'cancel' });
game = advanceOriginal(game, game => !game.response && !game.decision);
assert.equal(game.auction!.peekKnown, false);
assert.equal(viewGame(game, 'native').auction!.card, null);
assert.deepEqual(viewGame(game, 'occupier').auction!.card, actual);
assert.equal(viewGame(game, 'ally').auction!.card, null);
assert.equal(viewGame(game, 'observer').auction!.card, null);
assert.deepEqual(biddingPhysicalIds(game), original);
});
void test('Southern aggregates actual desert, completed shared legs and a Fremen occupied-bank award once; redistribution triggers actual Giedi hook only once', () => {
let game = freshSouthernProducerGame();
game = advanceOriginal(game, game => game.phase === 5 && !game.phaseOpening && !game.response && !game.decision);
// Labelled conserved position: original Arrakis counters return to their own
// reserves before being placed, with starred identities kept in their subset.
for (const player of game.players) {
player.reserves += Object.values(player.forces).reduce((sum, n) => sum + n, 0);
player.forces = {};
if (player.elites) {
player.elites.reserves += Object.values(player.elites.forces).reduce((sum, n) => sum + n, 0);
player.elites.forces = {};
}
}
const native = biddingPlayer(game, 'native'), ecaz = biddingPlayer(game, 'ecaz');
assert.ok(native.reserves - native.elites!.reserves >= 4);
native.reserves -= 4;
native.forces = { 'hagga_basin:12': 2, 'red_chasm:7': 1 };
clearDefenseNative(game, 'homeworld:atreides');
game.homeworlds!.custody!.visitors['homeworld:atreides'] = { native: { normal: 1, elite: 0 } };
ecaz.reserves--;
ecaz.forces['hagga_basin:12'] = 1;
native.ally = ecaz.id; ecaz.ally = native.id;
biddingPlayer(game, 'occupier').ally = 'ally';
biddingPlayer(game, 'ally').ally = 'occupier';
game.spice['hagga_basin:12'] = 5;
game.spice['red_chasm:7'] = 4;
qualifyDefensePosition(game, 'homeworld:fremen');
assertDefenseInventory(game);
const original = biddingPhysicalIds(game), before = cash(game);
const tanks = game.players.map(player => [player.id, player.tanks, player.elites?.tanks ?? 0]);
let bankLeg = '';
for (let step = 0; game.decision?.kind !== 'homeworldOccupiedPercentage' && step < 500; step++) {
if (game.phase === 5 && !game.response && !game.decision && !game.phaseOpening)
game = applyAction(game, game.active!, { type: 'endMovement' });
else if (game.decision?.kind === 'ecazSpice')
game = applyAction(game, game.decision.player, { type: 'decision', event: game.ecazCollection!.event, allocation: { kind: 'equal' } });
else if (game.decision?.kind === 'homeworldOccupiedIncome') {
const offer = viewGame(game, game.decision.player).homeworldOccupiedIncome!;
if (offer.world === 'homeworld:atreides') {
assert.equal(offer.amount, 2);
assert.equal(offer.owner, 'native');
bankLeg = `${offer.event}:${offer.world}:${offer.owner}`;
}
game = applyAction(game, game.decision.player, { type: 'decision', event: offer.event, world: offer.world, ownAmount: offer.amount });
} else {
const prior = game;
game = advanceOriginal(game, next => next !== prior);
}
}
assert.equal(game.decision?.kind, 'homeworldOccupiedPercentage');
const source = game.pendingHomeworldOccupiedPercentage!.source;
assert.equal(source.kind, 'fremen-collection');
if (source.kind !== 'fremen-collection') throw new RuleError('Expected original Collection source');
assert.equal(source.amount, 7, 'two printed desert spice, three actual shared spice and two actual Caladan bank spice aggregate before rounding');
assert.ok(bankLeg, 'the actual native Caladan bank allocation must have completed');
assert.deepEqual(source.collection.collected, [
{ id: 'ordinary:native', amount: 2 },
{ id: 'shared:hagga_basin:native', amount: 3 },
{ id: bankLeg, amount: 2 },
]);
assert.equal(game.spice['red_chasm:7'], 2, 'one normal desert force collects two, without Arrakeen/Carthag rate three');
assert.equal(game.spice['hagga_basin:12'], 0);
assert.equal(game.ecazCollection!.stage, 'complete');
assert.equal(game.ecazCollection!.settled[0].ecazAmount, 2);
assert.equal(game.ecazCollection!.settled[0].allyAmount, 3);
assert.equal(biddingPlayer(game, 'native').spice - before.native, 4, 'native keeps half of already credited actual Collection, not another credit');
assert.ok(game.giediCollection, 'original occupied bank Collection already exercised high Giedi once');
const offer = viewGame(game, 'occupier').homeworldOccupiedPercentage!;
game = applyAction(JSON.parse(JSON.stringify(game)), 'occupier', { type: 'decision', event: offer.event, ownAmount: 2 });
assert.equal(biddingPlayer(game, 'native').spice - before.native, 4);
assert.equal(biddingPlayer(game, 'occupier').spice - before.occupier, 6, 'two bank icons, one two-spice Giedi hook and two occupied percentage spice');
assert.equal(biddingPlayer(game, 'ally').spice - before.ally, 1);
assert.equal(Object.values(cash(game)).reduce((sum, amount) => sum + amount, 0) -
Object.values(before).reduce((sum, amount) => sum + amount, 0), 13,
'seven physical desert spice plus four printed bank icons and one two-spice Giedi award enter wallets once');
assert.equal(game.homeworldOccupiedPercentageLedger!.receipts.filter(row => row.source.kind === 'fremen-collection').length, 1);
assert.deepEqual(biddingPhysicalIds(game), original);
assert.deepEqual(game.players.map(player => [player.id, player.tanks, player.elites?.tanks ?? 0]), tanks);
assertDefenseInventory(game);
});
void test('original Richese Ambassador purchase displaces one real Giedi bonus, while full-hand failed acquisition creates no bonus', () => {
for (const nativeHand of [7, 8]) {
let game = freshBiddingGame('harkonnen', 'ecaz');
qualifyDefensePosition(game, 'homeworld:harkonnen');
game = advanceOriginal(game, game => game.phase === 5 && game.active === 'occupier' &&
!game.response && !game.decision && !game.phaseOpening);
// Controlled conserved marker position, not a fabricated purchase. Every
// original token identity remains; exchanging two cohort positions only
// arranges which original marker the forthcoming real entry will meet.
const state = game.ecazAmbassadors!, marker = state.tokens.find(token => token.effect === 'richese')!;
if (!state.cohort.includes(marker.id)) {
const displaced = state.tokens.find(token => token.id === state.cohort[0])!;
state.cohort[0] = marker.id;
displaced.zone = 'pool'; displaced.location = null;
}
for (const token of state.tokens) if (token.location === 'arrakeen') {
token.location = null; token.zone = 'supply';
}
marker.zone = 'placed'; marker.location = 'arrakeen';
validateAmbassadors(state);
// Restore a quiet original-stock position before the native entry producer.
for (const player of game.players) {
player.reserves += Object.values(player.forces).reduce((sum, n) => sum + n, 0);
player.forces = {};
if (player.elites) {
player.elites.reserves += Object.values(player.elites.forces).reduce((sum, n) => sum + n, 0);
player.elites.forces = {};
}
}
biddingPlayer(game, 'native').ally = 'ally';
biddingPlayer(game, 'ally').ally = 'native';
const mover = biddingPlayer(game, 'occupier');
assert.ok(mover.reserves > 0);
mover.reserves--;
mover.forces['imperial_basin:10'] = 1;
assert.equal(distance('imperial_basin:10', 'arrakeen:10'), 1);
recordDefensePosition(game, 'conserved-original-ambassador-adjacent-movement-position');
assertDefenseInventory(game);
stageBiddingHandSize(game, 'native', nativeHand);
stageBiddingHandSize(game, 'occupier', 2);
const buyer = biddingPlayer(game, 'native');
if (buyer.spice < 3) {
const donor = game.players.find(player => player.id !== buyer.id && player.spice >= 3)!;
const amount = 3 - buyer.spice;
donor.spice -= amount; buyer.spice += amount;
}
const original = biddingPhysicalIds(game);
const entryCash = cash(game), tanks = game.players.map(player => [player.id, player.tanks, player.elites?.tanks ?? 0]);
game = applyAction(game, 'occupier',
{ type: 'move', from: 'imperial_basin:10', territory: 'arrakeen', sector: 10, amount: 1, elite: 0 });
game = advanceOriginal(game, game => game.pendingAmbassador?.stage === 'offer');
assert.deepEqual(cash(game), entryCash, 'original clean movement creates no paid Guild income response');
assert.equal(game.response, null);
assert.equal(biddingPlayer(game, 'occupier').forces['imperial_basin:10'] ?? 0, 0);
assert.equal(biddingPlayer(game, 'occupier').forces['arrakeen:10'], 1);
assertDefenseInventory(game);
const before = cash(game), stock = game.deck.length + game.discard.length;
const entry = game.pendingAmbassador!.event;
game = applyAction(game, 'ally', { type: 'decision', event: entry, trigger: true, beneficiary: 'native' });
if (nativeHand === 8) {
assert.equal(game.pendingHomeworldOccupiedBonus, null);
assert.equal(biddingPlayer(game, 'native').spice, before.native);
assert.equal(game.deck.length + game.discard.length, stock);
assert.equal(biddingPlayer(game, 'native').hand.length, 8);
assert.equal(game.pendingAmbassador, null);
assert.equal(biddingPlayer(game, 'occupier').hand.length, 2);
} else {
assert.equal(game.decision?.kind, 'homeworldOccupiedBonus');
assert.equal(game.pendingAmbassador!.purchaseReceipt!.buyer, 'native');
assert.equal(biddingPlayer(game, 'native').spice, before.native - 3);
assert.equal(biddingPlayer(game, 'native').hand.length, 8);
const offer = viewGame(game, 'occupier').homeworldOccupiedBonus!;
assert.deepEqual(offer.recipients.map(recipient => recipient.player), ['occupier']);
game = applyAction(JSON.parse(JSON.stringify(game)), 'occupier',
{ type: 'decision', event: offer.event, recipient: 'occupier' });
assert.equal(biddingPlayer(game, 'occupier').hand.length, 3);
assert.equal(biddingPlayer(game, 'native').hand.length, 8);
assert.equal(game.deck.length + game.discard.length, stock - 2, 'one paid acquisition and exactly one physical bonus');
assert.equal(game.pendingAmbassador, null);
}
assert.deepEqual(biddingPhysicalIds(game), original);
assert.deepEqual(game.players.map(player => [player.id, player.tanks, player.elites?.tanks ?? 0]), tanks);
assertDefenseInventory(game);
}
});
void test('native Richese compulsory-cache Karama cancellation restores one ordinary lot without changing cache or wallets', () => {
let game = freshBiddingGame('richese');
qualifyDefensePosition(game, 'homeworld:richese');
const counter = stageBiddingKarama(game, 'observer');
game = advanceOriginal(game, game => game.response?.kind === 'richeseAuction' && !game.phaseOpening);
assert.equal(game.response!.owner, 'native');
const cache = game.richeseCache!.map(card => card.id), original = biddingPhysicalIds(game), before = cash(game);
const receipts = game.homeworldOccupiedPercentageLedger!.receipts.length;
const action = { type: 'card', card: counter.id, mode: 'cancel' };
for (const state of [game, JSON.parse(JSON.stringify(game)) as Game]) {
const canceled = applyAction(state, 'observer', action);
assert.equal(canceled.phase, 3);
assert.equal(canceled.richeseAuction, null, 'the compulsory cache lot does not run after cancellation');
assert.ok(canceled.auction && canceled.auction.cards.length > 0, 'one ordinary lot is restored');
assert.deepEqual(canceled.richeseCache!.map(card => card.id), cache, 'the physical cache is untouched');
assert.deepEqual(cash(canceled), before, 'the cancellation itself costs no spice');
assert.equal(biddingPlayer(canceled, 'observer').hand.some(card => card.id === counter.id), false, 'the Karama leaves its hand');
assert.equal(canceled.discard.filter(card => card.id === counter.id).length, 1, 'exactly one physical Karama is consumed');
assert.equal(canceled.pendingHomeworldOccupiedPercentage, null);
assert.equal(canceled.pendingHomeworldOccupiedBonus, null);
assert.equal(canceled.homeworldOccupiedPercentageLedger!.receipts.length, receipts);
assert.deepEqual(biddingPhysicalIds(canceled), original);
assertDefenseInventory(canceled);
}
});
void test('occupied Guild receipt preserves the original concurrent BG accompaniment until after its percentage choice', () => {
let game = freshGuildAdvisorProducerGame();
qualifyDefensePosition(game, 'homeworld:guild');
game = advanceOriginal(game, game => game.phase === 5 && !game.phaseOpening && !game.response && !game.decision);
// Labelled conserved quiet board; no original force or wallet is minted.
for (const player of game.players) {
player.reserves += Object.values(player.forces).reduce((sum, amount) => sum + amount, 0);
player.forces = {};
if (player.elites) {
player.elites.reserves += Object.values(player.elites.forces).reduce((sum, amount) => sum + amount, 0);
player.elites.forces = {};
}
}
const buyer = biddingPlayer(game, 'buyer'), totalCash = Object.values(cash(game)).reduce((sum, amount) => sum + amount, 0);
for (const donor of game.players) {
if (donor.id === buyer.id || buyer.spice >= 6) continue;
const amount = Math.min(donor.spice, 6 - buyer.spice);
donor.spice -= amount; buyer.spice += amount;
}
assert.ok(buyer.spice >= 6);
assert.equal(Object.values(cash(game)).reduce((sum, amount) => sum + amount, 0), totalCash);
recordDefensePosition(game, 'quiet-original-guild-advisor-source');
for (let step = 0; game.active !== 'buyer' && step < 80; step++) {
if (!game.phaseOpening && !game.response && !game.decision && game.active)
game = applyAction(game, game.active, { type: 'endMovement' });
else { const before = game; game = advanceOriginal(game, next => next !== before); }
}
assert.equal(game.active, 'buyer');
stageBiddingKarama(game, 'observer');
const before = cash(game), cards = biddingPhysicalIds(game);
game = applyAction(game, 'buyer', { type: 'ship', territory: 'hagga_basin', sector: 12,
amount: 3, elite: 0, allyPayment: 0, homeworldSources: nativeShipmentSources(viewGame(game, 'buyer'), 3, 0) });
assert.equal(game.decision?.kind, 'guildShipment');
game = applyAction(game, game.decision!.player, { type: 'decision', allow: true });
assert.equal(game.response?.kind, 'guildIncome');
assert.equal(game.decision?.kind, 'advisor');
game = advanceOriginal(game, game => game.decision?.kind === 'homeworldOccupiedPercentage');
const offer = viewGame(game, 'occupier').homeworldOccupiedPercentage!;
assert.equal(offer.amount, 3);
game = applyAction(JSON.parse(JSON.stringify(game)), 'occupier',
{ type: 'decision', event: offer.event, ownAmount: 3 });
assert.equal(game.decision?.kind, 'advisor');
assert.equal(game.decision!.player, 'observer');
const reserves = biddingPlayer(game, 'observer').reserves;
game = applyAction(game, 'observer', { type: 'decision', accept: true, accompany: true, amount: 1 });
game = advanceOriginal(game, game => !game.response && !game.decision);
assert.equal(biddingPlayer(game, 'observer').reserves, reserves - 1);
assert.equal(biddingPlayer(game, 'observer').forces['hagga_basin:12'], 1);
assert.equal(biddingPlayer(game, 'buyer').forces['hagga_basin:12'], 3);
assert.equal(biddingPlayer(game, 'buyer').spice, before.buyer - 6);
assert.equal(biddingPlayer(game, 'native').spice, before.native + 3);
assert.equal(biddingPlayer(game, 'occupier').spice, before.occupier + 3);
assert.equal(biddingPlayer(game, 'observer').spice, before.observer);
assert.deepEqual(biddingPhysicalIds(game), cards);
assertDefenseInventory(game);
});