Files
Dune/tests/native-world-marker.test.ts

286 lines
18 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, RuleError, viewGame, type Game } from '../game/engine';
import { spiceBankerControlState } from '../components/spice-banker';
import {
createNativeWorldMarkerFixture, nativeWorldMarkerInventory, nativeWorldMarkerPlan,
type NativeWorldMarkerFixture,
} from './fixture-native-world-marker';
import {
finishRicheseReinforcementsBattle, prepareRicheseReinforcementsPlan,
RICHESE_HARASS, RICHESE_REINFORCEMENTS, richeseReinforcementsPlayer,
sealRicheseReinforcementsPlan,
} from './fixture-richese-reinforcements';
function physicalCards(game: Game) {
return [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
...(game.ixSetupCards ?? []), ...game.players.flatMap(player => player.hand)].map(card => card.id).sort();
}
function primaryPool(game: Game, fixture: NativeWorldMarkerFixture) {
const home = viewGame(game, fixture.richese).homeworlds?.worlds
?.find(world => world.id === fixture.source.homeworld);
assert.ok(home);
return home.forces[fixture.richese];
}
function assertConserved(fixture: NativeWorldMarkerFixture, game: Game) {
assert.deepEqual(physicalCards(game), physicalCards(fixture.initial),
'Every original selected-deck/cache face retains one physical identity.');
for (const player of game.players) {
const before = nativeWorldMarkerInventory(fixture.afterSetup, player.id);
const after = nativeWorldMarkerInventory(game, player.id);
assert.deepEqual([after.total, after.normal, after.elite], [before.total, before.normal, before.elite],
`${player.faction}'s native reserves count once; special counters remain their own physical type.`);
assert.equal(after.total, 20);
if (player.faction === 'ixians') assert.equal(after.elite, 7);
assert.ok(player.hand.length <= (player.faction === 'harkonnen' ? 8 : 4));
}
const original = richeseReinforcementsPlayer(fixture.afterSetup, fixture.richese).noField!;
const current = richeseReinforcementsPlayer(game, fixture.richese).noField!;
assert.deepEqual(current.tokens, original.tokens, 'No token is minted, replaced or relabeled.');
assert.equal(current.lastShipped, fixture.token.id, 'Reveal preserves the last real shipment identity.');
}
function assertSpent(game: Game, card: string) {
assert.equal(game.discard.filter(face => face.id === card).length, 1);
assert.ok(game.players.every(player => player.hand.every(face => face.id !== card)));
assert.ok(game.deck.every(face => face.id !== card));
}
/** Preview and the first seal must not perform the eventual native reveal. */
function assertStillConcealed(fixture: NativeWorldMarkerFixture, game: Game) {
const owner = richeseReinforcementsPlayer(game, fixture.richese);
const before = richeseReinforcementsPlayer(fixture.game, fixture.richese);
assert.equal(game.battle!.revealed, false);
assert.deepEqual(owner.noField, before.noField);
assert.equal(owner.noFieldEvent, before.noFieldEvent);
assert.equal(owner.reserves, fixture.source.reservesBeforeReveal);
assert.deepEqual(primaryPool(game, fixture), { normal: owner.reserves, elite: 0 });
assert.equal(owner.forces[fixture.location] ?? 0, 0, 'A concealed marker is not a physical counter.');
assert.equal(owner.tanks, before.tanks);
}
for (const family of ['choam', 'ix+choam'] as const) {
// Opposite representative slots cover both paths without a Cartesian matrix.
const slots = [family === 'choam' ? 'weapon' : 'defense'] as const;
for (const slot of slots)
void test(`${family} World marker three funds R in the ${slot} slot only after its real primary reserve debit`, () => {
const fixture = createNativeWorldMarkerFixture({ family, marker: 3, reserves: 6 });
const original = structuredClone(fixture.game);
const preview = viewGame(fixture.game, fixture.richese).battle!.reinforcements!;
assert.deepEqual(fixture.game, original, 'The owner forecast does not materialize forces or update history.');
assert.equal(preview.blocked, null);
assert.deepEqual(preview.noField, { materializes: 3, reservesAfterReveal: 3 });
assertStillConcealed(fixture, fixture.game);
const first = prepareRicheseReinforcementsPlan(fixture.game, nativeWorldMarkerPlan(fixture, 'reinforcements', slot));
assertStillConcealed(fixture, first);
const revealed = sealRicheseReinforcementsPlan(first, fixture.plans.opponent);
const owner = richeseReinforcementsPlayer(revealed, fixture.richese);
assert.equal(revealed.battle!.revealed, true);
assert.deepEqual(primaryPool(revealed, fixture), { normal: 3, elite: 0 });
assert.equal(owner.reserves, 3, 'Materialization spends the same native primary counters once.');
assert.equal(owner.forces[fixture.location], 3);
assert.equal(owner.tanks, 0, 'The provisional fee settles at the actual result, not at first seal.');
assert.equal(owner.noField!.deployed, null);
assertConserved(fixture, revealed);
const done = finishRicheseReinforcementsBattle(revealed);
const after = richeseReinforcementsPlayer(done, fixture.richese);
assert.equal(done.lastBattleContext!.result, 'normal');
assert.equal(done.lastBattleContext!.winner, fixture.opponent);
assert.deepEqual(primaryPool(done, fixture), { normal: 0, elite: 0 });
assert.equal(after.reserves, 0);
assert.equal(after.tanks, 6, 'Three materialized casualties plus the three-reserve fee, not nine counters.');
assert.equal(after.battleLosses, 6);
assert.equal(after.forces[fixture.location] ?? 0, 0);
assertSpent(done, RICHESE_REINFORCEMENTS);
assertConserved(fixture, done);
});
for (const [marker, reserves, materializes] of [[0, 6, 0], [5, 8, 5]] as const)
void test(`${family} World funded marker ${marker} keeps its physical token and pays from the remaining native reserves`, () => {
const fixture = createNativeWorldMarkerFixture({ family, marker, reserves });
const preview = viewGame(fixture.game, fixture.richese).battle!.reinforcements!;
assert.equal(preview.blocked, null);
assert.deepEqual(preview.noField, { materializes, reservesAfterReveal: reserves - materializes });
const first = prepareRicheseReinforcementsPlan(fixture.game, nativeWorldMarkerPlan(fixture));
assertStillConcealed(fixture, first);
const revealed = sealRicheseReinforcementsPlan(first, fixture.plans.opponent);
assert.deepEqual(primaryPool(revealed, fixture), { normal: reserves - materializes, elite: 0 });
assert.equal(richeseReinforcementsPlayer(revealed, fixture.richese).forces[fixture.location] ?? 0, materializes);
const done = finishRicheseReinforcementsBattle(revealed);
const after = richeseReinforcementsPlayer(done, fixture.richese);
assert.equal(after.reserves, reserves - materializes - 3);
assert.deepEqual(primaryPool(done, fixture), { normal: after.reserves, elite: 0 });
assert.equal(after.tanks, materializes + 3);
assertSpent(done, RICHESE_REINFORCEMENTS);
assertConserved(fixture, done);
});
for (const [marker, reserves, materializes] of [[3, 5, 3], [5, 3, 3]] as const)
for (const slot of slots)
void test(`${family} World marker ${marker}/${reserves} refuses short R in ${slot} unchanged, without borrowing a future H return`, () => {
const fixture = createNativeWorldMarkerFixture({ family, marker, reserves });
const before = structuredClone(fixture.game);
const preview = viewGame(fixture.game, fixture.richese).battle!.reinforcements!;
assert.ok(preview.blocked);
assert.deepEqual(preview.noField, { materializes, reservesAfterReveal: reserves - materializes });
assert.equal(preview.normal + preview.elite, reserves - materializes);
assert.equal(viewGame(fixture.game, fixture.richese).battle!.harassWithdraw!.blocked, null);
assert.deepEqual(fixture.game, before);
// This exact original marker-three/reserves-five native position used
// to accept the raw first seal even though its owner preview refused it.
const rawR = nativeWorldMarkerPlan(fixture, 'reinforcements', slot);
delete rawR.action.reinforcementsSources;
for (const plan of [rawR, nativeWorldMarkerPlan(fixture, 'both', slot)]) {
assert.throws(() => applyAction(fixture.game, plan.actor, plan.action), RuleError);
assert.deepEqual(fixture.game, before, 'Rejection changes no wallet, cards, token, history, counters or windows.');
assert.deepEqual(fixture.game.battle!.plans, {});
}
assertStillConcealed(fixture, fixture.game);
const first = prepareRicheseReinforcementsPlan(fixture.game, nativeWorldMarkerPlan(fixture, 'harass', slot));
assertStillConcealed(fixture, first);
const revealed = sealRicheseReinforcementsPlan(first, fixture.plans.opponent);
assert.deepEqual(primaryPool(revealed, fixture), { normal: reserves - materializes, elite: 0 });
assert.equal(richeseReinforcementsPlayer(revealed, fixture.richese).forces[fixture.location], materializes,
'A five marker capped at three reserves reveals three, not five.');
const done = finishRicheseReinforcementsBattle(revealed);
const after = richeseReinforcementsPlayer(done, fixture.richese);
assert.equal(after.reserves, reserves - materializes + 1);
assert.deepEqual(primaryPool(done, fixture), { normal: after.reserves, elite: 0 },
'The real undialed counter deposits in the native primary World, not a foreign visitor pool.');
assert.equal(after.tanks, 2, 'Only the dialed counters die under H-only fallback.');
assertSpent(done, RICHESE_HARASS);
assert.ok(after.hand.some(card => card.id === RICHESE_REINFORCEMENTS), 'The rejected R remains unspent.');
assertConserved(fixture, done);
});
for (const slot of slots)
void test(`${family} World R+H in ${slot} charges native reserves before depositing the real undialed survivor`, () => {
const fixture = createNativeWorldMarkerFixture({ family, marker: 3, reserves: 6 });
const first = prepareRicheseReinforcementsPlan(fixture.game, nativeWorldMarkerPlan(fixture, 'both', slot));
assertStillConcealed(fixture, first);
const revealed = sealRicheseReinforcementsPlan(first, fixture.plans.opponent);
assert.deepEqual(primaryPool(revealed, fixture), { normal: 3, elite: 0 });
const done = finishRicheseReinforcementsBattle(revealed);
const after = richeseReinforcementsPlayer(done, fixture.richese);
assert.deepEqual(primaryPool(done, fixture), { normal: 1, elite: 0 });
assert.equal(after.reserves, 1, 'Three pay first; the one real undialed survivor returns afterwards.');
assert.equal(after.tanks, 5);
assert.equal(after.battleLosses, 5);
assertSpent(done, RICHESE_REINFORCEMENTS);
assertSpent(done, RICHESE_HARASS);
assertConserved(fixture, done);
});
for (const marker of [0, 3, 5] as const)
for (const slot of slots)
void test(`${family} Advanced World marker ${marker} pays R from its funded turn-one primary World in ${slot}`, () => {
const fixture = createNativeWorldMarkerFixture({ family, advanced: true, marker });
const before = richeseReinforcementsPlayer(fixture.game, fixture.richese);
const preview = viewGame(fixture.game, fixture.richese).battle!.reinforcements!;
assert.equal(preview.blocked, null);
assert.deepEqual(preview.noField, { materializes: marker, reservesAfterReveal: 20 - marker });
const first = prepareRicheseReinforcementsPlan(fixture.game, nativeWorldMarkerPlan(fixture, 'reinforcements', slot));
assertStillConcealed(fixture, first);
const revealed = sealRicheseReinforcementsPlan(first, fixture.plans.opponent);
assert.equal(richeseReinforcementsPlayer(revealed, fixture.richese).forces[fixture.location] ?? 0, marker);
assert.deepEqual(primaryPool(revealed, fixture), { normal: 20 - marker, elite: 0 });
const done = finishRicheseReinforcementsBattle(revealed);
const after = richeseReinforcementsPlayer(done, fixture.richese);
assert.equal(after.reserves, 17 - marker);
assert.deepEqual(primaryPool(done, fixture), { normal: 17 - marker, elite: 0 });
assert.equal(after.tanks, marker + 3);
assert.equal(after.spice, before.spice - Number(fixture.plans.reinforcements.action.support),
'The original physical Advanced support is still paid, not increased by two score strength.');
assertSpent(done, RICHESE_REINFORCEMENTS);
assertConserved(fixture, done);
});
void test(`${family} Advanced World marker five settles R then deposits four H survivors with original support`, () => {
const fixture = createNativeWorldMarkerFixture({ family, advanced: true, marker: 5 });
const before = richeseReinforcementsPlayer(fixture.game, fixture.richese);
const first = prepareRicheseReinforcementsPlan(fixture.game, nativeWorldMarkerPlan(fixture, 'both'));
const revealed = sealRicheseReinforcementsPlan(first, fixture.plans.opponent);
assert.deepEqual(primaryPool(revealed, fixture), { normal: 15, elite: 0 });
const done = finishRicheseReinforcementsBattle(revealed);
const after = richeseReinforcementsPlayer(done, fixture.richese);
assert.equal(after.reserves, 16, 'Fifteen minus the fee plus four actual undialed survivors.');
assert.deepEqual(primaryPool(done, fixture), { normal: 16, elite: 0 });
assert.equal(after.tanks, 4);
assert.equal(after.spice, before.spice - 1);
assertSpent(done, RICHESE_REINFORCEMENTS);
assertSpent(done, RICHESE_HARASS);
assertConserved(fixture, done);
});
}
void test('all14 mixed native World marker keeps Banker funding private and separate from R fee and H return', () => {
const f = createNativeWorldMarkerFixture({ family: 'ix+choam', advanced: true, marker: 5,
skills: true, skilled: true, training: { richese: 'spice-banker' }, bankerSpice: 1, opponentDial: 8 });
const view = viewGame(f.game, f.richese), owner = richeseReinforcementsPlayer(f.game, f.richese);
const leader = String(f.plans.plain.action.leader);
assert.deepEqual(spiceBankerControlState(view, leader, 1), { eligible: true, maximum: 3 });
assert.deepEqual(spiceBankerControlState(view, leader, 2), { eligible: true, maximum: 2 });
assert.deepEqual(spiceBankerControlState(viewGame(f.game, f.opponent), leader, 1),
{ eligible: false, maximum: 0 }, 'Another viewer cannot commit the native owner’s skill or wallet.');
assert.deepEqual(view.battle!.reinforcements!.noField, { materializes: 5, reservesAfterReveal: 15 });
const plan = nativeWorldMarkerPlan(f, 'both', 'defense');
assert.deepEqual(plan.action.reinforcementsSources,
{ [f.source.homeworld]: { normal: 3, elite: 0 } });
const before = structuredClone(f.game);
assert.throws(() => applyAction(f.game, f.richese,
{ ...plan.action, dial: 2, support: 2, bankerSpice: 3 }), RuleError);
assert.deepEqual(f.game, before, 'Reserve funding and later H returns do not pay an overcommitted own-spice budget.');
const first = prepareRicheseReinforcementsPlan(f.game, plan);
assertStillConcealed(f, first);
assert.equal(richeseReinforcementsPlayer(first, f.richese).spice, owner.spice);
assert.equal(viewGame(first, f.richese).battle!.plans[f.richese].bankerSpice, 1);
assert.deepEqual(viewGame(first, f.observer).battle!.plans, {});
const revealed = sealRicheseReinforcementsPlan(first, f.plans.opponent);
assert.deepEqual(primaryPool(revealed, f), { normal: 15, elite: 0 });
const done = finishRicheseReinforcementsBattle(revealed);
const after = richeseReinforcementsPlayer(done, f.richese);
assert.equal(done.lastBattleContext!.winner, f.opponent);
assert.deepEqual(primaryPool(done, f), { normal: 16, elite: 0 },
'Five materialize, three pay R, then four actual undialed H survivors return to the same primary World.');
assert.equal(after.reserves, 16);
assert.equal(after.tanks, 4);
assert.equal(after.spice, owner.spice - 2, 'One own support plus one Banker commitment, not a reserve-fee subsidy.');
assert.equal(done.phase, 7, 'The original battle continues into collection with no synthetic history.');
assertSpent(done, RICHESE_REINFORCEMENTS);
assertSpent(done, RICHESE_HARASS);
assertConserved(f, done);
});
void test('all14 capped World marker refuses R before a skilled Mentat or future H return, then uses legal H', () => {
const f = createNativeWorldMarkerFixture({ marker: 5, reserves: 3, skills: true, skilled: true,
training: { richese: 'mentat' }, opponentDial: 8 });
const before = structuredClone(f.game), preview = viewGame(f.game, f.richese).battle!;
assert.equal(f.game.battle!.leaderSkillHidden![f.richese], true);
assert.deepEqual(preview.reinforcements!.noField, { materializes: 3, reservesAfterReveal: 0 });
assert.ok(preview.reinforcements!.blocked);
const raw = nativeWorldMarkerPlan(f);
delete raw.action.reinforcementsSources;
for (const plan of [raw, nativeWorldMarkerPlan(f, 'both')]) {
assert.throws(() => applyAction(f.game, f.richese, plan.action), RuleError);
assert.deepEqual(f.game, before, 'The refused first seal spends no skill, cards, token, wallet or native stock.');
}
const first = prepareRicheseReinforcementsPlan(f.game, nativeWorldMarkerPlan(f, 'harass'));
assertStillConcealed(f, first);
const revealed = sealRicheseReinforcementsPlan(first, f.plans.opponent);
assert.deepEqual(primaryPool(revealed, f), { normal: 0, elite: 0 });
assert.equal(richeseReinforcementsPlayer(revealed, f.richese).forces[f.location], 3,
'The actual five-token reveal is capped by its three original native counters.');
const done = finishRicheseReinforcementsBattle(revealed);
assert.equal(done.lastBattleContext!.winner, f.opponent);
assert.deepEqual(primaryPool(done, f), { normal: 1, elite: 0 });
assert.equal(richeseReinforcementsPlayer(done, f.richese).tanks, 2);
assert.ok(richeseReinforcementsPlayer(done, f.richese).hand.some(card => card.id === RICHESE_REINFORCEMENTS));
assertSpent(done, RICHESE_HARASS);
assertConserved(f, done);
});