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[0][number]; const key = (id: StrongholdId) => `${id}:${territory(id).sectors[0]}`; const player = ( id: string, faction: Player['faction'], forces: Record = {}, ): 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).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), ); });