import { placeFixtureHand } from './fixture-hand'; import test from 'node:test'; import assert from 'node:assert/strict'; import { createGame, newPlayer, joinGame, applyAction, viewGame, type Game, } from '../game/engine'; import { spiceDeck, baseDeck } from '../game/cards'; import { TERRITORIES } from '../game/board'; import { createStormSource } from '../game/discovery-storm'; import { isStormCardDistance, stormCardDistance } from '../game/storm-cards'; function fixture() { let g = createGame('ADVTEST2', newPlayer('f', 'Fremen', 'fremen')); joinGame(g, newPlayer('a', 'Atreides', 'atreides')); g.players.forEach((p) => (p.ready = true)); g = applyAction(g, 'f', { type: 'start' }); for (const p of g.players) if (p.traitorChoices.length) g = applyAction(g, p.id, { type: 'traitor', leader: p.traitorChoices[0], }); g = applyAction(g, 'f', { type: 'fremenSetup', placements: { sietch_tabr: 10 }, }); g.players.forEach((p) => (p.hand = [])); g.advanced = true; g.players[0].elites = { reserves: 3, tanks: 0, forces: {}, revived: 0 }; return g; } function ready(state: Game) { let g = state; for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' }); return g; } function allow(state: Game) { let g = state; while (g.response) g = applyAction( g, g.players.find((p) => !g.response!.passed.includes(p.id))!.id, { type: 'passResponse' }, ); return g; } const land = spiceDeck().filter((c) => 'territory' in c); const worms = spiceDeck().filter((c) => 'worm' in c); function conserve(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, ); if (p.elites) assert.equal( p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((a, b) => a + b, 0), 3, ); } } void test('double spice blow keeps separate discard piles and returns first-turn worms only after both blows', () => { let g = fixture(); g.phase = 1; g.storm = 18; g.spiceDeck = [worms[0], land[0], worms[1], land[1], land[2]]; g = ready(g); assert.equal(g.spiceWindow?.territory, land[0].territory); assert.equal(g.spiceSequence?.pile, 0); assert.equal(g.spiceSequence?.skipped.length, 1); g = ready(g); assert.equal(g.spiceWindow?.territory, land[1].territory); assert.equal(g.spiceSequence?.pile, 1); assert.equal(g.spiceSequence?.skipped.length, 2); assert.deepEqual(g.spiceDeck, [land[2]]); assert.deepEqual(g.spiceDiscard, [[land[0]], [land[1]]]); g = ready(g); assert.equal(g.phase, 2); assert.equal(g.nexus, false); assert.equal(g.spiceSequence, null); assert.equal(g.spiceDeck.length, 3); assert.equal(g.spiceDeck.filter((c) => 'worm' in c).length, 2); }); void test('each advanced spice blow completes its Nexus and worm rides before the next; extra worm placement is per pile', () => { let g = fixture(); g.turn = 2; g.phase = 1; g.storm = 18; const source = `${land[0].territory}:${land[0].sector}`; const target = land[4]; const targetKey = `${target.territory}:${target.sector}`; g.spiceDiscard = [[land[0]], [land[1]]]; g.spiceDeck = [worms[0], worms[1], land[2], worms[2], land[3]]; g.players[0].forces = { [source]: 10 }; g.players[1].forces = { [targetKey]: 10 }; g.spice = { [targetKey]: 7 }; g = allow(ready(g)); assert.equal(g.decision?.kind, 'wormPlacement'); assert.equal(g.spiceSequence?.pile, 0); assert.throws( () => applyAction(g, 'f', { type: 'decision', accept: true, territory: 'arrakeen', }), /sand territory/, ); g = applyAction(g, 'f', { type: 'decision', accept: true, territory: target.territory, }); g = allow(g); assert.equal(g.players[1].tanks, 10); assert.equal(g.spice[targetKey], undefined); assert.equal(g.spiceWindow?.territory, land[2].territory); g = ready(g); assert.equal(g.nexus, true); assert.equal(g.spiceSequence?.pile, 0); g = ready(g); assert.equal(g.decision?.kind, 'wormRide'); assert.equal(g.spiceSequence?.pile, 0); g = applyAction(g, 'f', { type: 'decision', accept: false }); assert.equal(g.spiceSequence?.pile, 1); assert.equal(g.spiceWindow?.territory, land[3].territory); assert.equal(g.decision, null); g = ready(g); assert.equal(g.nexus, true); g = ready(g); assert.equal(g.phase, 2); assert.equal(g.log.filter((l) => l.text.startsWith('Nexus:')).length, 2); conserve(g); }); void test('Harvester can target the second blow without changing the first blow', () => { let g = fixture(); g.phase = 1; g.storm = 18; const cards = land.filter((c) => c.sector !== 18).slice(0, 2); g.spiceDeck = [...cards]; const harvester = baseDeck().find((c) => c.effect === 'harvester')!; placeFixtureHand(g, 1, [harvester]); g = ready(ready(g)); assert.equal(g.spiceSequence?.pile, 1); g = applyAction(g, 'a', { type: 'card', card: harvester.id }); assert.equal( g.spice[`${cards[0].territory}:${cards[0].sector}`], cards[0].amount, ); assert.equal( g.spice[`${cards[1].territory}:${cards[1].sector}`], cards[1].amount * 2, ); g = ready(g); assert.equal(g.phase, 2); }); void test('Fremen storm forecast stays private until allowed and can be canceled without changing the future storm', () => { let g = fixture(); g.players[1].hand = [baseDeck().find((c) => c.effect === 'karama')!]; g = applyAction(g, 'f', { type: 'stormDial', amount: 0 }); g = applyAction(g, 'a', { type: 'stormDial', amount: 0 }); g = ready(g); const forecast = g.stormCard!; assert.ok(forecast >= 1 && forecast <= 6); assert.equal(g.response?.kind, 'stormPeek'); assert.equal(viewGame(g, 'f').stormForecast, null); const accepted = allow(g); assert.equal(viewGame(accepted, 'f').stormForecast, forecast); assert.equal(viewGame(accepted, 'a').stormForecast, null); const karama = baseDeck().find((c) => c.effect === 'karama')!; g.players[1].hand = [karama]; g = applyAction(g, 'a', { type: 'card', card: karama.id, mode: 'cancel' }); assert.equal(viewGame(g, 'f').stormForecast, null); g.phase = 8; g.ready = []; g = ready(g); assert.equal(g.turn, 2); assert.equal(g.stormPending, forecast); assert.deepEqual(g.stormDialers, []); assert.equal(g.stormCard, null); assert.throws( () => applyAction(g, 'f', { type: 'stormDial', amount: 1 }), /not dialing/, ); }); function stormArmy(distance = 1, allowProtection = true) { let g = fixture(); if (!allowProtection) g.players[1].hand = [baseDeck().find((c) => c.effect === 'karama')!]; const t = TERRITORIES.find((t) => t.id === 'the_great_flat')!, sector = t.sectors[0], key = `${t.id}:${sector}`; g.storm = sector === 1 ? 18 : sector - 1; g.players[0].forces = { [key]: 5, 'sietch_tabr:14': 5 }; g.players[0].elites = { reserves: 1, tanks: 0, forces: { [key]: 2 }, revived: 0, }; g.players[1].forces = { [key]: 10 }; g.spice = { [key]: 8 }; g = applyAction(g, 'f', { type: 'stormDial', amount: distance }); g = applyAction(g, 'a', { type: 'stormDial', amount: 0 }); const result = ready(g); return { g: allowProtection ? allow(result) : result, key, sector }; } void test('storm half losses pause for Fremen elite choices and conserve both armies when resumed', () => { const setup = stormArmy(); let g = setup.g; const { key, sector } = setup; assert.equal(g.decision?.kind, 'stormLosses'); assert.equal(g.players[1].tanks, 10); assert.equal(g.spice[key], undefined); assert.equal(viewGame(g, 'f').decision?.kind, 'stormLosses'); assert.throws( () => applyAction(g, 'a', { type: 'decision', elite: 0 }), /pending decision/, ); assert.throws( () => applyAction(g, 'f', { type: 'decision', elite: 3 }), /Elite casualties/, ); g = applyAction(g, 'f', { type: 'decision', elite: 0 }); assert.equal(g.players[0].forces[key], 2); assert.equal(g.players[0].elites!.forces[key], 2); assert.equal(g.players[0].tanks, 3); assert.equal(g.storm, sector); assert.equal(g.stormResolution, null); assert.equal(g.response, null); assert.equal(viewGame(g, 'f').stormForecast, g.stormCard); conserve(g); }); void test('a storm making a second lap calculates further half losses from the surviving forces', () => { const setup = stormArmy(19); let g = setup.g; const { key } = setup; g = allow(applyAction(g, 'f', { type: 'decision', elite: 0 })); assert.equal(g.players[0].forces[key], 1); assert.equal(g.players[0].elites!.forces[key], 1); assert.equal(g.players[0].tanks, 4); conserve(g); }); void test('shipping Fremen into storm loses half the arriving group without damaging forces already there', () => { let g = fixture(); g.phase = 5; g.active = 'f'; const t = TERRITORIES.find((t) => t.id === 'the_great_flat')!, sector = t.sectors[0], key = `${t.id}:${sector}`; g.storm = sector; g.players[0].forces = { [key]: 5, 'sietch_tabr:14': 5 }; g.players[0].elites = { reserves: 1, tanks: 0, forces: { [key]: 2 }, revived: 0, }; g = applyAction(g, 'f', { type: 'ship', territory: t.id, sector, amount: 3, elite: 1, }); g = allow(g); assert.equal(g.decision?.kind, 'stormLosses'); g = applyAction(g, 'f', { type: 'decision', elite: 0 }); assert.equal(g.players[0].forces[key], 6); assert.equal(g.players[0].elites!.forces[key], 3); assert.equal(g.players[0].tanks, 2); assert.equal(g.players[0].reserves, 7); assert.throws( () => applyAction(g, 'f', { type: 'move', from: key, territory: 'polar_sink', sector: 0, amount: 1, }), /blocked/, ); conserve(g); }); void test('Karama removes storm protection for the current movement without suppressing the next forecast', () => { const setup = stormArmy(1, false); let g = setup.g; const { key } = setup; assert.equal(g.response?.kind, 'stormProtection'); const karama = baseDeck().find((c) => c.effect === 'karama')!; g.players[1].hand = [karama]; g = applyAction(g, 'a', { type: 'card', card: karama.id, mode: 'cancel' }); assert.equal(g.players[0].forces[key], undefined); assert.equal(g.players[0].tanks, 5); assert.equal(g.players[0].elites!.tanks, 2); assert.equal(g.players[1].tanks, 10); assert.equal(g.decision, null); assert.equal(g.response, null); assert.equal(viewGame(g, 'f').stormForecast, g.stormCard); assert.equal(g.stormResolution, null); conserve(g); }); void test('canceling storm shipment protection destroys only the arriving group and its selected elite', () => { let g = fixture(); g.phase = 5; g.active = 'f'; const t = TERRITORIES.find((t) => t.id === 'the_great_flat')!, sector = t.sectors[0], key = `${t.id}:${sector}`; g.storm = sector; g.players[0].forces = { [key]: 10 }; g.players[0].elites = { reserves: 1, tanks: 0, forces: { [key]: 2 }, revived: 0, }; const karama = baseDeck().find((c) => c.effect === 'karama')!; g.players[1].hand = [karama]; g = applyAction(g, 'f', { type: 'ship', territory: t.id, sector, amount: 3, elite: 1, }); assert.equal(g.response?.kind, 'stormProtection'); g = applyAction(g, 'a', { type: 'card', card: karama.id, mode: 'cancel' }); assert.equal(g.players[0].forces[key], 10); assert.equal(g.players[0].elites!.forces[key], 2); assert.equal(g.players[0].tanks, 3); assert.equal(g.players[0].elites!.tanks, 1); assert.equal(g.players[0].reserves, 7); assert.equal(g.decision, null); assert.equal(g.active, 'f'); assert.equal(g.players[0].shipped, true); conserve(g); }); void test('Weather Control overrides an advanced storm card after revelation and a new private forecast follows', () => { let g = fixture(); g = applyAction(g, 'f', { type: 'stormDial', amount: 0 }); g = applyAction(g, 'a', { type: 'stormDial', amount: 0 }); g = allow(ready(g)); const original = g.stormCard!; assert.ok(isStormCardDistance(original)); g.phase = 8; g.ready = []; g = ready(g); assert.equal(g.turn, 2); assert.equal(g.phase, 0); assert.equal(g.stormPending, original); assert.deepEqual( g.stormMovementSource, createStormSource(g.turn, 'card', original), ); const inspection = g.log.find( (entry) => stormCardDistance(entry.component) === original, )!; assert.equal(stormCardDistance(inspection.component), original); const previous = g.storm; const weather = baseDeck().find((c) => c.effect === 'weather')!; const karama = baseDeck().find((c) => c.effect === 'karama')!; placeFixtureHand(g, 1, [weather, karama]); g = applyAction(g, 'f', { type: 'ready' }); g = applyAction(g, 'a', { type: 'card', card: weather.id, amount: 9 }); assert.equal(g.stormPending, 9); assert.deepEqual( g.stormMovementSource, createStormSource(g.turn, 'weather', 9), ); for (const player of g.players) assert.equal( stormCardDistance( viewGame(g, player.id).log.find((entry) => entry.seq === inspection.seq) ?.component, ), original, ); assert.deepEqual(g.ready, []); g = ready(g); assert.equal(g.storm, ((previous - 1 + 9) % 18) + 1); assert.equal(g.stormMovementSource, undefined); assert.equal(g.stormPending, null); assert.equal(g.discard.filter((card) => card.id === weather.id).length, 1); assert.equal(g.response?.kind, 'stormPeek'); const forecast = g.stormCard!; assert.ok(isStormCardDistance(forecast)); assert.equal(viewGame(g, 'f').stormForecast, null); assert.equal(viewGame(g, 'a').stormForecast, null); g = allow(g); assert.equal(viewGame(g, 'f').stormForecast, forecast); assert.equal(viewGame(g, 'a').stormForecast, null); }); void test('canceling additional worm placement suppresses later placements in both piles for this turn only', () => { let g = fixture(); g.turn = 2; g.phase = 1; g.storm = 18; g.spiceDiscard = [[land[0]], [land[1]]]; g.spiceDeck = [ worms[0], worms[1], worms[2], land[2], worms[3], worms[4], land[3], ]; const karama = baseDeck().find((c) => c.effect === 'karama')!; g.players[1].hand = [karama]; g = ready(g); assert.equal(g.decision?.kind, 'wormPlacement'); g = applyAction(g, 'f', { type: 'decision', accept: true, territory: land[4].territory, }); assert.equal(g.response?.kind, 'wormPlacement'); g = applyAction(g, 'a', { type: 'card', card: karama.id, mode: 'cancel' }); assert.equal(g.wormPlacementCanceledTurn, 2); assert.equal(g.decision, null); assert.equal(g.spiceWindow?.territory, land[2].territory); g = ready(ready(g)); assert.equal(g.spiceSequence?.pile, 1); assert.equal(g.decision, null); assert.equal(g.spiceWindow?.territory, land[3].territory); g = ready(ready(g)); assert.equal(g.phase, 2); g.turn = 3; g.phase = 1; g.spiceDiscard = [[land[0]], [land[1]]]; g.spiceDeck = [worms[0], worms[1], land[2]]; g = ready(g); assert.equal(g.decision?.kind, 'wormPlacement'); }); void test('worm survival and optional allied protection have independent Karama windows before destruction', () => { let g = fixture(); g.turn = 2; g.phase = 1; const key = `${land[0].territory}:${land[0].sector}`; g.players[0].forces = { [key]: 10 }; g.players[1].forces = { [key]: 10 }; g.players[0].ally = 'a'; g.players[1].ally = 'f'; g.spiceDiscard = [[land[0]], []]; g.spiceDeck = [worms[0], land[1], land[2]]; g.spice = { [key]: 8 }; const karama = baseDeck().find((c) => c.effect === 'karama')!; g.players[1].hand = [karama]; g = ready(g); assert.equal(g.decision?.kind, 'wormProtection'); g = applyAction(g, 'f', { type: 'decision', accept: true }); assert.equal(g.response?.kind, 'wormAllyProtection'); const canceledAlly = allow( applyAction(g, 'a', { type: 'card', card: karama.id, mode: 'cancel' }), ); assert.equal(canceledAlly.players[0].forces[key], 10); assert.equal(canceledAlly.players[1].tanks, 10); assert.deepEqual(canceledAlly.wormRides, [land[0].territory]); g = applyAction(g, 'f', { type: 'passResponse' }); g = applyAction(g, 'a', { type: 'passResponse' }); assert.equal(g.response?.kind, 'wormSurvival'); assert.equal(g.players[0].tanks, 0); assert.equal(g.players[1].tanks, 0); const unchanged = structuredClone(g); assert.throws( () => applyAction(g, 'f', { type: 'card', card: karama.id, mode: 'cancel' }), /another faction/, ); assert.deepEqual(g, unchanged); g = applyAction(g, 'a', { type: 'card', card: karama.id, mode: 'cancel' }); assert.equal(g.players[0].tanks, 10); assert.equal(g.players[1].forces[key], 10); assert.equal(g.spice[key], undefined); assert.deepEqual(g.wormRides, []); assert.equal(g.nexus, true); assert.ok(g.spiceWindow); conserve(g); conserve(canceledAlly); });