Files
Dune/tests/stronghold-cards.test.ts
2026-09-08 07:37:52 +10:00

297 lines
11 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { MOBILE_STRONGHOLD, territory } from '../game/board';
import {
STRONGHOLD_CARDS,
createStrongholdCards,
ownedStrongholdCards,
strongholdBenefit,
strongholdControllers,
settleStrongholdCards,
strongholdSupportCost,
strongholdBattleIncome,
type StrongholdId,
type StrongholdState,
} from '../game/stronghold-cards';
type Player = Parameters<typeof strongholdControllers>[0][number];
const key = (id: StrongholdId) => `${id}:${territory(id).sectors[0]}`;
const player = (
id: string,
faction: Player['faction'],
forces: Record<string, number> = {},
): Player => ({ id, faction, ally: null, forces });
const stateWith = (...entries: [StrongholdId, string][]) => {
const state = createStrongholdCards();
for (const [id, owner] of entries) state.owners[id] = owner;
state.claimedTurn = 1;
return state;
};
void test('six verified Stronghold Cards have canonical board identities and start set aside', () => {
assert.deepEqual(
STRONGHOLD_CARDS.map((c) => c.id).sort(),
[
'arrakeen',
'carthag',
'habbanya_ridge_sietch',
'sietch_tabr',
'tueks_sietch',
MOBILE_STRONGHOLD,
].sort(),
);
for (const card of STRONGHOLD_CARDS) {
assert.ok(territory(card.id));
assert.ok(card.name && card.summary && card.gameplay.length > 0);
assert.ok(card.gameplay.every((text) => text.length > 0));
}
const a = createStrongholdCards(),
b = createStrongholdCards();
assert.deepEqual(a, {
claimedTurn: 0,
owners: Object.fromEntries(STRONGHOLD_CARDS.map((c) => [c.id, null])),
});
a.owners.arrakeen = 'p';
assert.equal(b.owners.arrakeen, null);
assert.deepEqual(ownedStrongholdCards(b, 'p'), []);
});
void test('ownership and benefit depend on recorded card custody rather than changing live occupation', () => {
const state = stateWith(['arrakeen', 'p'], ['carthag', 'q']);
const before = structuredClone(state);
assert.deepEqual(
ownedStrongholdCards(state, 'p').map((c) => c.id),
['arrakeen'],
);
assert.equal(strongholdBenefit(state, 'p', 'arrakeen'), 'arrakeen');
assert.equal(strongholdBenefit(state, 'p', 'carthag'), null);
assert.equal(strongholdBenefit(state, 'q', 'arrakeen'), null);
assert.equal(strongholdBenefit(state, 'p', 'polar_sink'), null);
assert.equal(strongholdBenefit(null, 'p', 'arrakeen'), null);
assert.deepEqual(ownedStrongholdCards(undefined, 'p'), []);
const cards = ownedStrongholdCards(state, 'p');
cards[0].gameplay.push('mutated output');
assert.notDeepEqual(cards, ownedStrongholdCards(state, 'p'));
assert.deepEqual(state, before);
});
void test('mobile benefit requires its own retained card and explicit nonmobile copy but does not require owning the copied card', () => {
const state = stateWith([MOBILE_STRONGHOLD, 'p'], ['arrakeen', 'q']);
for (const copy of STRONGHOLD_CARDS.map((c) => c.id)) {
assert.equal(
strongholdBenefit(state, 'p', MOBILE_STRONGHOLD, copy),
copy === MOBILE_STRONGHOLD ? null : copy,
);
assert.equal(strongholdBenefit(state, 'q', MOBILE_STRONGHOLD, copy), null);
}
for (const copy of [undefined, null, 'missing' as StrongholdId])
assert.equal(strongholdBenefit(state, 'p', MOBILE_STRONGHOLD, copy), null);
assert.equal(
strongholdBenefit(state, 'q', 'arrakeen', 'carthag'),
'arrakeen',
);
});
void test('control counts sole individual fighters across sectors and does not pool ordinary allies', () => {
for (const id of STRONGHOLD_CARDS.map((c) => c.id)) {
const p = player('p', 'atreides', { [key(id)]: 1 });
assert.equal(strongholdControllers([p], true)[id], 'p');
const q = player('q', 'guild', { [key(id)]: 1 });
assert.equal(strongholdControllers([p, q], true)[id], null);
p.ally = 'q';
q.ally = 'p';
assert.equal(strongholdControllers([p, q], true)[id], null);
q.forces = { [key(id)]: 0 };
assert.equal(strongholdControllers([p, q], true)[id], 'p');
}
assert.deepEqual(
strongholdControllers([], true),
createStrongholdCards().owners,
);
// No storm parameter or exclusion exists: surviving sole occupancy is sufficient.
assert.equal(
strongholdControllers([player('p', 'guild', { 'arrakeen:10': 1 })], true)
.arrakeen,
'p',
);
});
void test('Bene Gesserit advisors neither claim nor contest a card and the control query never mutates stance', () => {
const advisor = player('b', 'beneGesserit', { 'arrakeen:10': 2 });
advisor.advisors = { arrakeen: { lockedTurn: 2 } };
const p = player('p', 'atreides', { 'arrakeen:10': 1 });
const before = structuredClone([p, advisor]);
assert.equal(strongholdControllers([p, advisor], true).arrakeen, 'p');
assert.equal(strongholdControllers([advisor], true).arrakeen, null);
assert.deepEqual([p, advisor], before);
// Engine settles an obsolete sole-advisor stance before its control checkpoint.
const settled = structuredClone(advisor);
settled.advisors = {};
assert.equal(strongholdControllers([settled], true).arrakeen, 'b');
assert.equal(strongholdControllers([p, settled], true).arrakeen, null);
});
void test('a concealed No-Field is one public presence regardless of hidden physical value', () => {
const richese = player('r', 'richese');
richese.noField = {
deployed: { location: { territory: 'arrakeen', sector: 10 } },
};
const before = structuredClone(richese);
assert.equal(strongholdControllers([richese], true).arrakeen, 'r');
assert.equal(
strongholdControllers(
[richese, player('p', 'guild', { 'arrakeen:10': 1 })],
true,
).arrakeen,
null,
);
for (const value of [0, 3, 5]) {
const hiddenMarker = {
deployed: richese.noField!.deployed,
tokens: [{ tokenId: 'private', value }],
};
const hiddenVariant: Player = { ...richese, noField: hiddenMarker };
assert.deepEqual(
strongholdControllers([hiddenVariant], true),
strongholdControllers([richese], true),
);
}
assert.deepEqual(richese, before);
});
void test('Ecaz controls a stronghold shared only with its reciprocal ally, including No-Fields, but never with a third combatant', () => {
const ecaz = player('e', 'ecaz', { 'arrakeen:10': 1 });
const ally = player('r', 'richese');
ally.noField = {
deployed: { location: { territory: 'arrakeen', sector: 10 } },
};
ecaz.ally = 'r';
ally.ally = 'e';
assert.equal(strongholdControllers([ecaz, ally], true).arrakeen, 'e');
assert.equal(strongholdControllers([ally, ecaz], true).arrakeen, 'e');
const third = player('b', 'beneGesserit', { 'arrakeen:10': 1 });
assert.equal(strongholdControllers([ecaz, ally, third], true).arrakeen, null);
third.advisors = { arrakeen: {} };
assert.equal(strongholdControllers([ecaz, ally, third], true).arrakeen, 'e');
ally.ally = null;
assert.equal(strongholdControllers([ecaz, ally], true).arrakeen, null);
ecaz.forces = {};
assert.equal(strongholdControllers([ecaz, ally], true).arrakeen, 'r');
});
void test('unplaced mobile interior has no owner and its occupants never become occupants of its pointing territory', () => {
const ix = player('i', 'ixians', { [key(MOBILE_STRONGHOLD)]: 6 });
const p = player('p', 'atreides', { 'arrakeen:10': 1 });
assert.equal(strongholdControllers([ix, p], false)[MOBILE_STRONGHOLD], null);
const placed = strongholdControllers([ix, p], true);
assert.equal(placed[MOBILE_STRONGHOLD], 'i');
assert.equal(placed.arrakeen, 'p');
});
void test('end-turn settlement transfers or sets aside once, survives JSON, and leaves all input records unchanged', () => {
const initial = createStrongholdCards();
const firstControl = strongholdControllers(
[player('p', 'atreides', { 'arrakeen:10': 1 })],
false,
);
const first = settleStrongholdCards(initial, 1, firstControl);
assert.equal(initial.claimedTurn, 0);
assert.equal(initial.owners.arrakeen, null);
assert.equal(first.owners.arrakeen, 'p');
const secondControl = strongholdControllers(
[player('q', 'guild', { 'carthag:11': 1 })],
false,
);
const duplicate = settleStrongholdCards(first, 1, secondControl);
assert.deepEqual(duplicate, first);
assert.notEqual(duplicate, first);
assert.notEqual(duplicate.owners, first.owners);
const next = settleStrongholdCards(
JSON.parse(JSON.stringify(first)),
2,
secondControl,
);
assert.equal(next.owners.arrakeen, null);
assert.equal(next.owners.carthag, 'q');
assert.equal(first.owners.arrakeen, 'p');
next.owners.carthag = 'changed';
assert.equal(secondControl.carthag, 'q');
assert.throws(
() => settleStrongholdCards(next, 1, firstControl),
/non-stale/,
);
});
void test('invalid control, owner-map, and settlement inputs reject without mutation', () => {
const state = createStrongholdCards(),
before = structuredClone(state);
for (const turn of [0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1])
assert.throws(() => settleStrongholdCards(state, turn, state.owners));
for (const amount of [-1, 0.5, NaN, Infinity])
assert.throws(() =>
strongholdControllers(
[player('p', 'guild', { 'arrakeen:10': amount })],
true,
),
);
assert.throws(() =>
strongholdControllers([player('p', 'guild', { 'arrakeen:7': 1 })], true),
);
assert.throws(() =>
strongholdControllers(
[player('p', 'guild'), player('p', 'atreides')],
true,
),
);
const malformed = structuredClone(state);
delete (malformed.owners as Partial<StrongholdState['owners']>).arrakeen;
assert.throws(() => settleStrongholdCards(malformed, 1, state.owners));
assert.throws(() => settleStrongholdCards(state, 1, malformed.owners));
assert.throws(() => ownedStrongholdCards(malformed, 'p'));
assert.throws(() => strongholdBenefit(malformed, 'p', 'carthag'));
assert.deepEqual(state, before);
});
void test('Arrakeen subtracts only actual bank-covered support without minting spice', () => {
for (const support of [0, 0.5, 1, 1.5, 2, 2.5, 3, 20]) {
assert.equal(
strongholdSupportCost('arrakeen', support),
Math.max(0, support - 2),
);
for (const effect of [
null,
...STRONGHOLD_CARDS.map((c) => c.id).filter((id) => id !== 'arrakeen'),
])
assert.equal(strongholdSupportCost(effect, support), support);
}
for (const bad of [-1, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1])
assert.throws(() => strongholdSupportCost('arrakeen', bad));
});
void test('Tabr returns the winning opposing dial rounded down while Tuek pays each Worthless regardless of victory', () => {
for (const won of [false, true])
for (const dial of [0, 0.5, 1, 1.5, 2.5, 20])
for (const count of [0, 1, 2]) {
assert.equal(
strongholdBattleIncome('sietch_tabr', won, dial, count),
won ? Math.floor(dial) : 0,
);
assert.equal(
strongholdBattleIncome('tueks_sietch', won, dial, count),
2 * count,
);
for (const effect of [
null,
'arrakeen',
'carthag',
'habbanya_ridge_sietch',
MOBILE_STRONGHOLD,
] as const)
assert.equal(strongholdBattleIncome(effect, won, dial, count), 0);
}
for (const dial of [-1, NaN, Infinity])
assert.throws(() => strongholdBattleIncome('sietch_tabr', true, dial, 0));
for (const count of [-1, 0.5, Infinity, Number.MAX_SAFE_INTEGER])
assert.throws(() =>
strongholdBattleIncome('tueks_sietch', false, 0, count),
);
});