Complete provisional Basic Tupile slot lifecycle

Basic marked Homeworld games now use the existing provisional current-sole-controller policy for Tupile slots. Observed eligible intervals retain one original activation across turns, close on departure/foreign contest/native high, and create a fresh source on restoration even when same-turn qualification rows deduplicate. Preserve original valid source histories and the lease format. Advanced retains its authorized last-departure rule.

Original normal-limit cleanup remains binding before a returning holder can reuse a slot. Native low intelligence becomes available after Basic loss subject to actual population/contact, without clearing historical answers or lifetime faction usage. Existing owner-only cleanup controls and minimal policies use the native event and exact held cards. Persistent table and cleanup text explain the provisional policy. No new profile, state version or module opening.

Validation and exact frozen-source evidence are recorded in the canonical Tupile runtime contract. Two original public Basic programmes use actual setup, three paid normal lots, earned Guild shipment income, a paid invasion and native battle, one continuous slot, optional real fifth purchase, paid last departure and zero/one normal-limit cleanup. All four minimal profiles submit legal held-card choices and finish the turn. Controlled interval/reactivation, allied cleanup and intelligence probes remain separate. No broad save/recovery campaign or strategy tuning.

This is a first-version application interpretation under the standing recommended-choice direction; publisher Basic lifecycle clarification, full module/mode acceptance, final assurance and browser/deployed proof remain open. No saved-room/database/reset/restart/deployment operation.
This commit is contained in:
Quintine committed 2026-10-24 12:19:04 +11:00
1 parent ae2d0371ce
commit 26553dd175
14 files changed
+630 -52

No files matched your search

+113
View File
@@ -0,0 +1,113 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { handLimit, viewGame } from '../game/engine';
import { botActions } from '../game/bots';
import {
BASIC_TUPILE_WORLD, actBasicTupile, completeBasicTupileLifecycle, createBasicTupileLifecycleFixture,
departBasicTupile, basicTupilePlayer as player, type BasicTupileLifecycleFixture,
} from './fixture-basic-tupile-lifecycle';
function source(f: BasicTupileLifecycleFixture) {
assert.equal(f.game.advanced, false);
assert.deepEqual(f.game.expansions, ['choam']);
assert.ok(f.actions.some(step => step.actor === f.game.host && step.action.type === 'homeworlds' && step.action.enabled === true));
assert.ok(f.actions.some(step => step.actor === f.game.host && step.action.type === 'start'));
assert.equal(f.game.homeworldOccupationPreview, true);
assert.equal(f.game.homeworldTupilePreview, true);
assert.equal(player(f.afterSetup).hand.length, 1);
assert.equal(f.purchases.filter(purchase => purchase.before.turn === 1).length, 3);
assert.equal(player(f.filledFour).hand.length, 4);
assert.equal(player(f.beforeFirstAuction).spice - player(f.filledFour).spice, 3);
assert.equal(player(f.beforeIncome, f.incomePayer).spice - player(f.afterIncome, f.incomePayer).spice, 2);
assert.equal(player(f.afterIncome).spice - player(f.beforeIncome).spice, 2);
assert.equal(f.invasionQuote.cost, 1);
assert.equal(player(f.beforeInvasion).spice - player(f.afterInvasion).spice, 1);
assert.equal(player(f.beforeInvasion).reserves - player(f.afterInvasion).reserves, 2);
assert.deepEqual(f.afterInvasion.homeworlds!.custody!.visitors[BASIC_TUPILE_WORLD].guild, { normal: 2, elite: 0 });
assert.equal(player(f.occupied, f.native).reserves, 0);
assert.equal(player(f.occupied, f.native).tanks, 20);
assert.equal(player(f.occupied).tanks, 1);
assert.deepEqual(f.occupied.homeworlds!.custody!.visitors[BASIC_TUPILE_WORLD].guild, { normal: 1, elite: 0 });
assert.equal(player(f.occupied).tupileHandSlot, true);
assert.equal(handLimit(player(f.occupied)), 5);
assert.equal(f.occupied.homeworldTupileState!.leases.length, 1);
assert.equal(f.game.homeworldTupileState!.leases.length, 1);
assert.equal(f.game.homeworldTupileState!.current, 0);
assert.equal(f.occupied.homeworldTupileState!.leases[0].source.qualification,
f.game.homeworldTupileState!.leases[0].source.qualification);
assert.equal(viewGame(f.game, f.occupier).homeworldTupileBlocked, null);
assert.equal(f.sourceActionCount, f.actions.length);
}
function departed(f: BasicTupileLifecycleFixture, result: ReturnType<typeof completeBasicTupileLifecycle>) {
assert.equal(result.departure.quote.cost, 1);
assert.equal(player(result.game).spice, player(f.game).spice - 1);
assert.equal(player(result.game).reserves, player(f.game).reserves + 1);
assert.equal(result.game.homeworlds!.custody!.visitors[BASIC_TUPILE_WORLD], undefined);
assert.equal(player(result.game).tanks, player(f.game).tanks);
assert.equal(player(result.game).tupileHandSlot, undefined);
assert.equal(handLimit(player(result.game)), 4);
assert.equal(result.game.homeworldTupileState!.current, null);
assert.equal(result.game.homeworldTupileState!.cleanups.length, 1);
assert.equal(result.game.homeworldTupileState!.cleanups[0].status, 'completed');
assert.deepEqual(result.game.homeworldTupileState!.leases[0].lost, [f.occupier]);
assert.equal(result.game.active, f.occupier);
assert.equal(result.game.phase, 5);
assert.equal(player(result.game).shipped, true);
assert.equal(player(result.game).moved, 0);
assert.ok(result.game.movementRemaining!.includes(f.occupier));
assert.equal(viewGame(result.game, f.occupier).homeworldTupileBlocked, null);
assert.equal(result.actions.filter(step => step.action.type === 'homeworldShip').length, 1);
assert.throws(() => actBasicTupile(result.game, f.occupier, result.departure.step.action));
}
void test('genuine Basic Tupile battle grants one continuous slot and actual paid last departure loses it without a source block', () => {
const f = createBasicTupileLifecycleFixture({ fifthCard: false }); source(f);
const result = completeBasicTupileLifecycle(f); departed(f, result);
assert.equal(result.cleanups.length, 0, 'The original four-card hand needs no extra discard prompt');
assert.equal(player(result.game).hand.length, 4);
assert.deepEqual(player(result.game).hand, player(f.game).hand);
});
void test('genuine fifth paid auction card uses the Basic Tupile slot and one actual departure cleanup discards exactly its excess', () => {
const f = createBasicTupileLifecycleFixture(); source(f);
assert.ok(f.beforeFifth && f.acquiredFifth);
assert.equal(player(f.beforeFifth).hand.length, 4);
assert.equal(handLimit(player(f.beforeFifth)), 5);
assert.equal(player(f.acquiredFifth).hand.length, 5);
assert.equal(player(f.beforeFifth).spice - player(f.acquiredFifth).spice, 1);
assert.equal(f.purchases.filter(purchase => purchase.before.turn === 2).length, 1);
const fifth = f.purchases.find(purchase => purchase.before.turn === 2)!;
assert.ok(player(f.game).hand.some(card => card.id === fifth.card));
const result = completeBasicTupileLifecycle(f); departed(f, result);
assert.equal(result.cleanups.length, 1);
const cleanup = result.cleanups[0], offer = viewGame(cleanup.before, f.occupier).homeworldTupileCleanup!;
assert.equal(offer.excess, 1); assert.equal(offer.limit, 4); assert.equal(offer.blocked, null);
const cards = cleanup.step.action.cards as string[];
assert.equal(cards.length, 1);
assert.ok(player(f.game).hand.some(card => card.id === cards[0]));
assert.equal(result.game.discard.filter(card => card.id === cards[0]).length,
f.game.discard.filter(card => card.id === cards[0]).length + 1);
assert.equal(player(result.game).hand.length, 4);
assert.throws(() => actBasicTupile(result.game, f.occupier, cleanup.step.action));
});
void test('all four minimal profiles resolve the genuine Basic excess using an actually held card and retain original unused movement', () => {
const f = createBasicTupileLifecycleFixture(), departure = departBasicTupile(f);
assert.equal(departure.game.decision?.kind, 'homeworldTupileCleanup');
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const view = viewGame(departure.game, f.occupier);
view.players.find(player => player.id === f.occupier)!.bot = difficulty;
const offer = view.homeworldTupileCleanup!;
const action = botActions(view).find(action => action.type === 'decision' && action.event === offer.event); assert.ok(action);
assert.equal((action.cards as string[]).length, 1);
assert.ok(offer.eligibleCards.includes((action.cards as string[])[0]));
const game = actBasicTupile(structuredClone(departure.game), f.occupier, action);
assert.equal(player(game).hand.length, 4);
assert.equal(player(game).moved, 0);
assert.equal(player(game).shipped, true);
assert.equal(game.active, f.occupier);
assert.equal(viewGame(game, f.occupier).homeworldTupileBlocked, null);
assert.equal(game.homeworldTupileState!.cleanups[0].status, 'completed');
assert.doesNotThrow(() => actBasicTupile(game, f.occupier, { type: 'endMovement' }));
}
});
+183
View File
@@ -0,0 +1,183 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, handLimit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { nativeShipmentSources } from '../game/homeworld-options';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
export const BASIC_TUPILE_WORLD = 'homeworld:choam';
export type BasicTupileStep = { actor: string; action: Action };
export const basicTupilePlayer = (game: Game, actor = 'guild') => {
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
};
export const basicTupileClean = (game: Game) => !game.response && !game.phaseOpening &&
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard;
export function actBasicTupile(game: Game, actor: string, action: Action, actions: BasicTupileStep[] = []): Game {
const next = withClassicDiscoveryNexusLottery(() => applyAction(game, actor, action));
actions.push({ actor, action: structuredClone(action) }); return next;
}
/** Native controls only; a cleanup and unsealed human plan remain owned
* choices. Never spend native revival to rebuild the conquered world. */
export function nextBasicTupileStep(game: Game): BasicTupileStep | null {
if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) {
const decision = game.decision;
if (decision?.kind === 'homeworldTupileCleanup') return null;
if (decision?.kind === 'homeworldShipmentGuild') return { actor: decision.player,
action: { type: 'decision', event: decision.event, allow: true } };
if (decision?.kind === 'homeworldDefense') return { actor: decision.player,
action: { type: 'decision', event: decision.event, use: false } };
if (decision?.kind === 'homeworldRevivalDeployment' || decision?.kind === 'caladanReinforcement' ||
decision?.kind === 'grummanCollection') return { actor: decision.player,
action: { type: 'decision', event: decision.event, decline: true } };
if (game.biddingEnd) {
const actor = game.biddingEnd.owners.find(actor => !game.biddingEnd!.ready.includes(actor));
if (actor) return { actor, action: { type: 'biddingEnd', event: game.biddingEnd.event, mode: 'ready' } };
}
}
return nextStrongholdFactionsNativeStep(game);
}
export function advanceBasicTupile(game: Game, until: (state: Game) => boolean, actions: BasicTupileStep[] = []): Game {
for (let count = 0; count < 2200; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original Basic Tupile programme ended before its next control');
const step = nextBasicTupileStep(game); assert.ok(step, 'Choose the actual owned Tupile cleanup or human Battle Plan explicitly');
game = actBasicTupile(game, step.actor, step.action, actions);
}
throw Error('The original Basic Tupile programme did not reach its finite boundary');
}
function orderOriginalLand(game: Game) {
const index = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(index >= 0);
game.spiceDeck.unshift(game.spiceDeck.splice(index, 1)[0]);
}
type BasicTupilePurchase = { before: Game; step: BasicTupileStep; declared: Game; card: string };
/** One-spice bids in original ordinary lots, never direct hand assignment. */
function buyUntil(game: Game, count: number, actions: BasicTupileStep[]) {
const purchases: BasicTupilePurchase[] = [];
for (let step = 0; step < 750; step++) {
if (basicTupilePlayer(game).hand.length === count) return { game, purchases };
let next: BasicTupileStep | null = null;
if (basicTupileClean(game) && game.auction?.active === 'guild' && game.auction.bid === 0) {
const before = structuredClone(game), card = game.auction.cards[game.auction.index].id;
next = { actor: 'guild', action: { type: 'bid', amount: 1, allyPayment: 0 } };
game = actBasicTupile(game, next.actor, next.action, actions);
purchases.push({ before, step: next, declared: structuredClone(game), card }); continue;
}
next = nextBasicTupileStep(game); assert.ok(next, 'The actual ordinary auction needs its original next control');
game = actBasicTupile(game, next.actor, next.action, actions);
}
throw Error('The original funded ordinary purchases did not reach their genuine hand size');
}
/** Public Basic Homeworld start; no development initializer or played-save
* reflag. Three paid normal1 lots fill the original Guild hand to four. Real
* Atreides shipment income funds the actual invasion and later paid return.
* A native no-weapon battle creates sole Tupile control and its original slot.
* Only unopened land Spice positions/scalar lotteries are controlled. */
export function createBasicTupileLifecycleFixture(options: { fifthCard?: boolean } = {}) {
const fifthCard = options.fifthCard ?? true;
let game = createGame('BASICTUPILELIFECYCLE', newPlayer('atreides', 'Atreides', 'atreides'), false, ['choam']);
for (const faction of ['choam', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction));
const actions: BasicTupileStep[] = [];
const act = (actor: string, action: Action) => { game = actBasicTupile(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceBasicTupile(game, until, actions); };
act(game.host, { type: 'homeworlds', enabled: true });
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game);
act(game.host, { type: 'start' });
const setup = structuredClone(game);
reach(state => state.status === 'playing');
assert.equal(game.homeworldOccupationPreview, true);
assert.equal(game.homeworldTupilePreview, true);
const afterSetup = structuredClone(game);
orderOriginalLand(game);
reach(state => state.phase === 3 && !!state.auction && basicTupileClean(state));
const beforeFirstAuction = structuredClone(game);
const first = buyUntil(game, 4, actions); game = first.game;
const purchases = [...first.purchases], filledFour = structuredClone(game);
reach(state => state.phase === 5 && state.active === 'atreides' && basicTupileClean(state));
const beforeIncome = structuredClone(game), nativeSources = nativeShipmentSources(viewGame(game, 'atreides'), 1, 0); assert.ok(nativeSources);
const incomeShipment: BasicTupileStep = { actor: 'atreides', action: { type: 'ship',
territory: 'imperial_basin', sector: 9, amount: 1, elite: 0, allyPayment: 0, homeworldSources: nativeSources } };
act(incomeShipment.actor, incomeShipment.action); reach(basicTupileClean);
const afterIncome = structuredClone(game);
act('atreides', { type: 'endMovement' });
reach(state => state.phase === 5 && state.active === 'guild' && basicTupileClean(state));
const beforeInvasion = structuredClone(game);
const invasionQuote = homeworldShipmentChoice(viewGame(game, 'guild'), BASIC_TUPILE_WORLD,
{ 'homeworld:guild': { normal: 2, elite: 0 } });
assert.ok(invasionQuote.action, invasionQuote.blocked ?? 'The actual native Guild invasion must be available');
const invasion: BasicTupileStep = { actor: 'guild', action: invasionQuote.action };
act(invasion.actor, invasion.action); reach(game => basicTupileClean(game) && !game.pendingHomeworldShipment);
const afterInvasion = structuredClone(game);
act('guild', { type: 'endMovement' });
reach(state => state.phase === 6 && !!state.active && basicTupileClean(state));
const beforeBattle = structuredClone(game), choice = viewGame(game, game.active!).battleChoices
.find(choice => choice.territory === BASIC_TUPILE_WORLD); assert.ok(choice);
const chooseBattle: BasicTupileStep = { actor: choice.chooser, action: { type: 'chooseBattle',
territory: BASIC_TUPILE_WORLD, target: choice.chooser === 'guild' ? 'choam' : 'guild' } };
act(chooseBattle.actor, chooseBattle.action);
reach(state => !!state.battle && !state.battle.preparation && state.battle.preLeader?.closed !== false && basicTupileClean(state));
const battle = structuredClone(game);
const winner = [...basicTupilePlayer(game, 'guild').leaders].filter(leader => !leader.dead && !leader.usedAt)
.sort((a, b) => b.strength - a.strength)[0];
const native = [...basicTupilePlayer(game, 'choam').leaders].filter(leader => !leader.dead && !leader.usedAt)
.sort((a, b) => a.strength - b.strength)[0];
assert.ok(winner && native && winner.strength + 1 > native.strength);
const plans: BasicTupileStep[] = [
{ actor: 'guild', action: { type: 'battlePlan', leader: winner.id, dial: 1, support: 0 } },
{ actor: 'choam', action: { type: 'battlePlan', leader: native.id, dial: 0, support: 0 } },
];
for (const step of plans) act(step.actor, step.action);
reach(state => !state.battle && basicTupileClean(state));
const occupied = structuredClone(game);
assert.equal(game.homeworlds!.custody!.visitors[BASIC_TUPILE_WORLD].guild.normal, 1);
assert.equal(basicTupilePlayer(game, 'choam').reserves, 0);
assert.equal(basicTupilePlayer(game).tupileHandSlot, true);
assert.equal(handLimit(basicTupilePlayer(game)), 5);
reach(state => state.turn === 2 && state.phase === 1 && basicTupileClean(state));
orderOriginalLand(game);
let beforeFifth: Game | null = null, acquiredFifth: Game | null = null;
if (fifthCard) {
reach(state => state.turn === 2 && state.phase === 3 && !!state.auction && basicTupileClean(state));
beforeFifth = structuredClone(game);
const fifth = buyUntil(game, 5, actions); game = fifth.game; purchases.push(...fifth.purchases);
acquiredFifth = structuredClone(game);
}
reach(state => state.turn === 2 && state.phase === 5 && state.active === 'guild' && basicTupileClean(state));
assert.equal(basicTupilePlayer(game).hand.length, fifthCard ? 5 : 4);
assert.equal(game.homeworldTupileState!.leases.length, 1, 'Continuous original control across a turn grants no additional slot');
return { game, lobby, setup, afterSetup, beforeFirstAuction, filledFour, purchases, beforeIncome, incomeShipment, afterIncome,
beforeInvasion, invasionQuote, invasion, afterInvasion, beforeBattle, chooseBattle, battle, plans, occupied,
beforeFifth, acquiredFifth, fifthCard, occupier: 'guild', native: 'choam', incomePayer: 'atreides',
sourceActionCount: actions.length, actions,
staging: ['Public Basic Atreides/CHOAM/Guild + original Homeworld selection/start, genuine paid auctions and shipments, and an actual low-dial native Homeworld battle provide every card, force and occupation observation. Only unopened land Spice positions and scalar lotteries are selected. No hand, resource, force, phase, alliance, lease or history is installed; this is not the conserved Semuta/Tupile fixture.'] };
}
export type BasicTupileLifecycleFixture = ReturnType<typeof createBasicTupileLifecycleFixture>;
export function departBasicTupile(fixture: BasicTupileLifecycleFixture) {
const before = structuredClone(fixture.game), actions: BasicTupileStep[] = [];
const quote = homeworldShipmentChoice(viewGame(before, fixture.occupier), 'homeworld:guild',
{ [BASIC_TUPILE_WORLD]: { normal: 1, elite: 0 } });
assert.ok(quote.action, quote.blocked ?? 'The actual last Guild visitor must be able to return');
const step: BasicTupileStep = { actor: fixture.occupier, action: quote.action };
let game = actBasicTupile(before, step.actor, step.action, actions);
game = advanceBasicTupile(game, game => game.decision?.kind === 'homeworldTupileCleanup' ||
basicTupileClean(game) && !game.pendingHomeworldShipment, actions);
return { game, before, quote, step, actions };
}
/** Complete only the original hand cleanup, retaining the occupier's current
* movement turn and unused ordinary movement. No second shipment is issued. */
export function completeBasicTupileLifecycle(fixture: BasicTupileLifecycleFixture) {
const departure = departBasicTupile(fixture), actions = [...departure.actions];
let game = departure.game;
const cleanups: { before: Game; step: BasicTupileStep; after: Game }[] = [];
for (let count = 0; game.decision?.kind === 'homeworldTupileCleanup' && count < 3; count++) {
const before = structuredClone(game), actor = game.decision.player;
const offer = viewGame(game, actor).homeworldTupileCleanup; assert.ok(offer && !offer.blocked);
const step: BasicTupileStep = { actor, action: { type: 'decision', event: offer.event,
cards: offer.eligibleCards.slice(0, offer.excess) } };
game = actBasicTupile(game, step.actor, step.action, actions);
cleanups.push({ before, step, after: structuredClone(game) });
}
game = advanceBasicTupile(game, game => basicTupileClean(game) && !game.pendingHomeworldShipment, actions);
return { game, departure, cleanups, actions };
}
+84 -24
View File
@@ -177,13 +177,13 @@ void test('Advanced turnover and same-player return are new original epochs; pen
validateOccupiedTupileState(final.state, game);
});
void test('Basic unobserved turn, contest, native repopulation, competing source and departure remain unknown/read-only, never guessed cleanup', () => {
void test('Basic current-sole policy keeps continuous turns and closes or transfers slots at observed control changes', () => {
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.
// A new turn alone changes neither the original activation nor its slot.
} else if (mode === 'nativeHigh') {
restoreTupileNative(game, 11);
recordTupilePosition(game, 'pending-native-repopulation');
@@ -196,14 +196,18 @@ void test('Basic unobserved turn, contest, native repopulation, competing source
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, []);
assert.equal(quote.status, mode === 'turn' || mode === 'competitor' ? 'occupied' : 'unoccupied');
assert.equal(quote.lowIntelligence, mode === 'turn' || mode === 'competitor' ? 'suppressed' : 'available');
assert.equal(quote.blocked, null);
const result = syncOccupiedTupileLease(game, first.state);
assert.equal(result.state.current, first.state.current);
assert.deepEqual(result.pending, []);
if (mode === 'turn') {
assert.deepEqual(quote.source, first.authority.source);
assert.equal(result.state, first.state);
assert.deepEqual(result.pending, []);
} else {
assert.deepEqual(result.pending.map(row => row.player), ['occupier', 'ally']);
assert.deepEqual(result.slotHolders, mode === 'competitor' ? ['fremen'] : []);
}
assert.deepEqual({ game, state: first.state }, before);
assert.deepEqual(biddingPhysicalIds(game), cards);
}
@@ -223,6 +227,63 @@ void test('Basic observed continuous turn qualification keeps one existing slot
assert.deepEqual(biddingPhysicalIds(game), cards);
});
void test('Basic same-turn departure and return needs a new observed activation and settles the old excess before regaining slots', () => {
const { game, cards } = occupiedTupileFixture(false);
const first = syncOccupiedTupileLease(game, createOccupiedTupileState());
stageBiddingHandSize(game, 'occupier', 5);
stageBiddingHandSize(game, 'ally', 9);
const qualifications = game.homeworldOccupationHistory!.qualifications.length;
removeTupileForces(game, 'occupier');
recordTupilePosition(game, 'basic-observed-last-departure');
const closed = syncOccupiedTupileLease(game, first.state);
assert.equal(closed.state.current, null);
addTupileForces(game, 'occupier');
const before = structuredClone(game);
assert.equal(quoteOccupiedTupileAuthority(game).status, 'unknown');
assert.match(quoteOccupiedTupileAuthority(game).blocked!, /observed/);
assert.deepEqual(game, before);
recordTupilePosition(game, 'basic-observed-same-turn-return');
assert.equal(game.homeworldOccupationHistory!.qualifications.length, qualifications,
'The original qualification reducer deliberately deduplicates this same-turn fact');
const renewed = syncOccupiedTupileLease(game, closed.state);
assert.equal(renewed.authority.status, 'occupied');
assert.notEqual(renewed.lease!.source.qualification, first.lease!.source.qualification);
assert.deepEqual(renewed.slotHolders, []);
assert.deepEqual(renewed.pending.map(row => row.player), ['occupier', 'ally']);
const settled = settleAll(game, renewed.state);
const available = syncOccupiedTupileLease(game, settled);
assert.deepEqual(available.slotHolders, ['occupier', 'ally']);
assert.equal(tupilePlayer(game, 'occupier').hand.length, 4);
assert.equal(tupilePlayer(game, 'ally').hand.length, 8);
assert.equal(available.state.leases.length, 2);
assert.equal(syncOccupiedTupileLease(game, available.state).state, available.state);
assert.throws(() => completeOccupiedTupileCleanup(game, settled, renewed.pending[0].event, 'occupier'), HomeworldCustodyError);
assert.deepEqual(biddingPhysicalIds(game), cards);
});
for (const cause of ['contest', 'nativeHigh'] as const) void test(
`Basic ${cause} ends the slot and clearing it starts a new observed interval without stacking`, () => {
const { game, cards } = occupiedTupileFixture(false);
const first = syncOccupiedTupileLease(game, createOccupiedTupileState());
if (cause === 'contest') addTupileForces(game, 'fremen');
else restoreTupileNative(game, 11);
recordTupilePosition(game, `basic-${cause}-closes-control`);
const closed = syncOccupiedTupileLease(game, first.state);
assert.equal(closed.authority.status, 'unoccupied');
assert.equal(closed.authority.lowIntelligence, 'available');
assert.equal(closed.state.current, null);
if (cause === 'contest') removeTupileForces(game, 'fremen');
else clearDefenseNative(game, TUPILE_WORLD);
recordTupilePosition(game, `basic-${cause}-cleared`);
const renewed = syncOccupiedTupileLease(game, settleAll(game, closed.state));
assert.equal(renewed.authority.status, 'occupied');
assert.notEqual(renewed.lease!.source.qualification, first.lease!.source.qualification);
assert.deepEqual(renewed.slotHolders, ['occupier', 'ally']);
assert.equal(renewed.authority.capacities[0].occupied, 5);
validateOccupiedTupileState(renewed.state, game);
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());
@@ -284,8 +345,8 @@ void test('strict persisted JSON keeps original source/cleanup epochs and reject
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();
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} suppression and expiry preserve actual historical intelligence and lifetime faction use`, () => {
const { game } = freshTupileFixture(advanced);
clearDefenseNative(game, TUPILE_WORLD);
restoreTupileNative(game, 1);
addTupileForces(game, 'occupier');
@@ -306,7 +367,8 @@ void test('suppression and Advanced expiry preserve actual historical private in
tupileIntelligenceUsedFactions(game.tupileIntelligence!), first.authority.status, 'occupier', 'defenses'), HomeworldCustodyError);
removeTupileForces(game, 'occupier');
recordTupilePosition(game, 'intelligence-owner-epoch-expires');
syncOccupiedTupileLease(game, first.state);
const closed = syncOccupiedTupileLease(game, first.state);
assert.equal(closed.authority.lowIntelligence, 'available');
validateTupileIntelligenceState(game.tupileIntelligence!, game.players, game.turn);
assert.deepEqual(game.tupileIntelligence, observed);
assert.deepEqual(tupileIntelligenceUsedFactions(game.tupileIntelligence!), ['guild']);
@@ -363,7 +425,7 @@ void test('original Atomics hand reduction composes with one Tupile slot and cle
assertDefenseInventory(game);
});
void test('direct reciprocal ally replacement freezes former-holder cleanup and Basic uncertainty blocks its settlement without changing receipts', () => {
void test('Basic departure preserves a former ally’s original cleanup and allows its normal-limit settlement', () => {
const { game } = occupiedTupileFixture(false);
const first = syncOccupiedTupileLease(game, createOccupiedTupileState());
stageBiddingHandSize(game, 'ally', 9);
@@ -379,19 +441,17 @@ void test('direct reciprocal ally replacement freezes former-holder cleanup and
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');
const closed = syncOccupiedTupileLease(game, replacement.state);
assert.equal(closed.authority.status, 'unoccupied');
const owed = quoteOccupiedTupileCleanup(game, closed.state, event);
assert.equal(owed.blocked, null);
assert.equal(owed.receipt.player, 'ally');
assert.equal(owed.source.occupier, 'occupier');
assert.deepEqual({ game, state: replacement.state }, before);
// A separate conserved hand transfer is not permission to settle unknown expiry.
// Model the owner's exact physical discard before committing that receipt.
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);
const settled = completeOccupiedTupileCleanup(game, closed.state, event, 'ally');
assert.equal(settled.cleanups.find(row => row.event === event)!.status, 'completed');
});
void test('an active Tupile marker without its original occupation profile is unknown and cannot close existing grants', () => {