201 lines
10 KiB
TypeScript
201 lines
10 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import type { Game } from '../game/engine';
|
|
import type { FactionId } from '../game/catalog';
|
|
import type { HomeworldId } from '../game/homeworld-cards';
|
|
import { homeworldContext } from '../game/homeworld-game';
|
|
import { homeworldForceGroups } from '../game/homeworld-custody';
|
|
import {
|
|
quoteOccupiedHomeworldVoice,
|
|
quoteOccupiedHomeworldFaceDancer,
|
|
quoteOccupiedHomeworldTerror,
|
|
type OccupiedHomeworldDefenseQuote,
|
|
occupiedHomeworldSardaukarStatus,
|
|
} from '../game/homeworld-occupied-defenses';
|
|
import {
|
|
addDefenseVisitor,
|
|
assertDefenseInventory,
|
|
clearDefenseNative,
|
|
defensePlayer,
|
|
freshDefenseGame,
|
|
qualifyDefensePosition,
|
|
recordDefensePosition,
|
|
removeDefenseVisitor,
|
|
} from './fixture-homeworld-occupied-defenses';
|
|
|
|
const targeted: Record<'voice' | 'faceDancer' | 'terror', {
|
|
native: FactionId;
|
|
card: HomeworldId;
|
|
world: string;
|
|
quote: (game: Game, target: string) => OccupiedHomeworldDefenseQuote;
|
|
}> = {
|
|
voice: { native: 'beneGesserit', card: 'wallach_ix', world: 'homeworld:beneGesserit',
|
|
quote: quoteOccupiedHomeworldVoice },
|
|
faceDancer: { native: 'tleilaxu', card: 'tleilax', world: 'homeworld:tleilaxu',
|
|
quote: quoteOccupiedHomeworldFaceDancer },
|
|
terror: { native: 'moritani', card: 'grumman', world: 'homeworld:moritani',
|
|
quote: quoteOccupiedHomeworldTerror },
|
|
};
|
|
|
|
for (const [effect, rule] of Object.entries(targeted)) {
|
|
void test(`${effect}: original fresh setup, conserved sole qualification, exact occupier and reciprocal-ally scope`, () => {
|
|
const game = freshDefenseGame(rule.native);
|
|
assert.equal(rule.quote(game, 'occupier').status, 'allowed', 'fresh unoccupied card grants no immunity');
|
|
qualifyDefensePosition(game, rule.world);
|
|
const qualified = structuredClone(game);
|
|
assert.equal(rule.quote(game, 'occupier').status, 'prohibited');
|
|
assert.equal(rule.quote(game, 'ally').status, 'allowed', 'an unallied player is not protected');
|
|
assert.equal(rule.quote(game, 'opponent').status, 'allowed');
|
|
assert.equal(rule.quote(game, 'native').status, 'allowed', 'the benefit does not protect the native');
|
|
assert.deepEqual(game, qualified, 'quoting changes no physical counters, cards or history');
|
|
|
|
defensePlayer(game, 'occupier').ally = 'ally';
|
|
assert.equal(rule.quote(game, 'ally').status, 'allowed', 'one-way alliance gives no entitlement');
|
|
defensePlayer(game, 'ally').ally = 'occupier';
|
|
assert.equal(rule.quote(game, 'ally').status, 'prohibited');
|
|
assert.equal(rule.quote(game, 'opponent').status, 'allowed');
|
|
defensePlayer(game, 'occupier').ally = 'opponent';
|
|
assert.equal(rule.quote(game, 'ally').status, 'allowed', 'former reciprocal ally loses protection immediately');
|
|
assert.equal(rule.quote(game, 'opponent').status, 'allowed', 'new alliance must also be reciprocal');
|
|
defensePlayer(game, 'opponent').ally = 'occupier';
|
|
assert.equal(rule.quote(game, 'opponent').status, 'prohibited');
|
|
assert.equal(rule.quote(game, 'occupier').status, 'prohibited');
|
|
|
|
const saved: Game = JSON.parse(JSON.stringify(game));
|
|
const before = structuredClone(saved);
|
|
assert.equal(rule.quote(saved, 'opponent').status, 'prohibited');
|
|
assert.equal(rule.quote(saved, 'ally').status, 'allowed');
|
|
assert.deepEqual(saved, before, 'JSON history supplies the same pure quote');
|
|
assertDefenseInventory(saved);
|
|
delete saved.homeworldOccupationPreview;
|
|
assert.equal(rule.quote(saved, 'occupier').status, 'allowed', 'disabled preview preserves original behavior');
|
|
assert.equal(rule.quote(saved, 'opponent').status, 'allowed');
|
|
});
|
|
}
|
|
|
|
void test('Advanced occupied Salusa suppresses advantage while every original starred counter retains its typed custody', () => {
|
|
const game = freshDefenseGame('emperor');
|
|
const emperor = defensePlayer(game, 'native');
|
|
// Controlled conserved deployment: one actual Sardaukar leaves Salusa for
|
|
// Arrakis, where losing its advantage must not change its physical identity.
|
|
emperor.reserves--;
|
|
emperor.elites!.reserves--;
|
|
game.homeworlds!.custody!.salusa!.elite--;
|
|
emperor.forces['arrakeen:10'] = (emperor.forces['arrakeen:10'] ?? 0) + 1;
|
|
emperor.elites!.forces['arrakeen:10'] = (emperor.elites!.forces['arrakeen:10'] ?? 0) + 1;
|
|
recordDefensePosition(game, 'one-starred-counter-deployed-to-arrakis');
|
|
qualifyDefensePosition(game, 'homeworld:emperor:salusa');
|
|
const before = structuredClone(game);
|
|
assert.deepEqual(occupiedHomeworldSardaukarStatus(game), { suppressed: true, blocked: null });
|
|
assert.equal(emperor.elites!.forces['arrakeen:10'], 1);
|
|
assert.equal(emperor.elites!.tanks, 4);
|
|
assert.equal(emperor.elites!.reserves, 0);
|
|
assert.deepEqual(game, before, 'suppression is a rule quote, not counter conversion');
|
|
assertDefenseInventory(game);
|
|
const saved: Game = JSON.parse(JSON.stringify(game));
|
|
assert.deepEqual(occupiedHomeworldSardaukarStatus(saved), { suppressed: true, blocked: null });
|
|
delete saved.homeworldOccupationPreview;
|
|
assert.deepEqual(occupiedHomeworldSardaukarStatus(saved), { suppressed: false, blocked: null });
|
|
});
|
|
|
|
void test('low but unoccupied Advanced Salusa and occupied primary Kaitain do not suppress Sardaukar', () => {
|
|
const game = freshDefenseGame('emperor');
|
|
clearDefenseNative(game, 'homeworld:emperor:salusa');
|
|
recordDefensePosition(game, 'conserved-empty-unoccupied-salusa');
|
|
const before = structuredClone(game);
|
|
assert.deepEqual(occupiedHomeworldSardaukarStatus(game), { suppressed: false, blocked: null });
|
|
assert.deepEqual(game, before);
|
|
qualifyDefensePosition(game, 'homeworld:emperor');
|
|
assert.deepEqual(occupiedHomeworldSardaukarStatus(game), { suppressed: false, blocked: null },
|
|
'Kaitain is not Salusa even though both worlds belong to Emperor');
|
|
assertDefenseInventory(game);
|
|
});
|
|
|
|
void test('Basic has no Salusa: occupied Kaitain does not remove original Basic starred identities', () => {
|
|
const game = freshDefenseGame('emperor', false);
|
|
const emperor = defensePlayer(game, 'native');
|
|
assert.equal(game.homeworlds!.custody!.salusa, null);
|
|
assert.equal(homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!)
|
|
.some(home => home.secondary), false);
|
|
assert.equal(emperor.elites!.reserves, 5);
|
|
assert.equal(defensePlayer(game, 'fremen').elites!.reserves +
|
|
Object.values(defensePlayer(game, 'fremen').elites!.forces).reduce((a, b) => a + b, 0), 3);
|
|
qualifyDefensePosition(game, 'homeworld:emperor');
|
|
const before = structuredClone(game);
|
|
assert.deepEqual(occupiedHomeworldSardaukarStatus(game), { suppressed: false, blocked: null });
|
|
assert.equal(emperor.elites!.tanks, 5, 'Basic Homeworld Sardaukar retain physical starred identity');
|
|
assert.deepEqual(game, before);
|
|
assertDefenseInventory(game);
|
|
});
|
|
|
|
const ambiguityRules: Record<string, {
|
|
native: FactionId;
|
|
card: HomeworldId;
|
|
world: string;
|
|
high: number;
|
|
quote: (game: Game) => { blocked: string | null; suppressed?: boolean; status?: OccupiedHomeworldDefenseQuote['status'] };
|
|
}> = {
|
|
voice: { native: 'beneGesserit', card: 'wallach_ix', world: 'homeworld:beneGesserit', high: 11,
|
|
quote: game => quoteOccupiedHomeworldVoice(game, 'opponent') },
|
|
faceDancer: { native: 'tleilaxu', card: 'tleilax', world: 'homeworld:tleilaxu', high: 9,
|
|
quote: game => quoteOccupiedHomeworldFaceDancer(game, 'opponent') },
|
|
terror: { native: 'moritani', card: 'grumman', world: 'homeworld:moritani', high: 8,
|
|
quote: game => quoteOccupiedHomeworldTerror(game, 'opponent') },
|
|
sardaukar: { native: 'emperor', card: 'salusa_secundus', world: 'homeworld:emperor:salusa', high: 2,
|
|
quote: occupiedHomeworldSardaukarStatus },
|
|
};
|
|
|
|
for (const [effect, rule] of Object.entries(ambiguityRules)) {
|
|
void test(`${effect}: Advanced retains the original occupier until departure and admits a newly sole replacement`, () => {
|
|
const qualified = freshDefenseGame(rule.native);
|
|
qualifyDefensePosition(qualified, rule.world);
|
|
for (const mode of ['departed', 'contested', 'competing', 'restored-high', 'expired'] as const) {
|
|
const game = structuredClone(qualified);
|
|
if (mode === 'departed' || mode === 'competing') {
|
|
removeDefenseVisitor(game, rule.world);
|
|
recordDefensePosition(game, 'qualifier-departed');
|
|
if (mode === 'competing') {
|
|
addDefenseVisitor(game, rule.world, 'opponent');
|
|
recordDefensePosition(game, 'different-conserved-sole-arrival');
|
|
} else {
|
|
addDefenseVisitor(game, rule.world);
|
|
recordDefensePosition(game, 'qualifier-returned-after-departure');
|
|
}
|
|
} else if (mode === 'contested') {
|
|
addDefenseVisitor(game, rule.world, 'opponent');
|
|
recordDefensePosition(game, 'second-foreign-army-contests');
|
|
removeDefenseVisitor(game, rule.world, 'opponent');
|
|
recordDefensePosition(game, 'contest-ended-but-ruling-still-pending');
|
|
} else if (mode === 'restored-high') {
|
|
const native = defensePlayer(game, 'native');
|
|
native.tanks -= rule.high;
|
|
native.reserves += rule.high;
|
|
if (rule.card === 'salusa_secundus') {
|
|
native.elites!.tanks -= rule.high;
|
|
native.elites!.reserves += rule.high;
|
|
game.homeworlds!.custody!.salusa!.elite += rule.high;
|
|
}
|
|
recordDefensePosition(game, 'conserved-native-high-threshold-restored');
|
|
clearDefenseNative(game, rule.world);
|
|
recordDefensePosition(game, 'native-high-later-lost-without-invented-expiry-policy');
|
|
} else {
|
|
game.turn++;
|
|
// Controlled incomplete boundary: no new-turn source may be fabricated.
|
|
}
|
|
const before = structuredClone(game);
|
|
const actual = rule.quote(game);
|
|
if (effect !== 'sardaukar' && mode === 'competing') assert.notEqual(actual.blocked, null);
|
|
else assert.equal(actual.blocked, null);
|
|
if (effect !== 'sardaukar') assert.equal(actual.status, mode === 'competing' ? 'prohibited' : 'allowed');
|
|
if (effect === 'sardaukar') assert.equal(actual.suppressed, true);
|
|
assert.deepEqual(game, before);
|
|
const saved: Game = JSON.parse(JSON.stringify(game));
|
|
assert.deepEqual(rule.quote(saved), actual, 'original JSON history retains the Advanced beneficiary');
|
|
assertDefenseInventory(saved);
|
|
delete saved.homeworldOccupationPreview;
|
|
assert.deepEqual(rule.quote(saved), effect === 'sardaukar'
|
|
? { suppressed: false, blocked: null } : { status: 'allowed', blocked: null }, 'preview-off does not block old rules');
|
|
}
|
|
});
|
|
}
|