Retain independent36, original Homeworlds and Nexus12 through classic fresh setup with optional Skills and phase modules. Resolve own-World Reinforcements fees before computing temporary Sardaukar roles, preserving original Cunning declarations, printed traits and physical counters. Record the provisional ordinary-nonrole-first policy and align human/AI quotes and captions. Validation: types/lint/build, 128 affected tests across 15 files, bounded rule-source review and finite original-action smoke for Recruits, Harass, local-fee Cunning, all four legal policies and rendered source controls. Verified runtime c219ec587a54b44079539168d93835a5af904c8afb4de58bcec0a3d60c809f0c; documentation-only proof update 8ee21598e7be0c89a08fbad50786c346ebc8ae705492d8b31ea00fae0ffefa3c. Full modes, combinations, assurance and deployed acceptance remain open. No saved-room/provider changes.
165 lines
11 KiB
TypeScript
165 lines
11 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { applyAction, viewGame, type Game } from '../game/engine';
|
|
import { treacheryDeck } from '../game/cards';
|
|
import { ecazTreacheryCards } from '../game/ecaz-cards';
|
|
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
|
|
import { NEXUS_FACTIONS } from '../game/nexus-cards';
|
|
import { createTechTokens } from '../game/tech-tokens';
|
|
import { createStrongholdCards } from '../game/stronghold-cards';
|
|
import { combatArmy } from '../game/combat-location';
|
|
import { RECRUITS_CARD_ID, recruitsPlayAction } from '../game/recruits';
|
|
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
|
|
import { createClassicIndependentNexusFixture, type ClassicIndependentNexusOptions } from './fixture-classic-independent-nexus';
|
|
import {
|
|
actWorldIndependentCard, advanceWorldIndependentCards, createWorldIndependentHarassFixture,
|
|
createWorldIndependentRecruitsFixture, revealWorldIndependentHarass,
|
|
worldIndependentCardPlayer as player, worldIndependentCardsClean,
|
|
} from './fixture-nexus-world-independent-cards';
|
|
|
|
function stock(game: Game): void {
|
|
assert.deepEqual([...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
|
|
...(game.auction?.cards.slice(game.auction.index) ?? [])].map(card => card.id).sort(),
|
|
[...treacheryDeck([]), ...ecazTreacheryCards()].map(card => card.id).sort());
|
|
const nexus = game.nexusCards!.cards!;
|
|
assert.deepEqual([...nexus.deck, ...nexus.discard, ...Object.values(nexus.hands).filter(card => card !== null)].sort(),
|
|
[...NEXUS_FACTIONS].sort());
|
|
if (game.leaderSkills) assert.deepEqual([...game.leaderSkills.deck,
|
|
...game.leaderSkills.assignments.map(assignment => assignment.skill),
|
|
...Object.values(game.leaderSkills.offers).flatMap(offer => offer.cards)].sort(),
|
|
LEADER_SKILL_CARDS.map(card => card.id).sort());
|
|
}
|
|
function worldPool(game: Game, actor: string, location: string) {
|
|
const world = viewGame(game, actor).homeworlds!.worlds!.find(world => world.id === location); assert.ok(world);
|
|
return { ...(world.forces[actor] ?? { normal: 0, elite: 0 }) };
|
|
}
|
|
|
|
const setupSamples: Array<ClassicIndependentNexusOptions & { roster: NonNullable<ClassicIndependentNexusOptions['roster']> }> = [
|
|
{ roster: ['emperor', 'guild'] },
|
|
{ roster: ['emperor', 'guild', 'atreides'], tech: true },
|
|
{ roster: ['emperor', 'fremen', 'atreides', 'guild'], advanced: true, skills: true, tech: true, strongholds: true },
|
|
{ roster: ['emperor', 'fremen', 'atreides', 'guild', 'harkonnen', 'beneGesserit'], advanced: true, skills: true, tech: true, strongholds: true },
|
|
];
|
|
for (const sample of setupSamples) {
|
|
void test(`${sample.advanced ? 'Advanced all14/phase' : 'Basic'} ${sample.roster.length}-seat native start selects independent36, World custody and Nexus12 without early claims`, () => {
|
|
const fixture = createClassicIndependentNexusFixture({ ...sample, independent: true, homeworlds: true });
|
|
const { lobby, setup, game } = fixture;
|
|
assert.deepEqual(lobby.expansions, []);
|
|
assert.equal(lobby.lobbyOptions?.ecazTreachery, true);
|
|
assert.deepEqual(lobby.homeworlds, { custody: null });
|
|
assert.deepEqual(lobby.nexusCards, { cards: null, phase: null });
|
|
assert.equal(lobby.leaderSkills?.deck.length ?? 0, 0, 'No skill is manufactured before the selected native setup');
|
|
if (sample.tech) assert.deepEqual(lobby.techTokens, createTechTokens());
|
|
if (sample.strongholds) assert.deepEqual(lobby.strongholdCards, createStrongholdCards());
|
|
assert.equal(setup.status, 'setup');
|
|
assert.equal(game.status, 'playing');
|
|
assert.ok(game.homeworlds!.custody);
|
|
assert.equal(game.nexusCards!.cards!.deck.length, 12);
|
|
assert.ok(Object.values(game.nexusCards!.cards!.hands).every(card => card === null));
|
|
assert.equal(game.nexusCards!.phase, null);
|
|
if (sample.skills) {
|
|
assert.equal(game.leaderSkills!.assignments.length, sample.roster.length);
|
|
assert.deepEqual(game.leaderSkills!.offers, {});
|
|
} else assert.ok(!game.leaderSkills);
|
|
if (sample.strongholds) assert.deepEqual(game.strongholdCards, createStrongholdCards());
|
|
assert.ok(fixture.actions.some(step => step.action.type === 'start'));
|
|
assert.ok(fixture.actions.some(step => step.action.type === 'moduleOption' && step.action.module === 'ecazTreachery'));
|
|
stock(game);
|
|
});
|
|
}
|
|
|
|
for (const advanced of [false, true]) {
|
|
void test(`${advanced ? 'Advanced all14/phase' : 'Basic'} genuine Recruits deals recheck the native Fremen population on each return`, () => {
|
|
const fixture = createWorldIndependentRecruitsFixture({ advanced, skills: advanced, tech: advanced, strongholds: advanced });
|
|
const initial = fixture.game, spice = player(initial, 'fremen').spice;
|
|
assert.equal(initial.turn, 2);
|
|
assert.equal(initial.phase, 4);
|
|
assert.equal(player(initial, 'fremen').tanks, 18, 'Actual zero-dial first-turn defeat supplied the counters');
|
|
assert.equal(player(initial, 'fremen').reserves, 2);
|
|
assert.deepEqual(worldPool(initial, 'fremen', 'homeworld:fremen'), { normal: 2, elite: 0 });
|
|
const action = recruitsPlayAction(viewGame(initial, 'emperor').recruitsPreview); assert.ok(action);
|
|
assert.deepEqual(action, { type: 'card', card: RECRUITS_CARD_ID });
|
|
const played = actWorldIndependentCard(initial, 'emperor', action);
|
|
const before = viewGame(played, 'fremen');
|
|
assert.equal(before.revival.homeworldBonus, 1);
|
|
assert.equal(before.recruitsPreview!.rates.find(rate => rate.player === 'fremen')!.freeRate, 7);
|
|
assert.throws(() => applyAction(played, 'fremen', { type: 'revive', amount: 8, elite: 0 }));
|
|
const together = actWorldIndependentCard(played, 'fremen', { type: 'revive', amount: 7, elite: 0 });
|
|
assert.deepEqual(worldPool(together, 'fremen', 'homeworld:fremen'), { normal: 9, elite: 0 });
|
|
assert.equal(player(together, 'fremen').freeForcesRevived, 7);
|
|
assert.equal(player(together, 'fremen').spice, spice);
|
|
assert.equal(player(together, 'fremen').tanks, 11);
|
|
|
|
let separate = actWorldIndependentCard(played, 'fremen', { type: 'revive', amount: 1, elite: 0 });
|
|
assert.deepEqual(worldPool(separate, 'fremen', 'homeworld:fremen'), { normal: 3, elite: 0 });
|
|
const changed = viewGame(separate, 'fremen');
|
|
assert.equal(changed.revival.homeworldBonus, 0);
|
|
assert.equal(changed.revival.freeRemaining, 5);
|
|
assert.equal(changed.recruitsPreview!.rates.find(rate => rate.player === 'fremen')!.freeRate, 6);
|
|
assert.throws(() => applyAction(separate, 'fremen', { type: 'revive', amount: 6, elite: 0 }));
|
|
separate = actWorldIndependentCard(separate, 'fremen', { type: 'revive', amount: 5, elite: 0 });
|
|
assert.deepEqual(worldPool(separate, 'fremen', 'homeworld:fremen'), { normal: 8, elite: 0 });
|
|
assert.equal(player(separate, 'fremen').freeForcesRevived, 6);
|
|
assert.equal(player(separate, 'fremen').spice, spice);
|
|
assert.equal(viewGame(separate, 'fremen').revival.forcesRemaining, 0);
|
|
assert.throws(() => applyAction(separate, 'fremen', { type: 'revive', amount: 1, elite: 0 }));
|
|
assert.equal(separate.discard.filter(card => card.id === RECRUITS_CARD_ID).length, 1);
|
|
separate = advanceWorldIndependentCards(separate, state => state.phase === 5 && worldIndependentCardsClean(state));
|
|
assert.equal(separate.turn, 2);
|
|
stock(separate);
|
|
});
|
|
|
|
void test(`${advanced ? 'Advanced all14/phase' : 'Basic'} genuinely dealt Harass returns typed foreign World visitors through native destinations`, () => {
|
|
const fixture = createWorldIndependentHarassFixture({ advanced, skills: advanced, tech: advanced, strongholds: advanced });
|
|
assert.equal(fixture.shipmentStep.action.type, 'homeworldShip');
|
|
assert.deepEqual(combatArmy(fixture.afterShipment, fixture.visitor, fixture.location), { normal: 3, elite: 4 });
|
|
let game = revealWorldIndependentHarass(fixture.game, fixture.plans);
|
|
assert.equal(game.decision?.kind, 'harassWithdraw');
|
|
const allocation = viewGame(game, fixture.owner).battle!.harassAllocation!;
|
|
assert.deepEqual(allocation.context.locations, { [fixture.location]: { normal: 3, elite: 4 } });
|
|
const before = player(game, fixture.owner), kaitain = worldPool(game, fixture.owner, 'homeworld:emperor');
|
|
const salusa = advanced ? worldPool(game, fixture.owner, 'homeworld:emperor:salusa') : null;
|
|
assert.ok(game.decision && 'event' in game.decision);
|
|
game = actWorldIndependentCard(game, fixture.owner, { type: 'decision', event: game.decision.event, returns: fixture.returns });
|
|
assert.deepEqual(viewGame(game, fixture.owner).battle!.traitorVoters, [fixture.native]);
|
|
game = advanceWorldIndependentCards(game, state => !state.battle && !state.homeworldBattleLoss && worldIndependentCardsClean(state));
|
|
const after = player(game, fixture.owner);
|
|
assert.equal(game.lastBattleContext!.territory, fixture.location);
|
|
assert.equal(game.lastBattleContext!.result, 'normal');
|
|
assert.equal(game.lastBattleContext!.winner, fixture.native);
|
|
assert.equal(after.reserves, before.reserves + 5);
|
|
assert.equal(after.elites!.reserves, before.elites!.reserves + 3);
|
|
assert.equal(after.tanks, before.tanks + 2);
|
|
assert.equal(after.elites!.tanks, before.elites!.tanks + 1);
|
|
assert.deepEqual(combatArmy(game, fixture.owner, fixture.location), { normal: 0, elite: 0 });
|
|
assert.deepEqual(worldPool(game, fixture.owner, 'homeworld:emperor'), {
|
|
normal: kaitain.normal + 2, elite: kaitain.elite + (advanced ? 0 : 3),
|
|
});
|
|
if (salusa) assert.deepEqual(worldPool(game, fixture.owner, 'homeworld:emperor:salusa'), {
|
|
normal: salusa.normal, elite: salusa.elite + 3,
|
|
});
|
|
assert.equal(game.discard.filter(card => card.id === HARASS_WITHDRAW_CARD).length, 1);
|
|
game = advanceWorldIndependentCards(game, state => state.phase === 7 && worldIndependentCardsClean(state));
|
|
stock(game);
|
|
});
|
|
|
|
void test(`${advanced ? 'Advanced all14/phase' : 'Basic'} native own World still forbids the genuinely dealt Harass`, () => {
|
|
const fixture = createWorldIndependentHarassFixture({ advanced, native: true, skills: advanced, tech: advanced, strongholds: advanced });
|
|
assert.equal(fixture.native, fixture.owner);
|
|
assert.match(viewGame(fixture.game, fixture.owner).battle!.harassWithdraw!.blocked!, /own.*Homeworld/i);
|
|
assert.throws(() => applyAction(fixture.game, fixture.owner, fixture.plans[0].action), /own.*Homeworld/i);
|
|
assert.ok(player(fixture.game, fixture.owner).hand.some(card => card.id === HARASS_WITHDRAW_CARD));
|
|
assert.deepEqual(fixture.game.battle!.plans, {});
|
|
const plans = fixture.plans.map(plan => plan.actor === fixture.owner
|
|
? { ...plan, action: { ...plan.action, defense: null } } : plan);
|
|
let game = revealWorldIndependentHarass(fixture.game, plans);
|
|
assert.deepEqual(viewGame(game, fixture.owner).battle!.traitorVoters, [fixture.owner]);
|
|
game = advanceWorldIndependentCards(game, state => !state.battle && !state.homeworldBattleLoss && worldIndependentCardsClean(state));
|
|
assert.equal(game.lastBattleContext!.territory, fixture.location);
|
|
assert.equal(game.lastBattleContext!.result, 'normal');
|
|
assert.ok(player(game, fixture.owner).hand.some(card => card.id === HARASS_WITHDRAW_CARD));
|
|
assert.equal(game.discard.filter(card => card.id === HARASS_WITHDRAW_CARD).length, 0);
|
|
stock(game);
|
|
});
|
|
}
|