Connect provisional Basic current-sole Homeworld benefits and repair stale rule guards
This commit is contained in:
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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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?:\/\//);
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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' });
|
||||
|
||||
Reference in new issue
Block a user