Files
Dune/tests/window-card-play.test.ts
T

142 lines
8.9 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { WINDOW_CARD_EFFECTS, 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 } };
}
void test('window roles expose a shared exhaustive tuple and exact native printed-card mapping', () => {
assert.deepEqual(WINDOW_CARD_EFFECTS, ['weather', 'atomics', 'harvester', 'karamaAuction']);
assert.deepEqual(WINDOW_CARD_EFFECTS.map(windowCardNativeEffect), ['weather', 'atomics', 'harvester', 'karama']);
});
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);
});