import { placeFixtureHand } from './fixture-hand'; import test from 'node:test'; import type { FactionId } from '../game/catalog'; import assert from 'node:assert/strict'; import { createGame, newPlayer, joinGame, applyAction, viewGame, type Game, } from '../game/engine'; import { baseDeck, spiceDeck } from '../game/cards'; import { TERRITORIES, GRAPH, distance } from '../game/board'; /** Transfer fixture cards from the actual deck; never duplicate another seat's hand. */ function setBattleHand(g: Game, id: string, ids: string[]) { const owner = g.players.find((p) => p.id === id)!; g.deck.push(...owner.hand); owner.hand = []; owner.hand = ids.map((cardId) => { assert.ok( !g.players.some((p) => p.hand.some((card) => card.id === cardId)), `Fixture card ${cardId} is already held by another seat`, ); const index = g.deck.findIndex((card) => card.id === cardId); assert.ok( index >= 0, `Physical fixture card ${cardId} must be in the deck`, ); return g.deck.splice(index, 1)[0]; }); } function lobby(factions: FactionId[] = ['atreides', 'harkonnen']) { const g = createGame('TEST2345', newPlayer('p0', 'Player 0', factions[0])); for (let i = 1; i < factions.length; i++) joinGame(g, newPlayer(`p${i}`, `Player ${i}`, factions[i])); return g; } function started(factions: FactionId[] = ['atreides', 'harkonnen']) { let g = lobby(factions); g.players.forEach((p) => (p.ready = true)); g = applyAction(g, 'p0', { type: 'start' }); const bg = g.players.find((p) => p.faction === 'beneGesserit'); if (bg) g = applyAction(g, bg.id, { type: 'predict', faction: g.players.find((other) => other.id !== bg.id)!.faction, turn: 3, }); for (const p of g.players) if (p.traitorChoices.length) g = applyAction(g, p.id, { type: 'traitor', leader: p.traitorChoices[0], }); for (const p of g.players) if (p.faction === 'fremen') g = applyAction(g, p.id, { type: 'fremenSetup', placements: { sietch_tabr: 10, false_wall_south: 0, false_wall_west: 0, }, }); return g; } function readyAll(state: Game, closeSpiceWindow = true) { let g = state; for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' }); if (g.spiceWindow && closeSpiceWindow) return readyAll(g, false); return g; } function passResponses(state: Game) { let g = state; while (g.response) { const p = g.players.find((p) => !g.response!.passed.includes(p.id))!; g = applyAction(g, p.id, { type: 'passResponse' }); } return g; } function power( g: Game, id: string, action: { type: string; [key: string]: unknown }, ) { return passResponses(applyAction(g, id, action)); } function conservation(g: Game) { for (const p of g.players) { assert.equal( p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0), 20, p.faction, ); if (p.elites) { assert.equal( p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((a, b) => a + b, 0), p.faction === 'emperor' ? 5 : 3, ); assert.ok(p.elites.reserves >= 0 && p.elites.reserves <= p.reserves); assert.ok(p.elites.tanks >= 0 && p.elites.tanks <= p.tanks); for (const [key, n] of Object.entries(p.elites.forces)) assert.ok(n >= 0 && n <= (p.forces[key] ?? 0)); } assert.ok(p.spice >= 0); assert.ok(p.hand.length <= (p.faction === 'harkonnen' ? 8 : 4)); } } function combat() { const g = started(); g.phase = 6; g.storm = 18; g.order = ['p0', 'p1']; g.active = 'p0'; g.players[0].forces = { 'arrakeen:10': 10 }; g.players[1].forces = { 'arrakeen:10': 10 }; g.players.forEach((p) => { g.deck.push(...p.hand); p.hand = []; }); return applyAction(g, 'p0', { type: 'chooseBattle', territory: 'arrakeen', target: 'p1', }); } const plan = (state: Game, id: string, dial = 0, extra = {}) => { let g = passResponses(state); while ( g.battle?.preparation && g.battle.preparation.kind !== 'prescienceAnswer' ) g = applyAction(g, g.battle.preparation.owner, { type: 'declineBattlePower', }); if (g.decision?.kind === 'fullPlanOffer') g = applyAction(g, g.decision.player, { type: 'decision', decline: true }); return applyAction(g, id, { type: 'battlePlan', dial, leader: `${g.players.find((p) => p.id === id)!.faction}-0`, ...extra, }); }; const resolve = (state: Game, a = false, b = false) => applyAction( applyAction(state, 'p0', { type: 'traitorCall', call: a }), 'p1', { type: 'traitorCall', call: b }, ); void test('base decks have printed counts and unique IDs', () => { assert.equal(baseDeck().length, 33); assert.equal(new Set(baseDeck().map((c) => c.id)).size, 33); assert.equal(spiceDeck().length, 21); }); void test('board contains five strongholds and no phantom Arrakis territory', () => { assert.equal(TERRITORIES.filter((t) => t.type === 'stronghold').length, 5); assert.ok(!TERRITORIES.some((t) => t.id === 'arrakis')); for (const [a, neighbors] of Object.entries(GRAPH)) for (const b of neighbors) { assert.ok(GRAPH[b], `${b} exists`); assert.ok(GRAPH[b].includes(a), `${a} ↔ ${b}`); } }); void test('territory routes count territories not sector crossings', () => { assert.equal(distance('arrakeen:10', 'arrakeen:10'), 0); assert.equal(distance('arrakeen:10', 'imperial_basin:10'), 1); assert.equal(distance('arrakeen:10', 'carthag:11'), 2); assert.equal( distance('arrakeen:10', 'carthag:11', (k) => k.endsWith(':10')), Infinity, ); }); void test('faction seats are unique; maximum six players', () => { const g = lobby(); assert.throws(() => joinGame(g, newPlayer('x', 'X', 'atreides')), /taken/); const full = lobby([ 'atreides', 'harkonnen', 'emperor', 'fremen', 'guild', 'beneGesserit', ]); assert.throws(() => joinGame(full, newPlayer('x', 'X', 'ixians')), /six/); }); void test('host needs all players ready and at least two seats', () => { const g = lobby(); assert.throws(() => applyAction(g, 'p1', { type: 'start' }), /host/); assert.throws(() => applyAction(g, 'p0', { type: 'start' }), /ready/); }); void test('setup and traitor selections conserve forces and stay private', () => { const g = started(['atreides', 'harkonnen', 'fremen', 'beneGesserit']); conservation(g); assert.equal(g.status, 'playing'); assert.equal(g.players[1].traitors.length, 4); assert.equal(g.players[1].hand.length, 2); const v = viewGame(g, 'p0'); assert.equal(v.players[1].spice, undefined); assert.equal(v.players[1].hand, undefined); assert.equal(v.players[1].traitors, undefined); assert.equal(v.players[3].prediction, undefined); assert.equal('deck' in v, false); assert.equal('spiceDeck' in v, false); }); void test('invalid actions do not mutate the authoritative state', () => { const g = started(); const before = JSON.stringify(g); assert.throws(() => applyAction(g, 'p0', { type: 'ship', territory: 'arrakeen', sector: 10, amount: 5, }), ); assert.equal(JSON.stringify(g), before); }); void test('storm dials are sealed, immutable and reveal together', () => { let g = started(); g = applyAction(g, 'p0', { type: 'stormDial', amount: 3 }); assert.equal(g.phase, 0); assert.equal('stormDials' in viewGame(g, 'p1'), false); assert.throws( () => applyAction(g, 'p0', { type: 'stormDial', amount: 5 }), /locked/, ); g = applyAction(g, 'p1', { type: 'stormDial', amount: 0 }); assert.equal(g.phase, 0); assert.equal(g.stormPending, 3); g = readyAll(g); assert.equal(g.phase, 1); assert.equal(g.storm, 4); conservation(g); }); void test('first-turn worms are skipped and reshuffled', () => { let g = started(); g.phase = 1; g.spiceDeck = [ { worm: true }, { territory: 'red_chasm', amount: 8, sector: 7 }, ]; g = readyAll(g); assert.equal(g.nexus, false); assert.equal(g.phase, 2); assert.equal(g.spice['red_chasm:7'], 8); assert.ok(g.spiceDeck.some((c) => 'worm' in c)); }); void test('charity cannot be collected twice', () => { let g = started(); g.phase = 2; g.players[0].spice = 0; g = applyAction(g, 'p0', { type: 'charity' }); assert.equal(g.players[0].spice, 2); assert.throws(() => applyAction(g, 'p0', { type: 'charity' }), /Charity/); }); void test('auction winner pays and Harkonnen draws a bonus card', () => { let g = started(); // This case verifies automatic spice payment; a random starting Karama // would introduce a genuine payment choice instead. g.players[1].hand = g.players[1].hand.map((card) => { if (card.effect !== 'karama') return card; const at = g.deck.findIndex( (replacement) => replacement.effect !== 'karama', ); const replacement = g.deck.splice(at, 1)[0]; g.deck.push(card); return replacement; }); g.phase = 2; g = passResponses(readyAll(g)); g.auction!.active = 'p1'; g = applyAction(g, 'p1', { type: 'bid', amount: 3 }); g = applyAction(g, 'p0', { type: 'passBid' }); assert.notEqual(g.decision?.kind, 'auctionPayment'); g = passResponses(g); assert.equal(g.players[1].spice, 7); assert.equal(g.players[1].hand.length, 4); conservation(g); }); void test('passing all auctions returns cards to deck and advances', () => { let g = passResponses(readyAll({ ...started(), phase: 2 })); const deckCount = g.deck.length + g.auction!.cards.length; while (g.phase === 3) g = applyAction(g, g.auction!.active, { type: 'passBid' }); assert.equal(g.phase, 4); assert.equal(g.deck.length, deckCount); }); void test('revival honors free allowance and total cap across requests', () => { let g = started(); g.phase = 4; g.players[0].forces = {}; g.players[0].tanks = 10; g = applyAction(g, 'p0', { type: 'revive', amount: 2 }); assert.equal(g.players[0].spice, 10); g = applyAction(g, 'p0', { type: 'revive', amount: 1 }); assert.equal(g.players[0].spice, 8); assert.throws(() => applyAction(g, 'p0', { type: 'revive', amount: 1 })); conservation(g); }); void test('normal shipment costs and Guild income; no double shipment', () => { let g = started(['atreides', 'guild']); g.phase = 5; g.active = 'p0'; g = applyAction(g, 'p0', { type: 'ship', territory: 'arrakeen', sector: 10, amount: 3, }); assert.equal(g.players[0].spice, 7); g = passResponses(g); assert.equal(g.players[1].spice, 8); assert.throws(() => applyAction(g, 'p0', { type: 'ship', territory: 'arrakeen', sector: 10, amount: 1, }), ); conservation(g); }); void test('storm, overspending, negative, fractional, and third-faction entry are rejected', () => { const g = started(['atreides', 'harkonnen', 'emperor']); g.phase = 5; g.active = 'p0'; g.storm = 10; assert.throws( () => applyAction(g, 'p0', { type: 'ship', territory: 'arrakeen', sector: 10, amount: 1, }), /storm/, ); g.storm = 18; for (const n of [-1, 0.5, 100, NaN]) assert.throws(() => applyAction(g, 'p0', { type: 'ship', territory: 'arrakeen', sector: 10, amount: n, }), ); g.players[0].spice = 0; assert.throws( () => applyAction(g, 'p0', { type: 'ship', territory: 'arrakeen', sector: 10, amount: 1, }), /spice/, ); g.players[0].spice = 10; g.players[2].forces = { 'carthag:11': 1 }; assert.throws( () => applyAction(g, 'p0', { type: 'ship', territory: 'carthag', sector: 11, amount: 1, }), /three/, ); }); void test('sealed opponent battle plans are not exposed', () => { let g = combat(); g = plan(g, 'p0', 3); const view = viewGame(g, 'p1'); assert.equal(view.battle!.plans.p0, undefined); assert.equal(view.battle!.submitted[0], 'p0'); g = plan(g, 'p1', 3); assert.equal(viewGame(g, 'p1').battle!.plans.p0.dial, 3); }); void test('aggressor wins ties; loser keeps surviving leader and loses all forces', () => { let g = combat(); g = plan(g, 'p0', 4); g = plan(g, 'p1', 3); g = resolve(g); assert.equal(g.players[0].forces['arrakeen:10'], 6); assert.equal(g.players[1].tanks, 10); assert.equal(g.players[1].leaders[0].dead, false); conservation(g); }); void test('weapon kills leader and winner collects its value', () => { let g = combat(); const weapon = baseDeck().find((c) => c.kind === 'poison')!; setBattleHand(g, 'p0', [weapon.id]); g = plan(g, 'p0', 0, { weapon: weapon.id }); g = plan(g, 'p1', 0); g = resolve(g); assert.equal(g.players[1].leaders[0].dead, true); assert.equal(g.players[0].spice, 16); conservation(g); }); void test('traitor victory overrides lasgun/shield explosion', () => { let g = combat(); const lasgun = baseDeck().find((c) => c.kind === 'lasgun')!, shield = baseDeck().find((c) => c.kind === 'shield')!; setBattleHand(g, 'p0', [lasgun.id]); setBattleHand(g, 'p1', [shield.id]); g.players[0].traitors = ['harkonnen-0']; g = plan(g, 'p0', 8, { weapon: lasgun.id }); g = plan(g, 'p1', 5, { defense: shield.id }); g = resolve(g, true); assert.equal(g.players[0].forces['arrakeen:10'], 10); assert.equal(g.players[0].hand.length, 1); assert.equal(g.players[0].spice, 16); conservation(g); }); void test('lasgun/shield destroys both armies and spice without bounty', () => { let g = combat(); const lasgun = baseDeck().find((c) => c.kind === 'lasgun')!, shield = baseDeck().find((c) => c.kind === 'shield')!; setBattleHand(g, 'p0', [lasgun.id]); setBattleHand(g, 'p1', [shield.id]); g.spice = { 'arrakeen:10': 10 }; g = plan(g, 'p0', 0, { weapon: lasgun.id }); g = plan(g, 'p1', 0, { defense: shield.id }); g = resolve(g); assert.equal(g.players[0].tanks, 10); assert.equal(g.players[1].tanks, 10); assert.equal(g.spice['arrakeen:10'], undefined); assert.equal(g.players[0].spice, 10); conservation(g); }); void test('cannot reveal a traitor not held', () => { const g = plan(plan(combat(), 'p0'), 'p1'); g.players[0].traitors = []; assert.throws( () => applyAction(g, 'p0', { type: 'traitorCall', call: true }), /do not hold/, ); }); void test('two-player victory requires four strongholds', () => { let g = started(); g.phase = 7; g.players[0].forces = { 'arrakeen:10': 1, 'sietch_tabr:14': 1, 'tueks_sietch:5': 1, }; g = readyAll(g); assert.equal(g.status, 'playing'); g.phase = 7; g.players[0].forces['habbanya_ridge_sietch:17'] = 1; g = readyAll(g); assert.equal(g.status, 'finished'); assert.deepEqual(g.winner, ['p0']); }); void test('bribes are deferred until Mentat pause and not visible as spendable spice', () => { let g = started(); g.phase = 2; g = applyAction(g, 'p0', { type: 'bribe', target: 'p1', amount: 4 }); assert.equal(g.players[0].spice, 6); assert.equal(g.players[1].spice, 10); assert.equal(g.players[1].bribes, 4); g.phase = 7; g = readyAll(g); assert.equal(g.players[1].spice, 14); assert.equal(g.players[1].bribes, 0); }); void test('six-player table can complete ten turns without stalling', () => { let g = started([ 'atreides', 'harkonnen', 'emperor', 'fremen', 'guild', 'beneGesserit', ]); for (let step = 0; step < 1000 && g.status !== 'finished'; step++) { if (g.response) { g = passResponses(g); } else if (g.phase === 0 && g.stormPending === null) { const id = g.stormDialers.find((id) => g.stormDials[id] === undefined)!; g = applyAction(g, id, { type: 'stormDial', amount: g.turn === 1 ? 0 : 1, }); } else if (g.phase === 3) { g = applyAction(g, g.auction!.active, { type: 'passBid' }); } else if (g.phase === 5) { g = applyAction(g, g.active!, { type: 'endMovement' }); } else { assert.notEqual(g.phase, 6, 'No rival forces overlap in this scenario'); g = applyAction(g, g.players.find((p) => !g.ready.includes(p.id))!.id, { type: 'ready', }); } conservation(g); } assert.equal(g.status, 'finished'); assert.equal(g.turn, 10); assert.deepEqual(g.winner, ['p3']); }); void test('ordinary spice blows preserve the remaining deck for Atreides foresight', () => { const g = started(); g.turn = 2; g.phase = 1; const cards = spiceDeck().filter((c) => 'territory' in c); g.spiceDeck = cards; const next = readyAll(g); assert.deepEqual(next.spiceDeck, cards.slice(1)); assert.equal(next.spiceResolution, null); }); void test('Bene Gesserit shipment reaction blocks movement, is optional, and conserves forces', () => { let g = started(['atreides', 'beneGesserit']); g.phase = 5; g.active = 'p0'; g.storm = 18; const before = g.players[1].reserves; g = applyAction(g, 'p0', { type: 'ship', territory: 'arrakeen', sector: 10, amount: 1, }); assert.deepEqual(g.decision, { kind: 'advisor', player: 'p1', shipment: 'p0', destination: 'arrakeen:10', }); const original = structuredClone(g); assert.throws( () => applyAction(g, 'p0', { type: 'endMovement' }), /pending decision/, ); assert.throws( () => applyAction(g, 'p0', { type: 'decision', accept: true }), /pending decision/, ); assert.deepEqual(g, original); const declined = applyAction(g, 'p1', { type: 'decision', accept: false }); assert.equal(declined.players[1].reserves, before); g = passResponses(applyAction(g, 'p1', { type: 'decision', accept: true })); assert.equal(g.players[1].reserves, before - 1); assert.equal(g.players[1].forces['polar_sink:0'], 2); assert.equal(g.players[1].shipped, false); assert.equal(g.active, 'p0'); assert.equal(g.decision, null); conservation(g); assert.throws( () => applyAction(g, 'p1', { type: 'decision', accept: true }), /not available/, ); }); void test('Fremen reinforcements, BG own shipment and Guild on-planet transport do not grant BG a free force', () => { let g = started(['fremen', 'beneGesserit', 'guild']); g.phase = 5; g.active = 'p0'; g.storm = 18; const flat = TERRITORIES.find((t) => t.id === 'the_great_flat')!; g = applyAction(g, 'p0', { type: 'ship', territory: flat.id, sector: flat.sectors[0], amount: 1, }); assert.equal(g.decision, null); g.active = 'p1'; g = applyAction(g, 'p1', { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, }); assert.equal(g.decision, null); g = passResponses(g); g.active = 'p2'; const from = Object.keys(g.players[2].forces)[0]; g = applyAction(g, 'p2', { type: 'guildShip', from, territory: 'polar_sink', sector: 0, amount: 1, }); assert.equal(g.decision, null); conservation(g); }); function wormScenario() { const g = started(['fremen', 'atreides', 'harkonnen']); g.turn = 2; g.phase = 1; g.storm = 18; const blow = spiceDeck().find( (c) => 'territory' in c && c.territory === 'the_great_flat', )!; assert.ok('territory' in blow); const source = `${blow.territory}:${blow.sector}`; const next = spiceDeck().find( (c) => 'territory' in c && c.territory !== blow.territory, )!; const worms = spiceDeck() .filter((c) => 'worm' in c) .slice(0, 2); g.spiceDiscard = [[blow], []]; g.spiceDeck = [...worms, next]; g.players[0].forces = { [source]: 10 }; g.players[1].forces = { [source]: 10 }; g.players[2].forces = { [source]: 10 }; g.players[0].ally = 'p1'; g.players[1].ally = 'p0'; g.spice = { [source]: 8 }; return { g, source, territory: blow.territory }; } void test('Fremen choose ally worm protection before destruction and Nexus; subsequent worms do not devour twice', () => { const scenario = wormScenario(); let g = readyAll(scenario.g); assert.equal(g.decision?.kind, 'wormProtection'); assert.equal(g.players[1].tanks, 0); assert.equal(g.players[2].tanks, 0); assert.ok(g.spiceResolution); assert.throws( () => applyAction(g, 'p1', { type: 'ready' }), /pending decision/, ); assert.throws( () => applyAction(g, 'p0', { type: 'decision', accept: 'yes' }), /Choose whether/, ); const unprotected = power(g, 'p0', { type: 'decision', accept: false }); assert.equal(unprotected.players[1].tanks, 10); g = power(g, 'p0', { type: 'decision', accept: true }); assert.equal(g.players[0].tanks, 0); assert.equal(g.players[1].tanks, 0); assert.equal(g.players[2].tanks, 10); assert.equal(g.spice[scenario.source], undefined); assert.deepEqual(g.wormRides, [scenario.territory]); assert.equal(g.phase, 1); assert.equal(g.nexus, true); assert.equal(g.decision, null); assert.equal(g.spiceResolution, null); assert.deepEqual(g.ready, []); assert.ok(g.spiceWindow); conservation(g); }); void test('Fremen ride only after Nexus, can move part of their force, and leave destination armies intact', () => { const scenario = wormScenario(); let g = power(readyAll(scenario.g), 'p0', { type: 'decision', accept: true, }); g.players[2].forces = { 'arrakeen:10': 1 }; g.players[2].tanks = 9; g = readyAll(readyAll(g)); assert.equal(g.decision?.kind, 'wormRide'); assert.equal(viewGame(g, 'p1').decision?.player, 'p0'); assert.throws( () => applyAction(g, 'p0', { type: 'decision', accept: true, territory: 'arrakeen', sector: 10, forces: { 'sietch_tabr:14': 1 }, }), /where the worm appeared/, ); const declined = applyAction(g, 'p0', { type: 'decision', accept: false }); assert.equal(declined.phase, 2); assert.equal(declined.players[0].forces[scenario.source], 10); g = applyAction(g, 'p0', { type: 'decision', accept: true, territory: 'arrakeen', sector: 10, forces: { [scenario.source]: 4 }, }); assert.equal(g.phase, 2); assert.equal(g.nexus, false); assert.equal(g.players[0].forces[scenario.source], 6); assert.equal(g.players[0].forces['arrakeen:10'], 4); assert.equal(g.players[2].forces['arrakeen:10'], 1); assert.equal(g.players[0].moved, 0); conservation(g); }); void test('worm ride rejects storm and allied/fully occupied strongholds atomically', () => { const scenario = wormScenario(); let g = power(readyAll(scenario.g), 'p0', { type: 'decision', accept: false, }); g = readyAll(readyAll(g)); const action = { type: 'decision', accept: true, territory: 'arrakeen', sector: 10, forces: { [scenario.source]: 2 }, }; g.storm = 10; assert.throws(() => applyAction(g, 'p0', action), /storm/); g.storm = 18; g.players[1].forces = { 'arrakeen:10': 1 }; const original = structuredClone(g); assert.throws(() => applyAction(g, 'p0', action), /ally/); assert.deepEqual(g, original); g.players[0].ally = null; g.players[1].ally = null; g.players[2].forces = { 'arrakeen:10': 1 }; assert.throws(() => applyAction(g, 'p0', action), /three occupying factions/); }); void test('Fremen free revival is optional, phase-scoped and still capped at three', () => { let g = started(['fremen', 'atreides']); g.phase = 4; g.players[0].ally = 'p1'; g.players[1].ally = 'p0'; g.players[1].forces = {}; g.players[1].reserves = 15; g.players[1].tanks = 5; const paid = applyAction(g, 'p1', { type: 'revive', amount: 3 }); assert.equal(paid.players[1].spice, 8); assert.throws( () => applyAction(g, 'p1', { type: 'grantRevival' }), /Only Fremen/, ); g = applyAction(g, 'p0', { type: 'grantRevival' }); g = applyAction(g, 'p1', { type: 'revive', amount: 1 }); g = applyAction(g, 'p1', { type: 'revive', amount: 2 }); assert.equal(g.players[1].spice, 10); assert.throws( () => applyAction(g, 'p1', { type: 'revive', amount: 1 }), /integer/, ); conservation(g); g.phase = 3; g.auction = { cards: [baseDeck()[0]], index: 0, bid: 0, bidder: null, active: 'p0', passed: [], opener: 0, }; g.order = ['p0', 'p1']; g = applyAction(g, 'p0', { type: 'passBid' }); g = applyAction(g, 'p1', { type: 'passBid' }); assert.equal(g.phase, 4); assert.deepEqual(g.freeRevival, []); }); void test('winner explicitly keeps or discards played cards before battle phase ends', () => { let g = combat(); const weapon = baseDeck().find((c) => c.kind === 'poison')!; const spare = baseDeck().find((c) => c.kind === 'shield')!; setBattleHand(g, 'p0', [weapon.id, spare.id]); g = resolve(plan(plan(g, 'p0', 0, { weapon: weapon.id }), 'p1', 0)); assert.equal(g.phase, 6); assert.deepEqual(g.decision, { kind: 'battleCards', player: 'p0', territory: 'arrakeen', cards: [weapon.id], }); assert.throws( () => applyAction(g, 'p1', { type: 'decision', discard: [] }), /pending decision/, ); assert.throws( () => applyAction(g, 'p0', { type: 'decision', discard: [spare.id] }), /only cards played/, ); const kept = applyAction(g, 'p0', { type: 'decision', discard: [] }); assert.equal(kept.phase, 7); assert.equal(kept.players[0].hand.length, 2); g = applyAction(g, 'p0', { type: 'decision', discard: [weapon.id] }); assert.equal(g.phase, 7); assert.deepEqual(g.players[0].hand, [spare]); assert.ok(g.discard.some((c) => c.id === weapon.id)); }); void test('movement combines sectors of one territory into a single move', () => { let g = started(); g.phase = 5; g.active = 'p0'; g.players[0].forces = { 'pasty_mesa:5': 4, 'pasty_mesa:6': 6 }; g = applyAction(g, 'p0', { type: 'move', territory: 'tueks_sietch', sector: 5, forces: { 'pasty_mesa:5': 3, 'pasty_mesa:6': 2 }, }); assert.deepEqual(g.players[0].forces, { 'pasty_mesa:5': 1, 'pasty_mesa:6': 4, 'tueks_sietch:5': 5, }); assert.equal(g.players[0].moved, 1); assert.equal(g.players[0].shipped, true); conservation(g); assert.throws( () => applyAction(g, 'p0', { type: 'move', from: 'pasty_mesa:5', amount: 1, territory: 'tueks_sietch', sector: 5, }), /not available/, ); }); void test('group movement cannot mix territories, exceed stocks, or bypass a storm', () => { const g = started(); g.phase = 5; g.active = 'p0'; g.players[0].forces = { 'pasty_mesa:5': 4, 'pasty_mesa:6': 5, 'arrakeen:10': 1, }; const action = { type: 'move', territory: 'tueks_sietch', sector: 5 }; assert.throws( () => applyAction(g, 'p0', { ...action, forces: { 'pasty_mesa:5': 1, 'arrakeen:10': 1 }, }), /one territory/, ); assert.throws( () => applyAction(g, 'p0', { ...action, forces: { 'pasty_mesa:5': 5 } }), /integer/, ); assert.throws( () => applyAction(g, 'p0', { ...action, forces: { 'nonsense:1': 1 } }), /Invalid source/, ); assert.throws( () => applyAction(g, 'p0', { ...action, forces: {} }), /at least one/, ); g.storm = 6; const original = structuredClone(g); assert.throws( () => applyAction(g, 'p0', { ...action, forces: { 'pasty_mesa:5': 1, 'pasty_mesa:6': 1 }, }), /blocked/, ); assert.deepEqual(g, original); const legal = applyAction(g, 'p0', { ...action, forces: { 'pasty_mesa:5': 1, 'pasty_mesa:6': 0 }, }); assert.equal(legal.players[0].forces['tueks_sietch:5'], 1); }); void test('Guild groups from several sectors pay one rounded shipment cost', () => { let g = started(['guild', 'atreides']); g.phase = 5; g.active = 'p0'; g.players[0].forces = { 'pasty_mesa:5': 2, 'pasty_mesa:6': 3 }; const before = g.players[0].spice; const action = { type: 'guildShip', forces: { 'pasty_mesa:5': 1, 'pasty_mesa:6': 2 }, }; const returned = passResponses(applyAction(g, 'p0', { ...action, territory: 'reserves' })); assert.equal(returned.players[0].spice, before - 2); assert.equal(returned.players[0].reserves, 18); g = passResponses(applyAction(g, 'p0', { ...action, territory: 'sietch_tabr', sector: 14 })); assert.equal(g.players[0].spice, before - 2); assert.equal(g.players[0].forces['sietch_tabr:14'], 3); conservation(g); }); void test('allies may fund bids beyond personal spice, with private amounts and escrowed payment', () => { let g = started(['atreides', 'fremen', 'emperor']); g.phase = 3; g.order = ['p0', 'p1', 'p2']; g.players[0].ally = 'p1'; g.players[1].ally = 'p0'; g.players[0].spice = 1; g.players[1].spice = 10; g.players.forEach((p) => (p.hand = [])); g.auction = { cards: [baseDeck()[0]], index: 0, bid: 0, bidder: null, active: 'p0', passed: [], opener: 0, }; const emperorSpice = g.players[2].spice; g = applyAction(g, 'p1', { type: 'pledgeAid', amount: 6 }); assert.equal(g.players[1].spice, 4); assert.deepEqual(viewGame(g, 'p0').aid, { pledged: 0, available: 6 }); assert.deepEqual(viewGame(g, 'p2').aid, { pledged: 0, available: 0 }); assert.throws( () => applyAction(g, 'p0', { type: 'bid', amount: 8 }), /integer/, ); g = applyAction(g, 'p0', { type: 'bid', amount: 5 }); assert.equal(g.auction!.allyPayment, 4); assert.throws( () => applyAction(g, 'p1', { type: 'pledgeAid', amount: 3 }), /integer/, ); assert.throws( () => applyAction(g, 'p0', { type: 'bribe', target: 'p2', amount: 1 }), /integer/, ); g = applyAction(g, 'p1', { type: 'passBid' }); g = applyAction(g, 'p2', { type: 'passBid' }); assert.notEqual(g.decision?.kind, 'auctionPayment'); g = passResponses(g); assert.equal(g.phase, 4); assert.equal(g.players[0].spice, 0); assert.equal(g.players[1].spice, 6); assert.equal(g.players[2].spice, emperorSpice + 5); assert.deepEqual(g.aid, {}); }); void test('unused alliance funding is revocable but an outstanding bid remains funded', () => { let g = started(['atreides', 'fremen', 'emperor']); g.phase = 3; g.players[0].ally = 'p1'; g.players[1].ally = 'p0'; g.players[1].spice = 10; g.order = ['p0', 'p1', 'p2']; g.auction = { cards: [baseDeck()[0]], index: 0, bid: 0, bidder: null, active: 'p0', passed: [], opener: 0, }; g = applyAction(g, 'p1', { type: 'pledgeAid', amount: 6 }); g = applyAction(g, 'p0', { type: 'bid', amount: 4, allyPayment: 3 }); g = applyAction(g, 'p1', { type: 'pledgeAid', amount: 3 }); assert.equal(g.players[1].spice, 7); assert.throws( () => applyAction(g, 'p0', { type: 'pledgeAid', amount: 10 }), /integer/, ); g = applyAction(g, 'p1', { type: 'passBid' }); g = applyAction(g, 'p2', { type: 'bid', amount: 5 }); g = applyAction(g, 'p1', { type: 'pledgeAid', amount: 0 }); assert.equal(g.players[1].spice, 10); }); void test('ally shipment funding charges the selected share and refunds unused pledge at phase end', () => { let g = started(['atreides', 'fremen', 'guild']); g.phase = 5; g.active = 'p0'; g.order = ['p0', 'p1', 'p2']; g.players[0].ally = 'p1'; g.players[1].ally = 'p0'; g.players[1].spice = 10; const guild = g.players[2].spice; assert.throws( () => applyAction(g, 'p2', { type: 'pledgeAid', amount: 1 }), /fund an ally/, ); g = applyAction(g, 'p1', { type: 'pledgeAid', amount: 6 }); g = applyAction(g, 'p0', { type: 'ship', territory: 'arrakeen', sector: 10, amount: 4, allyPayment: 3, }); assert.equal(g.players[0].spice, 9); assert.equal(g.players[1].spice, 4); assert.equal(g.aid.p1.amount, 3); g = passResponses(g); assert.equal(g.players[2].spice, guild + 4); for (const id of g.order) g = applyAction(g, id, { type: 'endMovement' }); assert.equal(g.players[1].spice, 7); assert.deepEqual(g.aid, {}); conservation(g); }); void test('Harkonnen may reveal a traitor for their ally and the battle waits for their choice', () => { let g = started(['atreides', 'emperor', 'harkonnen']); g.phase = 6; g.order = ['p0', 'p1', 'p2']; g.active = 'p0'; g.players[0].ally = 'p2'; g.players[2].ally = 'p0'; g.players[0].forces = { 'arrakeen:10': 10 }; g.players[1].forces = { 'arrakeen:10': 10 }; g.players[1].reserves = 10; g.players[2].traitors = ['emperor-0']; g = applyAction(g, 'p0', { type: 'chooseBattle', territory: 'arrakeen', target: 'p1', }); g = plan(plan(g, 'p0', 7), 'p1', 8); g = resolve(g); assert.ok(g.battle, 'Harkonnen has not made its own choice yet'); assert.deepEqual(viewGame(g, 'p2').battle!.traitorVoters, ['p0', 'p1', 'p2']); assert.equal(g.players[1].tanks, 0); const declined = applyAction(g, 'p2', { type: 'traitorCall', call: false }); assert.equal(declined.players[0].tanks, 10); g = power(g, 'p2', { type: 'traitorCall', call: true }); assert.equal(g.players[0].tanks, 0); assert.equal(g.players[1].tanks, 10); assert.equal(g.players[0].spice, 16); assert.equal(g.players[2].spice, 10); conservation(g); }); void test('ordinary allies cannot call traitors for one another', () => { let g = started(['atreides', 'emperor', 'fremen']); g.phase = 6; g.order = ['p0', 'p1', 'p2']; g.active = 'p0'; g.players[0].ally = 'p2'; g.players[2].ally = 'p0'; g.players[1].forces = { 'arrakeen:10': 10 }; g.players[2].traitors = ['emperor-0']; g = applyAction(g, 'p0', { type: 'chooseBattle', territory: 'arrakeen', target: 'p1', }); g = plan(plan(g, 'p0'), 'p1'); assert.deepEqual(viewGame(g, 'p2').battle!.traitorVoters, ['p0', 'p1']); assert.throws( () => applyAction(g, 'p2', { type: 'traitorCall', call: true }), /not available/, ); }); function preparationBattle( factions: FactionId[] = ['atreides', 'beneGesserit', 'emperor'], ) { const g = started(factions); g.phase = 6; g.active = 'p0'; g.order = g.players.map((p) => p.id); g.players[1].forces = { 'arrakeen:10': 10 }; g.players[1].reserves = 10; g.players.forEach((p) => (p.hand = [])); return applyAction(g, 'p0', { type: 'chooseBattle', territory: 'arrakeen', target: 'p1', }); } void test('Voice has a guaranteed window before Atreides prescience and battle plans', () => { let g = preparationBattle(); assert.equal(g.battle!.preparation!.kind, 'voice'); assert.equal(g.battle!.preparation!.owner, 'p1'); assert.throws( () => applyAction(g, 'p0', { type: 'battlePlan', dial: 0, leader: 'atreides-0', }), /not available/, ); assert.throws( () => applyAction(g, 'p0', { type: 'prescience', field: 'weapon' }), /Voice decision/, ); assert.throws( () => applyAction(g, 'p0', { type: 'declineBattlePower' }), /do not own/, ); g = power(g, 'p1', { type: 'voice', kind: 'poison', must: false }); assert.equal(g.battle!.preparation!.kind, 'prescience'); assert.equal(g.battle!.voice!.target, 'p0'); assert.throws( () => applyAction(g, 'p1', { type: 'voice', kind: 'shield', must: false }), /before prescience/, ); g = applyAction(g, 'p0', { type: 'declineBattlePower' }); assert.equal(g.battle!.preparation, undefined); const poison = baseDeck().find((c) => c.kind === 'poison')!; g.players[0].hand = [poison]; assert.throws( () => applyAction(g, 'p0', { type: 'battlePlan', dial: 0, leader: 'atreides-0', weapon: poison.id, }), /Voice/, ); }); void test('prescience reveals exactly one element, hides it from spectators, and leaves other elements flexible', () => { let g = preparationBattle(); g = applyAction(g, 'p1', { type: 'declineBattlePower' }); const weapon = baseDeck().find((c) => c.kind === 'poison')!; const defense = baseDeck().find((c) => c.kind === 'shield')!; g.players[1].hand = [weapon, defense]; g = power(g, 'p0', { type: 'prescience', field: 'weapon' }); assert.throws( () => applyAction(g, 'p0', { type: 'prescienceAnswer', value: weapon.id }), /not revealing/, ); g = applyAction(g, 'p1', { type: 'prescienceAnswer', value: weapon.id }); assert.deepEqual(g.battle!.plans, {}); assert.equal(viewGame(g, 'p0').battle!.insight!.value, weapon.id); assert.equal(viewGame(g, 'p0').battle!.insight!.label, weapon.name); assert.equal(viewGame(g, 'p1').battle!.insight!.value, weapon.id); assert.equal(viewGame(g, 'p2').battle!.insight, null); assert.equal('value' in viewGame(g, 'p2').battle!.prescience!, false); assert.throws( () => applyAction(g, 'p1', { type: 'prescienceAnswer', value: null }), /not revealing/, ); assert.throws(() => plan(g, 'p1', 0), /remain unchanged/); g = plan(g, 'p1', 7, { weapon: weapon.id, defense: defense.id, leader: 'beneGesserit-3', }); const view = viewGame(g, 'p0'); assert.equal(view.battle!.plans.p1, undefined); assert.equal(view.battle!.insight!.value, weapon.id); g = plan(g, 'p0', 3); assert.equal(g.battle!.revealed, true); assert.equal(g.battle!.plans.p1.leader, 'beneGesserit-3'); assert.equal(g.battle!.plans.p1.dial, 7); }); void test('a prescience answer must permit a legal plan under the preceding Voice', () => { let g = preparationBattle(); // BG fights Atreides: require Atreides to use poison, then Atreides asks BG about its weapon. const poison = baseDeck().find((c) => c.kind === 'poison')!; const defense = baseDeck().find((c) => c.kind === 'shield')!; g.players[1].hand = [defense]; g = power(g, 'p1', { type: 'voice', kind: 'poison', must: true }); g = power(g, 'p0', { type: 'prescience', field: 'weapon' }); assert.throws( () => applyAction(g, 'p1', { type: 'prescienceAnswer', value: defense.id }), /legal battle plan/, ); assert.throws( () => applyAction(g, 'p1', { type: 'prescienceAnswer', value: poison.id }), /legal battle plan/, ); g = applyAction(g, 'p1', { type: 'prescienceAnswer', value: null }); assert.equal(viewGame(g, 'p0').battle!.insight!.value, null); g.players[1].hand.push(poison); assert.throws( () => plan(g, 'p1', 0, { weapon: poison.id }), /remain unchanged/, ); // Atreides has no poison: the impossible mandatory Voice does not block a legal plan. g = plan(g, 'p0', 0); assert.ok(g.battle!.plans.p0); }); void test('remote BG and Atreides owners explicitly choose whether to help an ally', () => { let g = started(['emperor', 'guild', 'beneGesserit', 'atreides']); g.phase = 6; g.order = g.players.map((p) => p.id); g.active = 'p0'; g.players[0].forces = { 'arrakeen:10': 10 }; g.players[1].forces = { 'arrakeen:10': 5 }; g.players[0].ally = 'p2'; g.players[2].ally = 'p0'; g.players[1].ally = 'p3'; g.players[3].ally = 'p1'; g = applyAction(g, 'p0', { type: 'chooseBattle', territory: 'arrakeen', target: 'p1', }); assert.equal(g.battle!.preparation!.owner, 'p2'); assert.throws( () => applyAction(g, 'p0', { type: 'voice', kind: 'hero', must: false }), /Bene Gesserit/, ); g = power(g, 'p2', { type: 'voice', kind: 'hero', must: false }); assert.equal(g.battle!.voice!.target, 'p1'); assert.equal(g.battle!.preparation!.owner, 'p3'); assert.throws( () => applyAction(g, 'p1', { type: 'prescience', field: 'dial' }), /not available/, ); g = power(g, 'p3', { type: 'prescience', field: 'dial' }); assert.equal(g.battle!.prescience!.player, 'p1'); assert.equal(g.battle!.preparation!.owner, 'p0'); g = applyAction(g, 'p0', { type: 'prescienceAnswer', value: 4 }); assert.equal(viewGame(g, 'p1').battle!.insight!.value, 4); assert.equal(viewGame(g, 'p2').battle!.insight, null); assert.equal( viewGame(g, 'p3').battle!.insight, null, 'The element is shown to the ally, not automatically shared with its owner', ); assert.throws(() => plan(g, 'p0', 3), /remain unchanged/); g = plan(g, 'p0', 4); assert.equal(g.battle!.plans.p0.dial, 4); }); void test('Voice and prescience together cannot commit an impossible element', () => { let g = started(['emperor', 'atreides', 'beneGesserit']); g.phase = 6; g.order = ['p0', 'p1', 'p2']; g.active = 'p0'; g.players[0].forces = { 'arrakeen:10': 10 }; g.players[1].ally = 'p2'; g.players[2].ally = 'p1'; const poison = baseDeck().find((c) => c.kind === 'poison')!; g.players[0].hand = [poison]; g = applyAction(g, 'p0', { type: 'chooseBattle', territory: 'arrakeen', target: 'p1', }); g = power(g, 'p2', { type: 'voice', kind: 'poison', must: true }); g = power(g, 'p1', { type: 'prescience', field: 'weapon' }); assert.throws( () => applyAction(g, 'p0', { type: 'prescienceAnswer', value: null }), /legal battle plan/, ); g = applyAction(g, 'p0', { type: 'prescienceAnswer', value: poison.id }); g = plan(g, 'p0', 0, { weapon: poison.id }); assert.equal(g.battle!.plans.p0.weapon, poison.id); }); void test('forbidding Cheap Hero lets a leaderless opponent fight without treachery', () => { let g = preparationBattle(); const hero = baseDeck().find((c) => c.kind === 'hero')!; g.players[0].hand = [hero]; g.players[0].leaders.forEach((l) => (l.dead = true)); g = power(g, 'p1', { type: 'voice', kind: 'hero', must: false }); g = applyAction(g, 'p0', { type: 'declineBattlePower' }); g = applyAction(g, 'p0', { type: 'battlePlan', dial: 0, leader: null }); assert.equal(g.battle!.plans.p0.leader, null); }); void test('legacy pending prescience resumes without losing secrets or mutating a read', () => { const g = combat(); delete g.battle!.prepared; delete g.battle!.preparation; g.battle!.prescience = { player: 'p0', field: 'dial' }; const original = structuredClone(g); const view = viewGame(g, 'p1'); assert.equal(view.battle!.preparation!.kind, 'prescienceAnswer'); assert.deepEqual(g, original); const next = applyAction(g, 'p1', { type: 'prescienceAnswer', value: 2 }); assert.equal(viewGame(next, 'p0').battle!.insight!.value, 2); assert.equal(next.battle!.prepared, true); }); void test('storm distance is public before movement, allowing Weather Control after dials', () => { let g = started(); const weather = baseDeck().find((c) => c.effect === 'weather')!; placeFixtureHand(g, 0, [weather]); g = applyAction(g, 'p0', { type: 'stormDial', amount: 3 }); assert.equal(viewGame(g, 'p1').stormRevealed, null); g = applyAction(g, 'p1', { type: 'stormDial', amount: 4 }); assert.deepEqual(viewGame(g, 'p0').stormRevealed, { p0: 3, p1: 4 }); assert.equal(g.storm, 1); assert.equal(g.stormPending, 7); g = applyAction(g, 'p1', { type: 'ready' }); g = applyAction(g, 'p0', { type: 'card', card: weather.id, amount: 2 }); assert.deepEqual(g.ready, []); assert.equal(g.stormPending, 2); assert.equal(g.storm, 1); g = readyAll(g); assert.equal(g.storm, 3); assert.equal(g.phase, 1); assert.equal(g.stormPending, null); assert.ok(g.discard.some((c) => c.id === weather.id)); }); void test('Family Atomics can follow revealed dials or Weather Control before storm movement', () => { let g = started(); const weather = baseDeck().find((c) => c.effect === 'weather')!; const atomics = baseDeck().find((c) => c.effect === 'atomics')!; placeFixtureHand(g, 0, [weather, atomics]); g.players[0].forces = { 'shield_wall:8': 1, 'arrakeen:10': 9 }; g.storm = 7; g.turn = 2; g = applyAction(g, 'p0', { type: 'stormDial', amount: 2 }); g = applyAction(g, 'p1', { type: 'stormDial', amount: 2 }); g = applyAction(g, 'p0', { type: 'card', card: weather.id, amount: 3 }); g = applyAction(g, 'p1', { type: 'ready' }); g = applyAction(g, 'p0', { type: 'card', card: atomics.id }); assert.deepEqual(g.ready, []); assert.equal(g.shieldWallDestroyed, true); assert.equal(g.players[0].tanks, 1); assert.equal(g.players[0].forces['arrakeen:10'], 9); g = readyAll(g); assert.equal(g.storm, 10); assert.equal(g.players[0].tanks, 10); conservation(g); }); void test('Weather Control before dialing replaces the dials, including zero movement', () => { let g = started(); const weather = baseDeck().find((c) => c.effect === 'weather')!; placeFixtureHand(g, 0, [weather]); assert.throws( () => applyAction(g, 'p0', { type: 'ready' }), /current phase action/, ); g = applyAction(g, 'p0', { type: 'card', card: weather.id, amount: 0 }); assert.equal(g.stormPending, 0); assert.throws( () => applyAction(g, 'p1', { type: 'stormDial', amount: 2 }), /not dialing/, ); g = readyAll(g); assert.equal(g.storm, 1); assert.equal(g.phase, 1); }); void test('Harvester doubles only the new blow and resets acceptance before Nexus or charity', () => { let g = started(); const harvester = baseDeck().find((c) => c.effect === 'harvester')!; placeFixtureHand(g, 0, [harvester]); g.phase = 1; g.spice = { 'red_chasm:7': 3 }; g.spiceDeck = [{ territory: 'red_chasm', amount: 8, sector: 7 }]; assert.throws( () => applyAction(g, 'p0', { type: 'card', card: harvester.id }), /immediately after/, ); g = readyAll(g, false); assert.equal(g.phase, 1); assert.equal(g.spice['red_chasm:7'], 11); g = applyAction(g, 'p1', { type: 'ready' }); g = applyAction(g, 'p0', { type: 'card', card: harvester.id }); assert.equal(g.spice['red_chasm:7'], 19); assert.equal(g.spiceWindow!.amount, 16); assert.deepEqual(g.ready, []); g = readyAll(g); assert.equal(g.phase, 2); assert.equal(g.spiceWindow, null); assert.ok(g.discard.some((c) => c.id === harvester.id)); }); void test('Harvester cannot rescue spice from storm or be used later during collection', () => { let g = started(); const harvester = baseDeck().find((c) => c.effect === 'harvester')!; placeFixtureHand(g, 0, [harvester]); g.phase = 1; g.storm = 7; g.spiceDeck = [{ territory: 'red_chasm', amount: 8, sector: 7 }]; g = readyAll(g, false); const later = readyAll(g); assert.throws( () => applyAction(later, 'p0', { type: 'card', card: harvester.id }), /immediately after/, ); g = applyAction(g, 'p0', { type: 'card', card: harvester.id }); assert.equal(g.spice['red_chasm:7'], undefined); g = readyAll(g); assert.equal(g.phase, 2); }); void test('Nexus opens after the spice response window and cannot be used early', () => { const scenario = wormScenario(); let g = power(readyAll(scenario.g), 'p0', { type: 'decision', accept: true, }); assert.ok(g.spiceWindow); assert.throws( () => applyAction(g, 'p0', { type: 'alliance', target: 'p2' }), /only during a Nexus/, ); g = readyAll(g); assert.equal(g.phase, 1); assert.equal(g.spiceWindow, null); assert.deepEqual(g.ready, []); }); function karamaAuction(factions: FactionId[] = ['atreides', 'emperor']) { const g = started(factions); g.phase = 3; g.order = g.players.map((p) => p.id); g.active = 'p0'; const karama = baseDeck().find((c) => c.effect === 'karama')!; g.players[0].spice = 2; g.players[1].hand = []; // Relocate the selected physical cards out of the shuffled setup deck. placeFixtureHand(g, 0, [karama, baseDeck()[0]]); const auctionCard = g.players[0].hand.pop()!; g.auction = { cards: [auctionCard], index: 0, bid: 0, bidder: null, active: 'p0', passed: [], opener: 0, }; return { g, karama }; } void test('holding Karama permits bids above spice and the winner can spend it without paying', () => { const scenario = karamaAuction(); let g = applyAction(scenario.g, 'p0', { type: 'bid', amount: 100 }); const emperorSpice = g.players[1].spice; g = applyAction(g, 'p1', { type: 'passBid' }); assert.equal(g.decision?.kind, 'auctionPayment'); assert.equal(g.players[0].hand[0].id, scenario.karama.id); assert.throws( () => applyAction(g, 'p0', { type: 'decision', karama: false }), /no longer funded/, ); g = applyAction(g, 'p0', { type: 'decision', karama: true }); assert.equal(g.phase, 4); assert.equal(g.players[0].spice, 2); assert.equal(g.players[1].spice, emperorSpice); assert.equal(g.players[0].hand[0].id, baseDeck()[0].id); assert.ok(g.discard.some((c) => c.id === scenario.karama.id)); }); void test('a bidder who loses keeps its Karama and the winner can choose ordinary payment', () => { const scenario = karamaAuction(); const winnerKarama = baseDeck().find( (c) => c.effect === 'karama' && c.id !== scenario.karama.id, )!; scenario.g.deck = scenario.g.deck.filter((c) => c.id !== winnerKarama.id); scenario.g.players[1].hand = [winnerKarama]; let g = applyAction(scenario.g, 'p0', { type: 'bid', amount: 5 }); g = applyAction(g, 'p1', { type: 'bid', amount: 6 }); g = applyAction(g, 'p0', { type: 'passBid' }); assert.equal(g.decision?.kind, 'auctionPayment'); assert.throws( () => applyAction(g, 'p1', { type: 'decision', karama: true, card: scenario.karama.id, }), /do not hold/, ); g = passResponses(applyAction(g, 'p1', { type: 'decision', karama: false })); assert.ok(g.players[0].hand.some((c) => c.id === scenario.karama.id)); assert.equal(g.players[0].spice, 2); assert.equal(g.players[1].spice, 4); assert.ok( g.players[1].hand.some((c) => c.id === winnerKarama.id), 'choosing spice retains the winner’s Karama', ); }); void test('Karama can take an auction card out of bidding order, but cannot bypass full hands', () => { const scenario = karamaAuction(); scenario.g.auction!.active = 'p1'; const full = structuredClone(scenario.g); full.players[0].hand.push( ...baseDeck() .filter((c) => c.kind === 'worthless') .slice(0, 3), ); assert.throws( () => applyAction(full, 'p0', { type: 'card', card: scenario.karama.id, mode: 'purchase', }), /eligible to bid/, ); const g = applyAction(scenario.g, 'p0', { type: 'card', card: scenario.karama.id, mode: 'purchase', }); assert.equal(g.players[0].spice, 2); assert.equal(g.players[0].hand[0].id, baseDeck()[0].id); assert.equal(g.phase, 4); }); void test('Harkonnen receives the extra card when buying with Karama', () => { const scenario = karamaAuction(['harkonnen', 'emperor']); const before = scenario.g.deck[0]; let g = applyAction(scenario.g, 'p0', { type: 'card', card: scenario.karama.id, mode: 'purchase', }); g = passResponses(g); assert.equal(g.players[0].hand.length, 2); assert.ok(g.players[0].hand.some((c) => c.id === before.id)); }); void test('Karama cancels one Voice before plans can lock, without canceling prescience', () => { let g = preparationBattle(); const karama = baseDeck().find((c) => c.effect === 'karama')!; const poison = baseDeck().find((c) => c.kind === 'poison')!; g.players[0].hand = [karama, poison]; const voiced = applyAction(g, 'p1', { type: 'voice', kind: 'poison', must: false, }); assert.equal(voiced.response!.kind, 'voice'); assert.throws( () => applyAction(voiced, 'p0', { type: 'battlePlan', dial: 0, leader: 'atreides-0', }), /response window/, ); g = applyAction(voiced, 'p0', { type: 'card', card: karama.id, mode: 'cancel', }); assert.equal(g.response, null); assert.equal(g.battle!.voice, undefined); assert.equal(g.battle!.preparation!.kind, 'prescience'); g = plan(g, 'p0', 0, { weapon: poison.id }); assert.equal(g.battle!.plans.p0.weapon, poison.id); assert.ok(g.discard.some((c) => c.id === karama.id)); }); void test('prescience can be canceled before its answer and no information leaks', () => { let g = preparationBattle(); const karama = baseDeck().find((c) => c.effect === 'karama')!; g.players[2].hand = [karama]; g = applyAction(g, 'p1', { type: 'declineBattlePower' }); const asked = applyAction(g, 'p0', { type: 'prescience', field: 'dial' }); assert.equal(asked.response!.kind, 'prescience'); assert.throws( () => applyAction(asked, 'p1', { type: 'prescienceAnswer', value: 2 }), /response window/, ); g = applyAction(asked, 'p2', { type: 'card', card: karama.id, mode: 'cancel', }); assert.equal(g.battle!.preparation, undefined); assert.equal(viewGame(g, 'p0').battle!.insight, null); assert.equal(g.battle!.prescience, undefined); g = plan(g, 'p1', 5); assert.equal(g.battle!.plans.p1.dial, 5); }); void test('Karama cancels a chosen spiritual advisor before the reserve is deployed', () => { let g = started(['atreides', 'beneGesserit']); const karama = baseDeck().find((c) => c.effect === 'karama')!; g.players[0].hand = [karama]; g.phase = 5; g.active = 'p0'; g = applyAction(g, 'p0', { type: 'ship', territory: 'arrakeen', sector: 10, amount: 1, }); g = applyAction(g, 'p1', { type: 'decision', accept: true }); assert.equal(g.players[1].reserves, 19); assert.equal(g.response!.kind, 'advisor'); g = applyAction(g, 'p0', { type: 'card', card: karama.id, mode: 'cancel' }); assert.equal(g.players[1].reserves, 19); assert.equal(g.players[1].forces['polar_sink:0'], 1); assert.equal(g.players[0].shipped, true); conservation(g); }); void test('a response cannot be skipped, paid for with another player’s card, or canceled by its owner', () => { let g = preparationBattle(); const karama = baseDeck().find((c) => c.effect === 'karama')!; g.players[1].hand = [karama]; g.players[2].hand = [ baseDeck().find((c) => c.effect === 'karama' && c.id !== karama.id)!, ]; g = applyAction(g, 'p1', { type: 'voice', kind: 'hero', must: false }); assert.throws( () => applyAction(g, 'p1', { type: 'card', card: karama.id, mode: 'cancel' }), /another faction/, ); assert.throws( () => applyAction(g, 'p0', { type: 'card', card: karama.id, mode: 'cancel' }), /in your hand/, ); g = applyAction(g, 'p0', { type: 'passResponse' }); const before = structuredClone(g); assert.throws( () => applyAction(g, 'p0', { type: 'passResponse' }), /already passed/, ); assert.deepEqual(g, before); g = passResponses(g); assert.equal(g.response, null); assert.ok(g.battle!.voice); }); void test('canceling Emperor income sends the paid spice to the bank and still delivers the card', () => { const scenario = karamaAuction(); let g = applyAction(scenario.g, 'p0', { type: 'bid', amount: 1 }); g = applyAction(g, 'p1', { type: 'passBid' }); g = applyAction(g, 'p0', { type: 'decision', karama: false }); assert.equal(g.response!.kind, 'emperorIncome'); assert.equal(g.players[1].spice, 10); g = applyAction(g, 'p0', { type: 'card', card: scenario.karama.id, mode: 'cancel', }); assert.equal(g.players[0].spice, 1); assert.equal(g.players[1].spice, 10); assert.ok(g.players[0].hand.some((c) => c.id === baseDeck()[0].id)); assert.equal(g.phase, 4); }); void test('canceling the Harkonnen bonus keeps the bonus card hidden and in the deck', () => { const scenario = karamaAuction(['harkonnen', 'emperor']); const second = baseDeck().filter((c) => c.effect === 'karama')[1]; scenario.g.players[1].hand = [second]; const originalDeck = structuredClone(scenario.g.deck); let g = applyAction(scenario.g, 'p0', { type: 'card', card: scenario.karama.id, mode: 'purchase', }); assert.equal(g.response!.kind, 'harkonnenBonus'); assert.equal(g.players[0].hand.length, 1); g = applyAction(g, 'p1', { type: 'card', card: second.id, mode: 'cancel' }); assert.equal(g.players[0].hand.length, 1); assert.deepEqual(g.deck, originalDeck); assert.equal(g.phase, 4); }); void test('Fremen starting forces can split between sectors and reject invalid setup atomically', () => { let g = lobby(['fremen', 'atreides']); g.players.forEach((p) => (p.ready = true)); g = applyAction(g, 'p0', { type: 'start' }); for (const p of g.players) g = applyAction(g, p.id, { type: 'traitor', leader: p.traitorChoices[0] }); const west = TERRITORIES.find((t) => t.id === 'false_wall_west')!; assert.ok(west.sectors.length > 1); const placements = { [`false_wall_west:${west.sectors[0]}`]: 4, [`false_wall_west:${west.sectors[1]}`]: 6, }; for (const bad of [ { 'arrakeen:10': 10 }, { 'false_wall_west:0': 10 }, { ...placements, unknown: 0 }, { [Object.keys(placements)[0]]: 9 }, ]) { const original = structuredClone(g); assert.throws(() => applyAction(g, 'p0', { type: 'fremenSetup', placements: bad }), ); assert.deepEqual(g, original); } g = applyAction(g, 'p0', { type: 'fremenSetup', placements }); assert.deepEqual(g.players[0].forces, placements); assert.equal(g.players[0].reserves, 10); assert.throws( () => applyAction(g, 'p0', { type: 'fremenSetup', placements }), /not available|already complete/, ); conservation(g); }); void test('Karama shipment pays rounded Guild rates to the bank and is consumed once', () => { let g = started(['atreides', 'guild']); g.phase = 5; g.active = 'p0'; g.order = ['p0', 'p1']; g.storm = 18; const card = baseDeck().find((c) => c.effect === 'karama')!; g.players[0].hand = [card]; const before = g.players.map((p) => p.spice); g = applyAction(g, 'p0', { type: 'card', card: card.id, mode: 'shipment' }); g = applyAction(g, 'p0', { type: 'ship', territory: 'arrakeen', sector: 10, amount: 3, }); assert.equal(g.players[0].spice, before[0] - 2); assert.equal(g.players[1].spice, before[1]); assert.equal(g.karamaShipping, null); assert.ok(g.discard.some((c) => c.id === card.id)); assert.throws( () => applyAction(g, 'p0', { type: 'ship', territory: 'arrakeen', sector: 10, amount: 1, }), /once/, ); conservation(g); }); void test('Karama may sponsor another player while that player controls the shipment and normal income resumes', () => { let g = started(['atreides', 'guild', 'harkonnen']); g.phase = 5; g.active = 'p2'; g.order = ['p2', 'p0', 'p1']; g.storm = 18; const card = baseDeck().find((c) => c.effect === 'karama')!; g.players[0].hand = [card]; assert.throws( () => applyAction(g, 'p0', { type: 'card', card: card.id, mode: 'shipment', target: 'p0', }), /active player/, ); g = applyAction(g, 'p0', { type: 'card', card: card.id, mode: 'shipment', target: 'p2', }); assert.throws( () => applyAction(g, 'p0', { type: 'ship', territory: 'carthag', sector: 11, amount: 3, }), /once/, ); const payer = g.players[2].spice, guild = g.players[1].spice; g = applyAction(g, 'p2', { type: 'ship', territory: 'carthag', sector: 11, amount: 3, }); assert.equal(g.players[2].spice, payer - 2); assert.equal(g.players[1].spice, guild); g = applyAction(g, 'p2', { type: 'endMovement' }); g = applyAction(g, 'p0', { type: 'ship', territory: 'arrakeen', sector: 10, amount: 2, }); g = passResponses(g); assert.equal(g.players[1].spice, guild + 2); conservation(g); }); void test('an unused Karama shipment cannot carry over after movement or the end of a turn', () => { let g = started(['atreides', 'guild']); g.phase = 5; g.active = 'p0'; g.order = ['p0', 'p1']; g.storm = 18; const card = baseDeck().find((c) => c.effect === 'karama')!; g.players[0].hand = [card]; g = applyAction(g, 'p0', { type: 'card', card: card.id, mode: 'shipment' }); const moved = applyAction(g, 'p0', { type: 'move', from: 'arrakeen:10', territory: 'imperial_basin', sector: 10, amount: 1, }); assert.equal(moved.karamaShipping, null); const ended = applyAction(g, 'p0', { type: 'endMovement' }); assert.equal(ended.karamaShipping, null); }); void test('Karama cancels a single Guild payment without undoing shipment or the BG decision', () => { let g = started(['atreides', 'guild', 'beneGesserit']); g.phase = 5; g.active = 'p0'; g.storm = 18; const card = baseDeck().find((c) => c.effect === 'karama')!; g.players[0].hand = [card]; const income = g.players[1].spice; g = applyAction(g, 'p0', { type: 'ship', territory: 'arrakeen', sector: 10, amount: 3, }); assert.equal(g.response?.kind, 'guildIncome'); assert.equal(g.players[1].spice, income); assert.equal(g.decision?.kind, 'advisor'); assert.throws( () => applyAction(g, 'p0', { type: 'endMovement' }), /response window/, ); g = applyAction(g, 'p0', { type: 'card', card: card.id, mode: 'cancel' }); assert.equal(g.players[1].spice, income); assert.equal(g.players[0].spice, 7); assert.equal(g.players[0].forces['arrakeen:10'], 13); assert.equal(g.decision?.kind, 'advisor'); g = applyAction(g, 'p2', { type: 'decision', accept: false }); conservation(g); }); void test('Karama cancels Harkonnen ally traitor support but cannot cancel their own traitor victory', () => { let g = started(['atreides', 'emperor', 'harkonnen']); g.phase = 6; g.active = 'p0'; g.storm = 18; g.order = ['p0', 'p1', 'p2']; g.players[0].ally = 'p2'; g.players[2].ally = 'p0'; g.players[0].forces = { 'arrakeen:10': 10 }; g.players[1].forces = { 'arrakeen:10': 10 }; g.players[1].reserves = 10; g.players[2].traitors = ['emperor-0']; const card = baseDeck().find((c) => c.effect === 'karama')!; g.players[1].hand = [card]; g = applyAction(g, 'p0', { type: 'chooseBattle', territory: 'arrakeen', target: 'p1', }); g = plan(plan(g, 'p0', 0), 'p1', 8); g = resolve(g); g = applyAction(g, 'p2', { type: 'traitorCall', call: true }); assert.equal(g.response?.kind, 'harkonnenTraitor'); assert.equal(g.players[1].tanks, 0); g = applyAction(g, 'p1', { type: 'card', card: card.id, mode: 'cancel' }); assert.equal(g.players[0].tanks, 10); assert.equal(g.players[1].tanks, 8); assert.equal(g.players[1].leaders[0].dead, false); assert.ok(g.players[2].traitors.includes('emperor-0')); conservation(g); let own = combat(); own.players[1].traitors = [own.players[0].leaders[0].id]; own = plan(plan(own, 'p0', 1), 'p1', 1); own = applyAction(own, 'p0', { type: 'traitorCall', call: false }); own = applyAction(own, 'p1', { type: 'traitorCall', call: true }); assert.equal(own.response, null); assert.equal(own.players[0].tanks, 10); }); void test('Emperor gifts become spendable immediately, stay private, and can be canceled once', () => { let g = started(['emperor', 'atreides', 'harkonnen']); g.phase = 3; g.players[0].ally = 'p1'; g.players[1].ally = 'p0'; const card = baseDeck().find((c) => c.effect === 'karama')!; g.players[2].hand = [card]; g = applyAction(g, 'p0', { type: 'emperorGift', amount: 3 }); assert.equal(g.players[1].spice, 10); assert.equal(viewGame(g, 'p2').response?.amount, undefined); assert.equal(viewGame(g, 'p1').response?.amount, 3); g = applyAction(g, 'p2', { type: 'card', card: card.id, mode: 'cancel' }); assert.equal(g.players[0].spice, 10); assert.equal(g.players[1].spice, 10); g = power(g, 'p0', { type: 'emperorGift', amount: 4 }); assert.equal(g.players[0].spice, 6); assert.equal(g.players[1].spice, 14); assert.equal(g.players[1].bribes, 0); assert.throws( () => applyAction(g, 'p1', { type: 'emperorGift', amount: 1 }), /Emperor/, ); assert.throws(() => applyAction(g, 'p0', { type: 'emperorGift', amount: 7 })); }); void test('Emperor extra revivals cost two each and allow six total without using normal free revivals', () => { let g = started(['emperor', 'atreides']); g.phase = 4; g.players[0].ally = 'p1'; g.players[1].ally = 'p0'; g.players[1].reserves = 0; g.players[1].tanks = 10; g = power(g, 'p0', { type: 'emperorRevival', amount: 2 }); g = power(g, 'p0', { type: 'emperorRevival', amount: 1 }); assert.equal(g.players[0].spice, 4); assert.equal(g.players[1].revived, 0); assert.equal(g.emperorExtra.p1, 3); g = applyAction(g, 'p1', { type: 'revive', amount: 3 }); assert.equal(g.players[1].spice, 8); assert.equal(g.players[1].reserves, 6); assert.equal(g.players[1].tanks, 4); assert.throws(() => applyAction(g, 'p0', { type: 'emperorRevival', amount: 1 }), ); assert.throws(() => applyAction(g, 'p1', { type: 'revive', amount: 1 })); conservation(g); }); void test('canceling Emperor extra revival spends no spice or quota and preserves eligible tanks', () => { let g = started(['emperor', 'fremen', 'atreides']); g.phase = 4; g.players[0].ally = 'p1'; g.players[1].ally = 'p0'; g.players[1].reserves = 0; g.players[1].tanks = 10; const card = baseDeck().find((c) => c.effect === 'karama')!; g.players[2].hand = [card]; g = applyAction(g, 'p0', { type: 'emperorRevival', amount: 3 }); g = applyAction(g, 'p2', { type: 'card', card: card.id, mode: 'cancel' }); assert.equal(g.players[0].spice, 10); assert.equal(g.players[1].tanks, 10); assert.equal(g.emperorExtra.p1, undefined); g = power(g, 'p0', { type: 'emperorRevival', amount: 3 }); g = applyAction(g, 'p1', { type: 'revive', amount: 3 }); assert.equal(g.players[1].reserves, 6); assert.equal(g.players[1].spice, 3); conservation(g); }); void test('Guild allies pay rounded half rates on ordinary shipment and Guild receives that payment', () => { let g = started(['atreides', 'guild']); g.players.forEach((p) => (p.hand = [])); g.phase = 5; g.active = 'p0'; g.storm = 18; g.players[0].ally = 'p1'; g.players[1].ally = 'p0'; g = applyAction(g, 'p0', { type: 'ship', territory: 'arrakeen', sector: 10, amount: 3, }); assert.equal(g.players[0].spice, 8); assert.equal(g.response, null); assert.equal(g.players[1].spice, 7); g = passResponses(g); assert.equal(g.players[1].spice, 7); conservation(g); }); void test('allied cross-shipment pays Guild, combines sectors and does not trigger a BG free force', () => { let g = started(['atreides', 'guild', 'beneGesserit']); g.players.forEach((p) => (p.hand = [])); g.phase = 5; g.active = 'p0'; g.storm = 18; g.players[0].ally = 'p1'; g.players[1].ally = 'p0'; g.players[0].forces = { 'imperial_basin:10': 4, 'imperial_basin:11': 6 }; const guild = g.players[1].spice; g = applyAction(g, 'p0', { type: 'guildShip', forces: { 'imperial_basin:10': 2, 'imperial_basin:11': 3 }, territory: 'carthag', sector: 11, }); assert.equal(g.players[0].spice, 7); assert.equal(g.players[1].spice, guild + 3); assert.equal(g.players[0].forces['carthag:11'], 5); assert.equal(g.decision, null); assert.equal(g.response, null); g = passResponses(g); assert.equal(g.players[1].spice, guild + 3); conservation(g); }); void test('Guild allies cannot return forces to reserves or transport through storm and lose privileges when unallied', () => { const g = started(['atreides', 'guild']); g.phase = 5; g.active = 'p0'; g.storm = 18; g.players[0].ally = 'p1'; g.players[1].ally = 'p0'; assert.throws( () => applyAction(g, 'p0', { type: 'guildShip', from: 'arrakeen:10', territory: 'reserves', amount: 1, }), /Only the Guild/, ); g.storm = 10; assert.throws( () => applyAction(g, 'p0', { type: 'guildShip', from: 'arrakeen:10', territory: 'carthag', sector: 11, amount: 1, }), /storm/, ); g.storm = 11; assert.throws( () => applyAction(g, 'p0', { type: 'guildShip', from: 'arrakeen:10', territory: 'carthag', sector: 11, amount: 1, }), /storm/, ); g.storm = 18; g.players[0].ally = null; g.players[1].ally = null; assert.throws( () => applyAction(g, 'p0', { type: 'guildShip', from: 'arrakeen:10', territory: 'carthag', sector: 11, amount: 1, }), /not available/, ); }); void test('Fremen allied to Guild can pay for distant southern-reserve transport without off-planet effects', () => { let g = started(['fremen', 'guild', 'beneGesserit']); g.players.forEach((p) => (p.hand = [])); g.phase = 5; g.active = 'p0'; g.storm = 18; g.players[0].ally = 'p1'; g.players[1].ally = 'p0'; g.players[0].spice = 10; const guild = g.players[1].spice; g = applyAction(g, 'p0', { type: 'guildShip', from: 'reserves', territory: 'carthag', sector: 11, amount: 3, }); assert.equal(g.players[0].spice, 8); assert.equal(g.players[0].reserves, 7); assert.equal(g.players[0].forces['carthag:11'], 3); assert.equal(g.players[1].spice, guild + 2); assert.equal(g.decision, null); assert.equal(g.response, null); g = passResponses(g); assert.equal(g.players[1].spice, guild + 2); conservation(g); }); function advancedCombat() { let g = started(['emperor', 'atreides']); g.advanced = true; g.phase = 6; g.storm = 18; g.active = 'p0'; g.order = ['p0', 'p1']; g.players[0].forces = { 'arrakeen:10': 6 }; g.players[0].reserves = 14; g.players[0].elites = { forces: { 'arrakeen:10': 1 }, reserves: 4, tanks: 0, revived: 0, }; g = applyAction(g, 'p0', { type: 'chooseBattle', territory: 'arrakeen', target: 'p1', }); return passResponses(g); } void test('advanced battle charges spice, preserves hidden support and waits for the winner casualty choice', () => { let g = advancedCombat(); g = plan(g, 'p0', 3, { support: 1 }); assert.equal(viewGame(g, 'p1').battle!.plans.p0, undefined); g = plan(g, 'p1', 0, { support: 0 }); g = resolve(g); assert.equal(g.decision?.kind, 'battleLosses'); assert.equal(g.players[0].spice, 9); assert.equal(g.players[0].forces['arrakeen:10'], 6); assert.equal(g.players[1].tanks, 10); assert.throws( () => applyAction(g, 'p1', { type: 'decision', choice: 0 }), /pending decision/, ); if (g.decision?.kind !== 'battleLosses') throw new Error('Missing casualties'); const eliteChoice = g.decision.options.findIndex( (o) => o.normal === 2 && o.elite === 1, ); const normalChoice = g.decision.options.findIndex( (o) => o.normal === 5 && o.elite === 0, ); const a = applyAction(g, 'p0', { type: 'decision', choice: eliteChoice }); const b = applyAction(g, 'p0', { type: 'decision', choice: normalChoice }); assert.equal(a.players[0].forces['arrakeen:10'], 3); assert.equal(a.players[0].elites!.tanks, 1); assert.equal(b.players[0].forces['arrakeen:10'], 1); assert.equal(b.players[0].elites!.forces['arrakeen:10'], 1); conservation(a); conservation(b); }); void test('advanced prescience accepts a dial that requires support without revealing the support', () => { let g = advancedCombat(); g = power(g, 'p1', { type: 'prescience', field: 'dial' }); g = applyAction(g, 'p0', { type: 'prescienceAnswer', value: 7 }); g = applyAction(g, 'p1', { type: 'decision', decline: true }); assert.equal(viewGame(g, 'p1').battle!.insight?.value, 7); assert.throws( () => applyAction(g, 'p0', { type: 'battlePlan', dial: 7, support: 0, leader: 'emperor-0', }), /legal force commitment/, ); g = applyAction(g, 'p0', { type: 'battlePlan', dial: 7, support: 6, leader: 'emperor-0', }); assert.equal(viewGame(g, 'p1').battle!.plans.p0, undefined); }); void test('advanced traitor winner spends no support and loser still pays its commitment', () => { let g = combat(); g.advanced = true; g.players[0].traitors = [g.players[1].leaders[0].id]; const before = g.players.map((p) => p.spice), bounty = g.players[1].leaders[0].strength; g = plan(plan(g, 'p0', 2, { support: 2 }), 'p1', 2, { support: 2 }); g = applyAction(g, 'p0', { type: 'traitorCall', call: true }); g = applyAction(g, 'p1', { type: 'traitorCall', call: false }); assert.equal(g.players[0].spice, before[0] + bounty + 2); assert.equal(g.players[1].spice, before[1] - 2); assert.notEqual(g.decision?.kind, 'battleLosses'); }); void test('advanced shipment and multi-sector movement preserve chosen elite tokens', () => { let g = started(['emperor', 'atreides']); g.advanced = true; g.phase = 5; g.active = 'p0'; g.storm = 18; g.players[0].elites = { reserves: 5, tanks: 0, forces: {}, revived: 0 }; g = applyAction(g, 'p0', { type: 'ship', territory: 'imperial_basin', sector: 10, amount: 4, elite: 2, }); assert.equal(g.players[0].elites!.reserves, 3); assert.equal(g.players[0].elites!.forces['imperial_basin:10'], 2); g = applyAction(g, 'p0', { type: 'move', from: 'imperial_basin:10', territory: 'arrakeen', sector: 10, amount: 3, elite: 1, }); assert.equal(g.players[0].elites!.forces['imperial_basin:10'], 1); assert.equal(g.players[0].elites!.forces['arrakeen:10'], 1); conservation(g); }); void test('Ghola and normal revival share the elite limit for the entire turn, not only the revival phase', () => { let g = started(['emperor', 'atreides']); g.advanced = true; g.players[0].reserves = 15; g.players[0].tanks = 5; g.players[0].elites = { reserves: 2, tanks: 3, forces: {}, revived: 0 }; const ghola = baseDeck().find((c) => c.effect === 'ghola')!; placeFixtureHand(g, 0, [ghola]); g = applyAction(g, 'p0', { type: 'card', card: ghola.id, amount: 1, elite: 1, }); assert.equal(g.players[0].elites!.revived, 1); assert.equal(g.players[0].elites!.reserves, 3); g.phase = 2; g.ready = []; g = passResponses(readyAll(g)); while (g.phase === 3) g = applyAction(g, g.auction!.active, { type: 'passBid' }); assert.equal(g.phase, 4); assert.throws( () => applyAction(g, 'p0', { type: 'revive', amount: 1, elite: 1 }), /one elite/, ); g = applyAction(g, 'p0', { type: 'revive', amount: 2, elite: 0 }); conservation(g); g.phase = 8; g.ready = []; g = readyAll(g); assert.equal(g.turn, 2); assert.equal(g.players[0].elites!.revived, 0); }); void test('Fedaykin survive a worm ride as the exact elite group selected by Fremen', () => { let g = started(['fremen', 'atreides']); g.advanced = true; g.phase = 1; g.storm = 18; const sector = TERRITORIES.find((t) => t.id === 'the_great_flat')!.sectors[0], key = `the_great_flat:${sector}`; g.players[0].forces = { [key]: 10 }; g.players[0].elites = { reserves: 1, tanks: 0, forces: { [key]: 2 }, revived: 0, }; g.decision = { kind: 'wormRide', player: 'p0', territory: 'the_great_flat' }; g = applyAction(g, 'p0', { type: 'decision', accept: true, territory: 'arrakeen', sector: 10, forces: { [key]: 4 }, eliteForces: { [key]: 2 }, }); assert.equal(g.players[0].forces[key], 6); assert.equal(g.players[0].elites!.forces[key], undefined); assert.equal(g.players[0].elites!.forces['arrakeen:10'], 2); conservation(g); }); void test('a traitor loss moves every defeated elite token to the tanks', () => { let g = advancedCombat(); g.players[1].traitors = ['emperor-0']; g = plan(plan(g, 'p0', 3, { support: 1 }), 'p1', 1, { support: 1 }); g = applyAction(g, 'p0', { type: 'traitorCall', call: false }); g = applyAction(g, 'p1', { type: 'traitorCall', call: true }); assert.equal(g.players[0].elites!.tanks, 1); assert.equal(g.players[0].elites!.forces['arrakeen:10'], undefined); conservation(g); }); void test('sealed battle spice cannot be spent through an Emperor gift while the opponent prepares', () => { let g = started(['emperor', 'atreides', 'harkonnen']); g.advanced = true; g.players.forEach((p) => (p.hand = [])); g.phase = 6; g.storm = 18; g.active = 'p0'; g.order = ['p0', 'p1', 'p2']; g.players[0].forces = { 'arrakeen:10': 6 }; g.players[0].reserves = 14; g.players[0].elites = { forces: { 'arrakeen:10': 1 }, reserves: 4, tanks: 0, revived: 0, }; g.players[0].ally = 'p2'; g.players[2].ally = 'p0'; g = applyAction(g, 'p0', { type: 'chooseBattle', territory: 'arrakeen', target: 'p1', }); g = plan(g, 'p0', 5, { support: 4 }); assert.throws(() => applyAction(g, 'p0', { type: 'emperorGift', amount: 7 })); const pending = applyAction(g, 'p0', { type: 'emperorGift', amount: 6 }); assert.equal(pending.response, null); assert.equal(pending.players[0].spice, 4); assert.equal(pending.players[2].spice, g.players[2].spice + 6); assert.equal(pending.battle!.plans.p0.support, 4); }); void test('Guild-funded allied shipping sends the Guild contribution to the bank instead of recycling it as income', () => { let g = started(['atreides', 'guild']); g.players.forEach((p) => (p.hand = [])); g.phase = 5; g.active = 'p0'; g.storm = 18; g.players[0].ally = 'p1'; g.players[1].ally = 'p0'; g = applyAction(g, 'p1', { type: 'pledgeAid', amount: 2 }); g = applyAction(g, 'p0', { type: 'ship', territory: 'arrakeen', sector: 10, amount: 5, allyPayment: 2, }); assert.equal(g.players[0].spice, 9); assert.equal(g.players[1].spice, 4); assert.equal(g.response, null); g = passResponses(g); assert.equal(g.players[1].spice, 4); conservation(g); }); void test('Atreides auction foresight is hidden until its response closes and a canceled peek does not affect the next card', () => { let g = started(['atreides', 'emperor']); g.players.forEach((p) => (p.hand = [])); g.phase = 2; const karama = baseDeck().find((c) => c.effect === 'karama')!; g.players[1].hand = [karama]; g.deck = baseDeck().filter((c) => c.effect !== 'karama'); g = readyAll(g); const first = g.auction!.cards[0]; const second = g.auction!.cards[1]; assert.equal(g.response?.kind, 'atreidesAuction'); assert.equal(viewGame(g, 'p0').auction!.card, null); assert.equal(viewGame(g, 'p1').auction!.card, null); assert.throws( () => applyAction(g, g.auction!.active, { type: 'bid', amount: 1 }), /response window/, ); const seen = passResponses(g); assert.deepEqual(viewGame(seen, 'p0').auction!.card, first); assert.equal(viewGame(seen, 'p1').auction!.card, null); g = applyAction(g, 'p1', { type: 'card', card: karama.id, mode: 'cancel' }); assert.equal(viewGame(g, 'p0').auction!.card, null); g = applyAction(g, 'p0', { type: 'bid', amount: 1 }); g = applyAction(g, 'p1', { type: 'passBid' }); assert.notEqual(g.decision?.kind, 'auctionPayment'); assert.equal(g.response, null); assert.equal(g.players[1].spice, 11); assert.deepEqual(viewGame(g, 'p0').auction!.card, second); assert.equal(viewGame(g, 'p1').auction!.card, null); conservation(g); }); void test('Atreides may inspect an auction card even when its own hand is full', () => { let g = started(['atreides', 'emperor']); g.players.forEach((p) => (p.hand = [])); g.phase = 2; g.players[0].hand = baseDeck().slice(0, 4); g.deck = baseDeck().slice(4); g = readyAll(g); assert.equal(g.auction!.active, 'p1'); assert.equal(g.auction!.cards.length, 1); assert.equal(g.response, null); g = passResponses(g); assert.deepEqual(viewGame(g, 'p0').auction!.card, g.auction!.cards[0]); }); void test('movement spice foresight reveals one private card only after the cancellation window and resets each turn', () => { let g = started(['atreides', 'harkonnen']); g.advanced = true; g.phase = 4; const cards = spiceDeck().filter((c) => 'territory' in c); g.spiceDeck = cards; const karama = baseDeck().find((c) => c.effect === 'karama')!; g.players[1].hand = [karama]; g = readyAll(g); assert.equal(g.phase, 5); assert.equal(g.response?.kind, 'atreidesSpice'); assert.equal(viewGame(g, 'p0').spicePeek, null); assert.throws( () => applyAction(g, g.active!, { type: 'endMovement' }), /response window/, ); const canceled = applyAction(g, 'p1', { type: 'card', card: karama.id, mode: 'cancel', }); assert.equal(viewGame(canceled, 'p0').spicePeek, null); assert.deepEqual(canceled.spiceDeck, cards); g = passResponses(g); assert.deepEqual(viewGame(g, 'p0').spicePeek, cards[0]); assert.equal(viewGame(g, 'p1').spicePeek, null); assert.deepEqual(g.spiceDeck, cards); g.turn++; g.phase = 4; g.spiceDeck = cards.slice(2); g = readyAll(g); assert.equal(viewGame(g, 'p0').spicePeek, null); g = passResponses(g); assert.deepEqual(viewGame(g, 'p0').spicePeek, cards[2]); }); void test('an exhausted spice deck is replenished before the movement peek and the revealed card remains on top', () => { let g = started(['atreides', 'harkonnen']); g.players.forEach((p) => (p.hand = [])); g.phase = 4; const cards = spiceDeck(); g.spiceDeck = []; g.spiceDiscard = [cards.slice(0, 10), cards.slice(10)]; g = readyAll(g); assert.equal(g.spiceDeck.length, cards.length); assert.deepEqual(g.spiceDiscard, [[], []]); assert.equal(g.response, null); assert.deepEqual(viewGame(g, 'p0').spicePeek, g.spiceDeck[0]); assert.equal(viewGame(g, 'p1').spicePeek, null); g = passResponses(g); assert.deepEqual(viewGame(g, 'p0').spicePeek, g.spiceDeck[0]); }); void test('skipping an empty battle phase collects spice only once and respects the per-force cap', () => { let g = started(['atreides', 'harkonnen']); g.phase = 5; g.active = 'p0'; g.order = ['p0', 'p1']; g.storm = 18; g.players[0].forces = { 'arrakeen:10': 9, 'red_chasm:7': 1 }; g.spice = { 'red_chasm:7': 8 }; const before = g.players[0].spice; g = applyAction(g, 'p0', { type: 'endMovement' }); g = applyAction(g, 'p1', { type: 'endMovement' }); assert.equal(g.phase, 7); assert.equal(g.players[0].spice, before + 3); assert.equal(g.spice['red_chasm:7'], 5); conservation(g); }); void test('a storm-contested stronghold does not count toward a normal victory, but sole occupation still counts', () => { let g = started(['atreides', 'harkonnen', 'emperor']); g.phase = 7; g.storm = 10; g.players[0].forces = { 'arrakeen:10': 1, 'sietch_tabr:14': 1, 'tueks_sietch:5': 1, }; g.players[1].forces = { 'arrakeen:10': 1 }; g.players[2].forces = {}; g = readyAll(g); assert.equal(g.status, 'playing'); g.phase = 7; g.ready = []; g.players[1].forces = {}; g = readyAll(g); assert.deepEqual(g.winner, ['p0']); }); void test('without Guild, Fremen wins at the final turn even when its normal special conditions fail', () => { let g = started(['fremen', 'atreides', 'emperor']); g.turn = 10; g.phase = 7; g.players[0].forces = {}; g.players[1].forces = { 'sietch_tabr:14': 1, 'tueks_sietch:5': 1 }; g.players[2].forces = { 'habbanya_ridge_sietch:17': 1 }; g = readyAll(g); assert.equal(g.status, 'finished'); assert.deepEqual(g.winner, ['p0']); }); void test('final fallback respects normal victories first and Guild before unconditional Fremen fallback', () => { const state = started(['fremen', 'atreides', 'guild']); state.turn = 10; state.phase = 7; state.players[0].forces = {}; state.players[1].forces = { 'sietch_tabr:14': 1 }; state.players[2].forces = {}; const guild = readyAll(state); assert.deepEqual(guild.winner, ['p2']); state.players[1].forces = { 'sietch_tabr:14': 1, 'arrakeen:10': 1, 'carthag:11': 1, }; assert.deepEqual(readyAll(state).winner, ['p1']); }); void test('without Fremen or Guild, final fallback selects the greatest individual stronghold count and includes all ties', () => { const state = started(['atreides', 'harkonnen', 'emperor']); state.turn = 10; state.phase = 7; state.players[0].forces = { 'arrakeen:10': 1, 'sietch_tabr:14': 1 }; state.players[1].forces = { 'carthag:11': 1 }; state.players[2].forces = {}; assert.deepEqual(readyAll(state).winner, ['p0']); state.players[1].forces['tueks_sietch:5'] = 1; assert.deepEqual(readyAll(state).winner, ['p0', 'p1']); state.players.forEach((p) => (p.forces = {})); assert.deepEqual(readyAll(state).winner, ['p0', 'p1', 'p2']); }); void test('storm-contested holds are excluded from final scores and fallback does not end an earlier turn', () => { const state = started(['atreides', 'harkonnen', 'emperor']); state.turn = 9; state.phase = 7; state.storm = 10; state.players[0].forces = { 'arrakeen:10': 1, 'sietch_tabr:14': 1 }; state.players[1].forces = { 'arrakeen:10': 1 }; state.players[2].forces = { 'carthag:11': 1 }; assert.equal(readyAll(state).status, 'playing'); state.turn = 10; assert.deepEqual(readyAll(state).winner, ['p0', 'p2']); }); void test('Bene Gesserit can predict the final stronghold fallback but cannot steal Guild or Fremen special victories', () => { for (const predicted of ['atreides', 'guild', 'fremen'] as const) { const state = started(['beneGesserit', predicted, 'emperor']); state.turn = 10; state.phase = 7; state.players[0].prediction = { faction: predicted, turn: 10 }; state.players[0].forces = {}; state.players[1].forces = { 'arrakeen:10': 1, 'carthag:11': 1 }; state.players[2].forces = { 'sietch_tabr:14': 1 }; assert.deepEqual(readyAll(state).winner, [ predicted === 'atreides' ? 'p0' : 'p1', ]); } });