286 lines
18 KiB
TypeScript
286 lines
18 KiB
TypeScript
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);
|
||
});
|