Compose classic Discovery skills and Advanced Orgiz collection
This commit is contained in:
1 parent
1947e354ce
commit
c12b463439
21 files changed
+1107
-83
No files matched your search
@@ -0,0 +1,260 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, handLimit, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { gameDistance, gameTerritories, location, splitLocation, validGameLocation } from '../game/board';
|
||||
import { strongholdPathBlocked } from '../game/occupancy';
|
||||
import { discoveryEntryMoveAction, discoveryEntryDeclineAction } from '../game/discovery-entry-options';
|
||||
import { discoveryFlightMove } from '../game/discovery-flight-options';
|
||||
import { groundMovementRange, planetologistLeader } from '../game/planetologist-movement';
|
||||
import { reserveShipmentCost } from '../game/shipment-price';
|
||||
import { ownedTech } from '../game/tech-tokens';
|
||||
import {
|
||||
advanceClassicDiscoverySkills, classicDiscoverySkillsClean, classicDiscoverySkillsEntryWindow,
|
||||
classicDiscoverySkillsShipmentWindow, createClassicDiscoverySkillsFixture, enterClassicDiscoverySkills,
|
||||
openClassicDiscoverySkillsBattle, revealClassicDiscoverySkills,
|
||||
settleClassicDiscoverySkillsArrival,
|
||||
} from './fixture-discovery-classic-skills';
|
||||
|
||||
/** Exercise the actual owned bot controls, not a separately invented strategy. */
|
||||
function legalPolicies(game: Game, actor: string): void {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game, actor);
|
||||
view.players.find(player => player.id === actor)!.bot = difficulty;
|
||||
const choices = botActions(view);
|
||||
assert.ok(choices.length, `${difficulty} needs an owned legal continuation.`);
|
||||
for (const action of choices) assert.doesNotThrow(() => applyAction(structuredClone(game), actor, action));
|
||||
}
|
||||
}
|
||||
function reject(game: Game, actor: string, action: Action): void {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
function routeAtDistance(game: Game, owner: string, from: string, distance: number) {
|
||||
return gameTerritories(game).flatMap(territory => territory.sectors.map(sector => ({ territory: territory.id, sector })))
|
||||
.find(target => target.sector !== game.storm && gameDistance(game, from, location(target.territory, target.sector), key => {
|
||||
const source = splitLocation(key);
|
||||
return (source.sector !== 0 && source.sector === game.storm) || strongholdPathBlocked(game.players, owner, source.territory, false);
|
||||
}) === distance && !strongholdPathBlocked(game.players, owner, target.territory, false));
|
||||
}
|
||||
|
||||
void test('classic trained offers and Discovery enter original Basic/Advanced setup and optional canonical Tech at three through six seats', () => {
|
||||
for (const advanced of [false, true]) for (const seats of [2, 3, 4, 5, 6] as const) {
|
||||
const fixture = createClassicDiscoverySkillsFixture({ advanced, seats, tech: seats >= 3 });
|
||||
assert.equal(fixture.setup.leaderSkills!.deck.length + Object.values(fixture.setup.leaderSkills!.offers)
|
||||
.reduce((sum, offer) => sum + offer.cards.length, 0), 14);
|
||||
assert.equal(fixture.setup.discoveries!.tokens.length, 8);
|
||||
assert.equal(fixture.game.leaderSkills!.assignments.length, seats);
|
||||
assert.equal(fixture.game.players.length, seats);
|
||||
assert.equal(fixture.game.turn, 1);
|
||||
assert.equal(fixture.game.phase, 7);
|
||||
if (seats >= 3) assert.equal(Object.values(fixture.game.techTokens!).filter(token => token.owner).length, 3);
|
||||
}
|
||||
});
|
||||
|
||||
void test('physical next-turn free entry preserves the real trained posture, wallet, reserves and ordinary movement allowance', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const fixture = createClassicDiscoverySkillsFixture({ advanced, tech: true });
|
||||
const revealed = revealClassicDiscoverySkills(fixture);
|
||||
const old = revealed.players.find(player => player.id === fixture.collector)!;
|
||||
assert.equal(old.forces[`${fixture.face}:0`] ?? 0, 0, 'Reveal itself never occupies the nested site.');
|
||||
assert.equal(validGameLocation(revealed, fixture.face, 0), true);
|
||||
const window = classicDiscoverySkillsEntryWindow(fixture);
|
||||
assert.equal(window.turn, 2);
|
||||
assert.equal(window.phase, 0);
|
||||
const view = viewGame(window, fixture.collector), before = window.players.find(player => player.id === fixture.collector)!;
|
||||
assert.equal(planetologistLeader(view, fixture.collector), fixture.leader);
|
||||
assert.ok(view.leaderSkills!.assignments.find(assignment => assignment.owner === fixture.collector)!.faceUp);
|
||||
legalPolicies(window, fixture.collector);
|
||||
const accept = discoveryEntryMoveAction(view, view.discoveryEntry!.sources);
|
||||
const decline = discoveryEntryDeclineAction(view);
|
||||
assert.ok(accept && decline);
|
||||
reject(window, fixture.opponent, accept);
|
||||
const declined = applyAction(structuredClone(window), fixture.collector, decline);
|
||||
assert.deepEqual(declined.players.find(player => player.id === fixture.collector)!.forces, before.forces);
|
||||
const entered = applyAction(window, fixture.collector, accept);
|
||||
const after = entered.players.find(player => player.id === fixture.collector)!;
|
||||
assert.equal(after.forces[`${fixture.face}:0`], 3);
|
||||
assert.equal(after.forces[`${fixture.parent}:${fixture.parentSector}`] ?? 0, 0);
|
||||
assert.equal(after.spice, before.spice);
|
||||
assert.equal(after.reserves, before.reserves);
|
||||
assert.equal(after.moved, before.moved);
|
||||
assert.equal(after.shipped, before.shipped);
|
||||
assert.equal(planetologistLeader(viewGame(entered, fixture.collector), fixture.collector), fixture.leader);
|
||||
const shipping = classicDiscoverySkillsShipmentWindow(entered, fixture.collector);
|
||||
const held = shipping.players.find(player => player.id === fixture.collector)!;
|
||||
assert.equal(held.forces[`${fixture.face}:0`], 3, 'Original Storm leaves the protected physical nested forces in place.');
|
||||
assert.equal(held.tanks, before.tanks);
|
||||
assert.equal(held.moved, 0);
|
||||
}
|
||||
});
|
||||
|
||||
void test('ordinary nested paid shipment and Planetologist range use original one-price and one-movement producers', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const fixture = createClassicDiscoverySkillsFixture({ advanced, tech: true });
|
||||
let game = classicDiscoverySkillsShipmentWindow(enterClassicDiscoverySkills(fixture), fixture.collector);
|
||||
const owner = structuredClone(game.players.find(player => player.id === fixture.collector)!);
|
||||
legalPolicies(game, fixture.collector);
|
||||
const site = gameTerritories(game).find(territory => territory.id === fixture.face)!;
|
||||
const cost = reserveShipmentCost({ faction: owner.faction, halfRate: true }, site.type, 1);
|
||||
assert.equal(cost, 1);
|
||||
game = applyAction(game, fixture.collector, { type: 'ship', territory: fixture.face, sector: 0, amount: 1, elite: 0, allyPayment: 0 });
|
||||
game = settleClassicDiscoverySkillsArrival(game);
|
||||
const shipped = game.players.find(player => player.id === fixture.collector)!;
|
||||
assert.equal(shipped.spice, owner.spice - cost);
|
||||
assert.equal(shipped.reserves, owner.reserves - 1);
|
||||
assert.equal(shipped.forces[`${fixture.face}:0`], 4);
|
||||
assert.equal(shipped.moved, 0);
|
||||
assert.equal(shipped.shipped, true);
|
||||
const target = routeAtDistance(game, fixture.collector, `${fixture.face}:0`, 2);
|
||||
assert.ok(target, 'The real nested board needs a two-territory ordinary Planetologist route.');
|
||||
assert.equal(groundMovementRange({ faction: shipped.faction, cityOrnithopters: false, selectedElites: 0, nativeBlocked: false }, 'range'), 2);
|
||||
const ordinary: Action = { type: 'move', from: `${fixture.face}:0`, amount: 1, elite: 0, ...target };
|
||||
reject(game, fixture.collector, ordinary);
|
||||
const beforeMove = structuredClone(shipped);
|
||||
game = applyAction(game, fixture.collector, { ...ordinary, planetologist: 'range' });
|
||||
game = settleClassicDiscoverySkillsArrival(game);
|
||||
const moved = game.players.find(player => player.id === fixture.collector)!;
|
||||
assert.equal(moved.moved, beforeMove.moved + 1);
|
||||
assert.equal(moved.spice, beforeMove.spice);
|
||||
assert.equal(moved.reserves, beforeMove.reserves);
|
||||
assert.equal(moved.forces[location(target.territory, target.sector)], 1);
|
||||
assert.equal(moved.forces[`${fixture.face}:0`], 3);
|
||||
reject(game, fixture.collector, { ...ordinary, planetologist: 'range' });
|
||||
}
|
||||
});
|
||||
|
||||
void test('carried Discovery Ornithopter offers a fixed-three ordinary replacement, consumes its physical token once and adds no action', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const fixture = createClassicDiscoverySkillsFixture({ advanced, face: 'ornithopter', tech: true });
|
||||
const acquired = revealClassicDiscoverySkills(fixture);
|
||||
assert.equal(acquired.discoveries!.tokens.find(token => token.id === fixture.token)!.status, 'carried');
|
||||
assert.ok(viewGame(acquired, fixture.collector).discoveryOrnithopter!.blocked);
|
||||
let game = classicDiscoverySkillsShipmentWindow(acquired, fixture.collector);
|
||||
const before = structuredClone(game.players.find(player => player.id === fixture.collector)!);
|
||||
const from = `${fixture.parent}:${fixture.parentSector}`, target = routeAtDistance(game, fixture.collector, from, 3);
|
||||
assert.ok(target, 'The physical parent board needs an original three-territory route.');
|
||||
const ordinary: Action = { type: 'move', from, amount: 1, elite: 0, ...target };
|
||||
reject(game, fixture.collector, ordinary);
|
||||
const quote = discoveryFlightMove(viewGame(game, fixture.collector), ordinary);
|
||||
assert.equal(quote.blocked, null);
|
||||
assert.ok(quote.action);
|
||||
legalPolicies(game, fixture.collector);
|
||||
game = applyAction(game, fixture.collector, quote.action);
|
||||
game = settleClassicDiscoverySkillsArrival(game);
|
||||
const after = game.players.find(player => player.id === fixture.collector)!;
|
||||
assert.equal(after.moved, before.moved + 1);
|
||||
assert.equal(after.spice, before.spice);
|
||||
assert.equal(after.reserves, before.reserves);
|
||||
assert.equal(after.forces[location(target.territory, target.sector)], 1);
|
||||
assert.equal(game.discoveries!.tokens.find(token => token.id === fixture.token)!.status, 'removed');
|
||||
assert.equal(viewGame(game, fixture.collector).discoveryOrnithopter, null);
|
||||
reject(game, fixture.collector, quote.action);
|
||||
reject(game, fixture.collector, { ...ordinary, planetologist: 'range' });
|
||||
}
|
||||
});
|
||||
|
||||
void test('original Harkonnen eight-card capacity overflows through actual Card Stash and only its owner selects the physical discard', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const fixture = createClassicDiscoverySkillsFixture({ advanced, collector: 'harkonnen', face: 'treachery-card-stash', amount: 1 });
|
||||
const owner = fixture.game.players.find(player => player.id === fixture.collector)!;
|
||||
const capacity = handLimit(owner);
|
||||
assert.equal(capacity, 8);
|
||||
// Explicit conserved physical card position, not an auction receipt or natural-play claim.
|
||||
while (owner.hand.length < capacity) {
|
||||
const card = fixture.game.deck.shift(); assert.ok(card); owner.hand.push(card);
|
||||
}
|
||||
fixture.staging.push('Moved original undealt base33 cards into the original Harkonnen eight-card hand to exercise the actual capacity boundary.');
|
||||
const wallet = owner.spice, originals = owner.hand.map(card => card.id), drawn = fixture.game.deck[0];
|
||||
assert.ok(drawn);
|
||||
const pending = revealClassicDiscoverySkills(fixture);
|
||||
assert.equal(pending.decision?.kind, 'discoveryDiscard');
|
||||
assert.equal(pending.decision?.player, fixture.collector);
|
||||
assert.deepEqual(pending.players.find(player => player.id === fixture.collector)!.hand.map(card => card.id), [...originals, drawn.id]);
|
||||
assert.equal(pending.players.find(player => player.id === fixture.collector)!.spice, wallet);
|
||||
legalPolicies(pending, fixture.collector);
|
||||
const action: Action = { type: 'decision', event: pending.discoveryStash!.event, card: drawn.id };
|
||||
reject(pending, fixture.opponent, action);
|
||||
reject(pending, fixture.collector, { ...action, card: 'not-a-physical-card' });
|
||||
const done = settleClassicDiscoverySkillsArrival(applyAction(pending, fixture.collector, action));
|
||||
assert.equal(done.players.find(player => player.id === fixture.collector)!.hand.length, capacity);
|
||||
assert.equal(done.players.find(player => player.id === fixture.collector)!.spice, wallet);
|
||||
assert.equal(done.discard.filter(card => card.id === drawn.id).length, 1);
|
||||
assert.equal(done.discoveries!.tokens.find(token => token.id === fixture.token)!.status, 'removed');
|
||||
reject(done, fixture.collector, action);
|
||||
}
|
||||
});
|
||||
|
||||
void test('real nested battle keeps trained/untrained leader posture, Suk casualties and subsequent winner Tech reward ordering', () => {
|
||||
for (const advanced of [false, true]) for (const hide of [false, true]) {
|
||||
const fixture = createClassicDiscoverySkillsFixture({ advanced, tech: true, skill: 'suk-graduate', face: 'jacurutu-sietch' });
|
||||
let game = openClassicDiscoverySkillsBattle(fixture, hide);
|
||||
const winner = game.players.find(player => player.id === fixture.collector)!;
|
||||
const loser = game.players.find(player => player.id === fixture.opponent)!;
|
||||
const loserTech = ownedTech(game.techTokens, loser.id);
|
||||
assert.equal(loserTech.length, 1);
|
||||
assert.equal(viewGame(game, winner.id).leaderSkills!.assignments.find(assignment => assignment.owner === winner.id)!.faceUp, !hide);
|
||||
const leader = hide ? fixture.leader : winner.leaders.filter(candidate => candidate.id !== fixture.leader && !candidate.dead)
|
||||
.sort((a, b) => b.strength - a.strength)[0].id;
|
||||
const enemyLeader = loser.leaders.filter(candidate => !candidate.dead &&
|
||||
!game.leaderSkills!.assignments.some(assignment => assignment.leader === candidate.id))
|
||||
.sort((a, b) => a.strength - b.strength)[0].id;
|
||||
const counters = { tanks: winner.tanks, reserves: winner.reserves, forces: winner.forces[`${fixture.face}:0`] };
|
||||
game = applyAction(game, winner.id, { type: 'battlePlan', dial: 1, support: advanced ? 1 : 0, leader });
|
||||
game = applyAction(game, loser.id, { type: 'battlePlan', dial: 0, support: 0, leader: enemyLeader });
|
||||
game = advanceClassicDiscoverySkills(game, state => state.decision?.kind === 'sukRescue' ||
|
||||
state.lastBattleContext?.sukRescue?.completed === true);
|
||||
if (hide) {
|
||||
assert.equal(game.decision?.kind, 'sukRescue');
|
||||
if (game.decision?.kind !== 'sukRescue') throw new Error('Original skilled Suk decision missing.');
|
||||
assert.equal(game.decision.mode, 'skilled');
|
||||
assert.deepEqual(ownedTech(game.techTokens, loser.id), loserTech, 'Tech transfer must wait for actual winner casualties and rescue.');
|
||||
legalPolicies(game, winner.id);
|
||||
const choice = game.decision.options.findIndex(option => option.normal === 1 && option.elite === 0);
|
||||
assert.ok(choice >= 0);
|
||||
game = applyAction(game, winner.id, { type: 'decision', event: game.decision.event, choice });
|
||||
} else {
|
||||
// One ordinary casualty has one legal normal rescue and resolves automatically.
|
||||
assert.equal(game.lastBattleContext!.sukRescue!.completed, true);
|
||||
}
|
||||
game = advanceClassicDiscoverySkills(game, state => !state.pendingSukRescue &&
|
||||
state.techTokens![loserTech[0]].owner === winner.id && classicDiscoverySkillsClean(state));
|
||||
const rescued = game.players.find(player => player.id === winner.id)!;
|
||||
assert.equal(rescued.tanks, counters.tanks);
|
||||
assert.equal(rescued.reserves, counters.reserves + Number(!hide));
|
||||
assert.equal(rescued.forces[`${fixture.face}:0`], counters.forces - Number(!hide));
|
||||
assert.equal(game.lastBattleContext!.sukRescue!.completed, true);
|
||||
assert.equal(game.lastBattleContext!.winner, winner.id);
|
||||
assert.equal(game.lastBattleContext!.territory, fixture.face);
|
||||
assert.equal(viewGame(game, winner.id).leaderSkills!.assignments.find(assignment => assignment.owner === winner.id)!.faceUp, true);
|
||||
assert.equal(game.players.find(player => player.id === loser.id)!.tanks, 1);
|
||||
}
|
||||
});
|
||||
|
||||
void test('original five-force nested invoice allows the distinct Bureaucrat holder to divert two or decline without changing shipment cost', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const fixture = createClassicDiscoverySkillsFixture({ advanced, skill: 'bureaucrat', skillOwner: 'emperor' });
|
||||
const revealed = revealClassicDiscoverySkills(fixture);
|
||||
const payer = revealed.players.find(player => player.faction === 'harkonnen')!.id;
|
||||
const window = classicDiscoverySkillsShipmentWindow(revealed, payer);
|
||||
const payerBefore = window.players.find(player => player.id === payer)!, receiverBefore = window.players.find(player => player.id === fixture.collector)!;
|
||||
let pending = applyAction(window, payer, { type: 'ship', territory: fixture.face, sector: 0, amount: 5, elite: 0, allyPayment: 0 });
|
||||
pending = advanceClassicDiscoverySkills(pending, game => game.decision?.kind === 'bureaucratPayment');
|
||||
assert.ok(pending.decision?.kind === 'bureaucratPayment');
|
||||
assert.equal(pending.decision!.player, fixture.opponent);
|
||||
legalPolicies(pending, fixture.opponent);
|
||||
for (const redirect of [false, true]) {
|
||||
let game = applyAction(structuredClone(pending), fixture.opponent, { type: 'decision', event: pending.decision!.event, redirect });
|
||||
game = settleClassicDiscoverySkillsArrival(game);
|
||||
const paid = game.players.find(player => player.id === payer)!, received = game.players.find(player => player.id === fixture.collector)!;
|
||||
assert.equal(paid.spice, payerBefore.spice - 5);
|
||||
assert.equal(paid.reserves, payerBefore.reserves - 5);
|
||||
assert.equal(paid.forces[`${fixture.face}:0`], 5);
|
||||
assert.equal(paid.moved, payerBefore.moved);
|
||||
assert.equal(paid.shipped, true);
|
||||
assert.equal(received.spice, receiverBefore.spice + 5 - (redirect ? 2 : 0));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -11,7 +11,10 @@ import {
|
||||
import {
|
||||
CISTERN,
|
||||
ORGIZ_PROCESSING_STATION,
|
||||
DiscoveryCollectionError,
|
||||
quoteDiscoveryCollection,
|
||||
type DiscoveryCollectionContext,
|
||||
type DiscoveryCollectionQuote,
|
||||
} from '../game/discovery-collection';
|
||||
import {
|
||||
applyAction,
|
||||
@@ -75,7 +78,7 @@ function composed(game: Game) {
|
||||
}
|
||||
|
||||
function balance(
|
||||
quote: ReturnType<typeof quoteDiscoveryCollection>,
|
||||
quote: DiscoveryCollectionQuote,
|
||||
player: string,
|
||||
) {
|
||||
return quote.receipts.find((receipt) => receipt.player === player)!.balance;
|
||||
@@ -151,7 +154,7 @@ void test('Cistern follows Collection advisor releases and excludes remaining ad
|
||||
]);
|
||||
});
|
||||
|
||||
void test('the bounded Orgiz quote transfers one spice from each uniquely collected observable board deposit', () => {
|
||||
void test('Basic Orgiz retains one spice from each uniquely collected observable board deposit', () => {
|
||||
const game = fixture();
|
||||
reveal(game, ORGIZ_PROCESSING_STATION);
|
||||
game.players[0].forces = {
|
||||
@@ -205,7 +208,7 @@ void test('the bounded Orgiz quote transfers one spice from each uniquely collec
|
||||
assert.deepEqual(quoteSpiceCollection(game), ordinary);
|
||||
});
|
||||
|
||||
void test('one rival pays once per collected pile, not once per player or stacked spice amount', () => {
|
||||
void test('Basic charges one rival per collected pile, not per player or stacked spice amount', () => {
|
||||
const game = fixture();
|
||||
reveal(game, ORGIZ_PROCESSING_STATION);
|
||||
game.players[0].forces = { [`${ORGIZ_PROCESSING_STATION}:0`]: 1 };
|
||||
@@ -409,7 +412,7 @@ void test('the real collection phase credits Cistern once and preserves the rece
|
||||
);
|
||||
});
|
||||
|
||||
void test('a revealed sole Orgiz occupant steals one from each rival collected deposit in Basic and Advanced', () => {
|
||||
void test('a revealed sole Orgiz occupant steals once from each rival territory with one deposit in either mode', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const game = fixture(advanced);
|
||||
game.discoveryEnabled = true;
|
||||
@@ -533,3 +536,121 @@ void test('a saved Ecaz response commits Cistern once only after the collection
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
void test('Advanced counts each territory once across positive partial sector collections; Basic retains both deposits', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const game = fixture(advanced);
|
||||
reveal(game, ORGIZ_PROCESSING_STATION);
|
||||
game.players[0].forces = { [`${ORGIZ_PROCESSING_STATION}:0`]: 1, 'red_chasm:7': 1 };
|
||||
game.players[1].forces = { 'hagga_basin:12': 1, 'hagga_basin:13': 1, 'cielago_south:3': 1 };
|
||||
game.spice = {
|
||||
'hagga_basin:12': 8, 'hagga_basin:13': 1, 'cielago_south:3': 3,
|
||||
'red_chasm:7': 2, 'cielago_south:2': 4, 'funeral_plain:15': 0,
|
||||
};
|
||||
const before = structuredClone(game);
|
||||
const { ordinary, discovery } = composed(game);
|
||||
assert.deepEqual(ordinary.spice, {
|
||||
'hagga_basin:12': 6, 'hagga_basin:13': 0, 'cielago_south:3': 1,
|
||||
'red_chasm:7': 0, 'cielago_south:2': 4, 'funeral_plain:15': 0,
|
||||
[`${ORGIZ_PROCESSING_STATION}:0`]: 0,
|
||||
});
|
||||
assert.deepEqual(discovery.effects.map(effect =>
|
||||
effect.kind === 'orgiz' ? [effect.from, effect.location] : effect.kind),
|
||||
advanced
|
||||
? [['h', 'cielago_south:3'], ['h', 'hagga_basin:12']]
|
||||
: [['h', 'cielago_south:3'], ['h', 'hagga_basin:12'], ['h', 'hagga_basin:13']]);
|
||||
const count = advanced ? 2 : 3;
|
||||
assert.equal(balance(discovery, 'a'), ordinary.receipts[0].balance + count);
|
||||
assert.equal(balance(discovery, 'h'), ordinary.receipts[1].balance - count);
|
||||
assert.deepEqual(discovery.receipts.map(({ balance: _, ...facts }) => facts),
|
||||
ordinary.receipts.map(({ balance: _, ...facts }) => facts));
|
||||
assert.equal(discovery.receipts.reduce((sum, receipt) => sum + receipt.balance, 0),
|
||||
ordinary.receipts.reduce((sum, receipt) => sum + receipt.balance, 0));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Advanced withholds a territory with distinct collected payers, including an own collection, but not independent theft', () => {
|
||||
for (const second of ['a', 'g']) {
|
||||
const game = fixture(true);
|
||||
reveal(game, ORGIZ_PROCESSING_STATION);
|
||||
game.players[0].forces = { [`${ORGIZ_PROCESSING_STATION}:0`]: 1 };
|
||||
game.players[1].forces = { 'hagga_basin:12': 1, 'red_chasm:7': 1 };
|
||||
game.players.find(player => player.id === second)!.forces['hagga_basin:13'] = 1;
|
||||
game.spice = { 'hagga_basin:12': 3, 'hagga_basin:13': 2, 'red_chasm:7': 2 };
|
||||
const { ordinary, discovery } = composed(game);
|
||||
assert.deepEqual(discovery.effects, [{
|
||||
kind: 'orgiz', player: 'a', from: 'h', location: 'red_chasm:7',
|
||||
amount: 1, source: 'player',
|
||||
}]);
|
||||
assert.equal(balance(discovery, 'a'), ordinary.receipts[0].balance + 1);
|
||||
assert.equal(balance(discovery, 'h'), ordinary.receipts[1].balance - 1);
|
||||
assert.equal(balance(discovery, 'g'), ordinary.receipts[2].balance);
|
||||
assert.equal(ordinary.spice['hagga_basin:13'], 0);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Advanced does not select a payer from an uncontested sector when another collected sector is contested', () => {
|
||||
const game = fixture(true);
|
||||
reveal(game, ORGIZ_PROCESSING_STATION);
|
||||
game.players[0].forces = { [`${ORGIZ_PROCESSING_STATION}:0`]: 1 };
|
||||
game.players[1].forces = { 'hagga_basin:12': 1, 'hagga_basin:13': 1, 'red_chasm:7': 1 };
|
||||
game.players[2].forces = { 'hagga_basin:13': 1 };
|
||||
game.spice = { 'hagga_basin:12': 4, 'hagga_basin:13': 4, 'red_chasm:7': 2 };
|
||||
const { ordinary, discovery } = composed(game);
|
||||
assert.equal(ordinary.spice['hagga_basin:12'], 2);
|
||||
assert.equal(ordinary.spice['hagga_basin:13'], 0);
|
||||
assert.deepEqual(discovery.effects, [{
|
||||
kind: 'orgiz', player: 'a', from: 'h', location: 'red_chasm:7',
|
||||
amount: 1, source: 'player',
|
||||
}]);
|
||||
assert.equal(balance(discovery, 'g'), ordinary.receipts[2].balance);
|
||||
});
|
||||
|
||||
void test('Advanced shared territories remain unpaid across all their source keys while independent Collection proceeds', () => {
|
||||
const game = fixture(true, [['e', 'ecaz'], ['a', 'atreides'], ['g', 'guild']]);
|
||||
reveal(game, ORGIZ_PROCESSING_STATION);
|
||||
game.players[0].ally = 'a';
|
||||
game.players[1].ally = 'e';
|
||||
game.players[0].forces = { 'hagga_basin:12': 1, 'red_chasm:7': 1 };
|
||||
game.players[1].forces = { 'hagga_basin:13': 1 };
|
||||
game.players[2].forces = { [`${ORGIZ_PROCESSING_STATION}:0`]: 1 };
|
||||
game.spice = { 'hagga_basin:12': 4, 'hagga_basin:13': 4, 'red_chasm:7': 2 };
|
||||
const { ordinary, discovery } = composed(game);
|
||||
assert.deepEqual(ordinary.shared, [{ territory: 'hagga_basin', ecaz: 'e', ally: 'a', amount: 4 }]);
|
||||
assert.deepEqual(discovery.effects, [{
|
||||
kind: 'orgiz', player: 'g', from: 'e', location: 'red_chasm:7',
|
||||
amount: 1, source: 'player',
|
||||
}]);
|
||||
assert.equal(balance(discovery, 'e'), 1);
|
||||
assert.equal(balance(discovery, 'g'), 1);
|
||||
});
|
||||
|
||||
void test('Advanced retains public effective No-Field presence for the Orgiz occupant and collected source', () => {
|
||||
const game = fixture(true, [['r', 'richese'], ['h', 'harkonnen']]);
|
||||
reveal(game, ORGIZ_PROCESSING_STATION);
|
||||
game.players[0].noField = {
|
||||
deployed: { location: { territory: ORGIZ_PROCESSING_STATION, sector: 0 } },
|
||||
} as NonNullable<Game['players'][number]['noField']>;
|
||||
game.players[1].forces = { 'hagga_basin:12': 1 };
|
||||
game.spice = { 'hagga_basin:12': 2 };
|
||||
assert.equal(composed(game).discovery.effects.length, 1);
|
||||
game.players[0].noField!.deployed!.location = { territory: 'hagga_basin', sector: 13 };
|
||||
game.players[1].forces = { [`${ORGIZ_PROCESSING_STATION}:0`]: 1 };
|
||||
game.spice = { 'hagga_basin:12': 2, 'hagga_basin:13': 4 };
|
||||
const { ordinary, discovery } = composed(game);
|
||||
assert.equal(ordinary.spice['hagga_basin:13'], 2);
|
||||
assert.deepEqual(discovery.effects, [{
|
||||
kind: 'orgiz', player: 'h', from: 'r', location: 'hagga_basin:13',
|
||||
amount: 1, source: 'player',
|
||||
}]);
|
||||
});
|
||||
|
||||
void test('Discovery Collection requires an explicit mode rather than treating missing Advanced as authoritative', () => {
|
||||
const game = fixture(true);
|
||||
const ordinary = quoteSpiceCollection(game);
|
||||
const { advanced: _, ...missingMode } = game;
|
||||
assert.throws(() => quoteDiscoveryCollection(
|
||||
missingMode as unknown as DiscoveryCollectionContext, ordinary,
|
||||
), DiscoveryCollectionError);
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, initializeDiscoveryGameForAudit, joinGame, newPlayer, RuleError, type Game } from '../game/engine';
|
||||
import { createGame, initializeDiscoveryGameForAudit, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, RuleError, type Game } from '../game/engine';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import { createTechTokens } from '../game/tech-tokens';
|
||||
import { createStrongholdCards } from '../game/stronghold-cards';
|
||||
@@ -51,3 +51,22 @@ void test('Discovery cannot redeal started components or silently remove another
|
||||
overlays[2].strongholdCards = createStrongholdCards();
|
||||
for (const game of overlays) assert.throws(() => initializeDiscoveryGameForAudit(game), RuleError);
|
||||
});
|
||||
|
||||
void test('classic Discovery Skills does not silently drop native families or unrelated modules and previews', () => {
|
||||
const native = lobby(['ixians', 'guild', 'fremen'], ['ix']);
|
||||
assert.throws(() => initializeLeaderSkillsGameForAudit(native), RuleError);
|
||||
const overlays = Array.from({ length: 5 }, () => lobby(['guild', 'emperor', 'harkonnen'], []));
|
||||
overlays[0].homeworlds = { custody: null };
|
||||
overlays[1].nexusCards = { cards: null, phase: null };
|
||||
overlays[2].strongholdCards = createStrongholdCards();
|
||||
overlays[3].mentatQuestionPreview = true;
|
||||
overlays[4].spiceBankerIncomePreview = true;
|
||||
for (const game of overlays) assert.throws(() => initializeLeaderSkillsGameForAudit(game), RuleError);
|
||||
const two = lobby(['guild', 'emperor'], []); two.techTokens = createTechTokens();
|
||||
assert.throws(() => initializeLeaderSkillsGameForAudit(two), RuleError);
|
||||
});
|
||||
|
||||
void test('Discovery Skills cannot replace already initialized Discovery components', () => {
|
||||
const started = initializeDiscoveryGameForAudit(lobby(['guild', 'emperor', 'harkonnen'], []));
|
||||
assert.throws(() => initializeLeaderSkillsGameForAudit(started), RuleError);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { splitLocation } from '../game/board';
|
||||
import { viewGame } from '../game/engine';
|
||||
import { nativeTypedPhase } from './fixture-discovery-native-typed';
|
||||
import { commitOrgizCollection, prepareOrgizCollection } from './fixture-discovery-orgiz-advanced';
|
||||
|
||||
for (const advanced of [true, false]) {
|
||||
void test(`${advanced ? 'Advanced territory events' : 'Basic positive deposits'}: original Discovery reveal and later Collection preserve bank income and pay the distinct Orgiz count`, () => {
|
||||
const position = prepareOrgizCollection(advanced);
|
||||
const count = advanced ? 2 : 3;
|
||||
const orgiz = position.discovery.effects.filter(effect => effect.kind === 'orgiz');
|
||||
assert.equal(orgiz.length, count);
|
||||
assert.deepEqual(orgiz.map(effect => [effect.from, splitLocation(effect.location).territory]), advanced
|
||||
? [['g', 'cielago_south'], ['g', 'hagga_basin']]
|
||||
: [['g', 'cielago_south'], ['g', 'hagga_basin'], ['g', 'hagga_basin']]);
|
||||
assert.equal(position.ordinary.receipts.find(receipt => receipt.player === 'g')!.collected, 5);
|
||||
assert.equal(position.ordinary.receipts.find(receipt => receipt.player === 'a')!.collected, 1);
|
||||
assert.equal(position.ordinary.receipts.find(receipt => receipt.player === 'f')!.strongholds, advanced ? 2 : 0);
|
||||
assert.deepEqual(position.discovery.receipts.map(({ balance: _, ...facts }) => facts),
|
||||
position.ordinary.receipts.map(({ balance: _, ...facts }) => facts));
|
||||
assert.equal(position.ordinary.spice[position.haggaKeys[0]], 1);
|
||||
assert.equal(position.ordinary.spice[position.haggaKeys[1]], 0);
|
||||
assert.equal(position.ordinary.spice[position.cielagoKey], 0);
|
||||
assert.equal(position.ordinary.spice[position.untouchedKey], position.boardSpice - 7);
|
||||
|
||||
const collected = commitOrgizCollection(position.game);
|
||||
assert.deepEqual(position.game, position.before);
|
||||
assert.equal(collected.turn, 2);
|
||||
assert.deepEqual(collected.spice, position.ordinary.spice);
|
||||
for (const receipt of position.discovery.receipts)
|
||||
assert.equal(collected.players.find(player => player.id === receipt.player)!.spice, receipt.balance);
|
||||
const ordinaryOwner = position.ordinary.receipts.find(receipt => receipt.player === 'a')!.balance;
|
||||
const ordinaryPayer = position.ordinary.receipts.find(receipt => receipt.player === 'g')!.balance;
|
||||
assert.equal(collected.players.find(player => player.id === 'a')!.spice, ordinaryOwner + count);
|
||||
assert.equal(collected.players.find(player => player.id === 'g')!.spice, ordinaryPayer - count);
|
||||
assert.equal(viewGame(collected, 'a').players.find(player => player.id === 'a')!.spice, ordinaryOwner + count);
|
||||
assert.equal(collected.players.reduce((sum, player) => sum + player.spice, 0),
|
||||
position.ordinary.receipts.reduce((sum, receipt) => sum + receipt.balance, 0));
|
||||
assert.equal(collected.players.reduce((sum, player) => sum + player.spice, 0) +
|
||||
Object.values(collected.spice).reduce((sum, amount) => sum + amount, 0),
|
||||
position.before.players.reduce((sum, player) => sum + player.spice, 0) + position.boardSpice + (advanced ? 2 : 0));
|
||||
const theftLogs = collected.log.filter(entry => entry.automatic?.name === 'Orgiz Processing Station');
|
||||
assert.equal(theftLogs.length, count);
|
||||
// Continue the original phase controls; never rerun or retarget a paid quote.
|
||||
const mentat = nativeTypedPhase(collected, 8);
|
||||
assert.deepEqual(mentat.players.map(player => player.spice), collected.players.map(player => player.spice));
|
||||
assert.equal(mentat.log.filter(entry => entry.automatic?.name === 'Orgiz Processing Station').length, count);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer,
|
||||
viewGame, type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
|
||||
import { DISCOVERY_CARD_PLACEMENTS, DISCOVERY_TOKEN_BY_ID, type DiscoveryTokenFace } from '../game/discoveries';
|
||||
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
|
||||
import { nextSpiceBankerIncomeNativeStep } from './fixture-spice-banker-income';
|
||||
|
||||
export type ClassicDiscoverySkillsStep = { actor: string; action: Action };
|
||||
export type ClassicDiscoverySkillsOptions = {
|
||||
/** Authenticated fresh lobby or original staged setup; never a played save. */
|
||||
initial?: Game;
|
||||
advanced?: boolean;
|
||||
tech?: boolean;
|
||||
seats?: 2 | 3 | 4 | 5 | 6;
|
||||
collector?: 'guild' | 'harkonnen';
|
||||
skill?: LeaderSkillId;
|
||||
skillOwner?: 'guild' | 'harkonnen' | 'emperor';
|
||||
face?: DiscoveryTokenFace;
|
||||
amount?: number;
|
||||
};
|
||||
export type ClassicDiscoverySkillsFixture = {
|
||||
initial: Game; setup: Game; game: Game; actions: ClassicDiscoverySkillsStep[];
|
||||
staging: string[]; collector: string; opponent: string; leader: string;
|
||||
token: string; face: DiscoveryTokenFace; parent: string; parentSector: number;
|
||||
};
|
||||
export const classicDiscoverySkillsClean = (game: Game) =>
|
||||
!game.response && !game.phaseOpening && !game.decision && !game.pendingTreacheryDiscard;
|
||||
|
||||
export function nextClassicDiscoverySkillsStep(game: Game): ClassicDiscoverySkillsStep {
|
||||
if (game.decision?.kind === 'discoveryEntry') return { actor: game.decision.player,
|
||||
action: { type: 'decision', event: game.decision.event, accept: false } };
|
||||
if (game.battle?.preLeader?.closed === false && !game.response && !game.decision && !game.battle.preparation) {
|
||||
const frame = game.battle.preLeader;
|
||||
return { actor: frame.ready.includes(game.battle.attacker) ? game.battle.defender : game.battle.attacker,
|
||||
action: { type: 'battlePreparationReady', event: frame.event } };
|
||||
}
|
||||
const next = nextSpiceBankerIncomeNativeStep(game);
|
||||
assert.ok(next, 'The original classic program needs a live owned continuation.');
|
||||
return next;
|
||||
}
|
||||
export function advanceClassicDiscoverySkills(state: Game, until: (game: Game) => boolean,
|
||||
actions?: ClassicDiscoverySkillsStep[]): Game {
|
||||
let game = state;
|
||||
for (let i = 0; !until(game) && i < 1600; i++) {
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const next = nextClassicDiscoverySkillsStep(game);
|
||||
actions?.push(next);
|
||||
game = applyAction(game, next.actor, next.action);
|
||||
}
|
||||
assert.ok(until(game), 'The connected classic program did not reach its original action window.');
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Physical offer lottery only: each original seat receives a source-clear skill.
|
||||
* The rest of setup entropy, including Treachery and Traitor cards, stays real. */
|
||||
function withSkillOffers<T>(skills: LeaderSkillId[], operation: () => T): T {
|
||||
const original = crypto.getRandomValues.bind(crypto);
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const current = LEADER_SKILL_CARDS.map(card => card.id) as LeaderSkillId[];
|
||||
const desired = Array<LeaderSkillId>(current.length);
|
||||
skills.forEach((skill, index) => { desired[index * 2] = skill; });
|
||||
const rest = current.filter(skill => !skills.includes(skill));
|
||||
for (let i = 0; i < desired.length; i++) if (!desired[i]) desired[i] = rest.shift()!;
|
||||
let index = current.length - 1;
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (index < 1) return Reflect.apply(original, crypto, [array]) as V;
|
||||
assert.ok(array instanceof Uint32Array && array.length === 1);
|
||||
const swap = current.indexOf(desired[index]);
|
||||
assert.ok(swap <= index);
|
||||
array[0] = Math.floor((swap + 0.5) / (index + 1) * 0x100000000);
|
||||
[current[index], current[swap]] = [current[swap], current[index]];
|
||||
index--;
|
||||
return array;
|
||||
};
|
||||
try { return operation(); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
function withDiscoveryPlacement<T>(game: Game, face: DiscoveryTokenFace, operation: () => T): T {
|
||||
const eligible = game.discoveries!.tokens.filter(token => token.status === 'supply' && token.type === DISCOVERY_TOKEN_BY_ID[face].type);
|
||||
const index = eligible.findIndex(token => token.face === face);
|
||||
assert.ok(index >= 0);
|
||||
const original = crypto.getRandomValues.bind(crypto);
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (array instanceof Uint32Array && array.length === 1)
|
||||
array[0] = Math.floor((index + 0.5) / eligible.length * 0x100000000);
|
||||
else Reflect.apply(original, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try { return operation(); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
|
||||
/** Original base33/all14 staged setup, original Storm/Bidding/Shipment/Collection.
|
||||
* Explicit positions: actual skill offers, four unplayed Spice Cards and supply
|
||||
* token lottery. Never stage clocks, phases, wallets, forces or pending frames. */
|
||||
export function createClassicDiscoverySkillsFixture(options: ClassicDiscoverySkillsOptions = {}): ClassicDiscoverySkillsFixture {
|
||||
const collectorFaction = options.collector ?? 'guild', requested = options.skill ?? 'planetologist';
|
||||
const face = options.face ?? 'cistern';
|
||||
let game: Game;
|
||||
if (options.initial) game = structuredClone(options.initial);
|
||||
else {
|
||||
const factions = [collectorFaction, 'emperor', collectorFaction === 'guild' ? 'harkonnen' : 'guild',
|
||||
'atreides', 'fremen', 'beneGesserit'] as FactionId[];
|
||||
game = createGame('CLASSICDISCOVERYSKILLS', newPlayer(factions[0], factions[0], factions[0]), options.advanced ?? false);
|
||||
for (const faction of factions.slice(1, options.seats ?? 3)) joinGame(game, newPlayer(faction, faction, faction));
|
||||
}
|
||||
assert.ok(game.status === 'lobby' || (game.status === 'setup' && game.turn === 1 && game.phase === 0));
|
||||
const controlledOffers = game.status === 'lobby';
|
||||
assert.deepEqual(game.expansions, []);
|
||||
assert.ok(!game.homeworlds && !game.nexusCards && !game.strongholdCards && !game.spiceBankerIncomePreview);
|
||||
const collector = game.players.find(player => player.faction === collectorFaction)!.id;
|
||||
const opponent = game.players.find(player => player.faction === 'emperor')!.id;
|
||||
const skillOwner = game.players.find(player => player.faction === (options.skillOwner ?? collectorFaction))!.id;
|
||||
const choices = (['planetologist', 'killer-medic', 'prana-bindu-adept', 'swordmaster-of-ginaz',
|
||||
'warmaster', 'master-of-assassins', 'bureaucrat'] as LeaderSkillId[]).filter(skill => skill !== requested);
|
||||
const skills = game.players.map(player => player.id === skillOwner ? requested : choices.shift()!);
|
||||
const actions: ClassicDiscoverySkillsStep[] = [];
|
||||
if (game.status === 'lobby') {
|
||||
if (options.tech && !game.techTokens) game = applyAction(game, game.host, { type: 'techTokens', enabled: true });
|
||||
game.discoveryEnabled = true;
|
||||
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
|
||||
}
|
||||
const initial = structuredClone(game);
|
||||
if (game.status === 'lobby') game = withSkillOffers(skills, () => initializeLeaderSkillsGameForAudit(game));
|
||||
assert.ok(game.discoveryEnabled && game.discoveries && game.leaderSkills);
|
||||
const setup = structuredClone(game);
|
||||
for (let i = 0; game.status === 'setup' && i < 200; i++) {
|
||||
if (game.setupStage !== 'leaderSkills') {
|
||||
const next = nextClassicDiscoverySkillsStep(game);
|
||||
actions.push(next); game = applyAction(game, next.actor, next.action); continue;
|
||||
}
|
||||
const actor = Object.keys(game.leaderSkills!.offers)[0], offer = game.leaderSkills!.offers[actor];
|
||||
const view = viewGame(game, actor).leaderSkills!;
|
||||
const skill = actor === skillOwner ? requested : offer.cards.find(card =>
|
||||
!['spice-banker', 'mentat', 'diplomat'].includes(card) && !view.unavailableSkills?.[card]);
|
||||
assert.ok(skill && offer.cards.includes(skill), 'Authenticated offer must contain the requested source-clear skill.');
|
||||
const eligible = view.eligibleLeaders.map(candidate => game.players.find(player => player.id === actor)!.leaders.find(leader => leader.id === candidate.id)!);
|
||||
const leader = eligible.sort((a, b) => b.strength - a.strength)[0];
|
||||
assert.ok(leader);
|
||||
const next: ClassicDiscoverySkillsStep = { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leader.id } };
|
||||
actions.push(next); game = applyAction(game, actor, next.action);
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
const leader = game.leaderSkills!.assignments.find(assignment => assignment.owner === collector)!.leader;
|
||||
const printed = DISCOVERY_TOKEN_BY_ID[face].type === 'hiereg' ? 'discovery-hagga-basin' : 'discovery-wind-pass-north';
|
||||
const index = game.spiceDeck.findIndex(card => 'territory' in card && card.discovery === printed);
|
||||
assert.ok(index >= 0);
|
||||
game.spiceDeck.unshift(game.spiceDeck.splice(index, 1)[0]);
|
||||
for (let position = 1; position < 4; position++) {
|
||||
const ordinary = game.spiceDeck.findIndex((card, i) => i >= position && 'territory' in card && !card.discovery);
|
||||
assert.ok(ordinary >= position);
|
||||
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(ordinary, 1)[0]);
|
||||
}
|
||||
const staging = [controlledOffers
|
||||
? 'Selected original physical all14 offer lottery; reordered four original unplayed Spice Cards; selected original supply-token lottery.'
|
||||
: 'Kept actual already-dealt Skill offers and assignments; reordered four original unplayed Spice Cards; selected original supply-token lottery.'];
|
||||
game = advanceClassicDiscoverySkills(game, state => state.phase === 1 && classicDiscoverySkillsClean(state), actions);
|
||||
game = withDiscoveryPlacement(game, face, () => advanceClassicDiscoverySkills(game,
|
||||
state => state.discoveries!.tokens.some(token => token.face === face && token.status === 'placed'), actions));
|
||||
game = advanceClassicDiscoverySkills(game, state => state.phase === 5 && state.active === collector && classicDiscoverySkillsClean(state), actions);
|
||||
const placement = DISCOVERY_CARD_PLACEMENTS[printed];
|
||||
const shipment: ClassicDiscoverySkillsStep = { actor: collector, action: { type: 'ship',
|
||||
territory: placement.territory, sector: placement.sector, amount: options.amount ?? 3, elite: 0, allyPayment: 0 } };
|
||||
actions.push(shipment); game = applyAction(game, collector, shipment.action);
|
||||
game = advanceClassicDiscoverySkills(game, state => state.phase === 7 && classicDiscoverySkillsClean(state), actions);
|
||||
const token = game.discoveries!.tokens.find(candidate => candidate.face === face)!;
|
||||
assert.equal(game.turn, 1);
|
||||
const offer = viewGame(game, collector).discoveries!;
|
||||
assert.ok(offer.canInspect.includes(token.id) || offer.canReveal.includes(token.id));
|
||||
return { initial, setup, game, actions, staging, collector, opponent, leader,
|
||||
token: token.id, face, parent: placement.territory, parentSector: placement.sector };
|
||||
}
|
||||
export function revealClassicDiscoverySkills(fixture: ClassicDiscoverySkillsFixture): Game {
|
||||
let game = fixture.game;
|
||||
if (viewGame(game, fixture.collector).discoveries!.canInspect.some(token => token === fixture.token))
|
||||
game = applyAction(game, fixture.collector, { type: 'discovery', token: fixture.token, reveal: false });
|
||||
return applyAction(game, fixture.collector, { type: 'discovery', token: fixture.token, reveal: true });
|
||||
}
|
||||
export function classicDiscoverySkillsEntryWindow(fixture: ClassicDiscoverySkillsFixture): Game {
|
||||
return advanceClassicDiscoverySkills(revealClassicDiscoverySkills(fixture), game =>
|
||||
game.turn === 2 && game.decision?.kind === 'discoveryEntry' && game.decision.player === fixture.collector);
|
||||
}
|
||||
export function enterClassicDiscoverySkills(fixture: ClassicDiscoverySkillsFixture): Game {
|
||||
const game = classicDiscoverySkillsEntryWindow(fixture), view = viewGame(game, fixture.collector);
|
||||
const action = discoveryEntryMoveAction(view, view.discoveryEntry!.sources);
|
||||
assert.ok(action);
|
||||
return applyAction(game, fixture.collector, action);
|
||||
}
|
||||
export function classicDiscoverySkillsShipmentWindow(state: Game, owner: string): Game {
|
||||
return advanceClassicDiscoverySkills(state, game => game.turn === 2 && game.phase === 5 &&
|
||||
game.active === owner && classicDiscoverySkillsClean(game));
|
||||
}
|
||||
export function settleClassicDiscoverySkillsArrival(state: Game): Game {
|
||||
return advanceClassicDiscoverySkills(state, game => !game.pendingShipment && classicDiscoverySkillsClean(game));
|
||||
}
|
||||
|
||||
/** Original two-turn nested battle: collector enters free, Emperor ships one
|
||||
* physical normal force at the printed nested rate, then original battle setup. */
|
||||
export function openClassicDiscoverySkillsBattle(fixture: ClassicDiscoverySkillsFixture, hide = true): Game {
|
||||
let game = classicDiscoverySkillsShipmentWindow(enterClassicDiscoverySkills(fixture), fixture.opponent);
|
||||
game = applyAction(game, fixture.opponent, { type: 'ship', territory: fixture.face, sector: 0, amount: 1, elite: 0, allyPayment: 0 });
|
||||
game = advanceClassicDiscoverySkills(game, state => state.phase === 6 && !!state.active && classicDiscoverySkillsClean(state));
|
||||
const actor = game.active!;
|
||||
assert.ok(actor === fixture.collector || actor === fixture.opponent);
|
||||
game = applyAction(game, actor, { type: 'chooseBattle', territory: fixture.face,
|
||||
target: actor === fixture.collector ? fixture.opponent : fixture.collector });
|
||||
for (let i = 0; (game.response || game.decision || game.battle?.preparation || game.battle?.preLeader?.closed === false) && i < 100; i++) {
|
||||
if (game.decision?.kind === 'leaderSkillVisibility') game = applyAction(game, game.decision.player,
|
||||
{ type: 'leaderSkillVisibility', event: game.decision.event, hide: game.decision.player === fixture.collector && hide });
|
||||
else {
|
||||
const next = nextClassicDiscoverySkillsStep(game); game = applyAction(game, next.actor, next.action);
|
||||
}
|
||||
}
|
||||
assert.ok(game.battle && !game.response && !game.decision);
|
||||
return game;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, initializeDiscoveryGameForAudit, viewGame, type Game } from '../game/engine';
|
||||
import { territory } from '../game/board';
|
||||
import { quoteSpiceCollection } from '../game/board-resolution-quote';
|
||||
import { ORGIZ_PROCESSING_STATION, quoteDiscoveryCollection } from '../game/discovery-collection';
|
||||
import { discoveryFixture, putDiscovery } from './fixture-discovery';
|
||||
import { nativeTypedClearBoard, nativeTypedPhase, nativeTypedPlace, nativeTypedStep } from './fixture-discovery-native-typed';
|
||||
|
||||
/** Fresh original classic Discovery setup and phase controls. No phase, clock,
|
||||
* wallet, deck identity or receipt is manufactured. Only conserved unplayed
|
||||
* Spice Card order, supply-token, reserve-counter and board-spice positions are
|
||||
* controlled. Orgiz inspect/reveal and Collection are original engine actions. */
|
||||
export function prepareOrgizCollection(advanced: boolean, initial?: Game) {
|
||||
let game: Game;
|
||||
if (initial) {
|
||||
assert.equal(initial.advanced, advanced);
|
||||
game = initializeDiscoveryGameForAudit({ ...structuredClone(initial), discoveryEnabled: true });
|
||||
for (let step = 0; game.status === 'setup' && step < 100; step++) game = nativeTypedStep(game);
|
||||
assert.equal(game.status, 'playing');
|
||||
} else game = discoveryFixture(advanced);
|
||||
const owner = game.players.find(player => player.faction === 'atreides')!.id;
|
||||
const payer = game.players.find(player => player.faction === 'guild')!.id;
|
||||
const supporter = game.players.find(player => player.faction === 'fremen')!.id;
|
||||
for (const [position, territory] of ['hagga_basin', 'cielago_south', 'red_chasm', 'funeral_plain'].entries()) {
|
||||
const index = game.spiceDeck.findIndex((card, i) => i >= position &&
|
||||
'territory' in card && card.territory === territory && !card.discovery);
|
||||
assert.ok(index >= position);
|
||||
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
|
||||
}
|
||||
game = nativeTypedPhase(game, 5);
|
||||
nativeTypedClearBoard(game);
|
||||
// Conserved original supply token -> its printed placement; no reveal staged.
|
||||
const token = putDiscovery(game, ORGIZ_PROCESSING_STATION);
|
||||
const source = `${token.territory}:${token.sector}`;
|
||||
assert.notEqual(token.sector, game.storm);
|
||||
nativeTypedPlace(game, owner, source, 2);
|
||||
game = nativeTypedPhase(game, 7);
|
||||
assert.ok(viewGame(game, owner).discoveries!.canInspect.includes(token.id));
|
||||
game = applyAction(game, owner, { type: 'discovery', token: token.id, reveal: false });
|
||||
assert.ok(viewGame(game, owner).discoveries!.canReveal.includes(token.id));
|
||||
game = applyAction(game, owner, { type: 'discovery', token: token.id, reveal: true });
|
||||
assert.equal(game.log.filter(entry => entry.automatic?.name === 'Orgiz Processing Station').length, 0);
|
||||
game = nativeTypedPhase(game, 8);
|
||||
game = nativeTypedPhase(game, 5, 2);
|
||||
nativeTypedClearBoard(game);
|
||||
// Conserved original reserve counters -> revealed Orgiz, three rival desert
|
||||
// sectors, own Collection, and a normal stronghold bank-income prefix.
|
||||
const haggaKeys = territory('hagga_basin').sectors.filter(sector => sector !== game.storm)
|
||||
.slice(0, 2).map(sector => `hagga_basin:${sector}`);
|
||||
assert.equal(haggaKeys.length, 2);
|
||||
const cielagoKey = `cielago_south:${territory('cielago_south').sectors.find(sector => sector !== game.storm)!}`;
|
||||
const ownKey = game.storm === 7 ? 'funeral_plain:15' : 'red_chasm:7';
|
||||
const untouchedKey = 'the_great_flat:15';
|
||||
nativeTypedPlace(game, owner, `${ORGIZ_PROCESSING_STATION}:0`, 1);
|
||||
nativeTypedPlace(game, owner, ownKey, 1);
|
||||
for (const key of [...haggaKeys, cielagoKey]) nativeTypedPlace(game, payer, key, 1);
|
||||
nativeTypedPlace(game, supporter, 'carthag:11', 1);
|
||||
assert.notEqual(game.storm, 11);
|
||||
// Relocate only actual spice already produced by original Spice Blows. Keep
|
||||
// every excess counter in an untouched deposit; do not debit a private wallet.
|
||||
const boardSpice = Object.values(game.spice).reduce((sum, amount) => sum + amount, 0);
|
||||
assert.ok(boardSpice >= 8);
|
||||
game.spice = {
|
||||
[haggaKeys[0]]: 3, [haggaKeys[1]]: 1, [cielagoKey]: 2,
|
||||
[ownKey]: 1, [untouchedKey]: boardSpice - 7,
|
||||
};
|
||||
const ordinary = quoteSpiceCollection(game);
|
||||
const discovery = quoteDiscoveryCollection(game, ordinary);
|
||||
const before = structuredClone(game);
|
||||
return { game, before, ordinary, discovery, boardSpice, owner, payer, token: token.id, source,
|
||||
haggaKeys, cielagoKey, ownKey, untouchedKey };
|
||||
}
|
||||
|
||||
/** Continue only original pending/phase controls into the new Collection. */
|
||||
export function commitOrgizCollection(state: Game): Game {
|
||||
let game = state;
|
||||
for (let step = 0; step < 100 && (game.phase !== 7 || game.phaseOpening || game.response || game.decision); step++) {
|
||||
assert.equal(game.turn, 2);
|
||||
assert.ok(game.phase === 5 || game.phase === 6 || game.phase === 7);
|
||||
game = nativeTypedStep(game);
|
||||
}
|
||||
assert.equal(game.phase, 7);
|
||||
assert.ok(!game.phaseOpening && !game.response && !game.decision);
|
||||
return game;
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import { TERRITORIES } from '../game/board';
|
||||
import type { Card } from '../game/cards';
|
||||
import { nextSpiceBankerIncomeNativeStep } from './fixture-spice-banker-income';
|
||||
|
||||
export type SkillsTechBattleStep = { actor: string; action: Action };
|
||||
|
||||
export type SkillsTechBattleOptions = {
|
||||
/** A fresh authenticated, undealt classic lobby; its seat IDs are retained. */
|
||||
initial?: Game;
|
||||
@@ -63,7 +65,7 @@ function withSkillOffer<T>(skill: SkillsTechBattleFixture['skill'], slot: number
|
||||
}
|
||||
|
||||
/** Native policy, with deliberate no-call/no-spend decisions at original windows. */
|
||||
export function nextSkillsTechBattleStep(game: Game): { actor: string; action: Action } {
|
||||
export function nextSkillsTechBattleStep(game: Game): SkillsTechBattleStep {
|
||||
if (game.decision?.kind === 'battleCards')
|
||||
return { actor: game.decision.player, action: { type: 'decision', discard: [] } };
|
||||
if (game.decision?.kind === 'techToken')
|
||||
|
||||
Reference in new issue
Block a user