Connect occupied Tupile capacity and original cleanup
This commit is contained in:
1 parent
d637dbaf63
commit
3e79021a6e
19 files changed
+1512
-14
No files matched your search
@@ -32,16 +32,19 @@ export function advanceOriginal(game: Game, stop: (game: Game) => boolean, limit
|
||||
|
||||
export function buyOriginalNormalLot(game: Game, buyer: string, amount: number): Game {
|
||||
assert.ok(game.auction && !game.phaseOpening && !game.response && !game.decision);
|
||||
const card = game.auction.cards[game.auction.index].id;
|
||||
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++) {
|
||||
for (let step = 0; !game.currentAuctionSale && !game.players.find(player => player.id === buyer)!.hand.some(held => held.id === card) &&
|
||||
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');
|
||||
assert.ok(game.players.find(player => player.id === buyer)!.hand.some(held => held.id === card),
|
||||
'the original purchase producer must deliver its actual physical card');
|
||||
return game;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
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 { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
|
||||
import { observeHomeworldOccupation } from '../game/homeworld-occupation-history';
|
||||
import { biddingPhysicalIds } from './fixture-homeworld-occupied-bidding';
|
||||
import { assertDefenseInventory, clearDefenseNative } from './fixture-homeworld-occupied-defenses';
|
||||
|
||||
export const TUPILE_WORLD = 'homeworld:choam';
|
||||
export const tupilePlayer = (game: Game, id: string) => game.players.find(player => player.id === id)!;
|
||||
|
||||
/** Real selected decks/lobby/fresh audit entry and all original setup choices.
|
||||
* Later positions are explicitly labelled conserved fixtures, not play traces. */
|
||||
export function freshTupileFixture(advanced = true, occupierFaction: 'guild' | 'moritani' = 'guild') {
|
||||
const roster: [string, FactionId][] = [
|
||||
['native', 'choam'], ['occupier', occupierFaction], ['ally', 'harkonnen'],
|
||||
['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('OCCUPIEDTUPILE', newPlayer('native', 'native', 'choam'), 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 Tupile 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(game.homeworldTupilePreview, true, 'only the original fresh entry initializes this profile');
|
||||
const cards = biddingPhysicalIds(game);
|
||||
const setup = structuredClone(game);
|
||||
return { game, cards, setup };
|
||||
}
|
||||
|
||||
export function recordTupilePosition(game: Game, label: string, cause: 'change' | 'turnStart' | 'turnEnd' = 'change') {
|
||||
game.homeworldOccupationHistory = observeHomeworldOccupation(game.homeworldOccupationHistory!,
|
||||
homeworldContext(game), game.homeworlds!.custody!, game.turn, cause,
|
||||
`controlled-tupile-${label}-${game.homeworldOccupationHistory!.sources.length}`);
|
||||
assertDefenseInventory(game);
|
||||
homeworldGameIntegrity(game);
|
||||
}
|
||||
|
||||
/** Typed transfer from the actual normal/starred reserve pools. Reserves is the
|
||||
* aggregate pool; starred counters are a subset, not additional armies. */
|
||||
export function addTupileForces(game: Game, id: string, normal = 1, elite = 0) {
|
||||
const player = tupilePlayer(game, id);
|
||||
assert.ok(player.reserves - (player.elites?.reserves ?? 0) >= normal);
|
||||
assert.ok(elite === 0 || player.elites && player.elites.reserves >= elite);
|
||||
player.reserves -= normal + elite;
|
||||
if (elite) player.elites!.reserves -= elite;
|
||||
const group = (game.homeworlds!.custody!.visitors[TUPILE_WORLD] ??= {})[id] ??= { normal: 0, elite: 0 };
|
||||
group.normal += normal;
|
||||
group.elite += elite;
|
||||
assertDefenseInventory(game);
|
||||
}
|
||||
|
||||
export function removeTupileForces(game: Game, id: string) {
|
||||
const group = game.homeworlds!.custody!.visitors[TUPILE_WORLD][id];
|
||||
const player = tupilePlayer(game, id);
|
||||
player.reserves += group.normal + group.elite;
|
||||
if (group.elite) player.elites!.reserves += group.elite;
|
||||
delete game.homeworlds!.custody!.visitors[TUPILE_WORLD][id];
|
||||
if (!Object.keys(game.homeworlds!.custody!.visitors[TUPILE_WORLD]).length)
|
||||
delete game.homeworlds!.custody!.visitors[TUPILE_WORLD];
|
||||
assertDefenseInventory(game);
|
||||
}
|
||||
|
||||
export function occupiedTupileFixture(advanced = true, allied = true) {
|
||||
const fixture = freshTupileFixture(advanced), game = fixture.game;
|
||||
clearDefenseNative(game, TUPILE_WORLD);
|
||||
if (allied) {
|
||||
tupilePlayer(game, 'occupier').ally = 'ally';
|
||||
tupilePlayer(game, 'ally').ally = 'occupier';
|
||||
}
|
||||
addTupileForces(game, 'occupier');
|
||||
recordTupilePosition(game, 'original-native-casualties-and-sole-arrival');
|
||||
assert.deepEqual(biddingPhysicalIds(game), fixture.cards);
|
||||
return fixture;
|
||||
}
|
||||
|
||||
/** Conserved native repopulation fixture; no counters are created to cross high. */
|
||||
export function restoreTupileNative(game: Game, amount: number) {
|
||||
const native = tupilePlayer(game, 'native');
|
||||
assert.ok(native.tanks >= amount);
|
||||
native.tanks -= amount;
|
||||
native.reserves += amount;
|
||||
assertDefenseInventory(game);
|
||||
}
|
||||
|
||||
/** Original-card discard at the caller-owned atomic settlement boundary. The
|
||||
* helper itself neither chooses cards nor duplicates the original discard. */
|
||||
export function discardTupileExcess(game: Game, id: string, limit: number) {
|
||||
const before = biddingPhysicalIds(game), player = tupilePlayer(game, id);
|
||||
while (player.hand.length > limit) game.discard.push(player.hand.pop()!);
|
||||
assert.deepEqual(biddingPhysicalIds(game), before);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, normalizeAutomaticGame, RuleError, viewGame, handLimit, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { occupiedTupileFixture, freshTupileFixture, tupilePlayer, removeTupileForces, recordTupilePosition, restoreTupileNative, addTupileForces, TUPILE_WORLD } from './fixture-homeworld-occupied-tupile';
|
||||
import { biddingPhysicalIds, stageBiddingHandSize } from './fixture-homeworld-occupied-bidding';
|
||||
import { assertDefenseInventory, clearDefenseNative } from './fixture-homeworld-occupied-defenses';
|
||||
import { advanceOriginal, buyOriginalNormalLot, cash } from './fixture-homeworld-occupied-producers';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
|
||||
|
||||
function cleanupFixture() {
|
||||
let game = normalizeAutomaticGame(occupiedTupileFixture(true, true).game);
|
||||
assert.equal(handLimit(tupilePlayer(game, 'occupier')), 5);
|
||||
assert.equal(handLimit(tupilePlayer(game, 'ally')), 9);
|
||||
stageBiddingHandSize(game, 'occupier', 5);
|
||||
stageBiddingHandSize(game, 'ally', 9);
|
||||
const before = cash(game), cards = biddingPhysicalIds(game), phase = game.phase;
|
||||
removeTupileForces(game, 'occupier');
|
||||
recordTupilePosition(game, 'runtime-last-original-force-left');
|
||||
game = normalizeAutomaticGame(JSON.parse(JSON.stringify(game)));
|
||||
return { game, before, cards, phase };
|
||||
}
|
||||
|
||||
void test('original Tupile holders discard their own actual excess once after departure, with private choices and all four legal policies', () => {
|
||||
const fixture = cleanupFixture();
|
||||
let game = fixture.game;
|
||||
const completed: string[] = [];
|
||||
for (let step = 0; game.decision?.kind === 'homeworldTupileCleanup' && step < 3; step++) {
|
||||
const actor = game.decision.player, offer = viewGame(game, actor).homeworldTupileCleanup!;
|
||||
assert.equal(offer.excess, 1);
|
||||
assert.equal(offer.limit, actor === 'occupier' ? 4 : 8);
|
||||
assert.equal(viewGame(game, 'native').homeworldTupileCleanup, null);
|
||||
const input = structuredClone(game);
|
||||
const action = { type: 'decision', event: offer.event, cards: [offer.eligibleCards[0]] };
|
||||
assert.throws(() => applyAction(game, 'native', action), RuleError);
|
||||
assert.throws(() => applyAction(game, actor, { ...action, event: `${offer.event}:stale` }), RuleError);
|
||||
assert.throws(() => applyAction(game, actor, { ...action, cards: [] }), RuleError);
|
||||
assert.throws(() => applyAction(game, actor, { ...action, cards: [action.cards[0], action.cards[0]] }), RuleError);
|
||||
assert.deepEqual(game, input);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game, actor);
|
||||
view.players.find(player => player.id === actor)!.bot = difficulty;
|
||||
const choice = botActions(view)[0];
|
||||
assert.ok(choice);
|
||||
const result = applyAction(JSON.parse(JSON.stringify(game)), actor, choice);
|
||||
assert.equal(tupilePlayer(result, actor).hand.length, offer.limit);
|
||||
assert.deepEqual(biddingPhysicalIds(result), fixture.cards);
|
||||
assert.deepEqual(cash(result), fixture.before);
|
||||
}
|
||||
game = applyAction(JSON.parse(JSON.stringify(game)), actor, action);
|
||||
assert.ok(game.discard.some(card => card.id === action.cards[0]));
|
||||
assert.equal(tupilePlayer(game, actor).hand.length, offer.limit);
|
||||
assert.throws(() => applyAction(game, actor, action), RuleError);
|
||||
completed.push(actor);
|
||||
}
|
||||
assert.deepEqual(completed.sort(), ['ally', 'occupier']);
|
||||
assert.equal(game.pendingHomeworldTupileCleanup, null);
|
||||
assert.equal(game.phase, fixture.phase);
|
||||
assert.equal(handLimit(tupilePlayer(game, 'occupier')), 4);
|
||||
assert.equal(handLimit(tupilePlayer(game, 'ally')), 8);
|
||||
assert.deepEqual(cash(game), fixture.before);
|
||||
assert.deepEqual(biddingPhysicalIds(game), fixture.cards);
|
||||
assertDefenseInventory(game);
|
||||
});
|
||||
|
||||
void test('native CHOAM low intelligence returns after the Advanced original departure without clearing prior usage or granting a new occupier', () => {
|
||||
let game = normalizeAutomaticGame(occupiedTupileFixture(true, false).game);
|
||||
assert.equal(viewGame(game, 'native').tupileIntelligence!.targets.find(row => row.player === 'occupier')!.blocked === null, false);
|
||||
removeTupileForces(game, 'occupier');
|
||||
restoreTupileNative(game, 1);
|
||||
addTupileForces(game, 'fremen');
|
||||
recordTupilePosition(game, 'runtime-native-and-unqualified-foreign-contact');
|
||||
game = normalizeAutomaticGame(game);
|
||||
assert.equal(viewGame(game, 'native').tupileIntelligence!.targets.find(row => row.player === 'fremen')!.blocked, null);
|
||||
const original = biddingPhysicalIds(game), before = cash(game);
|
||||
game = applyAction(game, 'native', { type: 'tupileIntelligence', target: 'fremen', category: 'weapons' });
|
||||
assert.equal(game.tupileIntelligence!.receipts.at(-1)!.spice, before.fremen);
|
||||
const frozen = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, 'native', { type: 'tupileIntelligence', target: 'fremen', category: 'defenses' }), RuleError);
|
||||
assert.deepEqual(game, frozen);
|
||||
assert.deepEqual(cash(game), before);
|
||||
assert.deepEqual(biddingPhysicalIds(game), original);
|
||||
assert.equal(handLimit(tupilePlayer(game, 'fremen')), 4);
|
||||
});
|
||||
|
||||
void test('the actual native Harkonnen auction admits its ninth card under the proved allied Tupile slot and draws no tenth bonus', () => {
|
||||
let game = freshTupileFixture(true).game;
|
||||
game = advanceOriginal(game, game => game.phase === 2 && !game.phaseOpening && !game.response && !game.decision);
|
||||
clearDefenseNative(game, TUPILE_WORLD);
|
||||
tupilePlayer(game, 'occupier').ally = 'ally';
|
||||
tupilePlayer(game, 'ally').ally = 'occupier';
|
||||
addTupileForces(game, 'occupier');
|
||||
recordTupilePosition(game, 'runtime-after-original-high-charity-sole-arrival');
|
||||
game = normalizeAutomaticGame(game);
|
||||
stageBiddingHandSize(game, 'ally', 8);
|
||||
game = advanceOriginal(game, game => game.phase === 3 && !!game.auction && !game.phaseOpening && !game.response && !game.decision);
|
||||
stageBiddingHandSize(game, 'ally', 8);
|
||||
const original = biddingPhysicalIds(game), before = cash(game), card = game.auction!.cards[game.auction!.index].id;
|
||||
assert.equal(viewGame(game, 'ally').players.find(player => player.id === 'ally')!.handLimit, 9);
|
||||
game = buyOriginalNormalLot(game, 'ally', 5);
|
||||
game = advanceOriginal(game, game => !game.currentAuctionSale && !game.response && !game.decision);
|
||||
assert.equal(tupilePlayer(game, 'ally').hand.length, 9);
|
||||
assert.ok(tupilePlayer(game, 'ally').hand.some(held => held.id === card));
|
||||
assert.equal(tupilePlayer(game, 'ally').spice, before.ally - 5);
|
||||
assert.deepEqual(biddingPhysicalIds(game), original);
|
||||
assertDefenseInventory(game);
|
||||
});
|
||||
|
||||
void test('older occupation profiles gain no new slot and an unbacked saved flag cannot authorize a larger hand', () => {
|
||||
const game: Game = occupiedTupileFixture(true, true).game;
|
||||
delete game.homeworldTupilePreview;
|
||||
delete game.homeworldTupileState;
|
||||
delete game.pendingHomeworldTupileCleanup;
|
||||
const original = biddingPhysicalIds(game), before = cash(game);
|
||||
const old = normalizeAutomaticGame(game);
|
||||
assert.equal(viewGame(old, 'occupier').players.find(player => player.id === 'occupier')!.handLimit, 4);
|
||||
assert.equal(viewGame(old, 'ally').players.find(player => player.id === 'ally')!.handLimit, 8);
|
||||
const forged = structuredClone(old);
|
||||
tupilePlayer(forged, 'occupier').tupileHandSlot = true;
|
||||
const frozen = structuredClone(forged);
|
||||
assert.throws(() => viewGame(forged, 'occupier'), RuleError);
|
||||
assert.deepEqual(forged, frozen);
|
||||
assert.deepEqual(biddingPhysicalIds(old), original);
|
||||
assert.deepEqual(cash(old), before);
|
||||
});
|
||||
|
||||
void test('actual paid foreign Tupile return queues both normal-limit cleanups and preserves original remaining movement without repeating forces or price', () => {
|
||||
let game = freshTupileFixture(true).game;
|
||||
game = advanceOriginal(game, game => game.phase === 2 && !game.phaseOpening && !game.response && !game.decision);
|
||||
clearDefenseNative(game, TUPILE_WORLD);
|
||||
tupilePlayer(game, 'occupier').ally = 'ally'; tupilePlayer(game, 'ally').ally = 'occupier';
|
||||
addTupileForces(game, 'occupier');
|
||||
recordTupilePosition(game, 'runtime-original-charity-before-real-return');
|
||||
game = normalizeAutomaticGame(game);
|
||||
for (let step = 0; !(game.phase === 5 && game.active === 'occupier' && !game.phaseOpening && !game.response && !game.decision) && step < 500; 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' } };
|
||||
} else if (game.decision?.kind === 'guildTiming')
|
||||
choice = { player: game.decision.player, action: { type: 'decision', take: true } };
|
||||
else if (game.phase === 3 && game.auction && !game.decision)
|
||||
choice = { player: game.auction.active, action: { type: 'passBid' } };
|
||||
else if (game.phase === 4 && !game.decision) {
|
||||
const player = game.players.find(player => !game.ready.includes(player.id));
|
||||
if (player) choice = { player: player.id, action: { type: 'ready' } };
|
||||
} else if (game.phase === 5 && game.active && !game.decision)
|
||||
choice = { player: game.active, action: { type: 'endMovement' } };
|
||||
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 = view.biddingEnd && !view.biddingEnd.ready.includes(player.id)
|
||||
? { type: 'biddingEnd', event: view.biddingEnd.event, mode: 'ready' } : botActions(view)[0];
|
||||
if (action) { choice = { player: player.id, action }; break; }
|
||||
}
|
||||
assert.ok(choice);
|
||||
game = applyAction(game, choice.player, choice.action);
|
||||
}
|
||||
assert.equal(game.phase, 5); assert.equal(game.active, 'occupier');
|
||||
stageBiddingHandSize(game, 'occupier', 5); stageBiddingHandSize(game, 'ally', 9);
|
||||
const cards = biddingPhysicalIds(game), before = cash(game), reserves = tupilePlayer(game, 'occupier').reserves;
|
||||
const quote = homeworldShipmentChoice(viewGame(game, 'occupier'), 'homeworld:guild',
|
||||
{ [TUPILE_WORLD]: { normal: 1, elite: 0 } });
|
||||
assert.equal(quote.blocked, null); assert.equal(quote.cost, 1); assert.ok(quote.action);
|
||||
game = applyAction(JSON.parse(JSON.stringify(game)), 'occupier', quote.action);
|
||||
for (let step = 0; game.decision?.kind !== 'homeworldTupileCleanup' && step < 40; step++) {
|
||||
if (game.response) {
|
||||
const player = game.players.find(player => !game.response!.passed.includes(player.id))!;
|
||||
game = applyAction(game, player.id, { type: 'passResponse' });
|
||||
} else if (game.decision?.kind === 'homeworldShipmentGuild')
|
||||
game = applyAction(game, game.decision.player, { type: 'decision', allow: true, event: game.decision.event });
|
||||
else game = normalizeAutomaticGame(game);
|
||||
}
|
||||
assert.equal(game.decision?.kind, 'homeworldTupileCleanup');
|
||||
for (let step = 0; game.decision?.kind === 'homeworldTupileCleanup' && step < 3; step++) {
|
||||
const player = game.decision.player, offer = viewGame(game, player).homeworldTupileCleanup!;
|
||||
game = applyAction(JSON.parse(JSON.stringify(game)), player,
|
||||
{ type: 'decision', event: offer.event, cards: offer.eligibleCards.slice(0, offer.excess) });
|
||||
}
|
||||
assert.equal(game.phase, 5); assert.equal(game.active, 'occupier');
|
||||
assert.equal(tupilePlayer(game, 'occupier').shipped, true);
|
||||
assert.equal(tupilePlayer(game, 'occupier').reserves, reserves + 1);
|
||||
assert.equal(game.homeworlds!.custody!.visitors[TUPILE_WORLD], undefined);
|
||||
assert.equal(tupilePlayer(game, 'occupier').spice, before.occupier - 1);
|
||||
assert.equal(tupilePlayer(game, 'occupier').hand.length, 4);
|
||||
assert.equal(tupilePlayer(game, 'ally').hand.length, 8);
|
||||
assert.deepEqual(biddingPhysicalIds(game), cards); assertDefenseInventory(game);
|
||||
const frozen = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, 'occupier', quote.action!), RuleError);
|
||||
assert.deepEqual(game, frozen);
|
||||
});
|
||||
@@ -0,0 +1,408 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { nativeHandLimit, type Game } from '../game/engine';
|
||||
import { HomeworldCustodyError } from '../game/homeworld-custody';
|
||||
import { homeworldContext } from '../game/homeworld-game';
|
||||
import {
|
||||
completeOccupiedTupileCleanup, createOccupiedTupileState, quoteOccupiedTupileAuthority,
|
||||
quoteOccupiedTupileCleanup, syncOccupiedTupileLease, validateOccupiedTupileState,
|
||||
type OccupiedTupileState,
|
||||
} from '../game/homeworld-occupied-tupile';
|
||||
import { quoteTupileIntelligenceRequest } from '../game/tupile-intelligence';
|
||||
import { quoteTupileIntelligenceAnswer } from '../game/tupile-intelligence-answer';
|
||||
import {
|
||||
appendTupileIntelligenceObservation, tupileIntelligenceUsedFactions, validateTupileIntelligenceState,
|
||||
} from '../game/tupile-intelligence-state';
|
||||
import { quoteMoritaniAtomics } from '../game/moritani-atomics';
|
||||
import { placeTerror, revealTerror } from '../game/moritani-terror';
|
||||
import { biddingPhysicalIds, stageBiddingHandSize } from './fixture-homeworld-occupied-bidding';
|
||||
import { assertDefenseInventory, clearDefenseNative } from './fixture-homeworld-occupied-defenses';
|
||||
import {
|
||||
addTupileForces, discardTupileExcess, freshTupileFixture, occupiedTupileFixture,
|
||||
recordTupilePosition, removeTupileForces, restoreTupileNative, TUPILE_WORLD, tupilePlayer,
|
||||
} from './fixture-homeworld-occupied-tupile';
|
||||
|
||||
/** Single parent transaction recipe: choose original held IDs, discard exactly
|
||||
* the normal-limit excess, then persist the returned completion ledger. */
|
||||
function settleAll(game: Game, state: OccupiedTupileState): OccupiedTupileState {
|
||||
let next = state;
|
||||
for (const receipt of state.cleanups.filter(row => row.status === 'pending')) {
|
||||
const quote = quoteOccupiedTupileCleanup(game, next, receipt.event);
|
||||
assert.equal(quote.blocked, null);
|
||||
discardTupileExcess(game, receipt.player, quote.limit);
|
||||
next = completeOccupiedTupileCleanup(game, next, receipt.event, receipt.player);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
void test('fresh setup and opt-in boundary preserve legacy profiles; original one-slot grants use each real receiver capacity', () => {
|
||||
const { game, cards } = freshTupileFixture();
|
||||
const empty = createOccupiedTupileState(), initial = structuredClone(game);
|
||||
assert.equal(quoteOccupiedTupileAuthority(game).status, 'unoccupied');
|
||||
assert.equal(syncOccupiedTupileLease(game, empty).state, empty);
|
||||
assert.deepEqual(game, initial);
|
||||
const occupied = occupiedTupileFixture();
|
||||
const quote = quoteOccupiedTupileAuthority(occupied.game);
|
||||
assert.equal(quote.provider, 'native');
|
||||
assert.equal(quote.source!.occupier, 'occupier');
|
||||
assert.deepEqual(quote.holders, ['occupier', 'ally']);
|
||||
assert.deepEqual(quote.capacities, [
|
||||
{ player: 'occupier', normal: 4, occupied: 5 }, { player: 'ally', normal: 8, occupied: 9 },
|
||||
]);
|
||||
assert.equal(quote.lowIntelligence, 'suppressed');
|
||||
const before = structuredClone(occupied.game), sync = syncOccupiedTupileLease(occupied.game, empty);
|
||||
assert.deepEqual(sync.slotHolders, ['occupier', 'ally']);
|
||||
assert.deepEqual(occupied.game, before);
|
||||
assert.equal(syncOccupiedTupileLease(occupied.game, sync.state).state, sync.state);
|
||||
assert.equal(sync.state.leases.length, 1);
|
||||
assert.deepEqual(sync.pending, []);
|
||||
const legacy = structuredClone(occupied.game);
|
||||
delete legacy.homeworldTupilePreview;
|
||||
assert.equal(quoteOccupiedTupileAuthority(legacy).status, 'unoccupied');
|
||||
assert.equal(quoteOccupiedTupileAuthority(legacy).lowIntelligence, 'available');
|
||||
assert.equal(syncOccupiedTupileLease(legacy, empty).state, empty);
|
||||
assert.deepEqual(biddingPhysicalIds(game), cards);
|
||||
assert.deepEqual(biddingPhysicalIds(occupied.game), occupied.cards);
|
||||
});
|
||||
|
||||
void test('retained Advanced source survives high-native, typed contest, extra armies and turns without another grant', () => {
|
||||
const { game, cards } = occupiedTupileFixture(), first = syncOccupiedTupileLease(game, createOccupiedTupileState());
|
||||
addTupileForces(game, 'occupier', 2);
|
||||
addTupileForces(game, 'fremen', 1, 1);
|
||||
restoreTupileNative(game, 11);
|
||||
recordTupilePosition(game, 'typed-contest-native-high-and-extra-owner-armies');
|
||||
game.turn++;
|
||||
recordTupilePosition(game, 'next-original-turn', 'turnStart');
|
||||
const before = structuredClone({ game, state: first.state }), quote = quoteOccupiedTupileAuthority(game);
|
||||
assert.deepEqual(quote.source, first.authority.source);
|
||||
assert.deepEqual(quote.capacities, first.authority.capacities);
|
||||
assert.equal(quote.lowIntelligence, 'suppressed');
|
||||
const current = syncOccupiedTupileLease(game, first.state);
|
||||
assert.equal(current.state, first.state);
|
||||
assert.deepEqual(current.pending, []);
|
||||
assert.deepEqual({ game, state: first.state }, before);
|
||||
assert.deepEqual(biddingPhysicalIds(game), cards);
|
||||
assertDefenseInventory(game);
|
||||
});
|
||||
|
||||
void test('Advanced last-force departure closes the original holder lease once and cleanup uses normal 4/8 not occupied 5/9', () => {
|
||||
const { game, cards } = occupiedTupileFixture();
|
||||
const first = syncOccupiedTupileLease(game, createOccupiedTupileState());
|
||||
stageBiddingHandSize(game, 'occupier', 5);
|
||||
stageBiddingHandSize(game, 'ally', 9);
|
||||
removeTupileForces(game, 'occupier');
|
||||
recordTupilePosition(game, 'last-original-force-departure');
|
||||
const before = structuredClone({ game, state: first.state }), closed = syncOccupiedTupileLease(game, first.state);
|
||||
assert.equal(closed.authority.status, 'unoccupied');
|
||||
assert.equal(closed.authority.lowIntelligence, 'available');
|
||||
assert.equal(closed.state.current, null);
|
||||
assert.deepEqual(closed.slotHolders, []);
|
||||
assert.deepEqual(closed.pending.map(row => row.player), ['occupier', 'ally']);
|
||||
assert.deepEqual({ game, state: first.state }, before);
|
||||
assert.equal(syncOccupiedTupileLease(game, closed.state).state, closed.state);
|
||||
for (const [index, limit] of [4, 8].entries()) {
|
||||
const receipt = closed.pending[index], plan = quoteOccupiedTupileCleanup(game, closed.state, receipt.event);
|
||||
assert.equal(plan.limit, limit);
|
||||
assert.equal(plan.excess, 1);
|
||||
assert.deepEqual(plan.source, first.authority.source);
|
||||
assert.throws(() => completeOccupiedTupileCleanup(game, closed.state, receipt.event, receipt.player), HomeworldCustodyError);
|
||||
}
|
||||
const settled = settleAll(game, closed.state);
|
||||
validateOccupiedTupileState(settled, game);
|
||||
assert.deepEqual(settled.cleanups.map(row => row.status), ['completed', 'completed']);
|
||||
assert.equal(tupilePlayer(game, 'occupier').hand.length, 4);
|
||||
assert.equal(tupilePlayer(game, 'ally').hand.length, 8);
|
||||
const done = structuredClone({ game, state: settled });
|
||||
assert.throws(() => completeOccupiedTupileCleanup(game, settled, closed.pending[0].event, 'occupier'), HomeworldCustodyError);
|
||||
assert.deepEqual({ game, state: settled }, done);
|
||||
assert.deepEqual(biddingPhysicalIds(game), cards);
|
||||
});
|
||||
|
||||
void test('reciprocal ally removal and a new ally close only the frozen old ally; asymmetric alliances never grant', () => {
|
||||
const { game } = occupiedTupileFixture();
|
||||
const first = syncOccupiedTupileLease(game, createOccupiedTupileState());
|
||||
stageBiddingHandSize(game, 'ally', 9);
|
||||
tupilePlayer(game, 'ally').ally = null;
|
||||
const removed = syncOccupiedTupileLease(game, first.state);
|
||||
assert.deepEqual(removed.slotHolders, ['occupier']);
|
||||
assert.deepEqual(removed.pending.map(row => row.player), ['ally']);
|
||||
assert.deepEqual(first.lease!.holders, ['occupier', 'ally']);
|
||||
assert.deepEqual(removed.lease!.holders, ['occupier']);
|
||||
const settled = settleAll(game, removed.state);
|
||||
tupilePlayer(game, 'occupier').ally = 'fremen';
|
||||
assert.deepEqual(quoteOccupiedTupileAuthority(game).holders, ['occupier']);
|
||||
tupilePlayer(game, 'fremen').ally = 'occupier';
|
||||
const changed = syncOccupiedTupileLease(game, settled);
|
||||
assert.deepEqual(changed.slotHolders, ['occupier', 'fremen']);
|
||||
assert.deepEqual(changed.authority.source, first.authority.source);
|
||||
assert.equal(changed.state.leases.length, 3);
|
||||
assert.deepEqual(changed.pending, []);
|
||||
assert.equal(syncOccupiedTupileLease(game, changed.state).state, changed.state);
|
||||
validateOccupiedTupileState(changed.state, game);
|
||||
});
|
||||
|
||||
void test('Advanced turnover and same-player return are new original epochs; pending old cleanup cannot lend new slots or replay', () => {
|
||||
const { game } = occupiedTupileFixture(true, false);
|
||||
const first = syncOccupiedTupileLease(game, createOccupiedTupileState());
|
||||
stageBiddingHandSize(game, 'occupier', 5);
|
||||
addTupileForces(game, 'fremen', 0, 1);
|
||||
recordTupilePosition(game, 'incoming-typed-contest-retains-owner');
|
||||
assert.equal(syncOccupiedTupileLease(game, first.state).state, first.state);
|
||||
removeTupileForces(game, 'occupier');
|
||||
recordTupilePosition(game, 'old-owner-last-departure-and-new-sole');
|
||||
const turnover = syncOccupiedTupileLease(game, first.state);
|
||||
assert.equal(turnover.lease!.source.occupier, 'fremen');
|
||||
assert.notEqual(turnover.lease!.source.qualification, first.lease!.source.qualification);
|
||||
assert.deepEqual(turnover.slotHolders, ['fremen']);
|
||||
assert.deepEqual(turnover.pending.map(row => row.player), ['occupier']);
|
||||
removeTupileForces(game, 'fremen');
|
||||
recordTupilePosition(game, 'successor-last-force-departure');
|
||||
const departed = syncOccupiedTupileLease(game, turnover.state);
|
||||
addTupileForces(game, 'occupier');
|
||||
recordTupilePosition(game, 'same-player-new-sole-return');
|
||||
const returned = syncOccupiedTupileLease(game, departed.state);
|
||||
assert.equal(returned.lease!.source.occupier, 'occupier');
|
||||
assert.notEqual(returned.lease!.source.qualification, first.lease!.source.qualification);
|
||||
assert.deepEqual(returned.slotHolders, []);
|
||||
const settled = settleAll(game, returned.state), renewed = syncOccupiedTupileLease(game, settled);
|
||||
assert.deepEqual(renewed.slotHolders, ['occupier']);
|
||||
assert.equal(renewed.state.leases.length, 3);
|
||||
assert.throws(() => quoteOccupiedTupileCleanup(game, settled, turnover.pending[0].event), HomeworldCustodyError);
|
||||
removeTupileForces(game, 'occupier');
|
||||
recordTupilePosition(game, 'returned-owner-new-last-departure');
|
||||
const final = syncOccupiedTupileLease(game, settled);
|
||||
assert.notEqual(final.pending[0].event, turnover.pending[0].event);
|
||||
assert.equal(final.pending[0].lease, 2);
|
||||
assert.equal(quoteOccupiedTupileCleanup(game, final.state, final.pending[0].event).excess, 0);
|
||||
validateOccupiedTupileState(final.state, game);
|
||||
});
|
||||
|
||||
void test('Basic unobserved turn, contest, native repopulation, competing source and departure remain unknown/read-only, never guessed cleanup', () => {
|
||||
for (const mode of ['turn', 'contest', 'nativeHigh', 'departure', 'competitor'] as const) {
|
||||
const { game, cards } = occupiedTupileFixture(false);
|
||||
const first = syncOccupiedTupileLease(game, createOccupiedTupileState());
|
||||
if (mode === 'turn') {
|
||||
game.turn++;
|
||||
// Controlled incomplete boundary: no current-turn qualification is fabricated.
|
||||
} else if (mode === 'nativeHigh') {
|
||||
restoreTupileNative(game, 11);
|
||||
recordTupilePosition(game, 'pending-native-repopulation');
|
||||
} else if (mode === 'departure') {
|
||||
removeTupileForces(game, 'occupier');
|
||||
recordTupilePosition(game, 'pending-basic-departure');
|
||||
} else {
|
||||
addTupileForces(game, 'fremen', 1, 1);
|
||||
if (mode === 'competitor') removeTupileForces(game, 'occupier');
|
||||
recordTupilePosition(game, `pending-basic-${mode}`);
|
||||
}
|
||||
const before = structuredClone({ game, state: first.state }), quote = quoteOccupiedTupileAuthority(game);
|
||||
assert.equal(quote.status, 'unknown');
|
||||
assert.equal(quote.lowIntelligence, 'unknown');
|
||||
assert.ok(quote.blocked);
|
||||
assert.equal(quote.source, null);
|
||||
assert.deepEqual(quote.holders, []);
|
||||
const result = syncOccupiedTupileLease(game, first.state);
|
||||
assert.equal(result.state.current, first.state.current);
|
||||
assert.deepEqual(result.pending, []);
|
||||
assert.deepEqual({ game, state: first.state }, before);
|
||||
assert.deepEqual(biddingPhysicalIds(game), cards);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Basic observed continuous turn qualification keeps one existing slot lease without a new cleanup or stacked allowance', () => {
|
||||
const { game, cards } = occupiedTupileFixture(false);
|
||||
const first = syncOccupiedTupileLease(game, createOccupiedTupileState());
|
||||
game.turn++;
|
||||
recordTupilePosition(game, 'original-basic-observed-next-turn', 'turnStart');
|
||||
const next = syncOccupiedTupileLease(game, first.state);
|
||||
assert.equal(next.authority.status, 'occupied');
|
||||
assert.equal(next.authority.capacities.find(row => row.player === 'occupier')!.occupied, 5);
|
||||
assert.equal(next.authority.capacities.find(row => row.player === 'ally')!.occupied, 9);
|
||||
assert.equal(next.state.leases.length, 1);
|
||||
assert.equal(next.pending.length, 0);
|
||||
assert.deepEqual(biddingPhysicalIds(game), cards);
|
||||
});
|
||||
|
||||
void test('unobserved Advanced writes cannot fabricate a new source or resurrect a closed original epoch', () => {
|
||||
const { game } = occupiedTupileFixture(true, false);
|
||||
const first = syncOccupiedTupileLease(game, createOccupiedTupileState());
|
||||
removeTupileForces(game, 'occupier');
|
||||
const closed = syncOccupiedTupileLease(game, first.state);
|
||||
assert.equal(closed.authority.status, 'unoccupied');
|
||||
addTupileForces(game, 'occupier');
|
||||
const stale = structuredClone({ game, state: closed.state });
|
||||
assert.throws(() => syncOccupiedTupileLease(game, closed.state), HomeworldCustodyError);
|
||||
assert.deepEqual({ game, state: closed.state }, stale);
|
||||
const fresh = freshTupileFixture().game;
|
||||
clearDefenseNative(fresh, TUPILE_WORLD);
|
||||
addTupileForces(fresh, 'occupier');
|
||||
const quote = quoteOccupiedTupileAuthority(fresh);
|
||||
assert.equal(quote.status, 'unknown');
|
||||
assert.equal(quote.source, null);
|
||||
assert.equal(syncOccupiedTupileLease(fresh, createOccupiedTupileState()).state.leases.length, 0);
|
||||
});
|
||||
|
||||
void test('native CHOAM cannot receive an allied self-world slot; no alternative provider is chosen', () => {
|
||||
const { game } = occupiedTupileFixture(true, false);
|
||||
tupilePlayer(game, 'occupier').ally = 'native';
|
||||
tupilePlayer(game, 'native').ally = 'occupier';
|
||||
const quote = quoteOccupiedTupileAuthority(game);
|
||||
assert.equal(quote.status, 'unknown');
|
||||
assert.equal(quote.provider, 'native');
|
||||
assert.deepEqual(quote.holders, []);
|
||||
assert.equal(syncOccupiedTupileLease(game, createOccupiedTupileState()).state.leases.length, 0);
|
||||
});
|
||||
|
||||
void test('strict persisted JSON keeps original source/cleanup epochs and rejects malformed or rewritten grants immutably', () => {
|
||||
const { game } = occupiedTupileFixture();
|
||||
const first = syncOccupiedTupileLease(game, createOccupiedTupileState());
|
||||
removeTupileForces(game, 'occupier');
|
||||
recordTupilePosition(game, 'json-original-last-departure');
|
||||
const closed = syncOccupiedTupileLease(game, first.state), state = JSON.parse(JSON.stringify(closed.state)) as OccupiedTupileState;
|
||||
validateOccupiedTupileState(state, game);
|
||||
assert.equal(syncOccupiedTupileLease(game, state).state, state);
|
||||
assert.deepEqual(quoteOccupiedTupileCleanup(game, state, closed.pending[0].event).source, first.authority.source);
|
||||
const bad = [
|
||||
{ ...state, signature: 'rewritten' },
|
||||
{ ...state, current: 0 },
|
||||
{ ...state, cleanups: state.cleanups.slice(1) },
|
||||
{ ...state, extra: true },
|
||||
{ ...state, leases: [{ ...state.leases[0], source: { ...state.leases[0].source, native: 'ally' } }] },
|
||||
{ ...state, leases: [{ ...state.leases[0], source: { ...state.leases[0].source, signature: 'not original' } }] },
|
||||
{ ...state, leases: [{ ...state.leases[0], holders: ['occupier', 'native'] }] },
|
||||
{ ...state, cleanups: [{ ...state.cleanups[0], player: 'fremen' }, state.cleanups[1]] },
|
||||
];
|
||||
for (const malformed of bad) {
|
||||
// Even a newly computed consistency signature cannot rewrite source identity.
|
||||
const signed = { ...malformed, signature: JSON.stringify([1, malformed.leases, malformed.current, malformed.cleanups]) };
|
||||
const candidate = malformed.signature === 'rewritten' ? malformed : signed;
|
||||
const before = structuredClone({ game, candidate });
|
||||
assert.throws(() => validateOccupiedTupileState(candidate as OccupiedTupileState, game), HomeworldCustodyError);
|
||||
assert.deepEqual({ game, candidate }, before);
|
||||
}
|
||||
assert.throws(() => completeOccupiedTupileCleanup(game, state, closed.pending[0].event, 'fremen'), HomeworldCustodyError);
|
||||
assert.throws(() => quoteOccupiedTupileCleanup(game, state, 'other-epoch'), HomeworldCustodyError);
|
||||
});
|
||||
|
||||
void test('suppression and Advanced expiry preserve actual historical private intelligence and lifetime faction use', () => {
|
||||
const { game } = freshTupileFixture();
|
||||
clearDefenseNative(game, TUPILE_WORLD);
|
||||
restoreTupileNative(game, 1);
|
||||
addTupileForces(game, 'occupier');
|
||||
recordTupilePosition(game, 'native-low-contact-without-sole-qualification');
|
||||
const request = quoteTupileIntelligenceRequest(homeworldContext(game), game.homeworlds!.custody!, 'native', [],
|
||||
quoteOccupiedTupileAuthority(game).status, 'occupier', 'weapons');
|
||||
const target = tupilePlayer(game, 'occupier');
|
||||
const answer = quoteTupileIntelligenceAnswer(target.hand, target.spice, request.category);
|
||||
game.tupileIntelligence = appendTupileIntelligenceObservation(game.tupileIntelligence!, {
|
||||
...request, ...answer, event: 'controlled-actual-original-contact-answer', turn: game.turn, phase: game.phase,
|
||||
});
|
||||
const observed = structuredClone(game.tupileIntelligence);
|
||||
clearDefenseNative(game, TUPILE_WORLD);
|
||||
recordTupilePosition(game, 'native-last-force-leaves-sole-occupation');
|
||||
const first = syncOccupiedTupileLease(game, createOccupiedTupileState());
|
||||
assert.equal(first.authority.lowIntelligence, 'suppressed');
|
||||
assert.throws(() => quoteTupileIntelligenceRequest(homeworldContext(game), game.homeworlds!.custody!, 'native',
|
||||
tupileIntelligenceUsedFactions(game.tupileIntelligence!), first.authority.status, 'occupier', 'defenses'), HomeworldCustodyError);
|
||||
removeTupileForces(game, 'occupier');
|
||||
recordTupilePosition(game, 'intelligence-owner-epoch-expires');
|
||||
syncOccupiedTupileLease(game, first.state);
|
||||
validateTupileIntelligenceState(game.tupileIntelligence!, game.players, game.turn);
|
||||
assert.deepEqual(game.tupileIntelligence, observed);
|
||||
assert.deepEqual(tupileIntelligenceUsedFactions(game.tupileIntelligence!), ['guild']);
|
||||
assert.deepEqual(game.tupileIntelligence!.receipts[0].spice, answer.spice);
|
||||
assert.deepEqual(game.tupileIntelligence!.receipts[0].count, answer.count);
|
||||
});
|
||||
|
||||
void test('original Atomics hand reduction composes with one Tupile slot and cleanup returns to penalized normal capacity', () => {
|
||||
// Pure-authority composition position, NOT a claim that the current engine
|
||||
// enables combined Atomics/Homeworld arrival play. Original setup/cards and
|
||||
// the actual original placed/revealed token supply all provenance.
|
||||
const { game, cards } = freshTupileFixture(true, 'moritani');
|
||||
const owner = tupilePlayer(game, 'occupier'), ally = tupilePlayer(game, 'ally');
|
||||
owner.ally = ally.id;
|
||||
ally.ally = owner.id;
|
||||
clearDefenseNative(game, TUPILE_WORLD);
|
||||
addTupileForces(game, owner.id);
|
||||
recordTupilePosition(game, 'composition-sole-owner-with-original-ally');
|
||||
const token = game.moritaniTerror!.tokens.find(row => row.kind === 'atomics')!;
|
||||
game.moritaniTerror = placeTerror(game.moritaniTerror!, token.id, 'arrakeen', game.turn);
|
||||
const placed = game.moritaniTerror.tokens.find(row => row.id === token.id)!;
|
||||
const plan = quoteMoritaniAtomics({ token: placed, territory: 'arrakeen', moritaniId: owner.id,
|
||||
turn: game.turn, aftermath: null, players: game.players.map(player => ({ ...player,
|
||||
handSize: player.hand.length, baseHandLimit: nativeHandLimit(player) })) });
|
||||
game.moritaniTerror = revealTerror(game.moritaniTerror, token.id);
|
||||
game.moritaniAtomics = plan.aftermath;
|
||||
for (const casualty of plan.casualties) {
|
||||
const player = tupilePlayer(game, casualty.playerId);
|
||||
player.forces[casualty.location] -= casualty.normal + casualty.elite;
|
||||
player.tanks += casualty.normal + casualty.elite;
|
||||
if (casualty.elite) {
|
||||
player.elites!.forces[casualty.location] -= casualty.elite;
|
||||
player.elites!.tanks += casualty.elite;
|
||||
}
|
||||
}
|
||||
for (const reduction of plan.handReductions) {
|
||||
tupilePlayer(game, reduction.playerId).atomicsHandLimitPenalty = true;
|
||||
discardTupileExcess(game, reduction.playerId, reduction.limit);
|
||||
}
|
||||
const first = syncOccupiedTupileLease(game, createOccupiedTupileState());
|
||||
assert.deepEqual(first.authority.capacities, [
|
||||
{ player: 'occupier', normal: 3, occupied: 4 }, { player: 'ally', normal: 7, occupied: 8 },
|
||||
]);
|
||||
stageBiddingHandSize(game, 'occupier', 4);
|
||||
stageBiddingHandSize(game, 'ally', 8);
|
||||
removeTupileForces(game, 'occupier');
|
||||
recordTupilePosition(game, 'composition-original-last-departure');
|
||||
const closed = syncOccupiedTupileLease(game, first.state);
|
||||
assert.deepEqual(closed.pending.map(row => quoteOccupiedTupileCleanup(game, closed.state, row.event).limit), [3, 7]);
|
||||
settleAll(game, closed.state);
|
||||
assert.equal(owner.hand.length, 3);
|
||||
assert.equal(ally.hand.length, 7);
|
||||
assert.deepEqual(biddingPhysicalIds(game), cards);
|
||||
assertDefenseInventory(game);
|
||||
});
|
||||
|
||||
void test('direct reciprocal ally replacement freezes former-holder cleanup and Basic uncertainty blocks its settlement without changing receipts', () => {
|
||||
const { game } = occupiedTupileFixture(false);
|
||||
const first = syncOccupiedTupileLease(game, createOccupiedTupileState());
|
||||
stageBiddingHandSize(game, 'ally', 9);
|
||||
tupilePlayer(game, 'ally').ally = null;
|
||||
tupilePlayer(game, 'occupier').ally = 'fremen';
|
||||
tupilePlayer(game, 'fremen').ally = 'occupier';
|
||||
const replacement = syncOccupiedTupileLease(game, first.state);
|
||||
assert.deepEqual(replacement.slotHolders, ['occupier', 'fremen']);
|
||||
assert.deepEqual(replacement.pending.map(row => row.player), ['ally']);
|
||||
const event = replacement.pending[0].event;
|
||||
assert.equal(quoteOccupiedTupileCleanup(game, replacement.state, event).limit, 8);
|
||||
assert.equal(quoteOccupiedTupileCleanup(game, replacement.state, event).excess, 1);
|
||||
removeTupileForces(game, 'occupier');
|
||||
recordTupilePosition(game, 'pending-basic-expiry-with-original-former-ally-cleanup');
|
||||
const before = structuredClone({ game, state: replacement.state });
|
||||
const unknown = syncOccupiedTupileLease(game, replacement.state);
|
||||
assert.equal(unknown.authority.status, 'unknown');
|
||||
assert.equal(unknown.state, replacement.state);
|
||||
const blocked = quoteOccupiedTupileCleanup(game, replacement.state, event);
|
||||
assert.ok(blocked.blocked);
|
||||
assert.equal(blocked.receipt.player, 'ally');
|
||||
assert.equal(blocked.source.occupier, 'occupier');
|
||||
assert.deepEqual({ game, state: replacement.state }, before);
|
||||
// A separate conserved hand transfer is not permission to settle unknown expiry.
|
||||
stageBiddingHandSize(game, 'ally', 8);
|
||||
const blockedBefore = structuredClone({ game, state: replacement.state });
|
||||
assert.throws(() => completeOccupiedTupileCleanup(game, replacement.state, event, 'ally'), HomeworldCustodyError);
|
||||
assert.deepEqual({ game, state: replacement.state }, blockedBefore);
|
||||
});
|
||||
|
||||
void test('an active Tupile marker without its original occupation profile is unknown and cannot close existing grants', () => {
|
||||
const { game } = occupiedTupileFixture();
|
||||
const first = syncOccupiedTupileLease(game, createOccupiedTupileState());
|
||||
delete game.homeworldOccupationPreview;
|
||||
const before = structuredClone({ game, state: first.state });
|
||||
const quote = quoteOccupiedTupileAuthority(game);
|
||||
assert.equal(quote.status, 'unknown');
|
||||
assert.equal(quote.source, null);
|
||||
assert.deepEqual(quote.holders, []);
|
||||
assert.equal(syncOccupiedTupileLease(game, first.state).state, first.state);
|
||||
assert.deepEqual({ game, state: first.state }, before);
|
||||
});
|
||||
Reference in new issue
Block a user