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.
469 lines
27 KiB
TypeScript
469 lines
27 KiB
TypeScript
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 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++;
|
||
// A new turn alone changes neither the original activation nor its slot.
|
||
} 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, 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);
|
||
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);
|
||
}
|
||
});
|
||
|
||
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('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());
|
||
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);
|
||
});
|
||
|
||
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');
|
||
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');
|
||
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']);
|
||
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('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);
|
||
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 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);
|
||
// Model the owner's exact physical discard before committing that receipt.
|
||
stageBiddingHandSize(game, 'ally', 8);
|
||
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', () => {
|
||
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);
|
||
});
|