Connect provisional Basic current-sole Homeworld benefits and repair stale rule guards

This commit is contained in:
Quintine committed 2026-10-09 01:26:20 +11:00
1 parent 66e061ccca
commit b892967eb3
23 files changed
+657 -344

No files matched your search

@@ -162,7 +162,7 @@ void test('borrowed Emperor free-return remains source guarded for either native
}
});
void test('quiet native Ecaz Cunning acquires the living unclaimed Duke separately from training, and rejects exceptional custody or an already-open battle', () => {
void test('quiet native Ecaz Cunning acquires the living unclaimed or set-aside dead Duke separately from training, and rejects exceptional custody or an already-open battle', () => {
const f = fixture({ native: 'ecaz' }), closing = close(f), acquired = cunning(f, closing.game);
assert.equal(acquired.before.status, 'playing');
assert.equal(acquired.before.turn, closing.game.turn);
@@ -176,10 +176,32 @@ void test('quiet native Ecaz Cunning acquires the living unclaimed Duke separate
assert.equal(acquired.after.nexusCards!.cards!.hands[f.owner], null);
assert.deepEqual(acquired.after.leaderSkills, acquired.before.leaderSkills);
assert.ok(acquired.after.leaderSkills!.assignments.every(a => a.leader !== DUKE_VIDAL_ID));
for (const property of ['dead', 'capturedBy', 'gholaBy', 'usedAt'] as const) {
const tanks = structuredClone(acquired.before);
tanks.dukeVidal!.leader = { ...tanks.dukeVidal!.leader, dead: true, deaths: 2, usedAt: 'cistern' };
const beforeRevival = structuredClone(tanks), revived = step(tanks, acquired.step);
assert.deepEqual(tanks, beforeRevival);
assert.deepEqual(revived.dukeVidal, {
...beforeRevival.dukeVidal!,
controller: f.owner, acquiredTurn: revived.turn, source: 'ecazNexus',
leader: { ...acquired.before.dukeVidal!.leader, dead: false, deaths: 2 },
});
assert.equal(revived.dukeVidal!.leader.usedAt, undefined);
assert.equal(revived.nexusCards!.cards!.hands[f.owner], null);
assert.equal(revived.nexusCards!.cards!.discard.filter(c => c === 'ecaz').length,
beforeRevival.nexusCards!.cards!.discard.filter(c => c === 'ecaz').length + 1);
assert.equal(revived.nexusEcazDukeHistory!.length, (beforeRevival.nexusEcazDukeHistory?.length ?? 0) + 1);
assert.deepEqual(revived.leaderSkills, beforeRevival.leaderSkills);
custody(revived);
for (const property of ['controller', 'acquiredTurn', 'source'] as const) {
const input = structuredClone(tanks);
if (property === 'controller') input.dukeVidal!.controller = f.opponent;
else if (property === 'acquiredTurn') input.dukeVidal!.acquiredTurn = input.turn;
else input.dukeVidal!.source = 'ecazNexus';
reject(input, f.owner, acquired.step.action);
}
for (const property of ['capturedBy', 'gholaBy', 'usedAt'] as const) {
const input = structuredClone(acquired.before);
if (property === 'dead') input.dukeVidal!.leader.dead = true;
else if (property === 'usedAt') input.dukeVidal!.leader.usedAt = 'cistern';
if (property === 'usedAt') input.dukeVidal!.leader.usedAt = 'cistern';
else input.dukeVidal!.leader[property] = f.opponent;
reject(input, f.owner, acquired.step.action);
}
+7 -7
View File
@@ -76,24 +76,24 @@ for (const advanced of [false, true]) {
assert.equal(row.blocked, null);
});
void test(`${advanced ? 'Advanced' : 'Basic'} established source authority distinguishes native repopulation from physical foreign presence`, () => {
void test(`${advanced ? 'Advanced' : 'Basic'} native repopulation expires the Basic holding outright while Advanced retains its original epoch`, () => {
const { game } = ecazHomeworldFixture(advanced);
ecazSeat(game, 'at').reserves = 20;
observeEcazPosition(game, 'controlled-native-caladan-repopulation');
const row = ecazHomeworldVictoryProgress(game)!;
assert.equal(row.qualifies, advanced);
assert.equal(row.foreignHomeworlds.length, advanced ? 2 : 1);
assert.equal(row.blocked === null, advanced);
assert.equal(row.blocked, null, 'decided expiry is not an unresolved ambiguity');
});
void test(`${advanced ? 'Advanced' : 'Basic'} third Homeworld visitors preserve Advanced retained authority but block unresolved Basic occupation`, () => {
void test(`${advanced ? 'Advanced' : 'Basic'} contest removes Basic current-sole control while Advanced retains its original epoch`, () => {
const { game } = ecazHomeworldFixture(advanced);
game.homeworlds!.custody!.visitors['homeworld:atreides'].observer = { normal: 1, elite: 0 };
ecazSeat(game, 'observer').reserves--;
observeEcazPosition(game, 'controlled-third-homeworld-visitor');
const row = ecazHomeworldVictoryProgress(game)!;
assert.equal(row.qualifies, advanced);
assert.equal(row.blocked === null, advanced);
assert.equal(row.blocked, null, 'contested presence is a decided expiry, not an ambiguity');
});
}
@@ -216,13 +216,13 @@ void test('missing or legacy source history blocks a potential two-native win wi
}
});
void test('Basic expiry stays unresolved; Advanced retains occupation while its original holder remains', () => {
void test('Basic current-sole control persists across turns while Advanced retains its original epoch', () => {
for (const advanced of [false, true]) {
const { game } = ecazHomeworldFixture(advanced);
game.turn++;
const row = ecazHomeworldVictoryProgress(game)!;
assert.equal(row.qualifies, advanced);
assert.equal(row.blocked === null, advanced);
assert.equal(row.qualifies, true);
assert.equal(row.blocked, null);
}
});
@@ -292,14 +292,37 @@ void test('each of the five genuine native discs may take its actual offered ski
assert.deepEqual(offered, before, 'Original own-seat offers cannot be redealt to satisfy a fixture request.');
});
void test('quiet-boundary custody guards remain pure and reject receipt creation; exceptional recovery is not staged as played history', () => {
void test('quiet-boundary Cunning revives the set-aside Duke from Tanks while invalid custody and timing remain immutable', () => {
const f = fixture({ tech: false, stronghold: false });
const g = f.beforeCunning, duke = g.dukeVidal!, event = viewGame(g, f.owner).nexusEcazDuke!.event;
// Quote-only hypothetical inputs, NOT accepted games, fabricated acquisitions,
// resurrected leaders or completed receipts. Actual runtime sources above are
// unchanged; each rejected alternative must leave the physical card unspent.
const tanks = structuredClone(g);
tanks.dukeVidal!.leader = { ...duke.leader, dead: true, deaths: 2, usedAt: f.territory };
const beforeRevival = structuredClone(tanks);
assert.equal(quoteNexusEcazDuke(tanks, f.owner)?.blocked, null);
const receipt = createNexusEcazDukeReceipt(tanks, f.owner);
assert.equal(receipt.before.leader.dead, true);
assert.deepEqual(tanks, beforeRevival);
const revived = act(tanks, f.owner, { type: 'nexusEcazDuke', event: receipt.event });
assert.deepEqual(tanks, beforeRevival);
assert.deepEqual(revived.dukeVidal, {
...duke, controller: f.owner, source: 'ecazNexus', acquiredTurn: revived.turn,
leader: { ...duke.leader, dead: false, deaths: 2 },
});
assert.equal(revived.dukeVidal!.leader.usedAt, undefined);
assert.equal(revived.nexusCards!.cards!.hands[f.owner], null);
assert.equal(revived.nexusCards!.cards!.discard.filter(c => c === 'ecaz').length,
beforeRevival.nexusCards!.cards!.discard.filter(c => c === 'ecaz').length + 1);
assert.equal(revived.nexusEcazDukeHistory!.length, (beforeRevival.nexusEcazDukeHistory?.length ?? 0) + 1);
assert.deepEqual(revived.leaderSkills, g.leaderSkills);
assert.deepEqual(revived.players.map(p => p.leaders), g.players.map(p => p.leaders));
reject(revived, f.owner, { type: 'nexusEcazDuke', event: receipt.event });
custody(revived);
const alternatives: Game[] = [
{ ...g, dukeVidal: { ...duke, leader: { ...duke.leader, dead: true } } },
{ ...tanks, dukeVidal: { ...tanks.dukeVidal!, controller: f.opponent } },
{ ...tanks, dukeVidal: { ...tanks.dukeVidal!, acquiredTurn: g.turn } },
{ ...tanks, dukeVidal: { ...tanks.dukeVidal!, source: 'ecazNexus' } },
{ ...tanks, dukeVidal: { ...tanks.dukeVidal!, leader: { ...tanks.dukeVidal!.leader, capturedBy: f.opponent } } },
{ ...tanks, dukeVidal: { ...tanks.dukeVidal!, leader: { ...tanks.dukeVidal!.leader, gholaBy: f.opponent } } },
{ ...g, dukeVidal: { ...duke, leader: { ...duke.leader, capturedBy: f.opponent } } },
{ ...g, dukeVidal: { ...duke, leader: { ...duke.leader, gholaBy: f.opponent } } },
{ ...g, dukeVidal: { ...duke, leader: { ...duke.leader, usedAt: f.territory } } },
@@ -309,8 +332,11 @@ void test('quiet-boundary custody guards remain pure and reject receipt creation
{ ...g, battle: f.game.battle },
];
for (const input of alternatives) {
const before = structuredClone(input);
assert.ok(quoteNexusEcazDuke(input, f.owner)?.blocked);
assert.throws(() => createNexusEcazDukeReceipt(input, f.owner));
assert.deepEqual(input, before);
reject(input, f.owner, { type: 'nexusEcazDuke', event: quoteNexusEcazDuke(input, f.owner)!.event });
assert.equal(g.nexusCards!.cards!.hands[f.owner], 'ecaz');
assert.equal(g.nexusEcazDukeHistory?.length ?? 0, 0);
}
+3 -2
View File
@@ -146,7 +146,7 @@ void test('Original concealed Richese presence permits a zero prospective pool,
assert.deepEqual(input, saved);
});
for (const role of ['ally', 'opponent'] as const) void test(`Mixed ordinary-plus-marker ${role} battle is rejected immutably`, () => {
for (const role of ['ally', 'opponent'] as const) void test(`Combined Occupy ordinary-plus-marker ${role} battle is rejected immutably`, () => {
const fixture = ecazOccupyRicheseFixture({ allyFaction: role === 'ally' ? 'richese' : 'guild',
opponentFaction: role === 'opponent' ? 'richese' : 'guild', marker: 5,
richeseReserves: 2, mixedRicheseForces: 1 });
@@ -154,7 +154,8 @@ for (const role of ['ally', 'opponent'] as const) void test(`Mixed ordinary-plus
const saved = structuredClone(game);
const choice = viewGame(game, game.active!).battleChoices.find(b => b.territory === fixture.territory)!;
assert.throws(() => applyAction(game, choice.chooser, { type: 'chooseBattle', territory: fixture.territory,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker }), /Mixed ordinary-force and No-Field/);
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker }),
{ message: 'Combined Occupy No-Field battle dialing awaits a ruling. Ordinary mixed battles use the visible provisional pool.' });
assert.deepEqual(game, saved);
});
+49 -38
View File
@@ -193,7 +193,7 @@ void test('Giedi uses original discard refill stock without ever drawing a physi
});
for (const [kind, rule] of Object.entries(effects) as [OccupiedBiddingKind, { native: FactionId; card: HomeworldId }][]) {
void test(`${rule.card}: Basic retains publisher turn-boundary qualification rather than a current garrison shortcut`, () => {
void test(`${rule.card}: Basic requires an observed qualification before current-sole control`, () => {
const game = freshBiddingGame(rule.native, 'emperor', false);
const world = `homeworld:${rule.native}`;
const high = HOMEWORLD_CARDS.find(card => card.id === rule.card)!.high.reserves.min;
@@ -215,59 +215,70 @@ for (const [kind, rule] of Object.entries(effects) as [OccupiedBiddingKind, { na
assertDefenseInventory(game);
});
void test(`${rule.card}: Basic pending lifecycle and missing historical sources fail closed`, () => {
void test(`${rule.card}: Basic current-sole control follows the present garrison while missing history still fails closed`, () => {
const qualified = freshBiddingGame(rule.native, 'emperor', false);
const world = `homeworld:${rule.native}`;
qualifyDefensePosition(qualified, world);
const high = HOMEWORLD_CARDS.find(card => card.id === rule.card)!.high.reserves.min;
for (const mode of ['departure', 'competition', 'replacement', 'repopulation', 'expired', 'missing-history'] as const) {
const originalCards = biddingPhysicalIds(qualified);
for (const mode of ['turn', 'contest-cleared', 'replacement', 'repopulation-cleared'] as const) {
const game = structuredClone(qualified);
if (mode === 'departure') {
removeDefenseVisitor(game, world);
recordDefensePosition(game, 'original-qualifier-left');
addDefenseVisitor(game, world);
recordDefensePosition(game, 'same-qualifier-returned-with-no-invented-expiry-rule');
} else if (mode === 'competition') {
if (mode === 'turn') game.turn++;
else if (mode === 'contest-cleared') {
addDefenseVisitor(game, world, 'observer');
recordDefensePosition(game, 'second-original-foreign-group-arrived');
recordDefensePosition(game, 'second-foreign-group-arrived');
assert.equal(quoteOccupiedBiddingAuthority(game, kind).occupied, false, 'two foreign factions never grant current-sole control');
removeDefenseVisitor(game, world, 'observer');
recordDefensePosition(game, 'contest-cleared-with-history-retained');
recordDefensePosition(game, 'contest-cleared');
} else if (mode === 'replacement') {
removeDefenseVisitor(game, world);
recordDefensePosition(game, 'original-qualifier-departed-before-turnover');
recordDefensePosition(game, 'original-qualifier-departed');
addDefenseVisitor(game, world, 'observer');
recordDefensePosition(game, 'different-original-sole-qualifier-arrived');
} else if (mode === 'repopulation') {
recordDefensePosition(game, 'different-sole-qualifier-arrived');
} else {
const native = biddingPlayer(game, 'native');
native.tanks -= high;
native.reserves += high;
recordDefensePosition(game, 'native-original-high-threshold-restored');
recordDefensePosition(game, 'native-high-threshold-restored');
assert.equal(quoteOccupiedBiddingAuthority(game, kind).occupied, false, 'native repopulation ends the provisional benefit');
clearDefenseNative(game, world);
recordDefensePosition(game, 'native-fell-low-after-restoration');
} else if (mode === 'expired') {
game.turn++;
} else delete game.homeworldOccupationHistory;
const before = structuredClone(game);
const blocked = quoteOccupiedBiddingAuthority(game, kind);
assert.notEqual(blocked.blocked, null);
assert.equal(blocked.provider, 'native', 'source provider is not reassigned even when effect must wait');
assert.equal(blocked.controller, null);
assert.deepEqual(blocked.inspectionAudience, [], 'uncertain history never leaks an original private pool/card');
assert.deepEqual(blocked.bonusRecipients, []);
assert.throws(() => requireOccupiedBiddingController(game, kind, 'native'), HomeworldCustodyError);
assert.throws(() => requireOccupiedBiddingController(game, kind, 'occupier'), HomeworldCustodyError);
if (kind === 'harkonnenBonus')
assert.throws(() => requireOccupiedBiddingBonusRecipient(game, 'occupier'), HomeworldCustodyError);
assert.deepEqual(game, before);
recordDefensePosition(game, 'native-fell-low-again');
}
const controller = mode === 'replacement' ? 'observer' : 'occupier';
const authority = quoteOccupiedBiddingAuthority(game, kind);
assert.equal(authority.blocked, null);
assert.equal(authority.occupied, true);
assert.equal(authority.provider, 'native');
assert.equal(authority.controller, kind === 'atreidesInspection' ? 'native' : controller);
if (kind === 'atreidesInspection' || kind === 'richeseCache')
assert.ok(authority.inspectionAudience.includes(controller), 'the current-sole controller shares the printed audience');
assert.deepEqual(biddingPhysicalIds(game), originalCards);
assertDefenseInventory(game);
const saved: Game = JSON.parse(JSON.stringify(game));
assert.equal(quoteOccupiedBiddingAuthority(saved, kind).controller, null);
assert.notEqual(quoteOccupiedBiddingAuthority(saved, kind).blocked, null);
delete saved.homeworldOccupationPreview;
const legacy = quoteOccupiedBiddingAuthority(saved, kind);
assert.equal(legacy.controller, 'native');
assert.equal(legacy.blocked, null, 'outside fresh preview does not convert or gate native saved-game rules');
}
const missing = structuredClone(qualified);
delete missing.homeworldOccupationHistory;
const before = structuredClone(missing);
const blocked = quoteOccupiedBiddingAuthority(missing, kind);
assert.notEqual(blocked.blocked, null);
assert.equal(blocked.provider, 'native', 'source provider is not reassigned even when effect must wait');
assert.equal(blocked.controller, null);
assert.deepEqual(blocked.inspectionAudience, [], 'uncertain history never leaks an original private pool/card');
assert.deepEqual(blocked.bonusRecipients, []);
assert.throws(() => requireOccupiedBiddingController(missing, kind, 'native'), HomeworldCustodyError);
assert.throws(() => requireOccupiedBiddingController(missing, kind, 'occupier'), HomeworldCustodyError);
if (kind === 'harkonnenBonus')
assert.throws(() => requireOccupiedBiddingBonusRecipient(missing, 'occupier'), HomeworldCustodyError);
assert.deepEqual(missing, before);
assertDefenseInventory(missing);
});
void test(`${rule.card}: deleting the fresh preview falls back to the native saved-game rule`, () => {
const qualified = freshBiddingGame(rule.native, 'emperor', false);
qualifyDefensePosition(qualified, `homeworld:${rule.native}`);
delete qualified.homeworldOccupationPreview;
const legacy = quoteOccupiedBiddingAuthority(qualified, kind);
assert.equal(legacy.controller, 'native');
assert.equal(legacy.blocked, null, 'outside fresh preview does not convert or gate native saved-game rules');
});
}
+54 -27
View File
@@ -5,6 +5,7 @@ import { HOMEWORLD_CARDS, type HomeworldId } from '../game/homeworld-cards';
import { homeworldGameIntegrity } from '../game/homeworld-game';
import { HomeworldCustodyError } from '../game/homeworld-custody';
import { occupiedHomeworldSardaukarStatus } from '../game/homeworld-occupied-defenses';
import { quoteStableHomeworldOccupation } from '../game/homeworld-stable-occupation';
import {
beginHomeworldOccupiedIncome, homeworldOccupiedIncomeOffer, quoteHomeworldOccupiedIncomeChoice,
type HomeworldOccupiedIncomeState,
@@ -147,46 +148,48 @@ void test('preview disabled never provides bank entitlement; repeated offer read
assert.notEqual(homeworldOccupiedIncomeOffer(state, game, 'owner')!.blocked, null);
});
void test('departed then returned qualifier is blocked by original history, not paid to its current garrison', () => {
void test('Basic current-sole control drops on departure and resumes for the returned qualifier without rerouting its frozen receipt', () => {
const { game, world } = occupiedIncomeFixture();
const state = beginHomeworldOccupiedIncome(game, 'collection-departure');
const owner = game.players.find(player => player.id === 'owner')!;
delete game.homeworlds!.custody!.visitors[world];
owner.reserves++;
observeControlledIncomePosition(game, 'controlled-departure');
assert.equal(beginHomeworldOccupiedIncome(game, 'collection-departed').queue.length, 0);
const before = structuredClone({ game, state });
assert.notEqual(homeworldOccupiedIncomeOffer(state, game, 'owner')!.blocked, null);
assert.throws(() => quoteHomeworldOccupiedIncomeChoice(state, game, 'owner', state.event, world, 2), HomeworldCustodyError);
assert.deepEqual({ game, state }, before);
game.homeworlds!.custody!.visitors[world] = { owner: { normal: 1, elite: 0 } };
owner.reserves--;
observeControlledIncomePosition(game, 'controlled-return');
const fresh = beginHomeworldOccupiedIncome(game, 'collection-returned');
assert.equal(fresh.queue.length, 1);
assert.equal(fresh.queue[0].occupier, 'owner');
assert.equal(fresh.queue[0].qualification, state.queue[0].qualification, 'the same observed source resumes; no new epoch is invented');
assert.deepEqual(settle(game, state, 2).credits, [{ player: 'owner', amount: 2 }]);
});
void test('Basic contested foreign presence removes current-sole control while a frozen receipt stays unreroutable', () => {
const { game, world } = occupiedIncomeFixture();
const state = beginHomeworldOccupiedIncome(game, 'collection-contested');
const custody = game.homeworlds!.custody!;
const competitor = game.players.find(player => player.id === 'competitor')!;
custody.visitors[world].competitor = { normal: 1, elite: 0 };
competitor.reserves--;
observeControlledIncomePosition(game, 'controlled-competing-arrival');
assert.equal(beginHomeworldOccupiedIncome(game, 'collection-contested-fresh').queue.length, 0);
const before = structuredClone({ game, state });
assert.notEqual(homeworldOccupiedIncomeOffer(state, game, 'owner')!.blocked, null);
assert.throws(() => beginHomeworldOccupiedIncome(game, 'collection-after-return'), HomeworldCustodyError);
assert.throws(() => quoteHomeworldOccupiedIncomeChoice(state, game, 'owner', state.event, world, 2), HomeworldCustodyError);
assert.throws(() => quoteHomeworldOccupiedIncomeChoice(state, game, 'owner', state.event, world, 1), HomeworldCustodyError);
assert.deepEqual({ game, state }, before);
delete custody.visitors[world].competitor;
competitor.reserves++;
observeControlledIncomePosition(game, 'controlled-contest-cleared');
assert.deepEqual(settle(game, state, 1).credits, [{ player: 'owner', amount: 1 }, { player: 'ally', amount: 1 }]);
});
void test('competing qualification or a competing foreign army blocks all bank settlement, including an earlier queued entitlement', () => {
for (const departed of [false, true]) {
const { game, world } = occupiedIncomeFixture();
const state = beginHomeworldOccupiedIncome(game, 'collection-contested');
const custody = game.homeworlds!.custody!;
if (departed) {
delete custody.visitors[world];
game.players.find(player => player.id === 'owner')!.reserves++;
observeControlledIncomePosition(game, 'controlled-original-departure');
}
custody.visitors[world] ??= {};
custody.visitors[world].competitor = { normal: 1, elite: 0 };
game.players.find(player => player.id === 'competitor')!.reserves--;
observeControlledIncomePosition(game, 'controlled-competing-arrival');
const before = structuredClone({ game, state });
assert.notEqual(homeworldOccupiedIncomeOffer(state, game, 'owner')!.blocked, null);
assert.throws(() => beginHomeworldOccupiedIncome(game, 'collection-ambiguous'), HomeworldCustodyError);
assert.throws(() => quoteHomeworldOccupiedIncomeChoice(state, game, 'owner', state.event, world, 1), HomeworldCustodyError);
assert.deepEqual({ game, state }, before);
}
});
void test('restoring the native high threshold after qualification blocks the pending bank grant without erasing history', () => {
void test('Basic native repopulation removes current-sole control without erasing history or a frozen receipt guard', () => {
const { game, world } = occupiedIncomeFixture();
const state = beginHomeworldOccupiedIncome(game, 'collection-native-restored');
const qualifications = structuredClone(game.homeworldOccupationHistory!.qualifications);
@@ -195,13 +198,37 @@ void test('restoring the native high threshold after qualification blocks the pe
native.tanks -= 6;
observeControlledIncomePosition(game, 'controlled-native-repopulation');
assert.deepEqual(game.homeworldOccupationHistory!.qualifications, qualifications);
assert.equal(beginHomeworldOccupiedIncome(game, 'collection-restored-native-fresh').queue.length, 0);
const before = structuredClone({ game, state });
assert.notEqual(homeworldOccupiedIncomeOffer(state, game, 'owner')!.blocked, null);
assert.throws(() => beginHomeworldOccupiedIncome(game, 'collection-restored-native'), HomeworldCustodyError);
assert.throws(() => quoteHomeworldOccupiedIncomeChoice(state, game, 'owner', state.event, world, 2), HomeworldCustodyError);
assert.deepEqual({ game, state }, before);
});
void test('Basic current-sole control transfers to a newly observed sole faction and never rewrites earlier history', () => {
const { game, world } = occupiedIncomeFixture();
const owner = game.players.find(player => player.id === 'owner')!;
const competitor = game.players.find(player => player.id === 'competitor')!;
const original = structuredClone(game.homeworldOccupationHistory!.qualifications);
const custody = game.homeworlds!.custody!;
delete custody.visitors[world];
owner.reserves++;
observeControlledIncomePosition(game, 'controlled-transfer-out');
assert.equal(beginHomeworldOccupiedIncome(game, 'collection-no-controller').queue.length, 0);
custody.visitors[world] = { competitor: { normal: 1, elite: 0 } };
competitor.reserves--;
assert.equal(
quoteStableHomeworldOccupation(game, 'caladan').blocked,
'The original Basic sole-occupation change must be observed before its benefit settles.',
'a physical write without its semantic observation grants nothing',
);
observeControlledIncomePosition(game, 'controlled-transfer-in');
const state = beginHomeworldOccupiedIncome(game, 'collection-new-controller');
assert.equal(state.queue.length, 1);
assert.equal(state.queue[0].occupier, 'competitor');
assert.deepEqual(game.homeworldOccupationHistory!.qualifications.slice(0, original.length), original);
});
void test('Advanced contested zero-icon Salusa retains its original strength penalty without blocking Kaitain income', () => {
const { game } = occupiedIncomeFixture('kaitain', true, false);
const secondary = 'homeworld:emperor:salusa';
+27 -12
View File
@@ -289,21 +289,13 @@ void test('wrong actor/event/turn and invalid splits cannot consume a pending pe
}
});
void test('changed reciprocal alliance or continuous entitlement cannot replace the original frozen recipient', () => {
for (const change of ['alliance', 'departure-return', 'competitor', 'repopulation'] as const) {
void test('changed reciprocal alliance, contest or native repopulation cannot replace the original frozen recipient', () => {
for (const change of ['alliance', 'competitor', 'repopulation'] as const) {
const { game, world } = occupiedIncomeFixture('richese');
const source = actualSource('richese-income', 7);
const quote = quoteOccupiedPercentageSource(game, source, createOccupiedPercentageState());
const owner = game.players.find(player => player.id === 'owner')!;
if (change === 'alliance') game.players.find(player => player.id === 'ally')!.ally = null;
else if (change === 'departure-return') {
delete game.homeworlds!.custody!.visitors[world];
owner.reserves++;
observeControlledIncomePosition(game, 'labelled-original-owner-departure');
game.homeworlds!.custody!.visitors[world] = { owner: { normal: 1, elite: 0 } };
owner.reserves--;
observeControlledIncomePosition(game, 'labelled-original-owner-return');
} else if (change === 'competitor') {
else if (change === 'competitor') {
game.homeworlds!.custody!.visitors[world].competitor = { normal: 1, elite: 0 };
game.players.find(player => player.id === 'competitor')!.reserves--;
observeControlledIncomePosition(game, 'labelled-second-foreign-garrison');
@@ -318,7 +310,7 @@ void test('changed reciprocal alliance or continuous entitlement cannot replace
assert.deepEqual({ game, state: quote.state }, before);
if (change !== 'alliance') {
const unknown = quoteOccupiedPercentageSource(game, { ...source, event: 'blocked-new-actual-source' }, createOccupiedPercentageState());
assert.equal(unknown.receipt.status, 'blocked');
assert.equal(unknown.receipt.status, 'settled', 'no current-sole controller means no occupied share, not an ambiguous source');
assert.equal(unknown.receipt.entitlement, null);
assert.deepEqual(unknown.receipt.credits, []);
assert.deepEqual(balances(game), balances(before.game));
@@ -329,6 +321,29 @@ void test('changed reciprocal alliance or continuous entitlement cannot replace
}
});
void test('Basic current-sole departure then return resumes the same observed source instead of a new epoch', () => {
const { game, world } = occupiedIncomeFixture('richese');
const source = actualSource('richese-income', 7);
const quote = quoteOccupiedPercentageSource(game, source, createOccupiedPercentageState());
const owner = game.players.find(player => player.id === 'owner')!;
delete game.homeworlds!.custody!.visitors[world];
owner.reserves++;
observeControlledIncomePosition(game, 'labelled-original-owner-departure');
const departed = quoteOccupiedPercentageSource(game, { ...source, event: 'departed-source' }, createOccupiedPercentageState());
assert.equal(departed.receipt.status, 'settled', 'an empty world earns no occupied share rather than an ambiguous block');
assert.equal(departed.receipt.entitlement, null);
assert.deepEqual(departed.receipt.credits, []);
assert.throws(() => allocateOccupiedPercentageReceipt(quote.state, game, 'owner', source.event, 2, source), HomeworldCustodyError);
game.homeworlds!.custody!.visitors[world] = { owner: { normal: 1, elite: 0 } };
owner.reserves--;
observeControlledIncomePosition(game, 'labelled-original-owner-return');
const resumed = allocateOccupiedPercentageReceipt(quote.state, game, 'owner', source.event, 2, source);
assert.equal(resumed.receipt.status, 'settled');
assert.equal(resumed.receipt.entitlement!.qualification, quote.receipt.entitlement!.qualification);
assert.equal(resumed.receipt.entitlement!.occupier, 'owner');
homeworldGameIntegrity(game);
});
void test('zero-share unknown entitlement completes through JSON without selecting a replacement or needing allocation', () => {
const { game, world } = occupiedIncomeFixture('southern_hemisphere');
game.homeworlds!.custody!.visitors[world].competitor = { normal: 1, elite: 0 };
@@ -482,7 +482,7 @@ void test('original Richese Ambassador purchase displaces one real Giedi bonus,
}
});
void test('native Richese conducting-auction cancellation remains SOURCE-BLOCKED pending the original ordinary-lot-count ruling, with immutable stock and wallets', () => {
void test('native Richese compulsory-cache Karama cancellation restores one ordinary lot without changing cache or wallets', () => {
let game = freshBiddingGame('richese');
qualifyDefensePosition(game, 'homeworld:richese');
const counter = stageBiddingKarama(game, 'observer');
@@ -492,17 +492,19 @@ void test('native Richese conducting-auction cancellation remains SOURCE-BLOCKED
const receipts = game.homeworldOccupiedPercentageLedger!.receipts.length;
const action = { type: 'card', card: counter.id, mode: 'cancel' };
for (const state of [game, JSON.parse(JSON.stringify(game)) as Game]) {
const frozen = structuredClone(state);
assert.throws(() => applyAction(state, 'observer', action), RuleError);
assert.deepEqual(state, frozen, 'blocked original cancellation preserves exact card/cache/wallet/control state');
assert.ok(biddingPlayer(state, 'observer').hand.some(card => card.id === counter.id));
assert.deepEqual(state.richeseCache!.map(card => card.id), cache);
assert.deepEqual(cash(state), before);
assert.equal(state.pendingHomeworldOccupiedPercentage, null);
assert.equal(state.pendingHomeworldOccupiedBonus, null);
assert.equal(state.homeworldOccupiedPercentageLedger!.receipts.length, receipts);
assert.deepEqual(biddingPhysicalIds(state), original);
assertDefenseInventory(state);
const canceled = applyAction(state, 'observer', action);
assert.equal(canceled.phase, 3);
assert.equal(canceled.richeseAuction, null, 'the compulsory cache lot does not run after cancellation');
assert.ok(canceled.auction && canceled.auction.cards.length > 0, 'one ordinary lot is restored');
assert.deepEqual(canceled.richeseCache!.map(card => card.id), cache, 'the physical cache is untouched');
assert.deepEqual(cash(canceled), before, 'the cancellation itself costs no spice');
assert.equal(biddingPlayer(canceled, 'observer').hand.some(card => card.id === counter.id), false, 'the Karama leaves its hand');
assert.equal(canceled.discard.filter(card => card.id === counter.id).length, 1, 'exactly one physical Karama is consumed');
assert.equal(canceled.pendingHomeworldOccupiedPercentage, null);
assert.equal(canceled.pendingHomeworldOccupiedBonus, null);
assert.equal(canceled.homeworldOccupiedPercentageLedger!.receipts.length, receipts);
assert.deepEqual(biddingPhysicalIds(canceled), original);
assertDefenseInventory(canceled);
}
});
+36 -23
View File
@@ -145,27 +145,40 @@ void test('zero native No-Field retains an actual skilled leader battle and no p
custody(game);
});
void test('original Smuggler companion remains physical and mixed No-Field battle stays guarded until voluntary native reveal', () => {
const staged = markerGame(5, 'smuggler', 'advanced', 20, true);
const owner = player(staged.game, 'richese');
assert.equal(owner.reserves, 19);
assert.equal(owner.forces['wind_pass:14'], 1);
reject(staged.game, staged.actor, { type: 'chooseBattle', territory: 'wind_pass', target: staged.target });
const token = owner.noField!.deployed!.tokenId;
reject(staged.game, staged.actor, { type: 'revealNoField', token, event: owner.noFieldEvent });
let game = applyAction(staged.beforeBattle, staged.actor, { type: 'revealNoField', token, event: owner.noFieldEvent });
assert.equal(player(game, 'richese').forces['wind_pass:14'], 6);
assert.equal(player(game, 'richese').reserves, 14);
Object.assign(game, { phase: 6, active: staged.actor, ready: [], phaseOpening: null, response: null, decision: null });
game = openAdvancedNativeSkillBattle(game, staged.actor, staged.target);
game = applyAction(game, staged.actor, { type: 'battlePlan', dial: 0, support: 0, leader: untrained(game, staged.actor) });
game = applyAction(game, staged.target, { type: 'battlePlan', dial: 0, support: 0, leader: untrained(game, staged.target) });
game = applyAction(game, staged.actor, { type: 'traitorCall', call: false });
game = applyAction(game, staged.target, { type: 'traitorCall', call: false });
game = finishAdvancedNativeSkillAftermath(game);
assert.equal(game.lastBattleContext!.winner, staged.target);
assert.equal(player(game, 'richese').tanks, 6);
assert.equal(player(game, 'richese').forces['wind_pass:14'] ?? 0, 0);
assert.equal(player(game, 'richese').reserves, 14);
custody(game);
void test('Basic and Advanced Smuggler companions join capped mixed No-Field battles and lose only real counters', () => {
for (const rules of ['basic', 'advanced'] as const) for (const reserves of [3, 20]) {
const staged = markerGame(5, 'smuggler', rules, reserves, true);
const owner = player(staged.game, 'richese');
const materialized = Math.min(5, reserves - 1), pool = 1 + materialized;
assert.equal(owner.reserves, reserves - 1);
assert.equal(owner.forces['wind_pass:14'], 1);
let game = openAdvancedNativeSkillBattle(staged.game, staged.actor, staged.target);
assert.deepEqual(game.battle!.mixedNoFieldPlayers, [staged.actor]);
assert.equal(viewGame(game, staged.actor).battle!.mixedNoField, true);
assert.equal(viewGame(game, staged.actor).battle!.ownForces!.normal, pool);
assert.equal(viewGame(game, staged.target).battle!.mixedNoField, undefined);
assert.equal(viewGame(game, staged.target).battle!.opponentForces, null);
const token = owner.noField!.deployed!.tokenId;
reject(game, staged.actor, { type: 'revealNoField', token, event: owner.noFieldEvent });
reject(game, staged.actor, { type: 'battlePlan', dial: pool + 1, support: 0, leader: untrained(game, staged.actor) });
game = applyAction(game, staged.actor, { type: 'battlePlan', dial: 0, support: 0, leader: untrained(game, staged.actor) });
assert.equal(game.battle!.revealed, false);
assert.ok(player(game, 'richese').noField!.deployed);
assert.equal(player(game, 'richese').forces['wind_pass:14'], 1);
assert.equal(player(game, 'richese').reserves, reserves - 1);
game = applyAction(game, staged.target, { type: 'battlePlan', dial: 0, support: 0, leader: untrained(game, staged.target) });
assert.equal(game.battle!.revealed, true);
assert.equal(player(game, 'richese').noField!.deployed, null);
assert.equal(player(game, 'richese').forces['wind_pass:14'], pool);
assert.equal(player(game, 'richese').reserves, reserves - 1 - materialized);
custody(game);
game = applyAction(game, staged.actor, { type: 'traitorCall', call: false });
game = applyAction(game, staged.target, { type: 'traitorCall', call: false });
game = finishAdvancedNativeSkillAftermath(game);
assert.equal(game.lastBattleContext!.winner, staged.target);
assert.equal(player(game, 'richese').tanks, 20 - reserves + pool);
assert.equal(player(game, 'richese').forces['wind_pass:14'] ?? 0, 0);
assert.equal(player(game, 'richese').reserves, reserves - 1 - materialized);
custody(game);
}
});
+36 -6
View File
@@ -186,17 +186,47 @@ void test('Tabr/Tuek original winner keeps spice/cards and optional Tech before
}
});
void test('original mixed ordinary-force/No-Field boundary remains guarded even with trained Suk and a retained Stronghold Card', () => {
void test('mixed ordinary-force/No-Field battle retains trained Suk and its Stronghold Card through real capped losses', () => {
const f = fixture({ kind: 'richese-marker' });
const game = structuredClone(f.beforeBattle), richese = player(game, f.owner);
let game = structuredClone(f.beforeBattle);
const richese = player(game, f.owner);
// Explicit conserved alternative board position: one existing Polar Sink
// counter accompanies the marker, not a shipment or played-turn claim.
richese.forces[f.boardSource]--;
richese.forces[f.location] = 1;
const before = structuredClone(game), actor = game.active!;
assert.throws(() => applyAction(game, actor, { type: 'chooseBattle', territory: f.territory,
target: actor === f.owner ? f.opponent : f.owner }), /Mixed ordinary-force and No-Field/);
assert.deepEqual(game, before); custody(game);
const actor = game.active!;
game = applyAction(game, actor, { type: 'chooseBattle', territory: f.territory,
target: actor === f.owner ? f.opponent : f.owner });
for (let i = 0; (game.decision || game.response || game.battle?.preparation || game.battle?.preLeader?.closed === false) && i < 100; i++) {
const next = nextNativeSkillsStrongholdStep(game); assert.ok(next);
game = applyAction(game, next.actor, next.action);
}
assert.equal(viewGame(game, f.owner).battle!.mixedNoField, true);
assert.equal(viewGame(game, f.owner).battle!.ownForces!.normal, 4);
assert.equal(viewGame(game, f.opponent).battle!.mixedNoField, undefined);
const before = structuredClone(game);
assert.throws(() => applyAction(game, f.owner, { ...f.plans[0].action, dial: 5 }));
assert.deepEqual(game, before);
game = applyAction(game, f.owner, f.plans[0].action);
assert.equal(game.battle!.revealed, false);
assert.ok(player(game, f.owner).noField!.deployed);
assert.equal(player(game, f.owner).forces[f.location], 1);
assert.equal(player(game, f.owner).reserves, 3);
game = applyAction(game, f.opponent, f.plans[1].action);
assert.equal(game.battle!.revealed, true);
assert.equal(player(game, f.owner).noField!.deployed, null);
assert.equal(player(game, f.owner).forces[f.location], 4);
assert.equal(player(game, f.owner).reserves, 0);
const revealed = game;
assert.equal(quoteNativeSkillsStrongholdBattle(revealed).payments.find(p => p.player === f.owner)!.bankSupport, 2);
const resolved = settle(revealed);
assert.equal(resolved.lastBattleContext!.winner, f.owner);
assert.equal(player(resolved, f.owner).forces[f.location], 2);
assert.equal(player(resolved, f.owner).reserves, 2);
assert.equal(player(resolved, f.owner).tanks, 0);
assert.equal(player(resolved, f.opponent).tanks, 6);
assert.equal(resolved.strongholdCards!.owners[f.territory], f.owner);
assertBattleWallets(revealed, resolved); custody(resolved);
});
void test('reserve-capped marker-only Richese materializes three after both plans, earns retained Arrakeen subsidy and rescues only physical casualties', () => {
+33 -4
View File
@@ -234,10 +234,6 @@ void test('saved destination and advisor stance must still permit the declared p
pendingRecord(g).territory = 'missing';
decisionRecord(g).territory = 'missing';
});
corrupted('ally moved into destination', (g) => {
g.players[2].forces = { 'arrakeen:10': 1 };
g.players[2].reserves = 19;
});
corrupted('stronghold filled', (g) => {
g.players[1].forces = { 'arrakeen:10': 1 };
g.players[1].reserves = 19;
@@ -250,6 +246,39 @@ void test('saved destination and advisor stance must still permit the declared p
});
});
void test('saved ordinary shipment commits after an ally enters the destination', () => {
const pending = declare();
pending.players[2].forces = { 'arrakeen:10': 1 };
pending.players[2].reserves = 19;
const restored = JSON.parse(JSON.stringify(pending)) as Game;
const snapshot = structuredClone(restored);
const allowed = permit(restored);
assert.deepEqual(restored, snapshot);
assert.equal(allowed.pendingShipment, null);
assert.equal(allowed.players[0].reserves, 16);
assert.equal(allowed.players[0].forces['arrakeen:10'], 4);
assert.equal(allowed.players[0].elites!.reserves, 3);
assert.equal(allowed.players[0].elites!.forces['arrakeen:10'], 2);
assert.equal(allowed.players[0].spice, 18);
assert.equal(allowed.players[2].spice, 17);
assert.equal(allowed.aid.a.amount, 1);
assert.equal(allowed.players[0].shipped, true);
assert.equal(allowed.players[2].forces['arrakeen:10'], 1);
assert.equal(allowed.players[2].reserves, 19);
assert.throws(() => permit(allowed));
const complete = finishFollowups(allowed);
assert.equal(complete.players[1].spice, 24);
inventory(complete);
const ended = applyAction(complete, 'e', { type: 'endMovement' });
assert.equal(ended.players[0].forces['arrakeen:10'] ?? 0, 0);
assert.equal(ended.players[0].tanks, 4);
assert.equal(ended.players[0].elites!.forces['arrakeen:10'] ?? 0, 0);
assert.equal(ended.players[0].elites!.tanks, 2);
assert.equal(ended.players[2].forces['arrakeen:10'], 1);
assert.equal(ended.players[2].tanks, 0);
inventory(ended);
});
void test('public Guild permission must bind to the saved shipper, destination, amount and actual Guild seat', () => {
for (const [key, value] of [
['shipper', 'a'],
+64 -10
View File
@@ -50,16 +50,70 @@ void test('Recruits owner has an accessible physical-card action; rivals cannot
assert.ok(cardPresentation(card).topics.some((topic) => topic.id === 'card-recruits'));
});
void test('late-paid and wrong-phase controls stay guarded through the shared action adapter', () => {
let g = ownerGame();
g = allowRevival(applyAction(g, 'at', { type: 'revive', amount: 3 }));
const preview = viewGame(g, 'at').recruitsPreview;
assert.match(preview?.play?.blocked ?? '', /paid normal force revival/);
assert.equal(recruitsPlayAction(preview), null);
assert.match(render(g), /aria-describedby="recruits-unavailable"/);
assert.match(render(g), /disabled=""/);
g.phase = 5;
assert.equal(render(g), '');
void test('late-paid controls allow provisional Recruits without refunding prior paid returns', () => {
const paid = allowRevival(applyAction(ownerGame(), 'at', { type: 'revive', amount: 3 }));
assert.equal(paid.players[0].revived, 3);
assert.equal(paid.players[0].freeForcesRevived, 2);
assert.equal(paid.players[0].spice, 18);
const preview = viewGame(paid, 'at').recruitsPreview;
assert.equal(preview?.play?.blocked, null);
const action = recruitsPlayAction(preview);
assert.deepEqual(action, { type: 'card', card: 'ecaz-recruits' });
assert.ok(action);
assert.match(render(paid), /Play Recruits · Discard card/);
assert.doesNotMatch(render(paid), /disabled=""/);
const played = applyAction(paid, 'at', action);
assert.deepEqual(played.recruits, { turn: paid.turn, player: 'at', card: 'ecaz-recruits' });
assert.equal(played.discard.filter((card) => card.id === 'ecaz-recruits').length, 1);
assert.ok(!played.players[0].hand.some((card) => card.id === 'ecaz-recruits'));
assert.deepEqual(played.players.map((player) => player.spice), paid.players.map((player) => player.spice));
assert.equal(played.players[0].revived, 3);
assert.equal(played.players[0].freeForcesRevived, 2);
assert.equal(viewGame(played, 'at').revival.freeRemaining, 2);
assert.equal(viewGame(played, 'at').revival.forcesRemaining, 4);
const free = allowRevival(applyAction(played, 'at', { type: 'revive', amount: 2 }));
assert.equal(free.players[0].revived, 5);
assert.equal(free.players[0].freeForcesRevived, 4);
assert.equal(free.players[0].spice, paid.players[0].spice);
assert.equal(viewGame(free, 'at').revival.freeRemaining, 0);
assert.equal(viewGame(free, 'at').revival.forcesRemaining, 2);
});
void test('missing and inconsistent ledgers, pending transactions, wrong phase and replay remain immutable guards', () => {
const paid = allowRevival(applyAction(ownerGame(), 'at', { type: 'revive', amount: 3 }));
const missing = structuredClone(paid);
delete missing.players[0].freeForcesRevived;
const inconsistent = structuredClone(paid);
inconsistent.players[0].freeForcesRevived = inconsistent.players[0].revived + 1;
const pending = applyAction(ownerGame(), 'at', { type: 'revive', amount: 3 });
assert.ok(pending.pendingRevival);
const wrongPhase = ownerGame();
wrongPhase.phase = 5;
const active = applyAction(ownerGame(), 'at', { type: 'card', card: 'ecaz-recruits' });
const recovered = structuredClone(active);
const [card] = recovered.discard.splice(recovered.discard.findIndex((held) => held.id === 'ecaz-recruits'), 1);
assert.ok(card);
recovered.players[0].hand.push(card);
for (const [label, g] of [
['missing ledger', missing],
['inconsistent ledger', inconsistent],
['pending transaction', pending],
['wrong phase', wrongPhase],
['already-active recovered copy', recovered],
] as const) {
const before = structuredClone(g);
const preview = viewGame(g, 'at').recruitsPreview;
assert.ok(preview?.play?.blocked, label);
assert.equal(recruitsPlayAction(preview), null, label);
assert.throws(() => applyAction(g, 'at', { type: 'card', card: 'ecaz-recruits' }), label);
assert.deepEqual(g, before, label);
}
const beforeReplay = structuredClone(active);
assert.throws(() => applyAction(active, 'at', { type: 'card', card: 'ecaz-recruits' }));
assert.deepEqual(active, beforeReplay);
assert.match(render(missing), /aria-describedby="recruits-unavailable"/);
assert.match(render(missing), /disabled=""/);
assert.equal(render(wrongPhase), '');
assert.equal(recruitsPlayAction(undefined), null);
});
+36 -17
View File
@@ -275,7 +275,7 @@ void test('Recruits restores privately and counts earlier free revivals once acr
}
});
void test('pending and already paid revivals reject Recruits after restart without changing the saved transaction', async (t) => {
void test('pending revivals stay immutable after restart while settled paid returns allow provisional Recruits without refunds', async (t) => {
const f = await fixture(t);
const pending = await act(f, f.ids[0], {
type: 'revive',
@@ -298,24 +298,43 @@ void test('pending and already paid revivals reject Recruits after restart witho
assert.equal(paid.players[0].revived, 3);
assert.equal(paid.players[0].freeForcesRevived, 2);
assert.equal(paid.players[0].spice, 18);
assert.match(
viewGame(paid, f.owner).recruitsPreview!.play!.blocked!,
/after a paid normal force revival/,
);
assert.equal(
recruitsPlayAction(viewGame(paid, f.owner).recruitsPreview),
null,
);
await noWrite(
f,
f.owner,
play,
undefined,
/after a paid normal force revival/,
const action = recruitsPlayAction(viewGame(paid, f.owner).recruitsPreview);
assert.deepEqual(action, play);
assert.ok(action);
const played = await act(f, f.owner, action);
assert.deepEqual(played.recruits, {
turn: paid.turn,
player: f.owner,
card: 'ecaz-recruits',
});
assert.deepEqual(
played.players.map((player) => player.spice),
paid.players.map((player) => player.spice),
);
assert.equal(played.players[0].revived, 3);
assert.equal(played.players[0].freeForcesRevived, 2);
assert.ok(!played.players[3].hand.some((card) => card.id === 'ecaz-recruits'));
assert.equal(played.discard.filter((card) => card.id === 'ecaz-recruits').length, 1);
const final = await restored(f);
assert.equal(final.recruits, undefined);
assert.ok(final.players[3].hand.some((card) => card.id === 'ecaz-recruits'));
assert.equal(viewGame(final, f.ids[0]).revival.freeRemaining, 2);
assert.equal(viewGame(final, f.ids[0]).revival.forcesRemaining, 4);
await noWrite(f, f.owner, play);
await act(f, f.ids[0], { type: 'revive', amount: 2, elite: 0 });
const free = await settleRevival(f);
assert.equal(free.players[0].revived, 5);
assert.equal(free.players[0].freeForcesRevived, 4);
assert.equal(free.players[0].spice, paid.players[0].spice);
assert.equal(viewGame(free, f.ids[0]).revival.freeRemaining, 0);
assert.equal(viewGame(free, f.ids[0]).revival.forcesRemaining, 2);
await act(f, f.ids[0], { type: 'revive', amount: 2, elite: 0 });
const capped = await settleRevival(f);
assert.equal(capped.players[0].revived, 7);
assert.equal(capped.players[0].freeForcesRevived, 4);
assert.equal(capped.players[0].spice, paid.players[0].spice - 4);
assert.equal(viewGame(capped, f.ids[0]).revival.forcesRemaining, 0);
assert.equal(capped.discard.filter((card) => card.id === 'ecaz-recruits').length, 1);
await noWrite(f, f.ids[0], { type: 'revive', amount: 1, elite: 0 });
await restored(f);
});
void test('competing authenticated Recruits plays commit one physical discard and one effect', async (t) => {
-119
View File
@@ -70,49 +70,6 @@ void test('reviewed feature checklists expose all five independent facets and re
}
});
void test('Richese checklist reports integrated auctions separately from inert card effects and unresolved rules', () => {
const topic = RULE_TOPICS.find(
(candidate) => candidate.id === 'richese-cards',
)!;
const guide = topic.steps.join(' ');
assert.equal(topic.coverage, 'Partial');
for (const phrase of [
'Once Around or Silent bidding',
'normal, Once Around or Silent bidding',
'Silent bids remain private',
'pledged ally spice',
'Atreides inspection',
'Harkonnen bonus',
'positive Black Market self-bids',
'exhausted-cache arithmetic',
'Ixian Technology substitution',
'skips the optional Black Market prelude',
'Juice of Sapho',
'Richese expansion starts remain disabled',
'persisted-room checks pass',
])
assert.ok(guide.includes(phrase), phrase);
assert.match(
guide,
/implementation guards.*not claims of official prohibitions/,
);
assert.equal(
topic.checklist!.find((item) => item.area === 'AI')!.status,
'Partial',
);
assert.ok(
topic
.checklist!.find((item) => item.area === 'Verification')!
.evidence!.includes('tests/richese-auction-bots.test.ts'),
);
assert.ok(
topic
.checklist!.find((item) => item.area === 'Verification')!
.evidence!.includes('tests/richese-engine.test.ts'),
);
assert.doesNotMatch(JSON.stringify(topic), /https?:\/\//);
});
void test('Richese acquisition guide separates purchase immunity, income response and independent transfer/search controls', () => {
const topic = RULE_TOPICS.find(
(candidate) => candidate.id === 'richese-acquisition',
@@ -139,40 +96,6 @@ void test('Richese acquisition guide separates purchase immunity, income respons
assert.doesNotMatch(JSON.stringify(topic), /https?:\/\//);
});
void test('Ecaz checklist distinguishes integrated lifecycle from unopened expansion starts', () => {
const topic = RULE_TOPICS.find(
(candidate) => candidate.id === 'ecaz-ambassadors',
)!;
assert.equal(topic.coverage, 'Partial');
const guide = topic.steps.join(' ');
assert.match(guide, /before spice is paid/);
assert.match(guide, /previous placements and payments remain/);
assert.match(
guide,
/supply policy is an interpretation, not an explicit official FAQ ruling/,
);
assert.match(
guide,
/CHOAM market, deferred technology income, then Ecaz placement/,
);
assert.match(guide, /not a priority established by a combined official FAQ/);
assert.match(
guide,
/Storm crossings return tokens.*overriding traitor victory leaves it placed/,
);
assert.match(guide, /Full Ecaz starts remain disabled/);
assert.ok(
RULE_TOPICS.find(
(candidate) => candidate.id === 'revival',
)!.related?.includes(topic.id),
);
assert.ok(
RULE_TOPICS.find(
(candidate) => candidate.id === 'ecaz-modules',
)!.related?.includes(topic.id),
);
});
void test('automation reference keeps single-outcome settlement, cosmetic notices and paced AI distinct', () => {
const casualty = RULE_TOPICS.find(
(topic) => topic.id === 'automatic-casualties',
@@ -325,45 +248,3 @@ void test('no-choice decision checklist preserves genuine alternatives and persi
.evidence!.includes('tests/automatic-decisions.test.ts'),
);
});
void test('Nullentropy checklist preserves paid-only privacy, real choices, recovery and explicit interpretation guards', () => {
const topic = RULE_TOPICS.find(
(candidate) => candidate.id === 'nullentropy-search',
)!;
const text = topic.steps.join(' ');
assert.equal(topic.coverage, 'Partial');
assert.equal(topic.checklist!.length, 5);
assert.ok(topic.checklist!.every((item) => item.status === 'Partial'));
for (const phrase of [
'two spice to the bank',
'no discard names or candidate count',
'Only the paying player',
'no free cancellation',
'exactly one legal card',
'without charging again',
'Temporary search access ends',
'pre-existing free hand slot',
'pending Guild refund claim',
'not a general ban',
'before payment',
'without discard lookahead',
'full Richese starts remain disabled',
]) {
assert.ok(
(text + JSON.stringify(topic.checklist)).includes(phrase),
phrase,
);
}
assert.ok(
topic
.checklist!.find((item) => item.area === 'Verification')!
.evidence!.includes('tests/nullentropy-box-recovery.test.ts'),
);
assert.ok(
RULE_TOPICS.find(
(candidate) => candidate.id === 'card-richese-nullentropy-box',
)!.related!.includes(topic.id),
);
assert.doesNotMatch(JSON.stringify(topic), /https?:\/\//);
});
+41 -31
View File
@@ -195,38 +195,48 @@ void test('marker-only and mixed physical movement preserve physical conservatio
assert.equal(single.players[0].moved, 1);
conserve(single);
});
void test('No-Field occupancy blocks a third stronghold faction and allied co-occupation', () => {
let g = marker(fixture());
g.players.push(newPlayer('e', 'Emperor', 'emperor'));
g.players[2].forces = { 'arrakeen:10': 1 };
g.players[2].reserves = 19;
g.active = 'h';
assert.throws(
() =>
send(g, 'h', {
type: 'ship',
territory: 'arrakeen',
sector: 10,
amount: 1,
void test('No-Field occupancy blocks a third stronghold faction but admits allied reserve visitors with ending losses', () => {
for (const advanced of [false, true]) {
let g = marker(fixture(advanced));
g.players.push(newPlayer('e', 'Emperor', 'emperor'));
g.players[2].forces = { 'arrakeen:10': 1 };
g.players[2].reserves = 19;
g.active = 'h';
const before = structuredClone(g);
assert.throws(
() => send(g, 'h', {
type: 'ship', territory: 'arrakeen', sector: 10, amount: 1,
}),
/three occupying factions/,
);
g.players.pop();
g.players[0].ally = 'h';
g.players[1].ally = 'r';
g = normalizeAutomaticGame(g);
assert.equal(g.decision?.kind, 'richeseAllyOpportunity');
g = send(g, 'r', { type: 'decision', decline: true });
assert.throws(
() =>
send(g, 'h', {
type: 'ship',
territory: 'arrakeen',
sector: 10,
amount: 1,
}),
/occupied by your ally/,
);
/three occupying factions/,
);
assert.deepEqual(g, before);
g.players.pop();
g.players[0].ally = 'h';
g.players[1].ally = 'r';
g.movementRemaining = ['h', 'r'];
g = normalizeAutomaticGame(g);
assert.equal(g.decision?.kind, 'richeseAllyOpportunity');
g = send(g, 'r', { type: 'decision', decline: true });
// Conserved pre-existing ally counters distinguish Basic visitor losses
// from Advanced separation of the actor's entire shared group.
g.players[1].forces['arrakeen:10'] = 2;
g.players[1].reserves = 18;
g = send(g, 'h', {
type: 'ship', territory: 'arrakeen', sector: 10, amount: 1,
});
assert.equal(g.players[1].forces['arrakeen:10'], 3);
assert.equal(g.players[1].reserves, 17);
assert.equal(g.players[1].spice, 9);
assert.ok(g.players[0].noField!.deployed);
conserve(g);
g = send(g, 'h', { type: 'endMovement' });
assert.equal(g.players[1].forces['arrakeen:10'] ?? 0, advanced ? 0 : 2);
assert.equal(g.players[1].tanks, advanced ? 3 : 1);
assert.equal(g.players[1].reserves, 17);
assert.ok(g.players[0].noField!.deployed);
assert.equal(g.players[0].reserves, 20);
conserve(g);
}
});
void test('marker-only collection treats every secret value as one collector, without reserve transfer', () => {
const target = TERRITORIES.find(
+46 -19
View File
@@ -158,25 +158,52 @@ void test('a real qualifying revival earns Banker independently; No-Field compan
assertAdvancedNativeCustody(continued);
});
void test('original mixed own-marker battle guard survives Skills + Tech; only native voluntary reveal before Battle permits physical combat', () => {
const fixture = createRicheseSkillsTechFixture({ kind: 'shipment', marker: 5 });
let paid = payRicheseSkillsTechShipment(fixture);
const enemy = player(paid, fixture.opponent);
enemy.reserves -= 6; enemy.forces[fixture.key] = 6; // Explicit conserved opponent position, not a played shipment claim.
const battle = advanceRicheseSkillsTechToPhase(paid, 6);
const actor = battle.active!, target = actor === fixture.owner ? fixture.opponent : fixture.owner;
reject(battle, actor, { type: 'chooseBattle', territory: fixture.territory, target });
const own = player(paid, fixture.owner), token = own.noField!.deployed!.tokenId;
reject(battle, fixture.owner, { type: 'revealNoField', token, event: own.noFieldEvent });
paid = applyAction(paid, fixture.owner, { type: 'revealNoField', token, event: own.noFieldEvent });
assert.equal(player(paid, fixture.owner).forces[fixture.key], 6);
assert.equal(player(paid, fixture.owner).reserves, 14);
assert.equal(player(paid, fixture.owner).noField!.deployed, null);
const ready = advanceRicheseSkillsTechToPhase(paid, 6);
const opened = openRicheseSkillsTechBattle({ ...fixture, game: ready });
assert.equal(viewGame(opened, fixture.owner).battle!.ownForces!.normal, 6);
assertAdvancedNativeCustody(opened);
});
for (const advanced of [false, true]) for (const marker of [0, 3, 5] as const)
void test(`${advanced ? 'Advanced' : 'Basic'} mixed Smuggler and No-Field ${marker} battle seals a capped pool before real losses and Tech reward`, () => {
const fixture = createRicheseSkillsTechFixture({ advanced, kind: 'shipment', marker });
const paid = payRicheseSkillsTechShipment(fixture);
const own = player(paid, fixture.owner), enemy = player(paid, fixture.opponent);
// Explicit conserved scarcity and opponent positions, not played shipment claims.
own.forces['polar_sink:0'] = own.reserves - 2; own.reserves = 2;
enemy.reserves -= 6; enemy.forces[fixture.key] = 6;
const ready = advanceRicheseSkillsTechToPhase(paid, 6);
let game = openRicheseSkillsTechBattle({ ...fixture, game: ready });
const materialized = Math.min(marker, 2), pool = 1 + materialized;
assert.equal(viewGame(game, fixture.owner).battle!.mixedNoField, true);
assert.equal(viewGame(game, fixture.owner).battle!.ownForces!.normal, pool);
assert.equal(viewGame(game, fixture.opponent).battle!.mixedNoField, undefined);
assert.equal(viewGame(game, fixture.opponent).battle!.opponentForces, null);
const token = own.noField!.deployed!.tokenId;
reject(game, fixture.owner, { type: 'revealNoField', token, event: own.noFieldEvent });
reject(game, fixture.owner, { ...fixture.ownerPlan, dial: pool + 1, support: advanced ? pool + 1 : 0 });
const dial = Math.min(2, pool);
game = applyAction(game, fixture.owner, { ...fixture.ownerPlan, dial, support: advanced ? dial : 0 });
assert.equal(game.battle!.revealed, false);
assert.ok(player(game, fixture.owner).noField!.deployed);
assert.equal(player(game, fixture.owner).forces[fixture.key], 1);
assert.equal(player(game, fixture.owner).reserves, 2);
game = applyAction(game, fixture.opponent, fixture.opponentPlan);
assert.equal(game.battle!.revealed, true);
assert.equal(player(game, fixture.owner).noField!.deployed, null);
assert.equal(player(game, fixture.owner).forces[fixture.key], pool);
assert.equal(player(game, fixture.owner).reserves, 2 - materialized);
assertAdvancedNativeCustody(game);
game = finishRicheseSkillsTechBattle(calls(game));
const winner = game.lastBattleContext!.winner;
assert.ok(winner === fixture.owner || winner === fixture.opponent);
const ownerWon = winner === fixture.owner;
assert.equal(player(game, fixture.owner).forces[fixture.key] ?? 0, ownerWon ? pool - dial : 0);
assert.equal(player(game, fixture.owner).tanks, ownerWon ? dial : pool);
assert.equal(player(game, fixture.owner).reserves, 2 - materialized);
assert.equal(player(game, fixture.opponent).forces[fixture.key] ?? 0, ownerWon ? 0 : 6);
assert.equal(player(game, fixture.opponent).tanks, ownerWon ? 6 : 0);
const loser = ownerWon ? fixture.opponent : fixture.owner;
const tokenReward = ownedTech(ready.techTokens, loser)[0]; assert.ok(tokenReward);
assert.deepEqual(changedTokens(ready, game), [tokenReward]);
assert.equal(game.techTokens![tokenReward].owner, winner);
assert.deepEqual(cards(game), cards(ready));
assertAdvancedNativeCustody(game);
});
for (const mode of ['kill', 'ignore'] as const) void test(`cache-acquired Stone ${mode} uses real undialed forces and cleanup before awarding the actual winner an original loser-owned Tech Token`, () => {
const fixture = createRicheseSkillsTechFixture({ kind: 'stone' });