Connect stable occupied Homeworld income sharing and native protections
This commit is contained in:
1 parent
84d2ba1d3d
commit
5e902e384d
24 files changed
+1536
-41
No files matched your search
@@ -0,0 +1,118 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction,
|
||||
createGame,
|
||||
initializeHomeworldOccupationGameForAudit,
|
||||
joinGame,
|
||||
newPlayer,
|
||||
viewGame,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import { botActions } from '../game/bots';
|
||||
import { homeworldContext } from '../game/homeworld-game';
|
||||
import { homeworldForceGroups } from '../game/homeworld-custody';
|
||||
import { observeHomeworldOccupation } from '../game/homeworld-occupation-history';
|
||||
|
||||
const sum = (counts: Record<string, number>) => Object.values(counts).reduce((a, b) => a + b, 0);
|
||||
export const defensePlayer = (game: Game, id: string) => game.players.find(p => p.id === id)!;
|
||||
|
||||
/** Original lobby and fresh Homeworld setup, completed through original legal
|
||||
* bot choices. Controlled positions below are fixtures, not natural play. */
|
||||
export function freshDefenseGame(native: FactionId, advanced = true): Game {
|
||||
const expansion = native === 'tleilaxu' ? ['ix'] : native === 'moritani' ? ['ecaz'] : [];
|
||||
let game = createGame('OCCUPIEDDEFENSES', newPlayer('native', native, native), advanced, expansion);
|
||||
for (const [id, faction] of [
|
||||
['occupier', 'guild'], ['ally', 'atreides'], ['opponent', 'harkonnen'], ['fremen', 'fremen'],
|
||||
] as const) joinGame(game, newPlayer(id, id, faction));
|
||||
game = applyAction(game, 'native', { type: 'homeworlds', enabled: true });
|
||||
for (const p of game.players) game = applyAction(game, p.id, { type: 'ready' });
|
||||
game = initializeHomeworldOccupationGameForAudit(game);
|
||||
for (let step = 0; game.status === 'setup' && step < 80; step++) {
|
||||
let progressed = false;
|
||||
for (const p of game.players) {
|
||||
const view = viewGame(game, p.id);
|
||||
view.players.find(player => player.id === p.id)!.bot = 'Easy';
|
||||
const action = botActions(view)[0];
|
||||
if (!action) continue;
|
||||
game = applyAction(game, p.id, action);
|
||||
progressed = true;
|
||||
break;
|
||||
}
|
||||
assert.ok(progressed, 'original fresh setup must have a legal next choice');
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
assert.equal(game.homeworldOccupationPreview, true);
|
||||
assert.ok(game.homeworldOccupationHistory);
|
||||
assert.deepEqual(game.homeworldOccupationHistory.qualifications, []);
|
||||
assertDefenseInventory(game);
|
||||
return game;
|
||||
}
|
||||
|
||||
export function recordDefensePosition(game: Game, label: string, cause: 'change' | 'turnEnd' = 'change') {
|
||||
game.homeworldOccupationHistory = observeHomeworldOccupation(
|
||||
game.homeworldOccupationHistory!, homeworldContext(game), game.homeworlds!.custody!,
|
||||
game.turn, cause, `controlled-defense-${label}-${game.homeworldOccupationHistory!.sources.length}`,
|
||||
);
|
||||
assertDefenseInventory(game);
|
||||
}
|
||||
|
||||
/** Conserved native casualties: each physical normal/starred reserve counter
|
||||
* moves to its own Tanks pile; other worlds and Arrakis counters are untouched. */
|
||||
export function clearDefenseNative(game: Game, world: string) {
|
||||
const home = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!)
|
||||
.find(home => home.id === world)!;
|
||||
const forces = home.forces[home.native];
|
||||
const native = defensePlayer(game, home.native);
|
||||
native.reserves -= forces.normal + forces.elite;
|
||||
native.tanks += forces.normal + forces.elite;
|
||||
if (forces.elite) {
|
||||
native.elites!.reserves -= forces.elite;
|
||||
native.elites!.tanks += forces.elite;
|
||||
}
|
||||
if (home.secondary) game.homeworlds!.custody!.salusa = { normal: 0, elite: 0 };
|
||||
}
|
||||
|
||||
/** Conserved normal reserve-to-visitor transfer; no qualification is fabricated. */
|
||||
export function addDefenseVisitor(game: Game, world: string, id = 'occupier') {
|
||||
defensePlayer(game, id).reserves--;
|
||||
(game.homeworlds!.custody!.visitors[world] ??= {})[id] = { normal: 1, elite: 0 };
|
||||
}
|
||||
|
||||
export function removeDefenseVisitor(game: Game, world: string, id = 'occupier') {
|
||||
const visitors = game.homeworlds!.custody!.visitors[world];
|
||||
const forces = visitors[id];
|
||||
assert.equal(forces.elite, 0);
|
||||
defensePlayer(game, id).reserves += forces.normal;
|
||||
delete visitors[id];
|
||||
if (!Object.keys(visitors).length) delete game.homeworlds!.custody!.visitors[world];
|
||||
}
|
||||
|
||||
export function qualifyDefensePosition(game: Game, world: string) {
|
||||
clearDefenseNative(game, world);
|
||||
addDefenseVisitor(game, world);
|
||||
recordDefensePosition(game, 'conserved-native-casualties-and-sole-arrival');
|
||||
const fact = game.homeworldOccupationHistory!.qualifications.find(fact => fact.world === world)!;
|
||||
assert.equal(fact.player, 'occupier');
|
||||
assert.equal(fact.cause, 'sole');
|
||||
assert.equal(fact.turn, game.turn);
|
||||
}
|
||||
|
||||
export function assertDefenseInventory(game: Game) {
|
||||
const groups = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!);
|
||||
for (const p of game.players) {
|
||||
const visitors = groups.filter(home => home.native !== p.id).map(home => home.forces[p.id]);
|
||||
const away = visitors.reduce((n, group) => n + (group ? group.normal + group.elite : 0), 0);
|
||||
const starredAway = visitors.reduce((n, group) => n + (group?.elite ?? 0), 0);
|
||||
assert.equal(p.reserves + p.tanks + sum(p.forces) + away, 20, `${p.id} physical counters`);
|
||||
if (p.elites) {
|
||||
assert.equal(p.elites.reserves + p.elites.tanks + sum(p.elites.forces) + starredAway,
|
||||
p.faction === 'emperor' ? 5 : p.faction === 'fremen' ? 3 : 7, `${p.id} starred identity`);
|
||||
assert.ok(p.elites.reserves <= p.reserves);
|
||||
assert.ok(p.elites.tanks <= p.tanks);
|
||||
for (const [key, n] of Object.entries(p.elites.forces)) assert.ok(n <= p.forces[key]);
|
||||
}
|
||||
}
|
||||
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand)];
|
||||
assert.equal(new Set(cards.map(card => card.id)).size, cards.length, 'original Treachery IDs are unique');
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeHomeworldOccupationGameForAudit, joinGame,
|
||||
newPlayer, viewGame, type Game,
|
||||
} from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { FACTIONS, type FactionId } from '../game/catalog';
|
||||
import { HOMEWORLD_CARDS, type HomeworldId } from '../game/homeworld-cards';
|
||||
import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
|
||||
import { observeHomeworldOccupation } from '../game/homeworld-occupation-history';
|
||||
|
||||
/** Original selected decks, fresh Homeworld occupation audit setup and real
|
||||
* setup actions. Only the later Collection positions/alliance are controlled
|
||||
* test positions; the original history observes them, not fabricated facts. */
|
||||
export function occupiedIncomeFixture(cardId: HomeworldId = 'caladan', advanced = false, allied = true) {
|
||||
const card = HOMEWORLD_CARDS.find(card => card.id === cardId)!;
|
||||
const nativeFaction = card.faction;
|
||||
const used = [nativeFaction];
|
||||
const choose = (choices: FactionId[]) => {
|
||||
const faction = choices.find(faction => !used.includes(faction))!;
|
||||
used.push(faction);
|
||||
return faction;
|
||||
};
|
||||
const factions = [nativeFaction, choose(['guild', 'atreides']),
|
||||
choose(['harkonnen', 'emperor']), choose(['fremen', 'beneGesserit'])];
|
||||
const expansion = FACTIONS.find(faction => faction.id === nativeFaction)!.expansion;
|
||||
let game = createGame('OCCUPIEDINCOME', newPlayer('native', 'Native', nativeFaction), advanced,
|
||||
expansion === 'base' ? [] : [expansion]);
|
||||
for (const [index, faction] of factions.slice(1).entries())
|
||||
joinGame(game, newPlayer(['owner', 'ally', 'competitor'][index], faction, faction));
|
||||
game = applyAction(game, 'native', { type: 'homeworlds', enabled: true });
|
||||
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
|
||||
game = initializeHomeworldOccupationGameForAudit(game);
|
||||
for (let step = 0; game.status === 'setup' && step < 120; step++) {
|
||||
let next: Game | undefined;
|
||||
for (const player of game.players) {
|
||||
const view = viewGame(game, player.id);
|
||||
view.players.find(seat => seat.id === player.id)!.bot = 'Easy';
|
||||
const action = botActions(view)[0];
|
||||
if (action) {
|
||||
next = applyAction(game, player.id, action);
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert.ok(next, 'Original setup must make progress.');
|
||||
game = next;
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
const setup = structuredClone(game);
|
||||
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand)].map(card => card.id).sort();
|
||||
// Controlled, conserved placement. Do not discard/deal or reset card IDs.
|
||||
for (const player of game.players) {
|
||||
Object.assign(player, { forces: {}, reserves: 20, tanks: 0, ally: null });
|
||||
if (player.elites) Object.assign(player.elites, {
|
||||
reserves: player.faction === 'fremen' ? 3 : player.faction === 'ixians' ? 7 : 5,
|
||||
tanks: 0, forces: {},
|
||||
});
|
||||
}
|
||||
const custody = game.homeworlds!.custody!;
|
||||
custody.visitors = {};
|
||||
if (advanced && game.players.some(player => player.faction === 'emperor'))
|
||||
custody.salusa = { normal: 0, elite: 5 };
|
||||
const native = game.players.find(player => player.id === 'native')!;
|
||||
native.reserves = 0;
|
||||
native.tanks = 20;
|
||||
if (native.elites) {
|
||||
native.elites.tanks = native.elites.reserves;
|
||||
native.elites.reserves = 0;
|
||||
}
|
||||
if (nativeFaction === 'emperor' && advanced) custody.salusa = { normal: 0, elite: 0 };
|
||||
const owner = game.players.find(player => player.id === 'owner')!;
|
||||
if (allied) {
|
||||
owner.ally = 'ally';
|
||||
game.players.find(player => player.id === 'ally')!.ally = owner.id;
|
||||
}
|
||||
const world = `homeworld:${nativeFaction}${cardId === 'salusa_secundus' ? ':salusa' : ''}`;
|
||||
custody.visitors[world] = { owner: { normal: 1, elite: 0 } };
|
||||
owner.reserves--;
|
||||
observeControlledIncomePosition(game, `controlled-${cardId}-sole`);
|
||||
homeworldGameIntegrity(game);
|
||||
assert.deepEqual([...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand)].map(card => card.id).sort(), cards);
|
||||
return { game, world, cards, setup };
|
||||
}
|
||||
|
||||
export function observeControlledIncomePosition(game: Game, event: string): void {
|
||||
game.homeworldOccupationHistory = observeHomeworldOccupation(game.homeworldOccupationHistory!,
|
||||
homeworldContext(game), game.homeworlds!.custody!, game.turn, 'change', event);
|
||||
homeworldGameIntegrity(game);
|
||||
}
|
||||
|
||||
/** Real original phases/actions into Movement; only the conserved position is
|
||||
* controlled. Never jump the phase clock to obtain a Collection decision. */
|
||||
export function occupiedIncomeMovementFixture(cardId: HomeworldId = 'caladan', advanced = false, allied = true) {
|
||||
const fixture = occupiedIncomeFixture(cardId, advanced, allied);
|
||||
let game = fixture.setup;
|
||||
for (let step = 0; !(game.phase === 5 && !game.response && !game.decision && !game.phaseOpening) && step < 500; step++) {
|
||||
let next: Game | undefined;
|
||||
for (const player of game.players) {
|
||||
const view = viewGame(game, player.id);
|
||||
view.players.find(seat => seat.id === player.id)!.bot = 'Easy';
|
||||
const action = botActions(view)[0];
|
||||
if (!action) continue;
|
||||
if (action.type === 'stormDial') action.amount = 0;
|
||||
next = applyAction(game, player.id, action);
|
||||
break;
|
||||
}
|
||||
assert.ok(next, 'Original phases must reach the controlled Movement boundary.');
|
||||
game = next;
|
||||
}
|
||||
assert.equal(game.phase, 5);
|
||||
for (const player of game.players) {
|
||||
assert.equal(player.tanks, 0, 'The position cannot resurrect previously lost counters.');
|
||||
const position = fixture.game.players.find(seat => seat.id === player.id)!;
|
||||
player.forces = structuredClone(position.forces);
|
||||
player.reserves = position.reserves;
|
||||
player.tanks = position.tanks;
|
||||
player.ally = position.ally;
|
||||
if (player.elites) player.elites = structuredClone(position.elites!);
|
||||
}
|
||||
game.homeworlds!.custody = structuredClone(fixture.game.homeworlds!.custody!);
|
||||
observeControlledIncomePosition(game, `controlled-${cardId}-real-movement-boundary`);
|
||||
return { ...fixture, game };
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { occupiedIncomeMovementFixture } from './fixture-homeworld-occupied-income';
|
||||
|
||||
function collectionDecision(initial: Game): Game {
|
||||
let game = initial;
|
||||
for (let n = 0; game.decision?.kind !== 'homeworldOccupiedIncome' && n < 100; n++) {
|
||||
if (game.phase === 5 && !game.decision && !game.response && !game.phaseOpening) {
|
||||
assert.ok(game.active);
|
||||
game = applyAction(game, game.active, { type: 'endMovement' });
|
||||
continue;
|
||||
}
|
||||
let next: Game | undefined;
|
||||
for (const player of game.players) {
|
||||
const view = viewGame(game, player.id);
|
||||
view.players.find(seat => seat.id === player.id)!.bot = 'Easy';
|
||||
const action = botActions(view)[0];
|
||||
if (action) { next = applyAction(game, player.id, action); break; }
|
||||
}
|
||||
assert.ok(next, 'Original phase/response actions must reach the bank choice.');
|
||||
game = next;
|
||||
}
|
||||
assert.equal(game.phase, 7);
|
||||
assert.equal(game.decision?.kind, 'homeworldOccupiedIncome');
|
||||
return game;
|
||||
}
|
||||
|
||||
void test('original Movement-to-Collection dispatch preserves a JSON partial bank split and one real Giedi bonus', () => {
|
||||
const fixture = occupiedIncomeMovementFixture('caladan', false, true);
|
||||
const before = Object.fromEntries(fixture.game.players.map(p => [p.id, p.spice]));
|
||||
let game = collectionDecision(fixture.game);
|
||||
const offer = viewGame(game, 'owner').homeworldOccupiedIncome!;
|
||||
const action = { type: 'decision', event: offer.event, world: offer.world, ownAmount: 1 };
|
||||
const waiting = structuredClone(game);
|
||||
for (const rejected of [
|
||||
{ actor: 'ally', action },
|
||||
{ actor: 'owner', action: { ...action, event: `${offer.event}-stale` } },
|
||||
{ actor: 'owner', action: { ...action, ownAmount: 3 } },
|
||||
]) {
|
||||
assert.throws(() => applyAction(game, rejected.actor, rejected.action));
|
||||
assert.deepEqual(game, waiting);
|
||||
}
|
||||
game = applyAction(JSON.parse(JSON.stringify(game)) as Game, 'owner', action);
|
||||
assert.equal(game.players.find(p => p.id === 'owner')!.spice - before.owner, 1);
|
||||
assert.equal(game.players.find(p => p.id === 'ally')!.spice - before.ally, 3);
|
||||
assert.equal(game.giediCollection!.qualifying, 1);
|
||||
assert.equal(game.giediCollection!.awarded, true);
|
||||
assert.equal(game.pendingHomeworldOccupiedIncome, null);
|
||||
assert.equal(game.decision, null);
|
||||
const settled = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, 'owner', action));
|
||||
assert.deepEqual(game, settled, 'A replay cannot credit either wallet or Giedi again.');
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
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}: departed, contested, competing, restored-high and expired evidence propagate the shared blocker without guessing`, () => {
|
||||
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);
|
||||
assert.notEqual(actual.blocked, null, 'an ambiguous beneficiary cannot authorize the native effect');
|
||||
if (effect !== 'sardaukar') assert.equal(actual.status, 'unresolved');
|
||||
if (effect === 'sardaukar') assert.equal(actual.suppressed, false, 'blocked is not selected strength');
|
||||
assert.deepEqual(game, before);
|
||||
const saved: Game = JSON.parse(JSON.stringify(game));
|
||||
assert.deepEqual(rule.quote(saved), actual, 'original JSON history retains ambiguity');
|
||||
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');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { Game } from '../game/engine';
|
||||
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 {
|
||||
beginHomeworldOccupiedIncome, homeworldOccupiedIncomeOffer, quoteHomeworldOccupiedIncomeChoice,
|
||||
type HomeworldOccupiedIncomeState,
|
||||
} from '../game/homeworld-occupied-income';
|
||||
import { occupiedIncomeFixture, observeControlledIncomePosition } from './fixture-homeworld-occupied-income';
|
||||
|
||||
const printed: Record<HomeworldId, number> = {
|
||||
caladan: 2, giedi_prime: 2, southern_hemisphere: 2, junction: 2, wallach_ix: 1,
|
||||
kaitain: 2, salusa_secundus: 0, ix: 2, tleilax: 2, tupile: 2, richese: 1, ecaz: 2, grumman: 2,
|
||||
};
|
||||
|
||||
/** Exercise the returned BANK credit legs against real setup wallets. This is
|
||||
* the engine consumer contract, not personal-wallet forwarding or bribery. */
|
||||
function settle(game: Game, state: HomeworldOccupiedIncomeState, ownAmount: number) {
|
||||
const row = state.queue[state.cursor];
|
||||
const before = structuredClone({ game, state });
|
||||
const result = quoteHomeworldOccupiedIncomeChoice(state, game, row.occupier, state.event, row.world, ownAmount);
|
||||
assert.deepEqual({ game, state }, before, 'quoting never mutates wallets or the pending receipt');
|
||||
for (const credit of result.credits) game.players.find(player => player.id === credit.player)!.spice += credit.amount;
|
||||
return result;
|
||||
}
|
||||
|
||||
void test('printed BANK grants preserve all thirteen faces and Basic/Advanced sums, independent of percentage text', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
let total = 0;
|
||||
for (const card of HOMEWORLD_CARDS) {
|
||||
if (!advanced && card.id === 'salusa_secundus') continue;
|
||||
const { game, world, cards } = occupiedIncomeFixture(card.id, advanced, false);
|
||||
const before = structuredClone(game);
|
||||
const state = beginHomeworldOccupiedIncome(game, `collection-${card.id}`);
|
||||
assert.deepEqual(game, before, 'Collection entry does not mint spice');
|
||||
assert.equal(state.queue.length, 1);
|
||||
assert.equal(state.queue[0].world, world);
|
||||
assert.equal(state.queue[0].card, card.id);
|
||||
assert.equal(state.queue[0].spice, printed[card.id]);
|
||||
const owner = game.players.find(player => player.id === 'owner')!;
|
||||
const balance = owner.spice;
|
||||
const result = settle(game, state, printed[card.id]);
|
||||
assert.deepEqual(result.credits, printed[card.id] ? [{ player: 'owner', amount: printed[card.id] }] : []);
|
||||
assert.equal(owner.spice - balance, printed[card.id]);
|
||||
assert.equal(result.state.receipts[0].status, 'settled');
|
||||
assert.equal(homeworldOccupiedIncomeOffer(result.state, game, 'owner'), null);
|
||||
assert.deepEqual([...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand)].map(card => card.id).sort(), cards);
|
||||
homeworldGameIntegrity(game);
|
||||
total += owner.spice - balance;
|
||||
}
|
||||
assert.equal(total, 22);
|
||||
}
|
||||
});
|
||||
|
||||
void test('full, partial and zero own splits credit the named reciprocal ally directly from the Bank and conserve total', () => {
|
||||
for (const ownAmount of [2, 1, 0]) {
|
||||
const { game } = occupiedIncomeFixture();
|
||||
const state = beginHomeworldOccupiedIncome(game, 'collection-split');
|
||||
const owner = game.players.find(player => player.id === 'owner')!;
|
||||
const ally = game.players.find(player => player.id === 'ally')!;
|
||||
owner.spice = 0;
|
||||
const ownerBefore = owner.spice, allyBefore = ally.spice;
|
||||
const offer = homeworldOccupiedIncomeOffer(state, game, owner.id)!;
|
||||
assert.equal(offer.ally, ally.id);
|
||||
assert.equal(offer.minOwnAmount, 0);
|
||||
assert.equal(offer.maxOwnAmount, 2);
|
||||
const result = settle(game, state, ownAmount);
|
||||
assert.equal(owner.spice - ownerBefore, ownAmount);
|
||||
assert.equal(ally.spice - allyBefore, 2 - ownAmount);
|
||||
assert.equal(result.credits.reduce((sum, credit) => sum + credit.amount, 0), 2);
|
||||
assert.deepEqual(result.state.receipts[0], { world: state.queue[0].world, status: 'settled', ownAmount, credits: result.credits });
|
||||
}
|
||||
});
|
||||
|
||||
void test('no ally permits only the full printed award; malformed amounts and actions reject without changing anything', () => {
|
||||
const { game, world } = occupiedIncomeFixture('caladan', false, false);
|
||||
const state = beginHomeworldOccupiedIncome(game, 'collection-bounds');
|
||||
const offer = homeworldOccupiedIncomeOffer(state, game, 'owner')!;
|
||||
assert.equal(offer.ally, null);
|
||||
assert.equal(offer.minOwnAmount, 2);
|
||||
assert.equal(homeworldOccupiedIncomeOffer(state, game, 'native'), null);
|
||||
const before = structuredClone({ game, state });
|
||||
for (const amount of [0, 1, -1, 3, 0.5, NaN, Infinity])
|
||||
assert.throws(() => quoteHomeworldOccupiedIncomeChoice(state, game, 'owner', state.event, world, amount), HomeworldCustodyError);
|
||||
for (const [actor, event, target] of [
|
||||
['ally', state.event, world], ['native', state.event, world], ['owner', 'stale-event', world],
|
||||
['owner', state.event, 'homeworld:guild'],
|
||||
]) assert.throws(() => quoteHomeworldOccupiedIncomeChoice(state, game, actor, event, target, 2), HomeworldCustodyError);
|
||||
assert.deepEqual({ game, state }, before);
|
||||
assert.deepEqual(settle(game, state, 2).credits, [{ player: 'owner', amount: 2 }]);
|
||||
});
|
||||
|
||||
void test('allied integer bounds, nonreciprocal alliance and alliance changes cannot reroute a frozen receipt', () => {
|
||||
const { game, world } = occupiedIncomeFixture();
|
||||
const state = beginHomeworldOccupiedIncome(game, 'collection-reciprocal');
|
||||
for (const amount of [-1, 3, 1.5, NaN])
|
||||
assert.throws(() => quoteHomeworldOccupiedIncomeChoice(state, game, 'owner', state.event, world, amount), HomeworldCustodyError);
|
||||
game.players.find(player => player.id === 'ally')!.ally = null;
|
||||
assert.notEqual(homeworldOccupiedIncomeOffer(state, game, 'owner')!.blocked, null);
|
||||
assert.throws(() => quoteHomeworldOccupiedIncomeChoice(state, game, 'owner', state.event, world, 2), HomeworldCustodyError);
|
||||
const unreciprocated = beginHomeworldOccupiedIncome(game, 'collection-nonreciprocal');
|
||||
assert.equal(unreciprocated.queue[0].ally, null);
|
||||
assert.throws(() => quoteHomeworldOccupiedIncomeChoice(unreciprocated, game, 'owner', unreciprocated.event, world, 0), HomeworldCustodyError);
|
||||
});
|
||||
|
||||
void test('Emperor worlds stay distinct; JSON continuation retains exact settled and pending receipts including zero Salusa', () => {
|
||||
const { game, world } = occupiedIncomeFixture('kaitain', true);
|
||||
const owner = game.players.find(player => player.id === 'owner')!;
|
||||
const secondary = 'homeworld:emperor:salusa';
|
||||
game.homeworlds!.custody!.visitors[secondary] = { owner: { normal: 1, elite: 0 } };
|
||||
owner.reserves--;
|
||||
observeControlledIncomePosition(game, 'controlled-separate-salusa');
|
||||
const state = beginHomeworldOccupiedIncome(game, 'collection-emperor');
|
||||
assert.deepEqual(state.queue.map(row => [row.world, row.card, row.spice]), [[world, 'kaitain', 2], [secondary, 'salusa_secundus', 0]]);
|
||||
const first = settle(game, state, 1);
|
||||
const resumedGame: Game = JSON.parse(JSON.stringify(game));
|
||||
const resumed: HomeworldOccupiedIncomeState = JSON.parse(JSON.stringify(first.state));
|
||||
assert.deepEqual(resumed.receipts, [
|
||||
{ world, status: 'settled', ownAmount: 1, credits: [{ player: 'owner', amount: 1 }, { player: 'ally', amount: 1 }] },
|
||||
{ world: secondary, status: 'pending' },
|
||||
]);
|
||||
assert.throws(() => quoteHomeworldOccupiedIncomeChoice(resumed, resumedGame, 'owner', resumed.event, world, 1), HomeworldCustodyError);
|
||||
assert.equal(homeworldOccupiedIncomeOffer(resumed, resumedGame, 'owner')!.amount, 0);
|
||||
const done = settle(resumedGame, resumed, 0);
|
||||
assert.deepEqual(done.credits, []);
|
||||
assert.deepEqual(done.state.receipts[0], first.state.receipts[0]);
|
||||
assert.deepEqual(done.state.receipts[1], { world: secondary, status: 'settled', ownAmount: 0, credits: [] });
|
||||
assert.throws(() => quoteHomeworldOccupiedIncomeChoice(done.state, resumedGame, 'owner', resumed.event, secondary, 0), HomeworldCustodyError);
|
||||
const corrupted: HomeworldOccupiedIncomeState = JSON.parse(JSON.stringify(first.state));
|
||||
corrupted.receipts[0] = { world, status: 'settled', ownAmount: 2, credits: [{ player: 'owner', amount: 2 }] };
|
||||
assert.throws(() => homeworldOccupiedIncomeOffer(corrupted, game, 'owner'), HomeworldCustodyError);
|
||||
resumedGame.turn++;
|
||||
assert.throws(() => homeworldOccupiedIncomeOffer(resumed, resumedGame, 'owner'), HomeworldCustodyError);
|
||||
});
|
||||
|
||||
void test('preview disabled never provides bank entitlement; repeated offer reads cannot pay or advance a receipt', () => {
|
||||
const { game } = occupiedIncomeFixture();
|
||||
const state = beginHomeworldOccupiedIncome(game, 'collection-read');
|
||||
const before = structuredClone({ game, state });
|
||||
for (let i = 0; i < 5; i++) assert.equal(homeworldOccupiedIncomeOffer(state, game, 'owner')!.amount, 2);
|
||||
assert.deepEqual({ game, state }, before);
|
||||
delete game.homeworldOccupationPreview;
|
||||
assert.equal(beginHomeworldOccupiedIncome(game, 'collection-disabled').queue.length, 0);
|
||||
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', () => {
|
||||
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');
|
||||
game.homeworlds!.custody!.visitors[world] = { owner: { normal: 1, elite: 0 } };
|
||||
owner.reserves--;
|
||||
observeControlledIncomePosition(game, 'controlled-return');
|
||||
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.deepEqual({ game, state }, before);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const { game, world } = occupiedIncomeFixture();
|
||||
const state = beginHomeworldOccupiedIncome(game, 'collection-native-restored');
|
||||
const qualifications = structuredClone(game.homeworldOccupationHistory!.qualifications);
|
||||
const native = game.players.find(player => player.id === 'native')!;
|
||||
native.reserves = 6;
|
||||
native.tanks -= 6;
|
||||
observeControlledIncomePosition(game, 'controlled-native-repopulation');
|
||||
assert.deepEqual(game.homeworldOccupationHistory!.qualifications, qualifications);
|
||||
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('zero-icon contested Salusa cannot block a real Kaitain bank award or choose a Sardaukar strength', () => {
|
||||
const { game } = occupiedIncomeFixture('kaitain', true, false);
|
||||
const secondary = 'homeworld:emperor:salusa';
|
||||
const owner = game.players.find(p => p.id === 'owner')!;
|
||||
owner.reserves--;
|
||||
game.homeworlds!.custody!.visitors[secondary] = { owner: { normal: 1, elite: 0 } };
|
||||
observeControlledIncomePosition(game, 'controlled-secondary-sole-arrival');
|
||||
game.players.find(p => p.id === 'competitor')!.reserves--;
|
||||
game.homeworlds!.custody!.visitors[secondary].competitor = { normal: 1, elite: 0 };
|
||||
observeControlledIncomePosition(game, 'controlled-secondary-contested');
|
||||
assert.notEqual(occupiedHomeworldSardaukarStatus(game).blocked, null);
|
||||
const before = owner.spice;
|
||||
const pending = beginHomeworldOccupiedIncome(game, 'collection-positive-primary-zero-secondary');
|
||||
const result = settle(game, pending, 2);
|
||||
assert.equal(owner.spice - before, 2);
|
||||
assert.equal(result.state.cursor, result.state.queue.length);
|
||||
assert.equal(occupiedHomeworldSardaukarStatus(game).suppressed, false);
|
||||
assert.notEqual(occupiedHomeworldSardaukarStatus(game).blocked, null,
|
||||
'Collection does not guess the still-unresolved mandatory combat penalty.');
|
||||
});
|
||||
Reference in new issue
Block a user