import assert from 'node:assert/strict'; import test from 'node:test'; import { WINDOW_CARD_EFFECTS, karamaShipmentOpportunities, parseKaramaShipmentSource, windowCardNativeEffect, windowCardOpportunity, type WindowCardGame } from '../game/window-card-play'; function storm(): WindowCardGame { return { status: 'playing', phase: 0, turn: 3, shieldWallDestroyed: false }; } function blow(): WindowCardGame { return { ...storm(), phase: 1, spiceWindow: { event: 'spice-blow:1', territory: 'hagga_basin', sector: 5, harvested: false } }; } function auction(): WindowCardGame { return { ...storm(), phase: 3, auction: { cards: [{ id: 'private-card:1' }, { id: 'private-card:2' }], index: 0 } }; } function publicAuction(): WindowCardGame { return { ...storm(), phase: 3, auction: { bid: 4, bidder: 'respondent', remaining: 2 }, bidPromiseLot: { turn: 3, lot: 1 } }; } const shipmentSource = (recipient: string, turn = 3) => JSON.stringify(['karamaShipment', turn, recipient, 'original']); function shipments(): WindowCardGame { return { ...storm(), phase: 5, active: 'first', movementRemaining: ['first', 'later', 'done'], players: [{ id: 'first', shipped: false }, { id: 'later', shipped: false }, { id: 'done', shipped: true }], karamaShipping: null }; } void test('window roles expose a shared exhaustive tuple and exact native printed-card mapping', () => { assert.deepEqual(WINDOW_CARD_EFFECTS, ['weather', 'atomics', 'harvester', 'karamaAuction', 'karamaShipment', 'thumper', 'amal']); assert.deepEqual(WINDOW_CARD_EFFECTS.map(windowCardNativeEffect), ['weather', 'atomics', 'harvester', 'karama', 'karama', 'thumper', 'amal']); }); void test('Amal names each original phase opening with only its public turn and phase identity', () => { for (let phase = 0; phase <= 8; phase++) { const source = { ...storm(), phase, amalWindowOpen: true }; assert.deepEqual(windowCardOpportunity(source, 'amal'), { effect: 'amal', source: `phase-opening:3:${phase}`, turn: 3 }); } for (const phase of [-1, 0.5, 9, NaN, Infinity]) assert.equal(windowCardOpportunity({ ...storm(), phase, amalWindowOpen: true }, 'amal'), null); }); void test('explicit Amal entitlement survives temporary Truth and discard suspension but actual close never reopens it', () => { const source = { ...storm(), phase: 4, amalWindowOpen: true, phaseOpening: { passed: ['first'] } }; for (const suspended of [{ truthtrance: {} }, { response: {} }, { decision: {} }, { phaseOpening: null }, { phaseOpening: null, pendingTreacheryDiscard: {} }]) { const waiting = { ...source, ...suspended }; assert.equal(windowCardOpportunity(waiting, 'amal')!.source, 'phase-opening:3:4'); assert.equal(windowCardOpportunity({ ...waiting, amalWindowOpen: false }, 'amal'), null); } assert.equal(windowCardOpportunity({ ...source, amalWindowOpen: false, phaseOpening: { passed: [] } }, 'amal'), null); }); void test('legacy Amal openings accept the public passed list without requiring native initialization metadata', () => { for (const phaseOpening of [{ passed: [] }, { passed: ['first', 'later'] }, { passed: [], initialize: true }]) assert.equal(windowCardOpportunity({ ...storm(), phase: 2, phaseOpening }, 'amal')!.source, 'phase-opening:3:2'); for (const phaseOpening of [undefined, null, [], {}, { passed: null }, { passed: 'first' }, { passed: ['first', 'first'] }, { passed: [''] }, { passed: [' first'] }, { passed: [1] }]) assert.equal(windowCardOpportunity({ ...storm(), phaseOpening }, 'amal'), null); }); void test('Amal rejects invalid flags and stale opening identities without changing the original state', () => { const source = { ...storm(), phase: 7, amalWindowOpen: true }, before = structuredClone(source); assert.equal(windowCardOpportunity(source, 'amal', 'phase-opening:3:7')!.source, 'phase-opening:3:7'); for (const expected of ['phase-opening:3:6', 'phase-opening:4:7', 'phase-opening:03:7', 'phase-opening:3:7 ']) assert.equal(windowCardOpportunity(source, 'amal', expected), null); for (const invalid of [{ amalWindowOpen: 'true' }, { amalWindowOpen: null }, { amalWindowOpen: 1 }, { status: 'setup' }, { turn: 0 }, { turn: 1.5 }, { turn: Number.MAX_SAFE_INTEGER + 1 }]) assert.equal(windowCardOpportunity({ ...source, ...invalid } as WindowCardGame, 'amal'), null); assert.deepEqual(source, before); }); void test('Thumper binds the public first-reveal entitlement without any hidden deck or physical-card source', () => { const source = { ...storm(), phase: 1, thumperWindowOpen: true }, before = structuredClone(source); assert.deepEqual(windowCardOpportunity(source, 'thumper'), { effect: 'thumper', source: 'first-spice-reveal:3', turn: 3 }); assert.equal(windowCardOpportunity(source, 'thumper', 'first-spice-reveal:3')!.source, 'first-spice-reveal:3'); assert.equal(windowCardOpportunity(source, 'thumper', 'first-spice-reveal:4'), null); assert.equal(windowCardOpportunity({ ...source, turn: 4 }, 'thumper')!.source, 'first-spice-reveal:4'); assert.deepEqual(source, before); }); void test('an explicitly open first-reveal entitlement survives temporary controls and summoned-worm or Nexus state', () => { const source = { ...storm(), phase: 1, thumperWindowOpen: true }; for (const waiting of [{ response: {} }, { decision: {} }, { truthtrance: {} }, { phaseOpening: {} }, { nexus: true }, { spiceResolution: { kind: 'summonedWorm' } }, { spiceSequence: { skipped: [] } }, { spiceWindow: blow().spiceWindow }]) assert.equal(windowCardOpportunity({ ...source, ...waiting }, 'thumper')!.source, 'first-spice-reveal:3'); }); void test('first real reveal or printed Thumper closes the first-reveal source through later Nexus and second-blow windows', () => { const source = { ...storm(), phase: 1, thumperWindowOpen: false }; for (const later of [{}, { nexus: true }, { spiceSequence: {} }, { spiceResolution: {} }, { spiceWindow: blow().spiceWindow }, { response: {} }]) assert.equal(windowCardOpportunity({ ...source, ...later }, 'thumper'), null); for (const invalid of [{ phase: 0 }, { phase: 2 }, { status: 'setup' }, { turn: 0 }, { turn: 1.5 }, { turn: Number.MAX_SAFE_INTEGER + 1 }, { thumperWindowOpen: 'yes' }, { thumperWindowOpen: null }]) assert.equal(windowCardOpportunity({ ...source, thumperWindowOpen: true, ...invalid } as WindowCardGame, 'thumper'), null); }); void test('legacy absent Thumper flag retains the original native clean first-reveal fallback', () => { const source = { ...storm(), phase: 1 }; assert.equal(windowCardOpportunity(source, 'thumper')!.source, 'first-spice-reveal:3'); for (const started of [{ spiceSequence: {} }, { spiceResolution: {} }, { spiceWindow: blow().spiceWindow }, { nexus: true }]) assert.equal(windowCardOpportunity({ ...source, ...started }, 'thumper'), null); for (const waiting of [{ response: {} }, { decision: {} }, { truthtrance: {} }, { phaseOpening: {} }]) assert.equal(windowCardOpportunity({ ...source, ...waiting }, 'thumper')!.source, 'first-spice-reveal:3'); }); void test('shipment-price windows retain every unused original entitlement while new questions select only the active recipient', () => { const source = shipments(), before = structuredClone(source); assert.deepEqual(karamaShipmentOpportunities(source), [ { effect: 'karamaShipment', turn: 3, source: shipmentSource('first'), recipient: 'first' }, { effect: 'karamaShipment', turn: 3, source: shipmentSource('later'), recipient: 'later' }, ]); assert.equal(windowCardOpportunity(source, 'karamaShipment')!.source, shipmentSource('first')); assert.equal(windowCardOpportunity(source, 'karamaShipment', shipmentSource('later'))!.source, shipmentSource('later')); assert.equal(windowCardOpportunity({ ...source, active: 'later' }, 'karamaShipment', shipmentSource('first'))!.source, shipmentSource('first')); assert.deepEqual(source, before); }); void test('shipment source decoding identifies original and Cunning beneficiaries even after a grant or shipment closes them', () => { assert.deepEqual(parseKaramaShipmentSource(shipmentSource('first')), { turn: 3, recipient: 'first', kind: 'original' }); const source = JSON.stringify(['karamaShipment', 3, 'first', 'cunning', 'cunning:1']); assert.deepEqual(parseKaramaShipmentSource(source), { turn: 3, recipient: 'first', kind: 'cunning', event: 'cunning:1' }); for (const invalid of [undefined, null, [], '{}', `${source} `, JSON.stringify(['karamaShipment', 0, 'first', 'original']), JSON.stringify(['karamaShipment', 3.5, 'first', 'original']), JSON.stringify(['karamaShipment', Number.MAX_SAFE_INTEGER + 1, 'first', 'original']), JSON.stringify(['karamaShipment', 3, ' first', 'original']), JSON.stringify(['karamaShipment', 3, 'first', 'cunning']), JSON.stringify(['karamaShipment', 3, 'first', 'cunning', '']), JSON.stringify(['karamaShipment', 3, 'first', 'original', 'extra'])]) assert.equal(parseKaramaShipmentSource(invalid), null); assert.equal(windowCardOpportunity({ ...shipments(), karamaShipping: { player: 'first' } }, 'karamaShipment', shipmentSource('first')), null); assert.equal(parseKaramaShipmentSource(shipmentSource('first'))!.recipient, 'first'); }); void test('a shipment-price grant closes only its beneficiary and actual shipment or turn completion closes that original source', () => { const source = shipments(); const granted = { ...source, karamaShipping: { player: 'first' } }; assert.equal(windowCardOpportunity(granted, 'karamaShipment'), null); assert.deepEqual(karamaShipmentOpportunities(granted).map(window => window.recipient), ['later']); const shipped = { ...source, players: source.players!.map(player => ({ ...player, shipped: true })) }; assert.deepEqual(karamaShipmentOpportunities(shipped), []); const finished = { ...source, active: 'later', movementRemaining: ['later', 'done'] }; assert.equal(windowCardOpportunity(finished, 'karamaShipment', shipmentSource('first')), null); assert.equal(windowCardOpportunity({ ...source, phase: 6 }, 'karamaShipment', shipmentSource('first')), null); assert.equal(windowCardOpportunity({ ...source, turn: 4 }, 'karamaShipment', shipmentSource('first')), null); }); void test('temporary native modal priority preserves original shipment entitlements without offering an inactive question', () => { for (const modal of [{ response: {} }, { decision: {} }, { truthtrance: {} }, { phaseOpening: {} }]) { const source = { ...shipments(), active: 'later', ...modal }; assert.equal(windowCardOpportunity(source, 'karamaShipment')!.source, shipmentSource('later')); assert.equal(windowCardOpportunity(source, 'karamaShipment', shipmentSource('first'))!.source, shipmentSource('first')); } assert.equal(windowCardOpportunity(storm(), 'weather', 'storm:3')!.source, 'storm:3'); assert.equal(windowCardOpportunity(storm(), 'weather', 'storm:4'), null); assert.equal(windowCardOpportunity(auction(), 'karamaAuction', 'ordinary-auction:3:2'), null); }); void test('current Cunning second shipment has its own event source and never reopens an unused original shipment', () => { const source = shipments(), receipt = { turn: 3, owner: 'first', event: 'cunning:1' }; const second = { ...source, nexusGuildCunningHistory: [{ receipt, stage: 'secondShipment' }] }; const expected = JSON.stringify(['karamaShipment', 3, 'first', 'cunning', 'cunning:1']); assert.equal(windowCardOpportunity(second, 'karamaShipment')!.source, expected); const projected = { ...source, players: source.players!.map(({ id }) => ({ id })), karamaShipmentWindows: karamaShipmentOpportunities(second) }; assert.equal(windowCardOpportunity(projected, 'karamaShipment')!.source, expected); assert.equal(windowCardOpportunity(second, 'karamaShipment', shipmentSource('first')), null); assert.equal(windowCardOpportunity({ ...second, active: 'later' }, 'karamaShipment', expected), null); assert.equal(windowCardOpportunity({ ...second, karamaShipping: { player: 'first' } }, 'karamaShipment', expected), null); for (const stage of ['pending', 'extraMove', 'completed', 'canceled']) { const closed = { ...source, nexusGuildCunningHistory: [{ receipt, stage }] }; assert.equal(windowCardOpportunity(closed, 'karamaShipment'), null); assert.equal(windowCardOpportunity(closed, 'karamaShipment', shipmentSource('first')), null); assert.ok(windowCardOpportunity(closed, 'karamaShipment', shipmentSource('later'))); } const old = { ...source, nexusGuildCunningHistory: [{ receipt: { ...receipt, turn: 2 }, stage: 'completed' }] }; assert.equal(windowCardOpportunity(old, 'karamaShipment')!.source, shipmentSource('first')); }); void test('public shipment projections require no shipped history and return copies of strictly coherent source rows', () => { const native = shipments(), windows = karamaShipmentOpportunities(native); const source = { ...native, players: native.players!.map(({ id }) => ({ id })), karamaShipmentWindows: windows }; const before = structuredClone(source), projected = karamaShipmentOpportunities(source); assert.deepEqual(projected, windows); assert.notEqual(projected, windows); assert.notEqual(projected[0], windows[0]); projected[0].recipient = 'changed-copy'; assert.deepEqual(source, before); assert.equal(windowCardOpportunity(source, 'karamaShipment', shipmentSource('later'))!.source, shipmentSource('later')); }); void test('malformed public shipment windows cannot invent a recipient turn entitlement or Cunning source', () => { const native = shipments(), window = karamaShipmentOpportunities(native)[0]; for (const invalid of [{ ...window, recipient: 'unseated' }, { ...window, turn: 2 }, { ...window, effect: 'karamaAuction' }, { ...window, source: '' }, { ...window, source: `${window.source} ` }, { ...window, source: shipmentSource('later') }, { ...window, source: JSON.stringify(['karamaShipment', 3, 'first', 'cunning']) }, { ...window, source: JSON.stringify(['karamaShipment', 3, 'first', 'cunning', '']) }, { ...window, card: 'private-card' }]) assert.deepEqual(karamaShipmentOpportunities({ ...native, karamaShipmentWindows: [invalid] } as WindowCardGame), []); for (const invalid of [[window, window], [window, { ...window, source: JSON.stringify(['karamaShipment', 3, 'first', 'cunning', 'cunning:1']) }]]) assert.deepEqual(karamaShipmentOpportunities({ ...native, karamaShipmentWindows: invalid }), []); assert.deepEqual(karamaShipmentOpportunities({ ...native, active: 'later', karamaShipmentWindows: [{ ...window, source: JSON.stringify(['karamaShipment', 3, 'first', 'cunning', 'cunning:1']) }] }), []); assert.deepEqual(karamaShipmentOpportunities({ ...native, movementRemaining: ['later'], active: 'later', karamaShipmentWindows: [window] }), []); assert.deepEqual(karamaShipmentOpportunities({ ...native, karamaShipping: { player: 'first' }, karamaShipmentWindows: [window] }), []); }); void test('malformed native shipment metadata cannot create a price opportunity', () => { const source = shipments(); for (const invalid of [{ players: [{ id: 'first' }] }, { players: [{ id: 'first', shipped: false }, { id: 'first', shipped: false }] }, { movementRemaining: ['first', 'first'] }, { movementRemaining: ['missing'] }, { movementRemaining: null }, { active: 'missing' }, { karamaShipping: { player: 'missing' } }, { status: 'setup' }, { turn: 0 }, { nexusGuildCunningHistory: [{ receipt: { turn: 4, owner: 'first', event: 'cunning:1' }, stage: 'secondShipment' }] }, { nexusGuildCunningHistory: [{ receipt: { turn: 3, owner: 'missing', event: 'cunning:1' }, stage: 'secondShipment' }] }]) assert.deepEqual(karamaShipmentOpportunities({ ...source, ...invalid }), []); }); void test('ordinary Karama binds the same public current-lot source from native and projected auction shapes', () => { const expected = { effect: 'karamaAuction', source: 'ordinary-auction:3:1', turn: 3 }; assert.deepEqual(windowCardOpportunity(auction(), 'karamaAuction'), expected); assert.deepEqual(windowCardOpportunity(publicAuction(), 'karamaAuction'), expected); assert.deepEqual(windowCardOpportunity({ ...auction(), bidPromiseLot: { turn: 3, lot: 1 } }, 'karamaAuction'), expected); const otherFace = { ...auction(), auction: { cards: [{ id: 'different-hidden-card' }], index: 0 } }; assert.deepEqual(windowCardOpportunity(otherFace, 'karamaAuction'), expected); }); void test('ordinary lot responses and a winning unpaid invoice retain Karama source until actual lot close', () => { for (const source of [auction(), publicAuction()]) { for (const modal of [{ response: { kind: 'karama' } }, { decision: { kind: 'auctionPayment', player: 'respondent' } }, { truthtrance: {} }, { phaseOpening: {} }]) assert.equal(windowCardOpportunity({ ...source, ...modal }, 'karamaAuction')!.source, 'ordinary-auction:3:1'); for (const exclusion of [{ richeseAuction: {} }, { currentAuctionSale: {} }, { ixAuction: {} }, { phase: 4 }, { status: 'setup' }, { auction: null }]) assert.equal(windowCardOpportunity({ ...source, ...exclusion }, 'karamaAuction'), null); } }); void test('Karama lot progression changes only the public source and never mutates physical auction metadata', () => { const source = auction(), before = structuredClone(source); assert.equal(windowCardOpportunity(source, 'karamaAuction')!.source, 'ordinary-auction:3:1'); assert.equal(windowCardOpportunity({ ...source, auction: { cards: [{}, {}], index: 1 } }, 'karamaAuction')!.source, 'ordinary-auction:3:2'); assert.equal(windowCardOpportunity({ ...publicAuction(), bidPromiseLot: { turn: 3, lot: 2 } }, 'karamaAuction')!.source, 'ordinary-auction:3:2'); assert.equal(windowCardOpportunity({ ...source, turn: 4 }, 'karamaAuction')!.source, 'ordinary-auction:4:1'); assert.deepEqual(source, before); }); void test('malformed or exhausted native auction arrays and indices cannot fall back to a public lot', () => { for (const native of [{ cards: [], index: 0 }, { cards: [{}], index: 1 }, { cards: [{}], index: -1 }, { cards: [{}], index: 0.5 }, { cards: [{}], index: Number.MAX_SAFE_INTEGER + 1 }, { cards: [null], index: 0 }, { cards: [undefined], index: 0 }, { cards: ['private-card'], index: 0 }, { cards: {}, index: 0 }, { cards: [{}] }, { index: 0 }, { cards: [{}], index: undefined }]) { assert.equal(windowCardOpportunity({ ...auction(), auction: native }, 'karamaAuction'), null); assert.equal(windowCardOpportunity({ ...publicAuction(), auction: native }, 'karamaAuction'), null); } }); void test('projected lot metadata is positive safe coherent and cannot invent an absent ordinary auction', () => { for (const bidPromiseLot of [null, undefined, { turn: 3, lot: 0 }, { turn: 3, lot: -1 }, { turn: 3, lot: 1.5 }, { turn: 3, lot: Number.MAX_SAFE_INTEGER + 1 }, { turn: 4, lot: 1 }, { turn: 3, lot: '1' }, { turn: 3, lot: 1, card: 'private-card' }, { lot: 1 }]) assert.equal(windowCardOpportunity({ ...publicAuction(), bidPromiseLot } as WindowCardGame, 'karamaAuction'), null); assert.equal(windowCardOpportunity({ ...auction(), bidPromiseLot: { turn: 3, lot: 2 } }, 'karamaAuction'), null); assert.equal(windowCardOpportunity({ ...publicAuction(), auction: [] }, 'karamaAuction'), null); assert.equal(windowCardOpportunity({ ...storm(), phase: 3, auction: {} }, 'karamaAuction'), null); }); void test('Weather and Family Atomics share the exact original current Storm source before movement', () => { assert.deepEqual(windowCardOpportunity(storm(), 'weather'), { effect: 'weather', source: 'storm:3', turn: 3 }); assert.deepEqual(windowCardOpportunity(storm(), 'atomics'), { effect: 'atomics', source: 'storm:3', turn: 3 }); assert.equal(windowCardOpportunity({ ...storm(), turn: 4 }, 'weather')!.source, 'storm:4'); }); void test('actual Storm close and movement resolution end the window while legacy absent open flag remains valid', () => { for (const effect of ['weather', 'atomics'] as const) { assert.equal(windowCardOpportunity({ ...storm(), stormCardWindowOpen: false }, effect), null); assert.equal(windowCardOpportunity({ ...storm(), stormResolution: { event: 'storm-movement:1' } }, effect), null); assert.equal(windowCardOpportunity({ ...storm(), phase: 1 }, effect), null); assert.ok(windowCardOpportunity({ ...storm(), stormCardWindowOpen: true }, effect)); } }); void test('a destroyed Shield Wall closes only Family Atomics rather than the remaining Weather window', () => { const source = { ...storm(), shieldWallDestroyed: true }; assert.equal(windowCardOpportunity(source, 'atomics'), null); assert.equal(windowCardOpportunity(source, 'weather')!.source, 'storm:3'); }); void test('temporary responses, decisions, phase openings and Truth preserve the native global source', () => { for (const blocker of ['response', 'decision', 'phaseOpening', 'truthtrance'] as const) { assert.equal(windowCardOpportunity({ ...storm(), [blocker]: {} }, 'weather')!.source, 'storm:3'); assert.equal(windowCardOpportunity({ ...blow(), [blocker]: {} }, 'harvester')!.source, 'spice-blow:1'); } }); void test('a current Harvester window binds its actual blow event and preserves later distinct blow identity', () => { const source = blow(); assert.deepEqual(windowCardOpportunity(source, 'harvester'), { effect: 'harvester', source: 'spice-blow:1', turn: 3 }); source.spiceWindow!.event = 'spice-blow:2'; assert.equal(windowCardOpportunity(source, 'harvester')!.source, 'spice-blow:2'); assert.equal(windowCardOpportunity({ ...source, phase: 2 }, 'harvester'), null); }); void test('Harvester uses existing unlimited physical stacking and replay availability without a per-window count cap', () => { const source = blow(); source.spiceWindow!.harvested = true; assert.equal(windowCardOpportunity(source, 'harvester'), null, 'Legacy harvested-only windows are closed'); for (const harvesters of [1, 2, 20, 100, Number.MAX_SAFE_INTEGER]) { source.spiceWindow!.harvesters = harvesters; assert.equal(windowCardOpportunity(source, 'harvester')!.source, 'spice-blow:1'); } source.spiceWindow!.harvesterClosed = true; assert.equal(windowCardOpportunity(source, 'harvester'), null); }); void test('legacy fresh blows get the specified deterministic source without mutating their original state', () => { const source = blow(); delete source.spiceWindow!.event; const before = structuredClone(source); assert.equal(windowCardOpportunity(source, 'harvester')!.source, 'legacy-spice-blow:3:hagga_basin:5'); assert.deepEqual(source, before); const otherSector = { ...source, spiceWindow: { ...source.spiceWindow!, sector: 6 } }; assert.notEqual(windowCardOpportunity(otherSector, 'harvester')!.source, windowCardOpportunity(source, 'harvester')!.source); }); void test('invalid public source metadata and unavailable game windows do not create a native opportunity', () => { for (const source of [{ ...storm(), status: 'setup' }, { ...storm(), turn: 0 }, { ...storm(), turn: 1.5 }, { ...storm(), turn: Number.MAX_SAFE_INTEGER + 1 }]) assert.equal(windowCardOpportunity(source, 'weather'), null); for (const source of [{ ...blow(), spiceWindow: null }, { ...blow(), spiceWindow: { ...blow().spiceWindow!, event: '' } }, { ...blow(), spiceWindow: { ...blow().spiceWindow!, territory: ' hagga_basin' } }, { ...blow(), spiceWindow: { ...blow().spiceWindow!, sector: -1 } }, { ...blow(), spiceWindow: { ...blow().spiceWindow!, harvesters: NaN } }]) assert.equal(windowCardOpportunity(source, 'harvester'), null); assert.equal(windowCardOpportunity(storm(), 'unknown' as never), null); });