293 lines
17 KiB
TypeScript
293 lines
17 KiB
TypeScript
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 requires an observed qualification before current-sole control`, () => {
|
|
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 current-sole control follows the present garrison while missing history still fails 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;
|
|
const originalCards = biddingPhysicalIds(qualified);
|
|
for (const mode of ['turn', 'contest-cleared', 'replacement', 'repopulation-cleared'] as const) {
|
|
const game = structuredClone(qualified);
|
|
if (mode === 'turn') game.turn++;
|
|
else if (mode === 'contest-cleared') {
|
|
addDefenseVisitor(game, world, 'observer');
|
|
recordDefensePosition(game, 'second-foreign-group-arrived');
|
|
assert.equal(quoteOccupiedBiddingAuthority(game, kind).occupied, false, 'two foreign factions never grant current-sole control');
|
|
removeDefenseVisitor(game, world, 'observer');
|
|
recordDefensePosition(game, 'contest-cleared');
|
|
} else if (mode === 'replacement') {
|
|
removeDefenseVisitor(game, world);
|
|
recordDefensePosition(game, 'original-qualifier-departed');
|
|
addDefenseVisitor(game, world, 'observer');
|
|
recordDefensePosition(game, 'different-sole-qualifier-arrived');
|
|
} else {
|
|
const native = biddingPlayer(game, 'native');
|
|
native.tanks -= high;
|
|
native.reserves += high;
|
|
recordDefensePosition(game, 'native-high-threshold-restored');
|
|
assert.equal(quoteOccupiedBiddingAuthority(game, kind).occupied, false, 'native repopulation ends the provisional benefit');
|
|
clearDefenseNative(game, world);
|
|
recordDefensePosition(game, 'native-fell-low-again');
|
|
}
|
|
const controller = mode === 'replacement' ? 'observer' : 'occupier';
|
|
const authority = quoteOccupiedBiddingAuthority(game, kind);
|
|
assert.equal(authority.blocked, null);
|
|
assert.equal(authority.occupied, true);
|
|
assert.equal(authority.provider, 'native');
|
|
assert.equal(authority.controller, kind === 'atreidesInspection' ? 'native' : controller);
|
|
if (kind === 'atreidesInspection' || kind === 'richeseCache')
|
|
assert.ok(authority.inspectionAudience.includes(controller), 'the current-sole controller shares the printed audience');
|
|
assert.deepEqual(biddingPhysicalIds(game), originalCards);
|
|
assertDefenseInventory(game);
|
|
}
|
|
const missing = structuredClone(qualified);
|
|
delete missing.homeworldOccupationHistory;
|
|
const before = structuredClone(missing);
|
|
const blocked = quoteOccupiedBiddingAuthority(missing, 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(missing, kind, 'native'), HomeworldCustodyError);
|
|
assert.throws(() => requireOccupiedBiddingController(missing, kind, 'occupier'), HomeworldCustodyError);
|
|
if (kind === 'harkonnenBonus')
|
|
assert.throws(() => requireOccupiedBiddingBonusRecipient(missing, 'occupier'), HomeworldCustodyError);
|
|
assert.deepEqual(missing, before);
|
|
assertDefenseInventory(missing);
|
|
});
|
|
|
|
void test(`${rule.card}: deleting the fresh preview falls back to the native saved-game rule`, () => {
|
|
const qualified = freshBiddingGame(rule.native, 'emperor', false);
|
|
qualifyDefensePosition(qualified, `homeworld:${rule.native}`);
|
|
delete qualified.homeworldOccupationPreview;
|
|
const legacy = quoteOccupiedBiddingAuthority(qualified, 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);
|
|
});
|