Connect occupied Homeworld economics and Bidding
This commit is contained in:
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');
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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'
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user