Files
Dune/tests/richese-skills-tech-runtime.test.ts
T

325 lines
21 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import { ownedTech, TECH_TOKENS } from '../game/tech-tokens';
import { assertAdvancedNativeCustody } from './fixture-advanced-native-skills';
import {
advanceRicheseSkillsTechToPhase, createRicheseSkillsTechFixture, finishRicheseSkillsTechBattle,
nextRicheseSkillsTechNativeStep, openRicheseSkillsTechBattle, payRicheseSkillsTechShipment,
richeseSkillsTechPlayer as player, type RicheseSkillsTechFixture,
} from './fixture-richese-skills-tech';
function reject(game: Game, actor: string, action: Action): void {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
function cards(game: Game): string[] {
return [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
...(game.richeseCache ?? []), ...(game.richeseRemoved ?? [])].map(c => c.id).sort();
}
function wallets(game: Game): Record<string, number> {
return Object.fromEntries(game.players.map(p => [p.id, p.spice]));
}
function changedTokens(before: Game, after: Game): string[] {
return TECH_TOKENS.filter(t => before.techTokens![t.id].owner !== after.techTokens![t.id].owner).map(t => t.id);
}
function reveal(fixture: RicheseSkillsTechFixture, hide = true): Game {
let game = openRicheseSkillsTechBattle(fixture, hide);
game = applyAction(game, fixture.owner, fixture.ownerPlan);
return applyAction(game, fixture.opponent, fixture.opponentPlan);
}
function calls(state: Game, callers: string[] = []): Game {
let game = state;
for (let n = 0; game.battle?.revealed && !game.decision && !game.response && n < 20; n++) {
const next = nextRicheseSkillsTechNativeStep(game);
if (next.action.type !== 'traitorCall') break;
next.action.call = callers.includes(next.actor);
game = applyAction(game, next.actor, next.action);
}
return game;
}
function reward(fixture: RicheseSkillsTechFixture, state: Game): Game {
const token = ownedTech(fixture.game.techTokens, fixture.opponent)[0];
assert.ok(token, 'Original three-seat first Storm gives the defeated seat one physical token.');
assert.deepEqual(changedTokens(fixture.game, state), [], 'Physical rescue/cleanup must precede token reward.');
const settled = finishRicheseSkillsTechBattle(state);
assert.deepEqual(changedTokens(fixture.game, settled), [token]);
assert.equal(settled.techTokens![token].owner, fixture.owner);
assert.equal(settled.pendingTech, null);
assert.deepEqual(cards(settled), cards(fixture.game));
assertAdvancedNativeCustody(settled);
return settled;
}
for (const advanced of [false, true]) for (const marker of [0, 3, 5] as const) {
void test(`${advanced ? 'Advanced' : 'Basic'} native Richese ${marker} marker reveals only after both plans, caps real Suk casualties, then rewards the original loser token`, () => {
const fixture = createRicheseSkillsTechFixture({ advanced, marker, ...(marker === 5 ? { reserves: 3 } : {}) });
const beforeShipment = fixture.beforeShipment!, shipped = fixture.afterShipment!;
assert.equal(player(shipped, fixture.owner).spice, player(beforeShipment, fixture.owner).spice - 1);
assert.equal(player(shipped, fixture.owner).reserves, player(beforeShipment, fixture.owner).reserves);
let game = openRicheseSkillsTechBattle(fixture);
const pool = marker === 5 ? 3 : marker;
reject(game, fixture.owner, { ...fixture.ownerPlan, dial: pool + 1, support: advanced ? pool + 1 : 0 });
game = applyAction(game, fixture.owner, fixture.ownerPlan);
assert.ok(player(game, fixture.owner).noField!.deployed);
assert.equal(player(game, fixture.owner).forces[fixture.key] ?? 0, 0);
assert.equal(viewGame(game, fixture.opponent).richeseNoField!.private, null);
game = applyAction(game, fixture.opponent, fixture.opponentPlan);
assert.equal(player(game, fixture.owner).noField!.deployed, null);
assert.equal(player(game, fixture.owner).forces[fixture.key] ?? 0, pool);
assert.equal(player(game, fixture.owner).reserves, (marker === 5 ? 3 : 20) - pool);
const before = player(fixture.game, fixture.owner).spice;
game = finishRicheseSkillsTechBattle(calls(game), 'skill');
assert.equal(game.lastBattleContext!.winner, fixture.owner);
assert.equal(player(game, fixture.owner).spice, before - (advanced ? pool : 0));
assert.equal(player(game, fixture.opponent).tanks, 6);
const losingCard = String(fixture.opponentPlan.weapon);
assert.equal(game.discard.filter(c => c.id === losingCard).length, 1);
if (pool) {
const d = game.decision;
assert.ok(d?.kind === 'sukRescue');
assert.equal(d.mode, 'skilled');
assert.equal(player(game, fixture.owner).tanks, 0, 'Do not pre-apply declared casualties before real rescue.');
const choice = d.options.findIndex(o => o.normal === pool && o.elite === 0 && o.kept?.key === fixture.key);
assert.ok(choice >= 0);
reject(game, fixture.opponent, { type: 'decision', event: d.event, choice });
reject(game, fixture.owner, { type: 'decision', event: `${d.event}-stale`, choice });
game = applyAction(game, fixture.owner, { type: 'decision', event: d.event, choice });
assert.equal(player(game, fixture.owner).forces[fixture.key], 1);
assert.equal(player(game, fixture.owner).reserves, (marker === 5 ? 3 : 20) - 1);
assert.equal(player(game, fixture.owner).tanks, 0);
assert.equal(game.lastBattleContext!.sukRescue!.completed, true);
} else {
assert.equal(player(game, fixture.owner).reserves, 20);
assert.equal(player(game, fixture.owner).tanks, 0);
assert.equal(player(game, fixture.owner).forces[fixture.key] ?? 0, 0);
}
reward(fixture, game);
});
}
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} normal Suk saves one actual three-marker casualty, never all declared losses`, () => {
const fixture = createRicheseSkillsTechFixture({ advanced, marker: 3, band: 'normal' });
let game = finishRicheseSkillsTechBattle(calls(reveal(fixture, false)), 'cleanup');
assert.equal(game.lastBattleContext!.winner, fixture.owner);
assert.equal(player(game, fixture.owner).forces[fixture.key] ?? 0, 0);
assert.equal(player(game, fixture.owner).tanks, 2);
assert.equal(player(game, fixture.owner).reserves, 18);
game = reward(fixture, game);
assert.equal(player(game, fixture.opponent).tanks, 6);
});
for (const advanced of [false, true]) for (const marker of [0, 3, 5] as const) {
void test(`${advanced ? 'Advanced' : 'Basic'} Smuggler adds one real companion to No-Field ${marker} without changing its one-marker fee or earning Banker`, () => {
const fixture = createRicheseSkillsTechFixture({ advanced, kind: 'shipment', marker });
const before = fixture.game, paid = payRicheseSkillsTechShipment(fixture);
const own = player(paid, fixture.owner);
assert.equal(own.spice, player(before, fixture.owner).spice - 1);
assert.equal(own.reserves, player(before, fixture.owner).reserves - 1);
assert.equal(own.forces[fixture.key], 1);
assert.equal(own.noField!.deployed!.tokenId, String(fixture.shipmentAction!.noField));
assert.equal(paid.techTokens!.heighliners.spice, 1);
assert.deepEqual(viewGame(paid, fixture.banker).spiceBankerIncome!.deferred, []);
assert.deepEqual(cards(paid), cards(before));
assertAdvancedNativeCustody(paid);
});
}
void test('a real qualifying revival earns Banker independently; No-Field companion triggers Heighliners and each collects at its original final leg exactly once', () => {
const fixture = createRicheseSkillsTechFixture({ kind: 'shipment', bankerPayment: true });
const before = fixture.game;
assert.deepEqual(viewGame(before, fixture.banker).spiceBankerIncome!.deferred, [{ owner: fixture.banker, amount: 1 }]);
const grants = before.spiceBankerIncome!.sources.filter(row => row.grant);
assert.deepEqual(grants.map(row => row.source.bankLegs), [[{ payer: fixture.opponent, amount: 4 }]]);
const paid = payRicheseSkillsTechShipment(fixture);
assert.equal(player(paid, fixture.owner).spice, player(before, fixture.owner).spice - 1);
assert.deepEqual(paid.spiceBankerIncome!.sources.filter(row => row.grant), grants);
const sources = structuredClone(paid.spiceBankerIncome!.sources);
const expected = Object.fromEntries(quoteSpiceCollection(paid).receipts.map(r => [r.player, r.balance]));
const techOwner = paid.techTokens!.heighliners.owner!;
expected[techOwner] += 1;
const collection = advanceRicheseSkillsTechToPhase(paid, 7);
assert.equal(collection.phase, 7, 'Empty Battle may correctly auto-skip from Movement to Collection.');
assert.deepEqual(wallets(collection), expected);
assert.equal(collection.techTokens!.heighliners.spice, 0);
assert.deepEqual(viewGame(collection, fixture.banker).spiceBankerIncome!.deferred, [{ owner: fixture.banker, amount: 1 }]);
const mentat = advanceRicheseSkillsTechToPhase(collection, 8);
expected[fixture.banker] += 1;
assert.deepEqual(wallets(mentat), expected);
assert.deepEqual(viewGame(mentat, fixture.banker).spiceBankerIncome!.deferred, []);
assert.deepEqual(mentat.spiceBankerIncome!.sources, sources, 'Neither Tech income nor Banker collection creates another bank-payment source.');
const next = nextRicheseSkillsTechNativeStep(mentat), continued = applyAction(mentat, next.actor, next.action);
assert.deepEqual(wallets(continued), expected);
assert.deepEqual(continued.spiceBankerIncome!.collections, mentat.spiceBankerIncome!.collections);
assertAdvancedNativeCustody(continued);
});
for (const advanced of [false, true]) for (const marker of [0, 3, 5] as const)
void test(`${advanced ? 'Advanced' : 'Basic'} mixed Smuggler and No-Field ${marker} battle seals a capped pool before real losses and Tech reward`, () => {
const fixture = createRicheseSkillsTechFixture({ advanced, kind: 'shipment', marker });
const paid = payRicheseSkillsTechShipment(fixture);
const own = player(paid, fixture.owner), enemy = player(paid, fixture.opponent);
// Explicit conserved scarcity and opponent positions, not played shipment claims.
own.forces['polar_sink:0'] = own.reserves - 2; own.reserves = 2;
enemy.reserves -= 6; enemy.forces[fixture.key] = 6;
const ready = advanceRicheseSkillsTechToPhase(paid, 6);
let game = openRicheseSkillsTechBattle({ ...fixture, game: ready });
const materialized = Math.min(marker, 2), pool = 1 + materialized;
assert.equal(viewGame(game, fixture.owner).battle!.mixedNoField, true);
assert.equal(viewGame(game, fixture.owner).battle!.ownForces!.normal, pool);
assert.equal(viewGame(game, fixture.opponent).battle!.mixedNoField, undefined);
assert.equal(viewGame(game, fixture.opponent).battle!.opponentForces, null);
const token = own.noField!.deployed!.tokenId;
reject(game, fixture.owner, { type: 'revealNoField', token, event: own.noFieldEvent });
reject(game, fixture.owner, { ...fixture.ownerPlan, dial: pool + 1, support: advanced ? pool + 1 : 0 });
const dial = Math.min(2, pool);
game = applyAction(game, fixture.owner, { ...fixture.ownerPlan, dial, support: advanced ? dial : 0 });
assert.equal(game.battle!.revealed, false);
assert.ok(player(game, fixture.owner).noField!.deployed);
assert.equal(player(game, fixture.owner).forces[fixture.key], 1);
assert.equal(player(game, fixture.owner).reserves, 2);
game = applyAction(game, fixture.opponent, fixture.opponentPlan);
assert.equal(game.battle!.revealed, true);
assert.equal(player(game, fixture.owner).noField!.deployed, null);
assert.equal(player(game, fixture.owner).forces[fixture.key], pool);
assert.equal(player(game, fixture.owner).reserves, 2 - materialized);
assertAdvancedNativeCustody(game);
game = finishRicheseSkillsTechBattle(calls(game));
const winner = game.lastBattleContext!.winner;
assert.ok(winner === fixture.owner || winner === fixture.opponent);
const ownerWon = winner === fixture.owner;
assert.equal(player(game, fixture.owner).forces[fixture.key] ?? 0, ownerWon ? pool - dial : 0);
assert.equal(player(game, fixture.owner).tanks, ownerWon ? dial : pool);
assert.equal(player(game, fixture.owner).reserves, 2 - materialized);
assert.equal(player(game, fixture.opponent).forces[fixture.key] ?? 0, ownerWon ? 0 : 6);
assert.equal(player(game, fixture.opponent).tanks, ownerWon ? 6 : 0);
const loser = ownerWon ? fixture.opponent : fixture.owner;
const tokenReward = ownedTech(ready.techTokens, loser)[0]; assert.ok(tokenReward);
assert.deepEqual(changedTokens(ready, game), [tokenReward]);
assert.equal(game.techTokens![tokenReward].owner, winner);
assert.deepEqual(cards(game), cards(ready));
assertAdvancedNativeCustody(game);
});
for (const mode of ['kill', 'ignore'] as const) void test(`cache-acquired Stone ${mode} uses real undialed forces and cleanup before awarding the actual winner an original loser-owned Tech Token`, () => {
const fixture = createRicheseSkillsTechFixture({ kind: 'stone' });
assert.ok(fixture.acquisition.before.richeseCache!.some(c => c.id === 'richese-stone-burner'));
assert.equal(fixture.acquisition.after.richeseCache!.some(c => c.id === 'richese-stone-burner'), false);
for (const p of fixture.acquisition.before.players) assert.equal(player(fixture.acquisition.after, p.id).spice, p.spice);
let game = reveal(fixture);
assert.ok(game.decision?.kind === 'stoneBurner');
game = applyAction(game, fixture.owner, { type: 'decision', event: game.decision.event, mode });
const paidBefore = player(fixture.game, fixture.owner).spice;
game = finishRicheseSkillsTechBattle(calls(game), 'skill');
assert.equal(game.lastBattleContext!.winner, fixture.owner, 'Eight minus two undialed counters beats four minus one, independent of leaders.');
for (const [actor, plan] of [[fixture.owner, fixture.ownerPlan], [fixture.opponent, fixture.opponentPlan]] as const) {
assert.equal(player(game, actor).leaders.find(l => l.id === plan.leader)!.dead, mode === 'kill');
}
const bounty = mode === 'kill' ? [fixture.ownerPlan, fixture.opponentPlan].reduce((sum, plan, i) =>
sum + player(fixture.game, i === 0 ? fixture.owner : fixture.opponent).leaders.find(l => l.id === plan.leader)!.strength, 0) : 0;
assert.equal(player(game, fixture.owner).spice, paidBefore - 2 + bounty);
if (mode === 'ignore') {
const d = game.decision;
assert.ok(d?.kind === 'sukRescue', 'Surviving hidden Suk trainer rescues actual Stone dialed casualties.');
const choice = d.options.findIndex(o => o.normal === 2 && o.elite === 0 && o.kept?.key === fixture.key);
assert.ok(choice >= 0);
game = applyAction(game, fixture.owner, { type: 'decision', event: d.event, choice });
assert.equal(player(game, fixture.owner).forces[fixture.key], 7);
assert.equal(player(game, fixture.owner).tanks, 0);
assert.equal(player(game, fixture.owner).reserves, 13);
assert.equal(game.lastBattleContext!.sukRescue!.completed, true);
} else {
assert.equal(game.lastBattleContext!.sukRescue, undefined, 'Killed skilled trainer cannot rescue.');
assert.equal(player(game, fixture.owner).forces[fixture.key], 6);
assert.equal(player(game, fixture.owner).tanks, 2);
}
assert.equal(player(game, fixture.opponent).tanks, 4);
const collection = quoteSpiceCollection(game).receipts.find(r => r.player === fixture.owner)!;
const settled = reward(fixture, game);
assert.ok(player(settled, fixture.owner).hand.some(c => c.id === 'richese-stone-burner'));
assert.equal(player(settled, fixture.owner).spice, paidBefore - 2 + bounty + collection.collected + collection.strongholds,
'Only real Stone support/bounty and independently quoted native Collection affect the wallet.');
});
for (const precedence of ['double-traitor', 'explosion'] as const) void test(`${precedence} takes precedence over actual cached Stone and gives no winner, no rescue and no Tech reward`, () => {
const fixture = createRicheseSkillsTechFixture({ kind: 'stone', precedence });
let game = reveal(fixture);
assert.ok(game.decision?.kind === 'stoneBurner');
game = applyAction(game, fixture.owner, { type: 'decision', event: game.decision.event, mode: 'kill' });
game = finishRicheseSkillsTechBattle(calls(game, precedence === 'double-traitor' ? [fixture.owner, fixture.opponent] : []));
assert.equal(game.lastBattleContext!.winner, null);
assert.deepEqual(changedTokens(fixture.game, game), []);
assert.equal(player(game, fixture.owner).tanks, 8);
assert.equal(player(game, fixture.opponent).tanks, 4);
assert.equal(game.discard.filter(c => c.id === 'richese-stone-burner').length, 1);
assert.deepEqual(cards(game), cards(fixture.game));
assertAdvancedNativeCustody(game);
});
void test('all four own-seat policies legally commit real capped marker plans, resolve skilled Suk and choose both-mode Stone controls without inventing token custody', () => {
const marker = createRicheseSkillsTechFixture({ marker: 5, reserves: 3 });
const stone = createRicheseSkillsTechFixture({ kind: 'stone' });
const pending = finishRicheseSkillsTechBattle(calls(reveal(marker)), 'skill');
assert.ok(pending.decision?.kind === 'sukRescue');
const choice = reveal(stone);
assert.ok(choice.decision?.kind === 'stoneBurner');
for (const difficulty of DIFFICULTIES) {
const plans = openRicheseSkillsTechBattle(marker);
const view = viewGame(plans, marker.owner); view.players.find(p => p.id === marker.owner)!.bot = difficulty;
const plan = botActions(view).find(a => a.type === 'battlePlan'); assert.ok(plan);
assert.ok(Number(plan.dial) <= 3 && Number(plan.support ?? 0) <= player(plans, marker.owner).spice);
const sealed = applyAction(plans, marker.owner, plan);
assert.ok(player(sealed, marker.owner).noField!.deployed);
assert.deepEqual(changedTokens(marker.game, sealed), []);
const rescueView = viewGame(pending, marker.owner); rescueView.players.find(p => p.id === marker.owner)!.bot = difficulty;
const rescue = botActions(rescueView)[0]; assert.ok(rescue?.type === 'decision');
const rescued = applyAction(pending, marker.owner, rescue);
assert.equal(player(rescued, marker.owner).forces[marker.key], 1);
reward(marker, rescued);
const stoneView = viewGame(choice, stone.owner); stoneView.players.find(p => p.id === stone.owner)!.bot = difficulty;
const mode = botActions(stoneView)[0]; assert.ok(mode.mode === 'kill' || mode.mode === 'ignore');
const chosen = applyAction(choice, stone.owner, mode);
assert.equal(chosen.battle!.stoneBurner![stone.owner], mode.mode);
const settled = finishRicheseSkillsTechBattle(calls(chosen));
assert.equal(settled.lastBattleContext!.winner, stone.owner);
assert.equal(ownedTech(settled.techTokens, stone.owner).length, 2);
assertAdvancedNativeCustody(settled);
}
});
void test('fresh authenticated original ready lobby retains IDs and seat order through actual marker payment, battle and original reward', () => {
const initial = createGame('AUTHRICH', newPlayer('human-enemy', 'Human Emperor', 'emperor'), true, ['choam']);
joinGame(initial, newPlayer('human-richese', 'Human Richese', 'richese'));
joinGame(initial, newPlayer('human-banker', 'Human Banker', 'atreides'));
const original = structuredClone(initial), identities = initial.players.map(p => ({ id: p.id, name: p.name, faction: p.faction }));
const fixture = createRicheseSkillsTechFixture({ initial, marker: 3 });
assert.deepEqual(initial, original);
assert.equal(fixture.owner, 'human-richese');
const completed = finishRicheseSkillsTechBattle(calls(reveal(fixture)));
assert.equal(completed.lastBattleContext!.winner, fixture.owner);
assert.deepEqual(completed.players.map(p => ({ id: p.id, name: p.name, faction: p.faction })), identities);
assert.equal(ownedTech(completed.techTokens, fixture.owner).length, 2);
assertAdvancedNativeCustody(completed);
});
void test('native Atreides dial prescience cannot disclose a concealed Richese marker with Skills and Tech', () => {
const fixture = createRicheseSkillsTechFixture({ marker: 5, opponent: 'atreides' });
let game = structuredClone(fixture.game);
const actor = game.active!;
game = applyAction(game, actor, { type: 'chooseBattle', territory: fixture.territory,
target: actor === fixture.owner ? fixture.opponent : fixture.owner });
for (let n = 0; game.battle?.preparation?.kind !== 'prescience' && n < 100; n++) {
const next = nextRicheseSkillsTechNativeStep(game); game = applyAction(game, next.actor, next.action);
}
assert.equal(game.battle!.preparation!.kind, 'prescience');
reject(game, fixture.opponent, { type: 'prescience', field: 'dial' });
game = applyAction(game, fixture.opponent, { type: 'declineBattlePower' });
assert.ok(player(game, fixture.owner).noField!.deployed);
assert.deepEqual(changedTokens(fixture.game, game), []);
assertAdvancedNativeCustody(game);
});