Files
Dune/tests/discovery-native-typed-runtime.test.ts
T

299 lines
17 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { gameDistance, gameTerritories } from '../game/board';
import { DISCOVERY_SPICE_CARDS } from '../game/discoveries';
import {
MOBILE_LOCATION, MOBILE_STRONGHOLD, nativeTypedCard, nativeTypedCollection,
nativeTypedCustody, nativeTypedEntry, nativeTypedFaceDance, nativeTypedMovementTurn,
nativeTypedPhase, nativeTypedPlace, nativeTypedPlayer, nativeTypedReload,
nativeTypedReveal, nativeTypedSetup, nativeTypedStep,
} from './fixture-discovery-native-typed';
function rejectUnchanged(game: Game, owner: string, action: Action) {
const before = nativeTypedReload(game);
assert.throws(() => applyAction(game, owner, action));
assert.deepEqual(game, before);
}
function legalPolicies(game: Game, owner: string) {
assert.equal(DIFFICULTIES.length, 4);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, owner);
view.players.find(p => p.id === owner)!.bot = difficulty;
const actions = botActions(view);
assert.ok(actions.length > 0);
for (const action of actions) {
const result = applyAction(nativeTypedReload(game), owner, action);
nativeTypedCustody(result);
}
}
}
function entryAction(game: Game, groups?: unknown): Action {
const owner = game.decision!.player;
const offer = viewGame(game, owner).discoveryEntry!;
return { type: 'decision', accept: true, event: offer.event, groups: groups ?? offer.sources };
}
function settle(state: Game): Game {
let game = state;
for (let step = 0; step < 100 && (game.decision || game.response || game.pendingShipment); step++)
game = nativeTypedStep(game);
assert.ok(!game.decision && !game.response && !game.pendingShipment);
return game;
}
void test('fresh E1 Discovery uses original 47-card setup, Sandtrout, seven cards/eight tokens, HMS and native private deals', () => {
for (const advanced of [false, true]) for (const tech of [false, true]) {
const { game, initialized } = nativeTypedSetup({ advanced, tech });
assert.equal(game.discoveryEnabled, true);
assert.equal(game.leaderSkills, undefined);
assert.equal(game.strongholdCards, undefined);
assert.equal(game.homeworlds, undefined);
assert.equal(game.nexusCards, undefined);
assert.deepEqual(initialized.spiceDeck.flatMap(c => 'territory' in c && c.discovery ? [c.discovery] : []).sort(),
DISCOVERY_SPICE_CARDS.map(c => c.discovery).sort());
assert.equal(viewGame(initialized, 'i').discoveries!.tokens.length, 0);
assert.equal(viewGame(game, 't').players.find(p => p.id === 't')!.faceDancers!.length, 3);
assert.equal(viewGame(game, 'g').players.find(p => p.id === 't')!.faceDancers, undefined);
assert.equal(nativeTypedPlayer(game, 'i').elites!.reserves, 4);
assert.equal(nativeTypedPlayer(game, 'i').elites!.forces[MOBILE_LOCATION], 3);
if (tech) {
assert.equal(game.techTokens!.heighliners.owner, 'i');
assert.equal(game.techTokens!.axlotl.owner, 't');
assert.equal(game.techTokens!.production.owner, 'f');
} else assert.equal(game.techTokens, undefined);
nativeTypedCustody(game);
}
});
void test('original native Cyborg/Suboid and ordinary/starred Fremen free entry transfers only actual typed counters once', () => {
for (const advanced of [false, true]) for (const owner of ['i', 'f']) {
const { game, source } = nativeTypedEntry({ advanced, tech: true }, owner);
const player = nativeTypedPlayer(game, owner);
const expectedElite = owner === 'i' || advanced ? 1 : 0;
const offer = viewGame(game, owner).discoveryEntry!;
assert.deepEqual(offer.sources, [{ source, normal: 2, elite: expectedElite }]);
assert.ok(offer.sources.every(group => group.source !== MOBILE_LOCATION));
const before = nativeTypedReload(game);
const action = entryAction(game, [{ source, normal: 1, elite: expectedElite }]);
legalPolicies(game, owner);
const done = applyAction(nativeTypedReload(game), owner, action);
const arrived = nativeTypedPlayer(done, owner);
assert.equal(arrived.forces['shrine:0'], 1 + expectedElite);
assert.equal(arrived.elites?.forces['shrine:0'] ?? 0, expectedElite);
assert.equal(arrived.forces[source], 1);
assert.equal(arrived.elites?.forces[source] ?? 0, 0);
assert.equal(arrived.reserves, player.reserves);
assert.equal(arrived.elites?.reserves, player.elites?.reserves);
assert.equal(arrived.spice, player.spice);
assert.equal(arrived.moved, player.moved);
assert.equal(arrived.shipped, player.shipped);
assert.deepEqual(done.techTokens, game.techTokens);
const progress = viewGame(done, owner).victoryProgress.find(row => row.player === owner)!;
assert.deepEqual(progress.strongholds, owner === 'i' ? [MOBILE_STRONGHOLD] : []);
assert.equal(progress.techStronghold, false);
assert.equal(progress.qualifies, false);
assert.equal(done.discoveryEntry, undefined);
assert.deepEqual(done.discoveries!.newlyRevealed, []);
assert.deepEqual(game, before);
assert.deepEqual(normalizeAutomaticGame(nativeTypedReload(done)), done);
rejectUnchanged(done, owner, action);
nativeTypedCustody(done);
}
});
void test('duplicate, stale, foreign and overdrawn native typed free entry selections reject atomically across JSON', () => {
const { game, source } = nativeTypedEntry({ advanced: true });
const group = { source, normal: 1, elite: 1 };
const action = entryAction(game, [group]);
rejectUnchanged(game, 't', action);
rejectUnchanged(game, 'i', { ...action, event: 'old-native-entry' });
rejectUnchanged(game, 'i', entryAction(game, [group, group]));
rejectUnchanged(game, 'i', entryAction(game, [{ source, normal: 3, elite: 1 }]));
rejectUnchanged(game, 'i', entryAction(game, [{ source, normal: 0, elite: 2 }]));
rejectUnchanged(game, 'i', entryAction(game, [{ source: MOBILE_LOCATION, normal: 1, elite: 1 }]));
const reloaded = nativeTypedReload(game);
const publicOffer = viewGame(reloaded, 'i').discoveryEntry!;
publicOffer.sources[0].elite = 7;
assert.deepEqual(reloaded, game);
assert.ok(game.decision?.kind === 'discoveryEntry');
const declined = applyAction(reloaded, 'i', { type: 'decision', accept: false, event: game.decision!.event });
assert.equal(nativeTypedPlayer(declined, 'i').forces['shrine:0'], undefined);
assert.equal(nativeTypedPlayer(declined, 'i').forces[source], 3);
nativeTypedCustody(declined);
});
void test('typed native free entry keeps a signed pending child and resumes exactly once after original advisor controls', () => {
let game = nativeTypedEntry({ advanced: true, advisors: true }).game;
nativeTypedPlace(game, 'b', 'shrine:0', 1);
const action = entryAction(game);
game = applyAction(game, 'i', action);
assert.equal(game.discoveryEntry?.stage, 'arrival');
assert.equal(game.decision?.kind, 'intrusion');
assert.equal(nativeTypedPlayer(game, 'i').forces['shrine:0'], 3);
assert.equal(nativeTypedPlayer(game, 'i').elites!.forces['shrine:0'], 1);
const malformed = nativeTypedReload(game);
if (malformed.decision?.kind === 'intrusion') malformed.decision.discoveryEntry!.elite = 0;
assert.throws(() => viewGame(malformed, 'b'));
rejectUnchanged(malformed, 'b', { type: 'decision', accept: false });
const arrived = nativeTypedReload(game);
nativeTypedPlayer(arrived, 'i').elites!.forces['shrine:0'] = 0;
assert.throws(() => normalizeAutomaticGame(arrived));
rejectUnchanged(arrived, 'b', { type: 'decision', accept: false });
game = applyAction(nativeTypedReload(game), 'b', { type: 'decision', accept: true });
game = settle(nativeTypedReload(game));
assert.equal(game.discoveryEntry, undefined);
assert.equal(game.phase, 0);
assert.equal(nativeTypedPlayer(game, 'i').forces['shrine:0'], 3);
rejectUnchanged(game, 'i', action);
nativeTypedCustody(game);
});
void test('ordinary paid Cyborg shipment and nested movement preserve physical HMS identity and optional native industry', () => {
for (const advanced of [false, true]) for (const tech of [false, true]) {
const entry = nativeTypedEntry({ advanced, tech });
assert.ok(entry.game.decision?.kind === 'discoveryEntry');
let game = applyAction(entry.game, 'i', { type: 'decision', accept: false, event: entry.game.decision!.event });
game = nativeTypedMovementTurn(nativeTypedPhase(game, 5), 'i');
const before = nativeTypedReload(game);
const hms = nativeTypedPlayer(game, 'i').forces[MOBILE_LOCATION];
game = settle(applyAction(nativeTypedReload(game), 'i', { type: 'ship', territory: 'shrine', sector: 0, amount: 2, elite: 1 }));
assert.equal(nativeTypedPlayer(game, 'i').forces['shrine:0'], 2);
assert.equal(nativeTypedPlayer(game, 'i').elites!.forces['shrine:0'], 1);
assert.equal(nativeTypedPlayer(game, 'i').reserves, nativeTypedPlayer(before, 'i').reserves - 2);
assert.equal(nativeTypedPlayer(game, 'i').elites!.reserves, nativeTypedPlayer(before, 'i').elites!.reserves - 1);
assert.equal(nativeTypedPlayer(game, 'i').spice, nativeTypedPlayer(before, 'i').spice - 2);
assert.equal(nativeTypedPlayer(game, 'g').spice, nativeTypedPlayer(before, 'g').spice + 2);
assert.equal(nativeTypedPlayer(game, 'i').shipped, true);
assert.equal(nativeTypedPlayer(game, 'i').forces[MOBILE_LOCATION], hms);
assert.notEqual(MOBILE_STRONGHOLD, 'shrine');
assert.equal(gameDistance(game, entry.source, 'shrine:0'), 1);
const movement: Action = { type: 'move', forces: { [entry.source]: 2 }, eliteForces: { [entry.source]: 1 }, territory: 'shrine', sector: 0 };
const moving = nativeTypedReload(game);
game = settle(applyAction(game, 'i', movement));
assert.equal(nativeTypedPlayer(game, 'i').forces['shrine:0'], 4);
assert.equal(nativeTypedPlayer(game, 'i').elites!.forces['shrine:0'], 2);
assert.equal(nativeTypedPlayer(game, 'i').forces[entry.source], 1);
assert.equal(nativeTypedPlayer(game, 'i').moved, 1);
assert.equal(nativeTypedPlayer(game, 'i').spice, nativeTypedPlayer(moving, 'i').spice);
rejectUnchanged(game, 'i', movement);
if (tech) {
assert.equal(game.techTokens!.heighliners.triggeredTurn, game.turn);
assert.equal(game.techTokens!.heighliners.spice, 1);
const wallet = nativeTypedPlayer(game, 'i').spice;
for (let step = 0; step < 100 && game.phase === 5; step++) game = nativeTypedStep(game);
assert.notEqual(game.phase, 5);
assert.equal(game.techTokens!.heighliners.spice, 0);
assert.equal(nativeTypedPlayer(game, 'i').spice, wallet + 1);
}
nativeTypedCustody(game);
}
});
void test('native Tleilaxu Collection stash draws an actual E1 card privately and all four policies recover a full hand', () => {
const position = nativeTypedCollection('treachery-card-stash', 't', { advanced: true });
const player = nativeTypedPlayer(position.game, 't');
while (player.hand.length < 4) nativeTypedCard(position.game, 't', () => true);
const drawn = position.game.deck[0];
const before = player.hand.map(c => c.id);
const game = nativeTypedReveal(position);
assert.equal(game.decision?.kind, 'discoveryDiscard');
assert.deepEqual(nativeTypedPlayer(game, 't').hand.map(c => c.id), [...before, drawn.id]);
assert.equal(viewGame(game, 'g').players.find(p => p.id === 't')!.hand, undefined);
assert.equal(JSON.stringify(viewGame(game, 'g')).includes(JSON.stringify(drawn.id)), false);
legalPolicies(game, 't');
const action = { type: 'decision', event: game.discoveryStash!.event, card: drawn.id };
rejectUnchanged(game, 'g', action);
rejectUnchanged(game, 't', { ...action, event: 'old-native-stash' });
const done = applyAction(nativeTypedReload(game), 't', action);
assert.equal(nativeTypedPlayer(done, 't').hand.length, 4);
assert.ok(done.discard.some(c => c.id === drawn.id));
assert.equal(done.discoveries!.tokens.find(t => t.id === position.token)!.status, 'removed');
rejectUnchanged(done, 't', action);
nativeTypedCustody(done);
});
void test('native nested battle retains original winner cards/Tech rewards before valid Face Dance replaces from actual sources', () => {
for (const advanced of [false, true]) for (const tech of [false, true]) {
const fixture = nativeTypedFaceDance(advanced, tech);
const { game, action } = fixture;
assert.ok(nativeTypedPlayer(game, 'g').hand.some(c => c.id === fixture.weapon.id));
assert.ok(game.discard.some(c => c.id === fixture.defense.id));
assert.ok(game.discard.some(c => c.id === fixture.loserCard.id));
assert.equal(nativeTypedPlayer(game, 't').tanks, 8);
assert.equal(game.lastBattleContext!.winner, 'g');
if (tech) {
assert.equal(fixture.cleanup.techTokens!.axlotl.owner, 't');
assert.equal(game.techTokens!.axlotl.owner, 'g');
assert.equal(game.techTokens!.heighliners.owner, 'i');
assert.equal(game.techTokens!.production.owner, 'f');
}
legalPolicies(game, 't');
rejectUnchanged(game, 'i', action);
rejectUnchanged(game, 't', { ...action, sector: 1 });
rejectUnchanged(game, 't', { ...action, sources: { reserves: 9 } });
rejectUnchanged(game, 't', { ...action, sources: { [MOBILE_LOCATION]: 1 } });
const done = applyAction(nativeTypedReload(game), 't', action);
assert.equal(nativeTypedPlayer(done, 'g').forces['shrine:0'], undefined);
assert.equal(nativeTypedPlayer(done, 'g').reserves, nativeTypedPlayer(game, 'g').reserves + 8);
assert.equal(nativeTypedPlayer(done, 't').forces['shrine:0'], 3);
assert.equal(nativeTypedPlayer(done, 't').forces[fixture.source], 1);
assert.equal(nativeTypedPlayer(done, 't').reserves, nativeTypedPlayer(game, 't').reserves - 1);
assert.equal(nativeTypedPlayer(done, 't').spice, nativeTypedPlayer(game, 't').spice);
assert.equal(nativeTypedPlayer(done, 'g').leaders.find(l => l.id === fixture.winnerLeader)!.dead, true);
assert.deepEqual(done.techTokens, game.techTokens);
assert.equal(done.phase, 6);
assert.deepEqual(viewGame(done, 't').victoryProgress.find(row => row.player === 't')!.strongholds, []);
rejectUnchanged(done, 't', action);
nativeTypedCustody(done);
}
});
void test('native third Face Dance refills only genuine private identities with conserved Traitor/Face Dancer inventory', () => {
const fixture = nativeTypedFaceDance(true);
// Labelled original revealed-card history, not a fabricated card or new identity.
for (const dancer of nativeTypedPlayer(fixture.game, 't').faceDancers!)
dancer.revealed = dancer.leader !== fixture.winnerLeader;
const identities = [...fixture.game.traitorReserve!,
...nativeTypedPlayer(fixture.game, 't').faceDancers!.map(c => c.leader)].sort();
const done = applyAction(nativeTypedReload(fixture.game), 't', fixture.action);
assert.equal(nativeTypedPlayer(done, 't').faceDancers!.length, 3);
assert.ok(nativeTypedPlayer(done, 't').faceDancers!.every(c => !c.revealed));
assert.deepEqual([...done.traitorReserve!, ...nativeTypedPlayer(done, 't').faceDancers!.map(c => c.leader)].sort(), identities);
assert.equal(viewGame(done, 'i').players.find(p => p.id === 't')!.faceDancers, undefined);
nativeTypedCustody(done);
});
void test('original E1 Discovery Spice Blow producer destroys actual ordinary/starred groups before physical placement', () => {
for (const advanced of [false, true]) {
let game = nativeTypedSetup({ advanced }).game;
game = nativeTypedPhase(game, 1);
const printed = DISCOVERY_SPICE_CARDS.find(card => card.sector !== game.storm)!;
const index = game.spiceDeck.findIndex(card => 'territory' in card && card.discovery === printed.discovery);
assert.ok(index >= 0);
const [chosen] = game.spiceDeck.splice(index, 1);
game.spiceDeck.unshift(chosen);
// One blow only: another card for the same territory would stack a second
// deposit and make the printed amount depend on the random storm.
game.spiceDeck = game.spiceDeck.filter(card => card === chosen || !('territory' in card && card.territory === printed.territory));
const key = `${printed.territory}:${printed.sector}`;
nativeTypedPlace(game, 'i', key, 1, 1);
nativeTypedPlace(game, 'f', key, 1, advanced ? 1 : 0);
const before = nativeTypedReload(game);
for (let step = 0; step < 100 && game.phase === 1; step++) game = nativeTypedStep(game);
assert.ok(game.spiceDiscard.flat().some(card => 'territory' in card && card.discovery === printed.discovery));
assert.equal(game.spice[key], 6);
for (const id of ['i', 'f']) {
const old = nativeTypedPlayer(before, id), player = nativeTypedPlayer(game, id);
assert.equal(player.forces[key], undefined);
assert.equal(player.tanks, old.tanks + old.forces[key]);
assert.equal(player.elites?.forces[key], undefined);
if (player.elites) assert.equal(player.elites.tanks, old.elites!.tanks + (old.elites!.forces[key] ?? 0));
}
assert.equal(game.discoveries!.tokens.filter(token => token.status === 'placed').length, 1);
assert.ok(gameTerritories(game).every(t => t.id !== 'shrine'));
nativeTypedCustody(game);
}
});