Connect occupied Homeworld economics and Bidding

This commit is contained in:
Quintine committed 2026-10-05 12:48:08 +11:00
1 parent a6e80c7e23
commit 0774ae6f83
32 files changed
+3220 -111

No files matched your search

@@ -0,0 +1,89 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeHomeworldOccupationGameForAudit,
joinGame, newPlayer, viewGame, type Game,
} from '../game/engine';
import { FACTIONS, type FactionId } from '../game/catalog';
import { botActions } from '../game/bots';
import { assertDefenseInventory } from './fixture-homeworld-occupied-defenses';
export const biddingPlayer = (game: Game, id: string) => game.players.find(player => player.id === id)!;
/** Original lobby, selected expansion decks and full original setup pipeline.
* All later conserved positions are labelled controlled fixtures, not gameplay. */
export function freshBiddingGame(native: FactionId, ally: FactionId = 'emperor', advanced = true): Game {
const roster: [string, FactionId][] = [
['native', native], ['occupier', 'guild'], ['ally', ally],
['observer', 'beneGesserit'], ['fremen', 'fremen'],
];
const expansions = [...new Set(roster.map(([, faction]) =>
FACTIONS.find(card => card.id === faction)!.expansion))].filter(expansion => expansion !== 'base');
let game = createGame('OCCUPIEDBIDDING', newPlayer('native', native, native), advanced, expansions);
for (const [id, faction] of roster.slice(1)) joinGame(game, newPlayer(id, id, faction));
game = applyAction(game, 'native', { type: 'homeworlds', enabled: true });
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
game = initializeHomeworldOccupationGameForAudit(game);
for (let step = 0; game.status === 'setup' && step < 100; step++) {
let progressed = false;
for (const player of game.players) {
const view = viewGame(game, player.id);
view.players.find(seat => seat.id === player.id)!.bot = 'Easy';
const action = botActions(view)[0];
if (!action) continue;
game = applyAction(game, player.id, action);
progressed = true;
break;
}
assert.ok(progressed, 'original Bidding fixture setup has a legal next choice');
}
assert.equal(game.status, 'playing');
assert.equal(game.homeworldOccupationPreview, true);
assert.deepEqual(game.homeworldOccupationHistory!.qualifications, []);
assertDefenseInventory(game);
assert.equal(new Set(biddingPhysicalIds(game)).size, biddingPhysicalIds(game).length);
return game;
}
/** Live physical ownership only: original knowledge aliases are not cards. */
export function biddingPhysicalIds(game: Game): string[] {
return [
...game.players.flatMap(player => player.hand), ...game.deck, ...game.discard,
...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
...(game.ixSetupCards ?? []), ...(game.ixAuction?.cards ?? []),
...(game.ornithopter ? [game.ornithopter.card] : []),
...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? []),
].map(card => card.id).sort();
}
/** Controlled original-card transfers to stage receiver capacity. No fake
* Treachery card, duplicate or destruction is used to manufacture a full hand. */
export function stageBiddingHandSize(game: Game, id: string, count: number) {
const original = biddingPhysicalIds(game);
const player = biddingPlayer(game, id);
while (player.hand.length > count) game.deck.push(player.hand.pop()!);
while (player.hand.length < count) {
const card = game.deck.shift() ?? game.discard.shift();
assert.ok(card, 'capacity fixture requires an original drawable card');
player.hand.push(card);
}
assert.equal(player.hand.length, count);
assert.deepEqual(biddingPhysicalIds(game), original, 'controlled hand transfer conserves all original physical IDs');
}
/** Labelled conserved transfer from original stock, including already-dealt
* cards. Random setup may deal every Karama; never fabricate a replacement. */
export function stageBiddingKarama(game: Game, id: string) {
const original = biddingPhysicalIds(game), receiver = biddingPlayer(game, id);
const held = receiver.hand.find(card => card.effect === 'karama');
if (held) return held;
const stock = [game.deck, game.discard, ...game.players.filter(player => player.id !== id).map(player => player.hand)];
for (const source of stock) {
const index = source.findIndex(card => card.effect === 'karama');
if (index < 0) continue;
const card = source.splice(index, 1)[0];
receiver.hand.push(card);
assert.deepEqual(biddingPhysicalIds(game), original);
return card;
}
throw new Error('Original unused fixture stock has no Karama.');
}
@@ -0,0 +1,119 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeHomeworldOccupationGameForAudit,
joinGame, newPlayer, type Game,
} from '../game/engine';
import { FACTIONS, type FactionId } from '../game/catalog';
import {
assertDefenseInventory, qualifyDefensePosition,
} from './fixture-homeworld-occupied-defenses';
import { biddingPhysicalIds, biddingPlayer, stageBiddingKarama } from './fixture-homeworld-occupied-bidding';
import { advanceOriginal } from './fixture-homeworld-occupied-producers';
export const privateLotPlayer = biddingPlayer;
export const privateLotPhysicalIds = biddingPhysicalIds;
export const privateLotReload = (game: Game): Game => JSON.parse(JSON.stringify(game));
/** Real fresh audit setup with the originally selected faction decks. Only the
* subsequent conserved qualification/card positions are controlled fixtures. */
export function freshPrivateLotGame(excludeCardId?: string) {
const roster: [string, FactionId][] = [
['nativeRichese', 'richese'], ['nativeAt', 'atreides'], ['occupier', 'guild'],
['ally', 'emperor'], ['rival', 'beneGesserit'], ['fremen', 'fremen'],
];
const expansions = [...new Set(roster.map(([, faction]) =>
FACTIONS.find(card => card.id === faction)!.expansion))].filter(expansion => expansion !== 'base');
let game = createGame('OCCUPIEDPRIVATELOTS', newPlayer('nativeRichese', 'Richese', 'richese'), true, expansions);
for (const [id, faction] of roster.slice(1)) joinGame(game, newPlayer(id, id, faction));
game = applyAction(game, 'nativeRichese', { type: 'homeworlds', enabled: true });
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
game = initializeHomeworldOccupationGameForAudit(game);
game = advanceOriginal(game, game => game.status === 'playing');
assert.equal(game.homeworldOccupationPreview, true);
assert.deepEqual(game.homeworldOccupationHistory!.qualifications, []);
assertDefenseInventory(game);
const original = privateLotPhysicalIds(game);
assert.equal(new Set(original).size, original.length);
// Controlled source-helper qualification: original native counters go to
// their own Tanks and one original Guild reserve becomes the sole visitor.
qualifyDefensePosition(game, 'homeworld:atreides');
privateLotPlayer(game, 'occupier').ally = 'ally';
privateLotPlayer(game, 'ally').ally = 'occupier';
// Original unused physical stock, not a manufactured face or cache alias.
const cardAt = game.deck.findIndex(card => card.effect !== 'karama' && card.id !== excludeCardId);
assert.ok(cardAt >= 0);
const card = game.deck.splice(cardAt, 1)[0];
privateLotPlayer(game, 'nativeRichese').hand.push(card);
const karama = stageBiddingKarama(game, 'rival');
assert.deepEqual(privateLotPhysicalIds(game), original);
assertDefenseInventory(game);
game = advanceOriginal(game, game => game.decision?.kind === 'richeseBlackMarket');
assert.equal(game.decision!.player, 'nativeRichese');
game = applyAction(game, 'nativeRichese', {
type: 'decision', event: game.richeseBidding!.event,
card: card.id, method: 'silent',
});
assert.equal(game.richeseAuction!.source, 'blackMarket');
assert.equal(game.richeseAuction!.owner, 'nativeRichese');
assert.equal(game.response?.kind, 'richeseBlackMarket');
assert.deepEqual(privateLotPhysicalIds(game), original);
return { game, card, karama, original };
}
/** Complete only the actual source response, without bot decisions/bids or
* phase-clock mutation. Every original eligible seat passes once. */
export function passPrivateLotWindow(game: Game, kind: 'richeseBlackMarket' | 'richeseAuction' | 'atreidesAuction'): Game {
assert.equal(game.response?.kind, kind);
for (let step = 0; game.response?.kind === kind && step < game.players.length; step++) {
const actor = game.players.find(player => !game.response!.passed.includes(player.id));
assert.ok(actor);
game = applyAction(game, actor.id, { type: 'passResponse' });
}
assert.notEqual(game.response?.kind, kind);
return game;
}
/** Pay the original funded Silent invoice: native Richese cannot self-bid;
* the Guild buyer pays its own real spice, every other eligible seat bids zero. */
export function sellPrivateBlackMarketLot(game: Game, amount = 2): Game {
assert.equal(game.response, null);
assert.equal(game.richeseAuction!.source, 'blackMarket');
assert.equal(game.richeseAuction!.method, 'silent');
const event = game.richeseAuction!.event;
const eligible = [...game.richeseAuction!.eligible];
assert.ok(eligible.includes('occupier'));
assert.ok(privateLotPlayer(game, 'occupier').spice >= amount);
for (const actor of eligible) game = applyAction(game, actor, {
type: 'richeseBid', event, amount: actor === 'occupier' ? amount : 0, allyPayment: 0,
});
assert.equal(game.decision?.kind, 'richeseDeclaration');
assert.equal(game.richeseBidding!.blackMarketSold, true);
assert.equal(game.currentAuctionSale, null);
assert.equal(game.richeseAuction, null);
return game;
}
/** Controlled conserved recycle position, NOT a claim that a game effect
* returns Black Market purchases. The next pool/lot is produced by the actual
* native declaration, original draw and original inspection handler. */
export function recyclePrivatePurchaseIntoOriginalNormalLot(game: Game, cardId: string): Game {
assert.equal(game.decision?.kind, 'richeseDeclaration');
const original = privateLotPhysicalIds(game);
const buyer = privateLotPlayer(game, 'occupier');
const at = buyer.hand.findIndex(card => card.id === cardId);
assert.ok(at >= 0);
game.deck.unshift(buyer.hand.splice(at, 1)[0]);
assert.deepEqual(privateLotPhysicalIds(game), original);
game = applyAction(game, 'nativeRichese', {
type: 'decision', event: game.richeseBidding!.event, position: 'last',
});
game = passPrivateLotWindow(game, 'richeseAuction');
assert.equal(game.response?.kind, 'atreidesAuction');
assert.equal(game.auction!.cards[game.auction!.index].id, cardId);
assert.equal(game.auction!.peekKnown, false);
assert.deepEqual(privateLotPhysicalIds(game), original);
return game;
}
@@ -0,0 +1,72 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, initializeHomeworldOccupationGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
/** Drive original phase/control producers, never synthesize a paid receipt or
* advance the phase clock by assignment. Tests may stop before any choice. */
export function advanceOriginal(game: Game, stop: (game: Game) => boolean, limit = 600): Game {
for (let step = 0; (game.phaseOpening || !stop(game)) && step < limit; step++) {
let choice: { player: string; action: Action } | undefined;
if (game.phaseOpening) {
const player = game.players.find(player => !game.phaseOpening!.passed.includes(player.id));
if (player) choice = { player: player.id, action: { type: 'ready' } };
} else if (game.response) {
const player = game.players.find(player => !game.response!.passed.includes(player.id));
if (player) choice = { player: player.id, action: { type: 'passResponse' } };
}
if (!choice) for (const player of game.players) {
const view = viewGame(game, player.id);
view.players.find(seat => seat.id === player.id)!.bot = 'Easy';
const action = botActions(view)[0];
if (!action) continue;
if (action.type === 'stormDial') action.amount = 0;
choice = { player: player.id, action };
break;
}
assert.ok(choice, 'original source progression must have a legal next action');
game = applyAction(game, choice.player, choice.action);
}
assert.ok(!game.phaseOpening && stop(game), 'original source progression must reach its settled requested boundary');
return game;
}
export function buyOriginalNormalLot(game: Game, buyer: string, amount: number): Game {
assert.ok(game.auction && !game.phaseOpening && !game.response && !game.decision);
for (let step = 0; game.auction!.active !== buyer && step < game.players.length; step++)
game = applyAction(game, game.auction!.active, { type: 'passBid' });
assert.equal(game.auction!.active, buyer);
game = applyAction(game, buyer, { type: 'bid', amount, allyPayment: 0 });
for (let step = 0; !game.currentAuctionSale && step < game.players.length + 2; step++) {
if (game.decision?.kind === 'auctionPayment')
game = applyAction(game, buyer, { type: 'decision', karama: false });
else game = applyAction(game, game.auction!.active, { type: 'passBid' });
}
assert.ok(game.currentAuctionSale, 'the original purchase producer must own its paid invoice');
return game;
}
export function cash(game: Game): Record<string, number> {
return Object.fromEntries(game.players.map(player => [player.id, player.spice]));
}
/** Fresh original Collection roster includes Ecaz shared legs and a real
* Harkonnen occupier; the ordinary setup inventory is never replaced. */
export function freshSouthernProducerGame(): Game {
let game = createGame('SOUTHERNPRODUCER', newPlayer('native', 'Fremen', 'fremen'), true, ['ecaz']);
for (const [id, faction] of [['occupier', 'harkonnen'], ['ally', 'guild'], ['ecaz', 'ecaz'], ['atreides', 'atreides']] as const)
joinGame(game, newPlayer(id, id, faction));
game = applyAction(game, 'native', { type: 'homeworlds', enabled: true });
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
game = initializeHomeworldOccupationGameForAudit(game);
return advanceOriginal(game, game => game.status === 'playing');
}
/** Original native Guild payment can coexist with a BG accompaniment choice. */
export function freshGuildAdvisorProducerGame(): Game {
let game = createGame('GUILDADVISORPRODUCER', newPlayer('native', 'Guild', 'guild'), true);
for (const [id, faction] of [['occupier', 'atreides'], ['observer', 'beneGesserit'], ['buyer', 'emperor']] as const)
joinGame(game, newPlayer(id, id, faction));
game = applyAction(game, 'native', { type: 'homeworlds', enabled: true });
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
return advanceOriginal(initializeHomeworldOccupationGameForAudit(game), game => game.status === 'playing');
}
+281
View File
@@ -0,0 +1,281 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { handLimit, type Game } from '../game/engine';
import type { FactionId } from '../game/catalog';
import { HOMEWORLD_CARDS, type HomeworldId } from '../game/homeworld-cards';
import { HomeworldCustodyError } from '../game/homeworld-custody';
import {
quoteOccupiedBiddingAuthority, requireOccupiedBiddingController,
requireOccupiedBiddingBonusRecipient, OccupiedBiddingError,
type OccupiedBiddingKind,
} from '../game/homeworld-occupied-bidding';
import {
addDefenseVisitor, assertDefenseInventory, clearDefenseNative,
qualifyDefensePosition, recordDefensePosition, removeDefenseVisitor,
} from './fixture-homeworld-occupied-defenses';
import {
biddingPhysicalIds, biddingPlayer, freshBiddingGame, stageBiddingHandSize,
} from './fixture-homeworld-occupied-bidding';
const effects: Record<OccupiedBiddingKind, { native: FactionId; card: HomeworldId }> = {
atreidesInspection: { native: 'atreides', card: 'caladan' },
ixAuction: { native: 'ixians', card: 'ix' },
richeseCache: { native: 'richese', card: 'richese' },
harkonnenBonus: { native: 'harkonnen', card: 'giedi_prime' },
};
void test('Caladan shares native inspection with exactly the original qualifier, never ally-only borrowing', () => {
const game = freshBiddingGame('atreides');
const originalCards = biddingPhysicalIds(game);
assert.deepEqual(quoteOccupiedBiddingAuthority(game, 'atreidesInspection').inspectionAudience, ['native']);
qualifyDefensePosition(game, 'homeworld:atreides');
biddingPlayer(game, 'occupier').ally = 'ally';
biddingPlayer(game, 'ally').ally = 'occupier';
const before = structuredClone(game);
const shared = quoteOccupiedBiddingAuthority(game, 'atreidesInspection');
assert.equal(shared.provider, 'native', 'native Atreides still produces the original inspection');
assert.deepEqual(shared.inspectionAudience, ['native', 'occupier']);
assert.equal(shared.inspectionAudience.includes('ally'), false);
assert.equal(shared.inspectionAudience.includes('observer'), false);
assert.deepEqual(shared.bonusRecipients, [], 'sharing an inspection never awards a card');
assert.equal(requireOccupiedBiddingController(game, 'atreidesInspection', 'native').provider, 'native');
assert.equal(requireOccupiedBiddingController(game, 'atreidesInspection', 'occupier').provider, 'native');
assert.throws(() => requireOccupiedBiddingController(game, 'atreidesInspection', 'ally'), HomeworldCustodyError);
assert.deepEqual(game, before);
assert.deepEqual(biddingPhysicalIds(game), originalCards);
const saved: Game = JSON.parse(JSON.stringify(game));
assert.deepEqual(quoteOccupiedBiddingAuthority(saved, 'atreidesInspection').inspectionAudience, ['native', 'occupier']);
delete saved.homeworldOccupationPreview;
assert.deepEqual(quoteOccupiedBiddingAuthority(saved, 'atreidesInspection').inspectionAudience, ['native']);
});
void test('Ix transfers the one actual selection and pool audience, not native setup or a duplicate peek', () => {
const game = freshBiddingGame('ixians');
const native = quoteOccupiedBiddingAuthority(game, 'ixAuction');
assert.equal(native.controller, 'native');
qualifyDefensePosition(game, 'homeworld:ixians');
// Labelled conserved original pool: every card leaves its deck once. The
// known receipt is only an alias; quoting cannot draw or return it again.
const originalCards = biddingPhysicalIds(game);
const pool = game.deck.splice(0, 3);
assert.equal(pool.length, 3);
game.ixAuction = { count: 2, cards: pool };
game.ixAuctionKnown = { turn: game.turn, cards: [...pool] };
biddingPlayer(game, 'occupier').ally = 'ally';
biddingPlayer(game, 'ally').ally = 'occupier';
const before = structuredClone(game);
const occupied = requireOccupiedBiddingController(game, 'ixAuction', 'occupier');
assert.equal(occupied.provider, 'native');
assert.deepEqual(occupied.inspectionAudience, ['occupier']);
assert.equal(occupied.inspectionAudience.includes('native'), false, 'native provider has no second pool inspection');
assert.equal(occupied.inspectionAudience.includes('ally'), false);
assert.throws(() => requireOccupiedBiddingController(game, 'ixAuction', 'native'), OccupiedBiddingError);
assert.throws(() => requireOccupiedBiddingController(game, 'ixAuction', 'ally'), HomeworldCustodyError);
assert.deepEqual(game, before, 'no source draw, sale-count change, selection, return or shuffle is performed by authority');
assert.deepEqual(biddingPhysicalIds(game), originalCards);
const saved: Game = JSON.parse(JSON.stringify(game));
assert.equal(requireOccupiedBiddingController(saved, 'ixAuction', 'occupier').provider, 'native');
delete saved.homeworldOccupationPreview;
assert.equal(requireOccupiedBiddingController(saved, 'ixAuction', 'native').controller, 'native');
assert.deepEqual(quoteOccupiedBiddingAuthority(saved, 'ixAuction').inspectionAudience, ['native']);
});
void test('Richese transfers only original cache choice while native cache custody and seller stay intact', () => {
const game = freshBiddingGame('richese');
assert.ok(game.richeseCache!.length > 0, 'original setup supplied real cache cards');
const originalCards = biddingPhysicalIds(game);
const originalCache = structuredClone(game.richeseCache);
qualifyDefensePosition(game, 'homeworld:richese');
const before = structuredClone(game);
const authority = requireOccupiedBiddingController(game, 'richeseCache', 'occupier');
assert.equal(authority.provider, 'native', 'choosing cannot become the sale/payment provider');
assert.deepEqual(authority.inspectionAudience, ['native', 'occupier'], 'native still knows its original cache');
assert.equal(authority.inspectionAudience.includes('ally'), false);
assert.equal(authority.inspectionAudience.includes('observer'), false);
assert.throws(() => requireOccupiedBiddingController(game, 'richeseCache', 'native'), OccupiedBiddingError);
assert.throws(() => requireOccupiedBiddingController(game, 'richeseCache', 'observer'), HomeworldCustodyError);
assert.deepEqual(game.richeseCache, originalCache);
assert.deepEqual(biddingPhysicalIds(game), originalCards);
assert.deepEqual(game, before, 'authority never removes/offers a cache card or changes native sale terms');
const saved: Game = JSON.parse(JSON.stringify(game));
assert.equal(requireOccupiedBiddingController(saved, 'richeseCache', 'occupier').provider, 'native');
delete saved.homeworldOccupationPreview;
assert.equal(requireOccupiedBiddingController(saved, 'richeseCache', 'native').controller, 'native');
});
void test('Giedi checks genuine four/five/eight-card receiver space, independent of the full native buyer', () => {
const game = freshBiddingGame('harkonnen', 'choam');
qualifyDefensePosition(game, 'homeworld:harkonnen');
const originalCards = biddingPhysicalIds(game);
const native = biddingPlayer(game, 'native');
const occupier = biddingPlayer(game, 'occupier');
const ally = biddingPlayer(game, 'ally');
assert.equal(handLimit(occupier), 4);
assert.equal(handLimit(ally), 5);
assert.equal(handLimit(native), 8);
occupier.ally = 'ally';
ally.ally = 'occupier';
stageBiddingHandSize(game, 'native', 8);
stageBiddingHandSize(game, 'occupier', 3);
stageBiddingHandSize(game, 'ally', 4);
const before = structuredClone(game);
const both = quoteOccupiedBiddingAuthority(game, 'harkonnenBonus');
assert.equal(both.provider, 'native');
assert.equal(both.controller, 'occupier');
assert.deepEqual(both.bonusRecipients, ['occupier', 'ally'], 'two alternatives for the same physical extra card');
assert.equal(requireOccupiedBiddingBonusRecipient(game, 'ally').provider, 'native');
assert.throws(() => requireOccupiedBiddingBonusRecipient(game, 'native'), OccupiedBiddingError);
assert.deepEqual(game, before, 'quoting alternatives never awards either card, let alone two');
stageBiddingHandSize(game, 'occupier', 4);
assert.deepEqual(quoteOccupiedBiddingAuthority(game, 'harkonnenBonus').bonusRecipients, ['ally']);
stageBiddingHandSize(game, 'native', 1);
assert.deepEqual(quoteOccupiedBiddingAuthority(game, 'harkonnenBonus').bonusRecipients, ['ally'], 'buyer space cannot replace full receiver space');
stageBiddingHandSize(game, 'ally', 5);
assert.deepEqual(quoteOccupiedBiddingAuthority(game, 'harkonnenBonus').bonusRecipients, []);
assert.throws(() => requireOccupiedBiddingBonusRecipient(game, 'ally'), HomeworldCustodyError);
// An eight-card faction can receive only as the current reciprocal ally,
// not automatically as native buyer. Requote against that real limit.
occupier.ally = 'native';
native.ally = 'occupier';
stageBiddingHandSize(game, 'native', 7);
assert.deepEqual(quoteOccupiedBiddingAuthority(game, 'harkonnenBonus').bonusRecipients, ['native']);
stageBiddingHandSize(game, 'native', 8);
assert.deepEqual(quoteOccupiedBiddingAuthority(game, 'harkonnenBonus').bonusRecipients, []);
stageBiddingHandSize(game, 'occupier', 3);
occupier.atomicsHandLimitPenalty = true;
assert.equal(handLimit(occupier), 3);
assert.deepEqual(quoteOccupiedBiddingAuthority(game, 'harkonnenBonus').bonusRecipients, [], 'printed base limit cannot bypass an actual capacity penalty');
assert.deepEqual(biddingPhysicalIds(game), originalCards);
assertDefenseInventory(game);
});
void test('Giedi recipient acceptance rechecks reciprocal alliance changes and retains native legacy behavior', () => {
const game = freshBiddingGame('harkonnen', 'choam');
qualifyDefensePosition(game, 'homeworld:harkonnen');
const occupier = biddingPlayer(game, 'occupier');
stageBiddingHandSize(game, 'occupier', 4);
stageBiddingHandSize(game, 'ally', 4);
stageBiddingHandSize(game, 'observer', 3);
occupier.ally = 'ally';
assert.deepEqual(quoteOccupiedBiddingAuthority(game, 'harkonnenBonus').bonusRecipients, [], 'one-way alliance confers no card');
biddingPlayer(game, 'ally').ally = 'occupier';
assert.equal(requireOccupiedBiddingBonusRecipient(game, 'ally').controller, 'occupier');
occupier.ally = 'observer';
assert.throws(() => requireOccupiedBiddingBonusRecipient(game, 'ally'), OccupiedBiddingError);
assert.throws(() => requireOccupiedBiddingBonusRecipient(game, 'observer'), OccupiedBiddingError);
biddingPlayer(game, 'observer').ally = 'occupier';
assert.deepEqual(quoteOccupiedBiddingAuthority(game, 'harkonnenBonus').bonusRecipients, ['observer']);
const saved: Game = JSON.parse(JSON.stringify(game));
assert.equal(requireOccupiedBiddingBonusRecipient(saved, 'observer').provider, 'native');
delete saved.homeworldOccupationPreview;
assert.deepEqual(quoteOccupiedBiddingAuthority(saved, 'harkonnenBonus').bonusRecipients, ['native']);
stageBiddingHandSize(saved, 'native', 8);
assert.deepEqual(quoteOccupiedBiddingAuthority(saved, 'harkonnenBonus').bonusRecipients, []);
});
void test('Giedi uses original discard refill stock without ever drawing a physical card during a quote', () => {
const game = freshBiddingGame('harkonnen');
qualifyDefensePosition(game, 'homeworld:harkonnen');
const originalCards = biddingPhysicalIds(game);
// Conserved exhausted-deck position: the original discarded stock is the
// only possible refill, not a cache/hand/knowledge receipt treated as supply.
game.discard.push(...game.deck.splice(0));
assert.equal(game.deck.length, 0);
assert.ok(game.discard.length > 0);
const before = structuredClone(game);
const authority = requireOccupiedBiddingBonusRecipient(game, 'occupier');
assert.equal(authority.bonusStockAvailable, true);
assert.deepEqual(authority.bonusRecipients, ['occupier']);
assert.deepEqual(game, before);
assert.deepEqual(biddingPhysicalIds(game), originalCards);
});
for (const [kind, rule] of Object.entries(effects) as [OccupiedBiddingKind, { native: FactionId; card: HomeworldId }][]) {
void test(`${rule.card}: Basic retains publisher turn-boundary qualification rather than a current garrison shortcut`, () => {
const game = freshBiddingGame(rule.native, 'emperor', false);
const world = `homeworld:${rule.native}`;
const high = HOMEWORLD_CARDS.find(card => card.id === rule.card)!.high.reserves.min;
const native = biddingPlayer(game, 'native');
const originalCards = biddingPhysicalIds(game);
clearDefenseNative(game, world);
native.tanks -= high - 1;
native.reserves += high - 1;
addDefenseVisitor(game, world);
recordDefensePosition(game, 'native-below-high-with-original-foreign-visitor');
assert.equal(quoteOccupiedBiddingAuthority(game, kind).occupied, false, 'a foreign visitor alongside native forces has no sole-source qualification');
recordDefensePosition(game, 'original-turn-end-qualifies-foreign-group', 'turnEnd');
const authority = quoteOccupiedBiddingAuthority(game, kind);
assert.equal(authority.occupied, true);
assert.equal(authority.controller, kind === 'atreidesInspection' ? 'native' : 'occupier');
assert.equal(authority.provider, 'native');
assert.equal(authority.blocked, null);
assert.deepEqual(biddingPhysicalIds(game), originalCards);
assertDefenseInventory(game);
});
void test(`${rule.card}: Basic pending lifecycle and missing historical sources fail closed`, () => {
const qualified = freshBiddingGame(rule.native, 'emperor', false);
const world = `homeworld:${rule.native}`;
qualifyDefensePosition(qualified, world);
const high = HOMEWORLD_CARDS.find(card => card.id === rule.card)!.high.reserves.min;
for (const mode of ['departure', 'competition', 'replacement', 'repopulation', 'expired', 'missing-history'] as const) {
const game = structuredClone(qualified);
if (mode === 'departure') {
removeDefenseVisitor(game, world);
recordDefensePosition(game, 'original-qualifier-left');
addDefenseVisitor(game, world);
recordDefensePosition(game, 'same-qualifier-returned-with-no-invented-expiry-rule');
} else if (mode === 'competition') {
addDefenseVisitor(game, world, 'observer');
recordDefensePosition(game, 'second-original-foreign-group-arrived');
removeDefenseVisitor(game, world, 'observer');
recordDefensePosition(game, 'contest-cleared-with-history-retained');
} else if (mode === 'replacement') {
removeDefenseVisitor(game, world);
recordDefensePosition(game, 'original-qualifier-departed-before-turnover');
addDefenseVisitor(game, world, 'observer');
recordDefensePosition(game, 'different-original-sole-qualifier-arrived');
} else if (mode === 'repopulation') {
const native = biddingPlayer(game, 'native');
native.tanks -= high;
native.reserves += high;
recordDefensePosition(game, 'native-original-high-threshold-restored');
clearDefenseNative(game, world);
recordDefensePosition(game, 'native-fell-low-after-restoration');
} else if (mode === 'expired') {
game.turn++;
} else delete game.homeworldOccupationHistory;
const before = structuredClone(game);
const blocked = quoteOccupiedBiddingAuthority(game, kind);
assert.notEqual(blocked.blocked, null);
assert.equal(blocked.provider, 'native', 'source provider is not reassigned even when effect must wait');
assert.equal(blocked.controller, null);
assert.deepEqual(blocked.inspectionAudience, [], 'uncertain history never leaks an original private pool/card');
assert.deepEqual(blocked.bonusRecipients, []);
assert.throws(() => requireOccupiedBiddingController(game, kind, 'native'), HomeworldCustodyError);
assert.throws(() => requireOccupiedBiddingController(game, kind, 'occupier'), HomeworldCustodyError);
if (kind === 'harkonnenBonus')
assert.throws(() => requireOccupiedBiddingBonusRecipient(game, 'occupier'), HomeworldCustodyError);
assert.deepEqual(game, before);
assertDefenseInventory(game);
const saved: Game = JSON.parse(JSON.stringify(game));
assert.equal(quoteOccupiedBiddingAuthority(saved, kind).controller, null);
assert.notEqual(quoteOccupiedBiddingAuthority(saved, kind).blocked, null);
delete saved.homeworldOccupationPreview;
const legacy = quoteOccupiedBiddingAuthority(saved, kind);
assert.equal(legacy.controller, 'native');
assert.equal(legacy.blocked, null, 'outside fresh preview does not convert or gate native saved-game rules');
}
});
}
void test('tampered original qualification evidence is a custody-domain error, never private permission', () => {
const game = freshBiddingGame('ixians');
qualifyDefensePosition(game, 'homeworld:ixians');
game.homeworldOccupationHistory!.qualifications[0].player = 'observer';
const before = structuredClone(game);
assert.throws(() => quoteOccupiedBiddingAuthority(game, 'ixAuction'), HomeworldCustodyError);
assert.deepEqual(game, before);
});
+6 -5
View File
@@ -146,7 +146,7 @@ const ambiguityRules: Record<string, {
};
for (const [effect, rule] of Object.entries(ambiguityRules)) {
void test(`${effect}: departed, contested, competing, restored-high and expired evidence propagate the shared blocker without guessing`, () => {
void test(`${effect}: Advanced retains the original occupier until departure and admits a newly sole replacement`, () => {
const qualified = freshDefenseGame(rule.native);
qualifyDefensePosition(qualified, rule.world);
for (const mode of ['departed', 'contested', 'competing', 'restored-high', 'expired'] as const) {
@@ -184,12 +184,13 @@ for (const [effect, rule] of Object.entries(ambiguityRules)) {
}
const before = structuredClone(game);
const actual = rule.quote(game);
assert.notEqual(actual.blocked, null, 'an ambiguous beneficiary cannot authorize the native effect');
if (effect !== 'sardaukar') assert.equal(actual.status, 'unresolved');
if (effect === 'sardaukar') assert.equal(actual.suppressed, false, 'blocked is not selected strength');
if (effect !== 'sardaukar' && mode === 'competing') assert.notEqual(actual.blocked, null);
else assert.equal(actual.blocked, null);
if (effect !== 'sardaukar') assert.equal(actual.status, mode === 'competing' ? 'prohibited' : 'allowed');
if (effect === 'sardaukar') assert.equal(actual.suppressed, true);
assert.deepEqual(game, before);
const saved: Game = JSON.parse(JSON.stringify(game));
assert.deepEqual(rule.quote(saved), actual, 'original JSON history retains ambiguity');
assert.deepEqual(rule.quote(saved), actual, 'original JSON history retains the Advanced beneficiary');
assertDefenseInventory(saved);
delete saved.homeworldOccupationPreview;
assert.deepEqual(rule.quote(saved), effect === 'sardaukar'
+45 -6
View File
@@ -202,7 +202,7 @@ void test('restoring the native high threshold after qualification blocks the pe
assert.deepEqual({ game, state }, before);
});
void test('zero-icon contested Salusa cannot block a real Kaitain bank award or choose a Sardaukar strength', () => {
void test('Advanced contested zero-icon Salusa retains its original strength penalty without blocking Kaitain income', () => {
const { game } = occupiedIncomeFixture('kaitain', true, false);
const secondary = 'homeworld:emperor:salusa';
const owner = game.players.find(p => p.id === 'owner')!;
@@ -212,13 +212,52 @@ void test('zero-icon contested Salusa cannot block a real Kaitain bank award or
game.players.find(p => p.id === 'competitor')!.reserves--;
game.homeworlds!.custody!.visitors[secondary].competitor = { normal: 1, elite: 0 };
observeControlledIncomePosition(game, 'controlled-secondary-contested');
assert.notEqual(occupiedHomeworldSardaukarStatus(game).blocked, null);
assert.deepEqual(occupiedHomeworldSardaukarStatus(game), { suppressed: true, blocked: null });
const before = owner.spice;
const pending = beginHomeworldOccupiedIncome(game, 'collection-positive-primary-zero-secondary');
const result = settle(game, pending, 2);
assert.equal(owner.spice - before, 2);
assert.equal(result.state.cursor, result.state.queue.length);
assert.equal(occupiedHomeworldSardaukarStatus(game).suppressed, false);
assert.notEqual(occupiedHomeworldSardaukarStatus(game).blocked, null,
'Collection does not guess the still-unresolved mandatory combat penalty.');
const zero = settle(game, result.state, 0);
assert.equal(zero.state.cursor, zero.state.queue.length);
assert.equal(owner.spice - before, 2);
assert.deepEqual(occupiedHomeworldSardaukarStatus(game), { suppressed: true, blocked: null });
});
void test('Advanced bank owner survives native return, foreign contest and turn change but expires at its last departure', () => {
const { game, world } = occupiedIncomeFixture('caladan', true, false);
const owner = game.players.find(player => player.id === 'owner')!;
const native = game.players.find(player => player.id === 'native')!;
const competitor = game.players.find(player => player.id === 'competitor')!;
const initial = beginHomeworldOccupiedIncome(game, 'advanced-original-collection');
const qualification = initial.queue[0].qualification;
native.tanks -= 6;
native.reserves += 6;
game.homeworlds!.custody!.visitors[world].competitor = { normal: 1, elite: 0 };
competitor.reserves--;
observeControlledIncomePosition(game, 'advanced-native-high-and-foreign-contest');
assert.equal(homeworldOccupiedIncomeOffer(initial, game, owner.id)!.blocked, null);
const before = owner.spice;
settle(game, initial, 2);
assert.equal(owner.spice - before, 2);
game.turn++;
observeControlledIncomePosition(game, 'advanced-next-turn-original-owner-remains');
const retained = beginHomeworldOccupiedIncome(game, 'advanced-next-collection');
assert.equal(retained.queue[0].occupier, owner.id);
assert.equal(retained.queue[0].qualification, qualification);
delete game.homeworlds!.custody!.visitors[world].owner;
owner.reserves++;
observeControlledIncomePosition(game, 'advanced-last-original-force-left');
assert.equal(beginHomeworldOccupiedIncome(game, 'advanced-no-sole-successor').queue.length, 0);
assert.throws(() => quoteHomeworldOccupiedIncomeChoice(retained, game, owner.id, retained.event, world, 2), HomeworldCustodyError);
native.tanks += native.reserves;
native.reserves = 0;
observeControlledIncomePosition(game, 'advanced-successor-alone');
const replacement = beginHomeworldOccupiedIncome(game, 'advanced-successor-collection');
assert.equal(replacement.queue[0].occupier, competitor.id);
assert.notEqual(replacement.queue[0].qualification, qualification);
const successorBalance = competitor.spice;
settle(game, replacement, 2);
assert.equal(competitor.spice - successorBalance, 2);
assert.equal(owner.spice - before, 2);
homeworldGameIntegrity(game);
});
+382
View File
@@ -0,0 +1,382 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { Game } from '../game/engine';
import { HomeworldCustodyError } from '../game/homeworld-custody';
import { homeworldGameIntegrity } from '../game/homeworld-game';
import {
allocateOccupiedPercentageReceipt, createOccupiedPercentageState, quoteOccupiedPercentageSource,
validateOccupiedPercentageState,
type OccupiedPercentageKind, type OccupiedPercentageQuote, type OccupiedPercentageSource,
type OccupiedPercentageState,
} from '../game/homeworld-occupied-percentage';
import { occupiedIncomeFixture as originalIncomeFixture, observeControlledIncomePosition } from './fixture-homeworld-occupied-income';
import type { HomeworldId } from '../game/homeworld-cards';
const worlds: Record<OccupiedPercentageKind, HomeworldId> = {
'emperor-treachery': 'kaitain', 'guild-shipping': 'junction',
'richese-income': 'richese', 'fremen-collection': 'southern_hemisphere',
};
/** Labelled cash-only fixture redistribution conserves the original setup total
* and keeps every payer solvent. Physical cards/forces/history remain original. */
function occupiedIncomeFixture(...args: Parameters<typeof originalIncomeFixture>) {
const fixture = originalIncomeFixture(...args), game = fixture.game;
const before = game.players.reduce((sum, player) => sum + player.spice, 0);
const minimum: Record<string, number> = { native: 5, owner: 5, ally: 0, competitor: 11 };
for (const receiver of game.players) {
for (const donor of game.players) {
if (receiver.id === donor.id) continue;
const transfer = Math.min(Math.max(0, minimum[receiver.id] - receiver.spice),
Math.max(0, donor.spice - minimum[donor.id]));
donor.spice -= transfer;
receiver.spice += transfer;
}
assert.ok(receiver.spice >= minimum[receiver.id], 'Original setup cash must fund the labelled source.');
}
assert.equal(game.players.reduce((sum, player) => sum + player.spice, 0), before);
return fixture;
}
function actualSource(kind: OccupiedPercentageKind, amount: number, event = 'actual-source'): OccupiedPercentageSource {
const base = { event, native: 'native', amount };
if (kind === 'fremen-collection') return { ...base, kind, collection: {
id: event, binding: 'completed original Collection credit proof', complete: true,
collected: [{ id: 'actual-credit', amount }],
} };
const payment = { id: event, binding: 'original funded producer receipt', paid: amount,
contributions: [{ payer: 'competitor', amount }], recipient: 'native' };
if (kind === 'richese-income') return { ...base, kind, payment: { ...payment, received: amount } };
return { ...base, kind, payment, nativeIncome: 'received' };
}
function balances(game: Game): Record<string, number> {
return Object.fromEntries(game.players.map(player => [player.id, player.spice]));
}
/** Exercise exactly the parent's cash contract against real setup wallets:
* payer debits occur before admission, then native/occupied cash is committed.
* bankRetained stays in the Bank; quote/allocation never change any wallet. */
function commit(game: Game, quote: OccupiedPercentageQuote, ownAmount = quote.receipt.occupiedAmount) {
const before = structuredClone({ game, state: quote.state });
const result = quote.receipt.status === 'pending'
? allocateOccupiedPercentageReceipt(quote.state, game, 'owner', quote.receipt.source.event, ownAmount, quote.receipt.source)
: { ...quote, credits: quote.receipt.credits };
assert.deepEqual({ game, state: quote.state }, before);
assert.equal(result.receipt.status, 'settled');
game.players.find(player => player.id === result.receipt.source.native)!.spice += result.receipt.nativeRetained;
for (const credit of result.credits) game.players.find(player => player.id === credit.player)!.spice += credit.amount;
return result;
}
void test('each printed producer conserves odd/even/zero actual sources without touching original payer or physical custody', () => {
for (const kind of Object.keys(worlds) as OccupiedPercentageKind[]) {
for (const amount of [0, 1, 5, 8]) {
const { game, cards } = occupiedIncomeFixture(worlds[kind], false, false);
const original = actualSource(kind, amount), initial = balances(game);
if (kind !== 'fremen-collection') game.players.find(player => player.id === 'competitor')!.spice -= amount;
const paid = structuredClone({ game, original });
const quote = quoteOccupiedPercentageSource(game, original, createOccupiedPercentageState());
assert.deepEqual({ game, original }, paid, 'admission never charges a payer again');
const done = commit(game, quote);
const after = balances(game);
assert.equal(after.native - initial.native, Math.ceil(amount / 2));
assert.equal(after.owner - initial.owner, Math.floor(amount / 2));
assert.equal(after.ally - initial.ally, 0);
assert.equal(after.competitor, initial.competitor - (kind === 'fremen-collection' ? 0 : amount));
assert.equal(done.receipt.nativeRetained + done.credits.reduce((sum, credit) => sum + credit.amount, 0) +
done.receipt.bankRetained, amount);
assert.equal(Object.values(after).reduce((sum, cash) => sum + cash, 0) -
Object.values(initial).reduce((sum, cash) => sum + cash, 0), kind === 'fremen-collection' ? amount : 0);
assert.deepEqual([...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand)].map(card => card.id).sort(), cards);
homeworldGameIntegrity(game);
}
}
});
void test('Kaitain FAQ actual five-spice occupier purchase is debited once and returns three/two, including immediate ally splits', () => {
for (const ownAmount of [2, 1, 0]) {
const { game } = occupiedIncomeFixture('kaitain');
const original = actualSource('emperor-treachery', 5);
assert.equal(original.kind, 'emperor-treachery');
if (original.kind !== 'emperor-treachery') throw new Error('Fixture source kind');
const source: OccupiedPercentageSource = { ...original,
payment: { ...original.payment, contributions: [{ payer: 'owner', amount: 5 }] } };
const before = balances(game);
game.players.find(player => player.id === 'owner')!.spice -= 5;
const quote = quoteOccupiedPercentageSource(game, source, createOccupiedPercentageState());
const done = commit(game, quote, ownAmount), after = balances(game);
assert.equal(after.native - before.native, 3);
assert.equal(after.owner - before.owner, -5 + ownAmount);
assert.equal(after.ally - before.ally, 2 - ownAmount);
assert.equal(done.credits.reduce((sum, credit) => sum + credit.amount, 0), 2);
assert.equal(Object.values(after).reduce((sum, cash) => sum + cash, 0), Object.values(before).reduce((sum, cash) => sum + cash, 0));
}
});
void test('Junction excludes actual Guild funding before rounding; Guild-only payment has no allocation', () => {
for (const otherAmount of [0, 2, 5]) {
const { game } = occupiedIncomeFixture('junction', false, false);
const original = actualSource('guild-shipping', otherAmount);
if (original.kind !== 'guild-shipping') throw new Error('Fixture source kind');
const source: OccupiedPercentageSource = { ...original, payment: { ...original.payment, paid: otherAmount + 5,
contributions: [{ payer: 'native', amount: 5 }, { payer: 'competitor', amount: otherAmount }] } };
const before = balances(game);
game.players.find(player => player.id === 'native')!.spice -= 5;
game.players.find(player => player.id === 'competitor')!.spice -= otherAmount;
const quote = quoteOccupiedPercentageSource(game, source, createOccupiedPercentageState());
const done = commit(game, quote), after = balances(game);
assert.equal(after.native - before.native, -5 + Math.ceil(otherAmount / 2));
assert.equal(after.owner - before.owner, Math.floor(otherAmount / 2));
assert.equal(after.competitor, before.competitor - otherAmount);
assert.equal(Object.values(after).reduce((sum, cash) => sum + cash, 0) - Object.values(before).reduce((sum, cash) => sum + cash, 0), -5);
if (otherAmount === 0) {
assert.equal(quote.receipt.status, 'settled');
assert.deepEqual(done.credits, []);
assert.throws(() => allocateOccupiedPercentageReceipt(done.state, game, 'owner', source.event, 0, source), HomeworldCustodyError);
}
}
});
void test('Guild unresolved two odd contributions remain blocked, while a conserved even/odd original payment is reachable', () => {
const { game } = occupiedIncomeFixture('junction', false, false);
const original = actualSource('guild-shipping', 4);
if (original.kind !== 'guild-shipping') throw new Error('Fixture source kind');
const twoOdd: OccupiedPercentageSource = { ...original, payment: { ...original.payment,
contributions: [{ payer: 'owner', amount: 1 }, { payer: 'competitor', amount: 3 }] } };
const before = structuredClone(game);
assert.throws(() => quoteOccupiedPercentageSource(game, twoOdd, createOccupiedPercentageState()), HomeworldCustodyError);
assert.deepEqual(game, before);
const supported: OccupiedPercentageSource = { ...original, amount: 5, payment: { ...original.payment, paid: 5,
contributions: [{ payer: 'owner', amount: 2 }, { payer: 'competitor', amount: 3 }] } };
const initial = balances(game);
game.players.find(player => player.id === 'owner')!.spice -= 2;
game.players.find(player => player.id === 'competitor')!.spice -= 3;
commit(game, quoteOccupiedPercentageSource(game, supported, createOccupiedPercentageState()));
const after = balances(game);
assert.equal(after.native - initial.native, 3);
assert.equal(after.owner - initial.owner, 0);
assert.equal(after.competitor - initial.competitor, -3);
});
void test('Richese splits only actual native net received income, not gross sale or money routed elsewhere', () => {
const { game } = occupiedIncomeFixture('richese', false, false);
const original = actualSource('richese-income', 7);
if (original.kind !== 'richese-income') throw new Error('Fixture source kind');
const source: OccupiedPercentageSource = { ...original, payment: { ...original.payment, paid: 10,
contributions: [{ payer: 'competitor', amount: 10 }], received: 7 } };
const before = balances(game);
game.players.find(player => player.id === 'competitor')!.spice -= 10;
// The original producer, not this percentage helper, routed the other three.
game.players.find(player => player.id === 'ally')!.spice += 3;
commit(game, quoteOccupiedPercentageSource(game, source, createOccupiedPercentageState()));
const after = balances(game);
assert.equal(after.native - before.native, 4);
assert.equal(after.owner - before.owner, 3);
assert.equal(after.ally - before.ally, 3);
assert.equal(after.competitor - before.competitor, -10);
assert.equal(Object.values(after).reduce((sum, cash) => sum + cash, 0), Object.values(before).reduce((sum, cash) => sum + cash, 0));
for (const wrong of [
{ ...source, amount: 10 },
{ ...source, payment: { ...source.payment, recipient: 'owner' } },
{ ...source, payment: { ...source.payment, received: 11 } },
]) assert.throws(() => quoteOccupiedPercentageSource(game, wrong, createOccupiedPercentageState()), HomeworldCustodyError);
});
void test('Southern Hemisphere rounds the completed actual Collection total once, not odd piles independently', () => {
const { game } = occupiedIncomeFixture('southern_hemisphere');
const source: OccupiedPercentageSource = { kind: 'fremen-collection', native: 'native', event: 'collection-total', amount: 5,
collection: { id: 'collection-total', binding: 'all settled native Collection legs', complete: true,
collected: [{ id: 'first-pile', amount: 1 }, { id: 'shared-allocation', amount: 1 }, { id: 'last-credit', amount: 3 }] } };
const before = balances(game);
const quote = quoteOccupiedPercentageSource(game, source, createOccupiedPercentageState());
const done = commit(game, quote, 1), after = balances(game);
assert.equal(after.native - before.native, 3);
assert.equal(after.owner - before.owner, 1);
assert.equal(after.ally - before.ally, 1);
assert.equal(Object.values(after).reduce((sum, cash) => sum + cash, 0) - Object.values(before).reduce((sum, cash) => sum + cash, 0), 5);
const another = actualSource('fremen-collection', 3, 'another-pile-event');
assert.throws(() => quoteOccupiedPercentageSource(game, another, done.state), HomeworldCustodyError);
const wrongTotal = { ...source, amount: 3 };
assert.throws(() => quoteOccupiedPercentageSource(game, wrongTotal, createOccupiedPercentageState()), HomeworldCustodyError);
const unfinished = { ...source, collection: { ...source.collection, complete: false } } as unknown as OccupiedPercentageSource;
assert.throws(() => quoteOccupiedPercentageSource(game, unfinished, createOccupiedPercentageState()), HomeworldCustodyError);
});
void test('native Karama does not cancel printed occupied percentage receipts or manufacture native/bank cash', () => {
for (const kind of ['emperor-treachery', 'guild-shipping'] as const) {
const { game } = occupiedIncomeFixture(worlds[kind], false, false);
const original = actualSource(kind, 5);
if (original.kind !== 'emperor-treachery' && original.kind !== 'guild-shipping') throw new Error('Fixture source kind');
const source: OccupiedPercentageSource = { ...original, nativeIncome: 'karama-canceled' };
const before = balances(game);
game.players.find(player => player.id === 'competitor')!.spice -= 5;
const done = commit(game, quoteOccupiedPercentageSource(game, source, createOccupiedPercentageState()));
const after = balances(game);
assert.equal(after.native - before.native, 0);
assert.equal(after.owner - before.owner, 2);
assert.equal(after.competitor - before.competitor, -5);
assert.equal(done.receipt.bankRetained, 3);
assert.equal(Object.values(after).reduce((sum, cash) => sum + cash, 0) - Object.values(before).reduce((sum, cash) => sum + cash, 0), -3);
}
});
void test('outside the original fresh occupation profile physical-low Kaitain/Junction keep their existing bank remainder', () => {
for (const kind of ['emperor-treachery', 'guild-shipping'] as const) {
const { game } = occupiedIncomeFixture(worlds[kind]);
delete game.homeworldOccupationPreview;
const source = actualSource(kind, 5), before = balances(game);
game.players.find(player => player.id === 'competitor')!.spice -= 5;
const quote = quoteOccupiedPercentageSource(game, source, createOccupiedPercentageState());
assert.equal(quote.receipt.status, 'settled');
const done = commit(game, quote), after = balances(game);
assert.equal(after.native - before.native, 3);
assert.equal(after.owner - before.owner, 0);
assert.equal(after.ally - before.ally, 0);
assert.equal(done.receipt.bankRetained, 2);
assert.equal(Object.values(after).reduce((sum, cash) => sum + cash, 0) - Object.values(before).reduce((sum, cash) => sum + cash, 0), -2);
}
});
void test('JSON pending and completed ledger preserve source/beneficiary and reject re-admission, replay and changed provenance', () => {
const { game } = occupiedIncomeFixture('richese');
const source = actualSource('richese-income', 11), initial = balances(game);
game.players.find(player => player.id === 'competitor')!.spice -= 11;
const quote = quoteOccupiedPercentageSource(game, source, createOccupiedPercentageState());
const resumedGame: Game = JSON.parse(JSON.stringify(game));
const pending: OccupiedPercentageState = JSON.parse(JSON.stringify(quote.state));
const before = structuredClone({ game: resumedGame, state: pending });
assert.throws(() => quoteOccupiedPercentageSource(resumedGame, source, pending), HomeworldCustodyError);
assert.throws(() => allocateOccupiedPercentageReceipt(pending, resumedGame, 'owner', source.event, 3,
{ ...source, event: 'different-source' }), HomeworldCustodyError);
if (source.kind !== 'richese-income') throw new Error('Fixture source kind');
for (const changed of [
{ ...source, payment: { ...source.payment, binding: 'different original lot' } },
{ ...source, payment: { ...source.payment, contributions: [{ payer: 'owner', amount: 11 }] } },
{ ...source, amount: 9, payment: { ...source.payment, received: 9 } },
]) assert.throws(() => allocateOccupiedPercentageReceipt(pending, resumedGame, 'owner', source.event, 3, changed), HomeworldCustodyError);
assert.deepEqual({ game: resumedGame, state: pending }, before);
const done = commit(resumedGame, { receipt: pending.receipts[0], state: pending }, 3);
const after = balances(resumedGame);
assert.equal(after.native - initial.native, 6);
assert.equal(after.owner - initial.owner, 3);
assert.equal(after.ally - initial.ally, 2);
const settled: OccupiedPercentageState = JSON.parse(JSON.stringify(done.state));
validateOccupiedPercentageState(settled, resumedGame);
const stableCash = balances(resumedGame);
assert.throws(() => allocateOccupiedPercentageReceipt(settled, resumedGame, 'owner', source.event, 3, source), HomeworldCustodyError);
assert.throws(() => quoteOccupiedPercentageSource(resumedGame, { ...source, event: 'new-event-same-payment' }, settled), HomeworldCustodyError);
assert.deepEqual(balances(resumedGame), stableCash);
const tampered: OccupiedPercentageState = { ...done.state, receipts: done.state.receipts.map((row, i) =>
i === 0 ? { ...row, nativeRetained: row.nativeRetained + 1 } : row) };
assert.throws(() => validateOccupiedPercentageState(tampered, resumedGame), HomeworldCustodyError);
});
void test('wrong actor/event/turn and invalid splits cannot consume a pending percentage receipt', () => {
for (const allied of [false, true]) {
const { game } = occupiedIncomeFixture('kaitain', false, allied);
const source = actualSource('emperor-treachery', 9);
const quote = quoteOccupiedPercentageSource(game, source, createOccupiedPercentageState());
const before = structuredClone({ game, state: quote.state });
for (const actor of ['native', 'ally', 'competitor'])
assert.throws(() => allocateOccupiedPercentageReceipt(quote.state, game, actor, source.event, 4, source), HomeworldCustodyError);
assert.throws(() => allocateOccupiedPercentageReceipt(quote.state, game, 'owner', 'stale-event', 4, source), HomeworldCustodyError);
for (const amount of [-1, 5, 1.5, NaN, Infinity, ...(allied ? [] : [0, 3])])
assert.throws(() => allocateOccupiedPercentageReceipt(quote.state, game, 'owner', source.event, amount, source), HomeworldCustodyError);
assert.deepEqual({ game, state: quote.state }, before);
const stale = structuredClone(game);
stale.turn++;
assert.throws(() => allocateOccupiedPercentageReceipt(quote.state, stale, 'owner', source.event, 4, source), HomeworldCustodyError);
const initial = balances(game);
commit(game, quote, 4);
assert.equal(balances(game).owner - initial.owner, 4);
assert.equal(balances(game).native - initial.native, 5);
}
});
void test('changed reciprocal alliance or continuous entitlement cannot replace the original frozen recipient', () => {
for (const change of ['alliance', 'departure-return', 'competitor', 'repopulation'] as const) {
const { game, world } = occupiedIncomeFixture('richese');
const source = actualSource('richese-income', 7);
const quote = quoteOccupiedPercentageSource(game, source, createOccupiedPercentageState());
const owner = game.players.find(player => player.id === 'owner')!;
if (change === 'alliance') game.players.find(player => player.id === 'ally')!.ally = null;
else if (change === 'departure-return') {
delete game.homeworlds!.custody!.visitors[world];
owner.reserves++;
observeControlledIncomePosition(game, 'labelled-original-owner-departure');
game.homeworlds!.custody!.visitors[world] = { owner: { normal: 1, elite: 0 } };
owner.reserves--;
observeControlledIncomePosition(game, 'labelled-original-owner-return');
} else if (change === 'competitor') {
game.homeworlds!.custody!.visitors[world].competitor = { normal: 1, elite: 0 };
game.players.find(player => player.id === 'competitor')!.reserves--;
observeControlledIncomePosition(game, 'labelled-second-foreign-garrison');
} else {
const native = game.players.find(player => player.id === 'native')!;
native.reserves = 10;
native.tanks -= 10;
observeControlledIncomePosition(game, 'labelled-native-high-restored');
}
const before = structuredClone({ game, state: quote.state });
assert.throws(() => allocateOccupiedPercentageReceipt(quote.state, game, 'owner', source.event, 2, source), HomeworldCustodyError);
assert.deepEqual({ game, state: quote.state }, before);
if (change !== 'alliance') {
const unknown = quoteOccupiedPercentageSource(game, { ...source, event: 'blocked-new-actual-source' }, createOccupiedPercentageState());
assert.equal(unknown.receipt.status, 'blocked');
assert.equal(unknown.receipt.entitlement, null);
assert.deepEqual(unknown.receipt.credits, []);
assert.deepEqual(balances(game), balances(before.game));
assert.throws(() => allocateOccupiedPercentageReceipt(unknown.state, game, 'competitor', unknown.receipt.source.event, 3,
unknown.receipt.source), HomeworldCustodyError);
}
homeworldGameIntegrity(game);
}
});
void test('zero-share unknown entitlement completes through JSON without selecting a replacement or needing allocation', () => {
const { game, world } = occupiedIncomeFixture('southern_hemisphere');
game.homeworlds!.custody!.visitors[world].competitor = { normal: 1, elite: 0 };
game.players.find(player => player.id === 'competitor')!.reserves--;
observeControlledIncomePosition(game, 'labelled-zero-contested-world');
for (const amount of [0, 1]) {
const source = actualSource('fremen-collection', amount), before = balances(game);
const quote = quoteOccupiedPercentageSource(game, source, createOccupiedPercentageState());
const saved: OccupiedPercentageState = JSON.parse(JSON.stringify(quote.state));
validateOccupiedPercentageState(saved, game);
assert.equal(saved.receipts[0].status, 'settled');
assert.equal(saved.receipts[0].entitlement, null);
commit(game, { receipt: saved.receipts[0], state: saved });
const after = balances(game);
assert.equal(after.native - before.native, amount);
assert.equal(after.owner - before.owner, 0);
assert.equal(after.ally - before.ally, 0);
assert.equal(after.competitor - before.competitor, 0);
assert.throws(() => allocateOccupiedPercentageReceipt(saved, game, 'owner', source.event, 0, source), HomeworldCustodyError);
assert.throws(() => quoteOccupiedPercentageSource(game, source, saved), HomeworldCustodyError);
}
});
void test('malformed or unbacked paid provenance cannot manufacture a percentage source, including safe-integer limits', () => {
const { game } = occupiedIncomeFixture('kaitain');
const original = actualSource('emperor-treachery', 5);
if (original.kind !== 'emperor-treachery') throw new Error('Fixture source kind');
const state = createOccupiedPercentageState(), before = structuredClone({ game, state });
const malformed: OccupiedPercentageSource[] = [
...[-1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1].map(amount => ({ ...original, amount })),
{ ...original, native: 'owner' },
{ ...original, payment: { ...original.payment, recipient: 'owner' } },
{ ...original, payment: { ...original.payment, paid: 6 } },
{ ...original, payment: { ...original.payment, binding: '' } },
{ ...original, payment: { ...original.payment, contributions: [{ payer: 'missing-player', amount: 5 }] } },
{ ...original, payment: { ...original.payment, contributions: [{ payer: 'competitor', amount: 2 }, { payer: 'competitor', amount: 3 }] } },
{ ...original, payment: { ...original.payment, paid: Number.MAX_SAFE_INTEGER,
contributions: [{ payer: 'owner', amount: Number.MAX_SAFE_INTEGER }, { payer: 'competitor', amount: 1 }] } },
];
for (const source of malformed)
assert.throws(() => quoteOccupiedPercentageSource(game, source, state), HomeworldCustodyError);
assert.deepEqual({ game, state }, before);
const initial = balances(game);
game.players.find(player => player.id === 'competitor')!.spice -= 5;
commit(game, quoteOccupiedPercentageSource(game, original, state), 1);
const after = balances(game);
assert.equal(after.native - initial.native, 3);
assert.equal(after.owner - initial.owner, 1);
assert.equal(after.ally - initial.ally, 1);
assert.equal(after.competitor - initial.competitor, -5);
});
@@ -0,0 +1,186 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame, RuleError, type Game } from '../game/engine';
import {
addDefenseVisitor, assertDefenseInventory, recordDefensePosition, removeDefenseVisitor,
} from './fixture-homeworld-occupied-defenses';
import { cash } from './fixture-homeworld-occupied-producers';
import {
freshPrivateLotGame, passPrivateLotWindow, privateLotPhysicalIds,
privateLotPlayer, privateLotReload, recyclePrivatePurchaseIntoOriginalNormalLot,
sellPrivateBlackMarketLot,
} from './fixture-homeworld-occupied-private-lots';
function concealedBlackMarket(game: Game) {
for (const id of ['nativeAt', 'occupier', 'ally', 'rival', 'fremen']) {
assert.equal(viewGame(game, id).richeseAuction!.card, null, `${id} has no private face before the original peek`);
assert.equal(viewGame(game, id).richeseAuction!.cardId, null, `${id} has no seller-only identity`);
}
assert.deepEqual(viewGame(game, 'nativeRichese').richeseAuction!.card, game.richeseOfferedCard);
}
for (const canceled of [false, true]) {
void test(`concealed original Black Market inspection ${canceled ? 'survives actual native Karama denial only for the occupier' : 'shares the actual face with native Atreides and the occupier only'}`, () => {
const fixture = freshPrivateLotGame();
let game = fixture.game;
concealedBlackMarket(game);
assert.equal(game.richesePeekKnown, false);
const beforeCash = cash(game);
const sellerHand = structuredClone(privateLotPlayer(game, 'nativeRichese').hand);
const custody = structuredClone(game.homeworlds!.custody);
game = passPrivateLotWindow(privateLotReload(game), 'richeseBlackMarket');
assert.equal(game.response?.kind, 'atreidesAuction');
assert.equal(game.response!.owner, 'nativeAt');
concealedBlackMarket(game);
assert.equal(game.homeworldAuctionInspection!.occupiedKnown, false);
if (canceled) game = applyAction(privateLotReload(game), 'rival', {
type: 'card', card: fixture.karama.id, mode: 'cancel',
});
else game = passPrivateLotWindow(privateLotReload(game), 'atreidesAuction');
assert.equal(game.response, null);
assert.equal(game.richesePeekKnown, !canceled, 'native cancellation remains separate from the printed shared inspection');
assert.equal(game.homeworldAuctionInspection!.occupiedKnown, true);
assert.deepEqual(viewGame(game, 'nativeAt').richeseAuction!.card, canceled ? null : fixture.card);
assert.deepEqual(viewGame(game, 'occupier').richeseAuction!.card, fixture.card);
assert.deepEqual(viewGame(game, 'nativeRichese').richeseAuction!.card, fixture.card);
for (const id of ['ally', 'rival', 'fremen']) assert.equal(viewGame(game, id).richeseAuction!.card, null);
assert.deepEqual(privateLotPlayer(game, 'nativeRichese').hand, sellerHand, 'inspection never acquires the seller card');
assert.deepEqual(game.homeworlds!.custody, custody);
assert.deepEqual(cash(game), beforeCash, 'inspection or native cancellation cannot change the invoice payer');
assert.deepEqual(privateLotPhysicalIds(game), fixture.original);
if (canceled) {
assert.ok(game.discard.some(card => card.id === fixture.karama.id));
assert.equal(privateLotPlayer(game, 'rival').hand.some(card => card.id === fixture.karama.id), false);
}
const saved = privateLotReload(game);
assert.deepEqual(viewGame(saved, 'occupier').richeseAuction!.card, fixture.card);
assert.deepEqual(viewGame(saved, 'nativeAt').richeseAuction!.card, canceled ? null : fixture.card);
assert.equal(viewGame(saved, 'ally').richeseAuction!.card, null);
game = sellPrivateBlackMarketLot(saved);
assert.equal(privateLotPlayer(game, 'occupier').spice, beforeCash.occupier - 2);
assert.equal(privateLotPlayer(game, 'nativeRichese').spice, beforeCash.nativeRichese + 2);
for (const id of ['nativeAt', 'ally', 'rival', 'fremen']) assert.equal(privateLotPlayer(game, id).spice, beforeCash[id]);
assert.ok(privateLotPlayer(game, 'occupier').hand.some(card => card.id === fixture.card.id));
assert.equal(privateLotPlayer(game, 'nativeRichese').hand.some(card => card.id === fixture.card.id), false);
assert.deepEqual(privateLotPhysicalIds(game), fixture.original);
assert.equal(game.homeworldAuctionInspection, undefined, 'original sale completion retires its private receipt');
assertDefenseInventory(game);
});
}
void test('a frozen Black Market receipt cannot authorize the same physical card recycled into an actual new original normal lot', () => {
const fixture = freshPrivateLotGame();
let game = passPrivateLotWindow(fixture.game, 'richeseBlackMarket');
game = passPrivateLotWindow(game, 'atreidesAuction');
const oldReceipt = structuredClone(game.homeworldAuctionInspection!);
assert.equal(oldReceipt.occupiedKnown, true);
assert.deepEqual(viewGame(game, 'occupier').richeseAuction!.card, fixture.card);
game = sellPrivateBlackMarketLot(privateLotReload(game));
const paidCash = cash(game);
game = recyclePrivatePurchaseIntoOriginalNormalLot(game, fixture.card.id);
const currentReceipt = structuredClone(game.homeworldAuctionInspection!);
assert.notEqual(currentReceipt.lot, oldReceipt.lot);
assert.equal(currentReceipt.occupiedKnown, false);
assert.equal(game.auction!.cards[game.auction!.index].id, fixture.card.id);
for (const id of ['nativeAt', 'occupier', 'ally', 'rival']) assert.equal(viewGame(game, id).auction!.card, null);
const stale = privateLotReload(game);
stale.homeworldAuctionInspection = oldReceipt;
const frozen = structuredClone(stale);
const physical = privateLotPhysicalIds(stale);
for (const id of ['nativeAt', 'occupier', 'ally', 'rival']) assert.equal(viewGame(stale, id).auction!.card, null);
assert.deepEqual(stale, frozen, 'private projection cannot mutate or repair a stale receipt');
assert.deepEqual(privateLotPhysicalIds(stale), physical);
assert.deepEqual(cash(stale), paidCash);
assert.throws(() => passPrivateLotWindow(stale, 'atreidesAuction'), 'the old lot receipt cannot settle the new original source');
assert.deepEqual(stale, frozen, 'rejected original continuation preserves the exact stale source');
// Real current receipt, same card, original current response: the face becomes
// visible only when THIS lot's original inspection opportunity completes.
game = passPrivateLotWindow(privateLotReload(game), 'atreidesAuction');
assert.deepEqual(viewGame(game, 'nativeAt').auction!.card, fixture.card);
assert.deepEqual(viewGame(game, 'occupier').auction!.card, fixture.card);
assert.equal(viewGame(game, 'ally').auction!.card, null);
assert.equal(viewGame(game, 'rival').auction!.card, null);
assert.deepEqual(cash(game), paidCash);
assert.deepEqual(privateLotPhysicalIds(game), fixture.original);
});
void test('a frozen receipt discloses neither a new concealed original Black Market card nor its face before the original peek opportunity', () => {
const first = freshPrivateLotGame();
let game = passPrivateLotWindow(first.game, 'richeseBlackMarket');
game = passPrivateLotWindow(game, 'atreidesAuction');
const oldReceipt = structuredClone(game.homeworldAuctionInspection!);
game = sellPrivateBlackMarketLot(game);
assert.ok(privateLotPlayer(game, 'occupier').hand.some(card => card.id === first.card.id));
// Independent fresh original producer: no artificial round/phase/event.
// Pick a different original unused card before the native offer is made.
const next = freshPrivateLotGame(first.card.id);
assert.notEqual(next.game.richeseAuction!.event, oldReceipt.lot);
assert.notEqual(next.card.id, first.card.id);
const saved = privateLotReload(next.game);
saved.homeworldAuctionInspection = oldReceipt;
concealedBlackMarket(saved);
assert.equal(saved.richesePeekKnown, false);
const beforeCash = cash(saved);
const physical = privateLotPhysicalIds(saved);
const opened = passPrivateLotWindow(saved, 'richeseBlackMarket');
assert.equal(opened.homeworldAuctionInspection!.lot, opened.richeseAuction!.event);
assert.equal(opened.homeworldAuctionInspection!.occupiedKnown, false);
concealedBlackMarket(opened);
const revealed = passPrivateLotWindow(privateLotReload(opened), 'atreidesAuction');
assert.deepEqual(viewGame(revealed, 'occupier').richeseAuction!.card, next.card);
assert.equal(viewGame(revealed, 'ally').richeseAuction!.card, null);
assert.deepEqual(cash(revealed), beforeCash);
assert.deepEqual(privateLotPhysicalIds(revealed), physical);
});
void test('Advanced p22 retains the original shared private inspection while the qualifier remains despite native return and a competitor', () => {
const fixture = freshPrivateLotGame();
let game = fixture.game;
// Labelled conserved original counters: no new sole qualifier is fabricated.
const native = privateLotPlayer(game, 'nativeAt');
native.tanks--;
native.reserves++;
addDefenseVisitor(game, 'homeworld:atreides', 'rival');
recordDefensePosition(game, 'private-lot-native-return-and-competing-visitor-with-original-qualifier-retained');
const custody = structuredClone(game.homeworlds!.custody);
let departed = privateLotReload(game);
removeDefenseVisitor(departed, 'homeworld:atreides');
recordDefensePosition(departed, 'private-lot-last-original-qualifier-departure');
game = passPrivateLotWindow(privateLotReload(game), 'richeseBlackMarket');
game = passPrivateLotWindow(game, 'atreidesAuction');
assert.deepEqual(viewGame(game, 'nativeAt').richeseAuction!.card, fixture.card);
assert.deepEqual(viewGame(game, 'occupier').richeseAuction!.card, fixture.card);
assert.equal(viewGame(game, 'rival').richeseAuction!.card, null);
assert.equal(viewGame(game, 'ally').richeseAuction!.card, null);
assert.deepEqual(game.homeworlds!.custody, custody);
assert.deepEqual(privateLotPhysicalIds(game), fixture.original);
assertDefenseInventory(game);
departed = passPrivateLotWindow(privateLotReload(departed), 'richeseBlackMarket');
departed = passPrivateLotWindow(departed, 'atreidesAuction');
assert.deepEqual(viewGame(departed, 'nativeAt').richeseAuction!.card, fixture.card);
assert.equal(viewGame(departed, 'occupier').richeseAuction!.card, null, 'last departure ends the retained printed share');
assert.equal(viewGame(departed, 'rival').richeseAuction!.card, null, 'a competitor alongside native forces never qualified as a replacement');
assert.equal(viewGame(departed, 'ally').richeseAuction!.card, null);
assert.deepEqual(privateLotPhysicalIds(departed), fixture.original);
assertDefenseInventory(departed);
});
void test('a JSON continuation missing original qualification history cannot use the frozen original private inspection receipt', () => {
const fixture = freshPrivateLotGame();
let game = passPrivateLotWindow(fixture.game, 'richeseBlackMarket');
assert.equal(game.response?.kind, 'atreidesAuction');
game = privateLotReload(game);
delete game.homeworldOccupationHistory;
const frozen = structuredClone(game);
for (const viewer of ['nativeAt', 'occupier', 'ally', 'nativeRichese'])
assert.throws(() => viewGame(game, viewer), RuleError);
assert.throws(() => passPrivateLotWindow(game, 'atreidesAuction'), RuleError);
assert.deepEqual(game, frozen);
assert.deepEqual(privateLotPhysicalIds(game), fixture.original);
});
@@ -0,0 +1,564 @@
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 conducting-auction cancellation remains SOURCE-BLOCKED pending the original ordinary-lot-count ruling, with immutable stock and 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 frozen = structuredClone(state);
assert.throws(() => applyAction(state, 'observer', action), RuleError);
assert.deepEqual(state, frozen, 'blocked original cancellation preserves exact card/cache/wallet/control state');
assert.ok(biddingPlayer(state, 'observer').hand.some(card => card.id === counter.id));
assert.deepEqual(state.richeseCache!.map(card => card.id), cache);
assert.deepEqual(cash(state), before);
assert.equal(state.pendingHomeworldOccupiedPercentage, null);
assert.equal(state.pendingHomeworldOccupiedBonus, null);
assert.equal(state.homeworldOccupiedPercentageLedger!.receipts.length, receipts);
assert.deepEqual(biddingPhysicalIds(state), original);
assertDefenseInventory(state);
}
});
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);
});
+1
View File
@@ -174,6 +174,7 @@ void test('response, decision and Truthtrance controls retain priority over opti
position: null,
cache: [],
normalCount: null,
cacheTerms: null,
offerBlocked: null,
};
assert.equal(botActions(v)[0].type, 'decision');
+1
View File
@@ -246,6 +246,7 @@ void test('pending responses, decisions and Truthtrance precede all optional No-
position: null,
cache: [],
normalCount: null,
cacheTerms: null,
offerBlocked: null,
};
assert.equal(botActions(v)[0].type, 'decision');