Connect occupied Tupile capacity and original cleanup

This commit is contained in:
Quintine committed 2026-10-05 14:03:25 +11:00
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;
}
+114
View File
@@ -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);
});
+408
View File
@@ -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);
});