Integrate Imperial Homeworld movement with Ornithopter
This commit is contained in:
1 parent
7f999f4573
commit
97edc3eac8
16 files changed
+1043
-114
No files matched your search
@@ -0,0 +1,156 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { treacheryDeck } from '../game/cards';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
|
||||
import { homeworldForceGroups } from '../game/homeworld-custody';
|
||||
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import type { EmperorHomeworld } from '../game/homeworld-emperor-move';
|
||||
import type { OrnithopterMode } from '../game/ornithopter';
|
||||
import { actFamilyAtomics, familyAtomicsPlayer, familyAtomicsReload } from './fixture-family-atomics';
|
||||
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
||||
import type { StrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
|
||||
export const EMPEROR_WORLD_ORNITHOPTER = 'richese-ornithopter';
|
||||
export type OrnithopterEmperorWorldStep = StrongholdFactionsNativeStep;
|
||||
export const actOrnithopterEmperorWorld = actFamilyAtomics;
|
||||
export const ornithopterEmperorWorldPlayer = familyAtomicsPlayer;
|
||||
export const ornithopterEmperorWorldReload = familyAtomicsReload;
|
||||
export const ornithopterEmperorWorldClean = (game: Game) => !game.response && !game.phaseOpening &&
|
||||
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingShipment;
|
||||
export function ornithopterEmperorWorldPools(game: Game) {
|
||||
return homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!);
|
||||
}
|
||||
export function ornithopterEmperorWorldArmy(game: Game, world: EmperorHomeworld, owner = 'emperor') {
|
||||
const pool = ornithopterEmperorWorldPools(game).find(pool => pool.id === world); assert.ok(pool);
|
||||
return structuredClone(pool.forces[owner] ?? { normal: 0, elite: 0 });
|
||||
}
|
||||
export function ornithopterEmperorWorldInventory(game: Game) {
|
||||
homeworldGameIntegrity(game);
|
||||
const cards = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
|
||||
...(game.removedTreachery ?? []), ...game.players.flatMap(player => player.hand),
|
||||
...(game.ornithopter ? [game.ornithopter.card] : []),
|
||||
...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? [])]
|
||||
.map(card => card.id).sort();
|
||||
assert.deepEqual(cards, [...treacheryDeck(game.expansions), ...richeseCards()].map(card => card.id).sort());
|
||||
for (const player of game.players)
|
||||
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, amount) => sum + amount, 0), 20);
|
||||
return { cards, traitors: [...(game.traitorReserve ?? []), ...game.players.flatMap(player =>
|
||||
[...player.traitors, ...player.traitorChoices])].sort(),
|
||||
spice: [...game.spiceDeck, ...game.spiceDiscard.flat()].map(card => JSON.stringify(card)).sort() };
|
||||
}
|
||||
export function nextOrnithopterEmperorWorld(game: Game): OrnithopterEmperorWorldStep | null {
|
||||
if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) {
|
||||
const decision = game.decision;
|
||||
if (decision?.kind === 'richeseCache') return { actor: decision.player, action: { type: 'decision',
|
||||
event: game.richeseBidding!.event, card: EMPEROR_WORLD_ORNITHOPTER, method: 'onceAround', direction: 'clockwise' } };
|
||||
if (decision?.kind === 'richeseUnbid') return { actor: decision.player, action: {
|
||||
type: 'decision', event: game.richeseBidding!.event, keep: false } };
|
||||
if (decision?.kind === 'guildTiming') return { actor: decision.player, action: { type: 'decision', take: false } };
|
||||
if (decision?.kind === 'richeseAllyOpportunity') return { actor: decision.player, action: { type: 'decision', decline: true } };
|
||||
}
|
||||
if (ornithopterEmperorWorldClean(game) && game.richeseAuction?.cardId === EMPEROR_WORLD_ORNITHOPTER &&
|
||||
!game.richeseAuction.outcome && game.richeseAuction.active === 'emperor' && game.richeseAuction.bid === 0)
|
||||
return { actor: 'emperor', action: { type: 'richeseBid', event: game.richeseAuction.event, amount: 1, allyPayment: 0 } };
|
||||
return nextRicheseStrongholdsNativeStep(game);
|
||||
}
|
||||
export function advanceOrnithopterEmperorWorld(game: Game, until: (state: Game) => boolean,
|
||||
actions: OrnithopterEmperorWorldStep[] = []): Game {
|
||||
for (let count = 0; count < 1800; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original Emperor flight source ended before its requested boundary');
|
||||
const next = nextOrnithopterEmperorWorld(game); assert.ok(next, 'Complete the actual native pending control');
|
||||
game = actOrnithopterEmperorWorld(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The original Emperor/Ornithopter chronology did not reach its finite boundary');
|
||||
}
|
||||
export function settleOrnithopterEmperorWorld(game: Game, actions: OrnithopterEmperorWorldStep[] = []): Game {
|
||||
return advanceOrnithopterEmperorWorld(game, ornithopterEmperorWorldClean, actions);
|
||||
}
|
||||
export function ornithopterEmperorWorldMove(game: Game, origin: EmperorHomeworld,
|
||||
normal: number, elite: number, mode?: OrnithopterMode): Action {
|
||||
const offer = viewGame(game, 'emperor').homeworldMove; assert.ok(offer);
|
||||
return { type: 'emperorHomeworldMove', event: offer.event, origin, normal, elite,
|
||||
...(mode ? { movementCard: EMPEROR_WORLD_ORNITHOPTER, ornithopter: mode }
|
||||
: game.ornithopter ? { ornithopterEvent: game.ornithopter.event } : {}) };
|
||||
}
|
||||
export function ornithopterEmperorGroundMove(game: Game, mode?: OrnithopterMode): Action {
|
||||
return { type: 'move', from: 'imperial_basin:9', territory: 'shield_wall', sector: 9, amount: 1, elite: 0,
|
||||
...(mode ? { movementCard: EMPEROR_WORLD_ORNITHOPTER, ornithopter: mode }
|
||||
: game.ornithopter ? { ornithopterEvent: game.ornithopter.event } : {}) };
|
||||
}
|
||||
|
||||
/** Genuine public Homeworld opt-in, printed native World allocation, actual
|
||||
* cache1 purchase, and optional typed ordinary shipment. Advanced Homeworld
|
||||
* source retains its real no-Semuta profile; no marker is removed to admit it.
|
||||
*/
|
||||
export function createOrnithopterEmperorWorldFixture(options: { groundArmy?: boolean; hajr?: boolean } = {}) {
|
||||
const groundArmy = options.groundArmy ?? true, owner = 'emperor', richese = 'richese', observer = 'atreides';
|
||||
let game = createGame('ORNITHOPTEREMPERORWORLD', newPlayer(richese, 'Richese', 'richese'), true, ['choam']);
|
||||
joinGame(game, newPlayer(owner, 'Emperor', 'emperor')); joinGame(game, newPlayer(observer, 'Atreides', 'atreides'));
|
||||
const initial = structuredClone(game), actions: OrnithopterEmperorWorldStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actOrnithopterEmperorWorld(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceOrnithopterEmperorWorld(game, until, actions); };
|
||||
act(game.host, { type: 'homeworlds', enabled: true });
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game); act(game.host, { type: 'start', advancedPreview: true });
|
||||
const setup = structuredClone(game); assert.equal(game.status, 'setup'); assert.ok(game.players.every(player => !player.hand.length));
|
||||
const first = game.deck.find(card => card.kind === 'projectile'), last = game.deck.find(card => card.kind === 'shield');
|
||||
const middle = game.deck.find(card => options.hajr ? card.effect === 'hajr' : card.kind === 'snooper'); assert.ok(first && middle && last);
|
||||
const deal = [first, middle, last];
|
||||
const dealOrder = deal.map((card, position) => {
|
||||
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { position, from, card: card.id };
|
||||
});
|
||||
reach(state => state.status === 'playing');
|
||||
const afterSetup = structuredClone(game), setupActions = structuredClone(actions);
|
||||
assert.ok(game.homeworlds?.custody); assert.equal(Boolean(game.semutaPreview), false);
|
||||
assert.deepEqual(ornithopterEmperorWorldArmy(game, 'homeworld:emperor'), { normal: 15, elite: 0 });
|
||||
assert.deepEqual(ornithopterEmperorWorldArmy(game, 'homeworld:emperor:salusa'), { normal: 0, elite: 5 });
|
||||
const original = ornithopterEmperorWorldInventory(game), spiceOrder = [0, 1].map(position => {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery && card.territory !== 'imperial_basin');
|
||||
assert.ok(from >= position); const [card] = game.spiceDeck.splice(from, 1); game.spiceDeck.splice(position, 0, card);
|
||||
return { position, from, card: structuredClone(card) };
|
||||
});
|
||||
let beforeBid: Game | undefined, bid: OrnithopterEmperorWorldStep | undefined, bidAt = -1;
|
||||
for (let count = 0; !ornithopterEmperorWorldPlayer(game, owner).hand.some(card => card.id === EMPEROR_WORLD_ORNITHOPTER) && count < 900; count++) {
|
||||
const next = nextOrnithopterEmperorWorld(game); assert.ok(next);
|
||||
if (next.actor === owner && next.action.type === 'richeseBid' && next.action.amount === 1) {
|
||||
beforeBid = structuredClone(game); bid = structuredClone(next); bidAt = actions.length;
|
||||
}
|
||||
act(next.actor, next.action);
|
||||
}
|
||||
assert.ok(beforeBid && bid && bidAt >= 0);
|
||||
const acquisition = { before: beforeBid, bid, steps: structuredClone(actions.slice(bidAt)), after: structuredClone(game) };
|
||||
assert.equal(ornithopterEmperorWorldPlayer(beforeBid, owner).spice - ornithopterEmperorWorldPlayer(game, owner).spice, 1);
|
||||
reach(state => state.phase === 5 && state.active === owner && ornithopterEmperorWorldClean(state));
|
||||
const beforeShipment = structuredClone(game), beforeShipmentActions = structuredClone(actions);
|
||||
let shipment: { before: Game; steps: OrnithopterEmperorWorldStep[]; after: Game } | null = null;
|
||||
if (groundArmy) {
|
||||
const sources = nativeShipmentSources(viewGame(game, owner), 1, 0); assert.ok(sources);
|
||||
const before = structuredClone(game), from = actions.length;
|
||||
act(owner, { type: 'ship', territory: 'imperial_basin', sector: 9, amount: 1, elite: 0, allyPayment: 0, homeworldSources: sources });
|
||||
reach(ornithopterEmperorWorldClean);
|
||||
shipment = { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
|
||||
assert.equal(ornithopterEmperorWorldPlayer(before, owner).spice - ornithopterEmperorWorldPlayer(game, owner).spice, 2);
|
||||
assert.equal(ornithopterEmperorWorldPlayer(game, owner).forces['imperial_basin:9'], 1);
|
||||
}
|
||||
const hajr = options.hajr ? middle.id : null;
|
||||
assert.equal(Boolean(game.semutaPreview), false); assert.equal(ornithopterEmperorWorldPlayer(game, owner).moved, 0);
|
||||
assert.ok(ornithopterEmperorWorldPlayer(game, owner).hand.some(card => card.id === EMPEROR_WORLD_ORNITHOPTER));
|
||||
assert.deepEqual(ornithopterEmperorWorldInventory(game), original);
|
||||
return { game, sourceGame: structuredClone(game), initial, lobby, setup, afterSetup, owner, richese, observer,
|
||||
card: EMPEROR_WORLD_ORNITHOPTER, hajr, groundArmy, source: 'imperial_basin:9', groundDestination: 'shield_wall:9',
|
||||
beforeShipment, beforeShipmentActions, acquisition, shipment, original, dealOrder, spiceOrder, setupActions, actions,
|
||||
sourceActions: actions, sourceActionCount: actions.length,
|
||||
sourceCounts: { setup: setupActions.length, acquisition: acquisition.steps.length, beforeShipment: beforeShipmentActions.length,
|
||||
shipment: shipment?.steps.length ?? 0, source: actions.length },
|
||||
sourceDisclosure: 'Actual public Advanced preview Richese/Emperor/Atreides +CHOAM deck and actual Homeworld opt-in before readiness/start. No audit initializer, Semuta, Skills, Discovery or Nexus module. ' +
|
||||
'Only original unopened starting-card/land-Spice positions and scalar lotteries selected. Emperor really bids1 in the original Richese cache auction to acquire physical Ornithopter; printed World stocks start normal15Kaitain/star5Salusa. ' +
|
||||
(groundArmy ? 'Actual typed normal1 shipment from Kaitain to ImperialBasin9 costs2; original remaining14normal/5stars supply World moves. '
|
||||
: 'Stop at the actual unused Emperor shipment/movement window, before any force shipment. ') +
|
||||
(hajr ? 'Emperor also received the original unopened Hajr in its native starting-deal slot. ' : '') +
|
||||
'Counts derive from recorded arrays. No played hand, purse, army, phase, World custody, allowance, flight or receipt is installed.' };
|
||||
}
|
||||
export type OrnithopterEmperorWorldFixture = ReturnType<typeof createOrnithopterEmperorWorldFixture>;
|
||||
@@ -0,0 +1,147 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import type { Difficulty } from '../game/bot-profiles';
|
||||
import { actOrnithopterEmperorWorld as act, createOrnithopterEmperorWorldFixture as fixture,
|
||||
ornithopterEmperorWorldPlayer as p, ornithopterEmperorWorldArmy as army,
|
||||
ornithopterEmperorWorldInventory as inventory, ornithopterEmperorWorldReload as reload,
|
||||
ornithopterEmperorWorldMove as worldMove, ornithopterEmperorGroundMove as groundMove,
|
||||
settleOrnithopterEmperorWorld as settle } from './fixture-ornithopter-emperor-world';
|
||||
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
function aggregate(game: Game, owner: string) {
|
||||
const own = p(game, owner); return { reserves: own.reserves, tanks: own.tanks, eliteReserves: own.elites!.reserves,
|
||||
eliteTanks: own.elites!.tanks, spice: own.spice };
|
||||
}
|
||||
function completed(f: ReturnType<typeof fixture>, game: Game) {
|
||||
assert.equal(game.ornithopter ?? null, null); assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.equal(p(game, f.owner).hand.some(card => card.id === f.card), false);
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.deepEqual(inventory(game), f.original);
|
||||
}
|
||||
|
||||
void test('public Advanced Emperor World source pays for its original cache card and typed ground army without Semuta', () => {
|
||||
const f = fixture();
|
||||
assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup'); assert.equal(Boolean(f.game.semutaPreview), false);
|
||||
const optIn = f.sourceActions.findIndex(step => step.action.type === 'homeworlds' && step.action.enabled);
|
||||
const ready = f.sourceActions.findIndex(step => step.action.type === 'ready'); assert.ok(optIn >= 0 && optIn < ready);
|
||||
assert.equal(f.sourceCounts.setup, f.setupActions.length); assert.equal(f.sourceCounts.source, f.sourceActions.length);
|
||||
assert.equal(f.sourceCounts.acquisition, f.acquisition.steps.length); assert.ok(f.shipment);
|
||||
assert.equal(f.acquisition.bid.actor, f.owner); assert.equal(f.acquisition.bid.action.amount, 1);
|
||||
assert.equal(p(f.acquisition.before, f.owner).spice - p(f.acquisition.after, f.owner).spice, 1);
|
||||
assert.equal(p(f.shipment.before, f.owner).spice - p(f.shipment.after, f.owner).spice, 2);
|
||||
assert.deepEqual(army(f.afterSetup, 'homeworld:emperor'), { normal: 15, elite: 0 });
|
||||
assert.deepEqual(army(f.game, 'homeworld:emperor'), { normal: 14, elite: 0 });
|
||||
assert.deepEqual(army(f.game, 'homeworld:emperor:salusa'), { normal: 0, elite: 5 });
|
||||
assert.equal(p(f.game, f.owner).forces[f.source], 1); assert.equal(p(f.game, f.owner).moved, 0);
|
||||
assert.deepEqual(inventory(f.game), f.original);
|
||||
});
|
||||
|
||||
void test('World first then real ground second share one physical two-group flight and earn only its completed extra credit', () => {
|
||||
const f = fixture(), before = aggregate(f.game, f.owner);
|
||||
let game = settle(act(reload(f.game), f.owner, worldMove(f.game, 'homeworld:emperor', 1, 0, 'twoGroups')));
|
||||
assert.equal(game.ornithopter?.completed, 1); assert.equal(game.ornithopter?.homeworldCohort, true);
|
||||
assert.equal(p(game, f.owner).moved, 1); assert.equal(p(game, f.owner).ornithopterExtraMoves ?? 0, 0);
|
||||
assert.deepEqual(aggregate(game, f.owner), before); assert.deepEqual(army(game, 'homeworld:emperor:salusa'), { normal: 1, elite: 5 });
|
||||
const event = game.ornithopter!.event, second = groundMove(game); assert.equal(second.ornithopterEvent, event);
|
||||
game = settle(act(game, f.owner, second)); completed(f, game);
|
||||
assert.equal(p(game, f.owner).moved, 2); assert.equal(p(game, f.owner).ornithopterExtraMoves, 1);
|
||||
assert.equal(p(game, f.owner).forces[f.source] ?? 0, 0); assert.equal(p(game, f.owner).forces[f.groundDestination], 1);
|
||||
assert.deepEqual(aggregate(game, f.owner), before);
|
||||
});
|
||||
|
||||
void test('ground first then World second uses the original typed World cohort and discards once after the last World group', () => {
|
||||
const f = fixture(), before = aggregate(f.game, f.owner);
|
||||
let game = settle(act(reload(f.game), f.owner, groundMove(f.game, 'twoGroups')));
|
||||
assert.equal(game.ornithopter?.completed, 1); assert.equal(game.ornithopter?.homeworldCohort, true);
|
||||
const last = worldMove(game, 'homeworld:emperor:salusa', 0, 1); assert.equal(last.ornithopterEvent, game.ornithopter!.event);
|
||||
assert.equal(last.movementCard, undefined);
|
||||
game = settle(act(game, f.owner, last)); completed(f, game);
|
||||
assert.deepEqual(army(game, 'homeworld:emperor'), { normal: 14, elite: 1 });
|
||||
assert.deepEqual(army(game, 'homeworld:emperor:salusa'), { normal: 0, elite: 4 });
|
||||
assert.deepEqual(aggregate(game, f.owner), before); assert.equal(p(game, f.owner).moved, 2);
|
||||
assert.equal(p(game, f.owner).ornithopterExtraMoves, 1);
|
||||
});
|
||||
|
||||
void test('two World groups cannot reuse arriving normals and may move only the original Salusa stars after a typed merge', () => {
|
||||
const f = fixture({ groundArmy: false }), before = aggregate(f.game, f.owner);
|
||||
assert.equal(p(f.game, f.owner).shipped, false);
|
||||
let game = settle(act(reload(f.game), f.owner, worldMove(f.game, 'homeworld:emperor', 15, 0, 'twoGroups')));
|
||||
assert.equal(p(game, f.owner).shipped, true); assert.equal(p(game, f.owner).moved, 1);
|
||||
assert.deepEqual(army(game, 'homeworld:emperor:salusa'), { normal: 15, elite: 5 });
|
||||
reject(game, f.owner, worldMove(game, 'homeworld:emperor:salusa', 1, 0));
|
||||
game = settle(act(game, f.owner, worldMove(game, 'homeworld:emperor:salusa', 0, 1))); completed(f, game);
|
||||
assert.deepEqual(army(game, 'homeworld:emperor'), { normal: 0, elite: 1 });
|
||||
assert.deepEqual(army(game, 'homeworld:emperor:salusa'), { normal: 15, elite: 4 });
|
||||
assert.deepEqual(aggregate(game, f.owner), before); assert.equal(p(game, f.owner).moved, 2);
|
||||
assert.equal(p(game, f.owner).ornithopterExtraMoves, 1);
|
||||
});
|
||||
|
||||
void test('range-three World use completes its single native route without creating a foreign World or Arrakis route', () => {
|
||||
const f = fixture({ groundArmy: false }), first = worldMove(f.game, 'homeworld:emperor', 1, 0, 'range3');
|
||||
reject(f.game, f.owner, { ...first, origin: 'homeworld:atreides' });
|
||||
reject(f.game, f.owner, { ...first, destination: 'homeworld:atreides' });
|
||||
reject(f.game, f.owner, { ...first, territory: 'imperial_basin', sector: 9 });
|
||||
const before = aggregate(f.game, f.owner), game = settle(act(reload(f.game), f.owner, first)); completed(f, game);
|
||||
assert.equal(p(game, f.owner).moved, 1); assert.equal(p(game, f.owner).ornithopterExtraMoves ?? 0, 0);
|
||||
assert.equal(p(game, f.owner).shipped, true); assert.deepEqual(aggregate(game, f.owner), before);
|
||||
assert.deepEqual(army(game, 'homeworld:emperor:salusa'), { normal: 1, elite: 5 });
|
||||
reject(game, f.owner, worldMove(game, 'homeworld:emperor:salusa', 0, 1));
|
||||
});
|
||||
|
||||
void test('stale foreign and forged World flight packets reject before movement or printed-card cost', () => {
|
||||
const f = fixture(), first = worldMove(f.game, 'homeworld:emperor', 1, 0, 'twoGroups');
|
||||
reject(f.game, f.observer, first); reject(f.game, f.owner, { ...first, event: 'old-world-custody' });
|
||||
reject(f.game, f.owner, { ...first, movementCard: 'foreign-card' });
|
||||
reject(f.game, f.owner, { ...first, ornithopter: 'both' });
|
||||
let game = settle(act(reload(f.game), f.owner, first));
|
||||
const next = worldMove(game, 'homeworld:emperor:salusa', 0, 1);
|
||||
reject(game, f.owner, { ...next, ornithopterEvent: 'old-flight' });
|
||||
reject(game, f.owner, { ...next, event: first.event });
|
||||
reject(game, f.owner, { ...next, movementCard: f.card, ornithopter: 'twoGroups' });
|
||||
game = settle(act(game, f.owner, next)); completed(f, game);
|
||||
});
|
||||
|
||||
void test('ending after one real World group disposes the physical flight without inventing a completed extra move', () => {
|
||||
const f = fixture({ groundArmy: false });
|
||||
let game = settle(act(reload(f.game), f.owner, worldMove(f.game, 'homeworld:emperor', 1, 0, 'twoGroups')));
|
||||
assert.equal(game.ornithopter?.completed, 1); assert.equal(p(game, f.owner).ornithopterExtraMoves ?? 0, 0);
|
||||
game = settle(act(game, f.owner, { type: 'endMovement' })); completed(f, game);
|
||||
assert.notEqual(game.active, f.owner); assert.equal(p(game, f.owner).moved, 1);
|
||||
assert.equal(p(game, f.owner).ornithopterExtraMoves ?? 0, 0);
|
||||
assert.deepEqual(army(game, 'homeworld:emperor:salusa'), { normal: 1, elite: 5 });
|
||||
});
|
||||
|
||||
void test('original dealt Hajr follows the completed World flight and grants one separate ordinary World movement', () => {
|
||||
const f = fixture({ groundArmy: false, hajr: true }); assert.ok(f.hajr);
|
||||
let game = settle(act(reload(f.game), f.owner, worldMove(f.game, 'homeworld:emperor', 1, 0, 'twoGroups')));
|
||||
game = settle(act(game, f.owner, worldMove(game, 'homeworld:emperor:salusa', 0, 1))); completed(f, game);
|
||||
game = settle(act(game, f.owner, { type: 'card', card: f.hajr }));
|
||||
game = settle(act(game, f.owner, worldMove(game, 'homeworld:emperor', 0, 1)));
|
||||
assert.equal(p(game, f.owner).moved, 3); assert.equal(p(game, f.owner).ornithopterExtraMoves, 1);
|
||||
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.equal(game.discard.filter(card => card.id === f.hajr).length, 1);
|
||||
assert.deepEqual(army(game, 'homeworld:emperor'), { normal: 14, elite: 0 });
|
||||
assert.deepEqual(army(game, 'homeworld:emperor:salusa'), { normal: 1, elite: 5 });
|
||||
assert.deepEqual(inventory(game), f.original);
|
||||
});
|
||||
|
||||
void test('all four minimal policies start and legally finish an owned World flight without a fabricated ground route', () => {
|
||||
const f = fixture({ groundArmy: false });
|
||||
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] satisfies Difficulty[]) {
|
||||
const view = viewGame(f.game, f.owner); view.players.find(player => player.id === f.owner)!.bot = difficulty;
|
||||
const first = botActions(view).find(action => action.type === 'emperorHomeworldMove' && action.movementCard === f.card);
|
||||
assert.ok(first, `${difficulty} supplies the printed owned World flight option`);
|
||||
let game = settle(act(reload(f.game), f.owner, first));
|
||||
if (game.ornithopter) {
|
||||
const pending = viewGame(game, f.owner); pending.players.find(player => player.id === f.owner)!.bot = difficulty;
|
||||
const next = botActions(pending).find(action => action.type === 'emperorHomeworldMove' && action.ornithopterEvent === game.ornithopter!.event)
|
||||
?? botActions(pending).find(action => action.type === 'endMovement');
|
||||
assert.ok(next); game = settle(act(game, f.owner, next));
|
||||
}
|
||||
completed(f, game); assert.equal(p(game, f.owner).reserves, p(f.game, f.owner).reserves);
|
||||
assert.equal(p(game, f.owner).tanks, 0);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import type { GameView } from '../game/engine';
|
||||
import { homeworldForceGroups } from '../game/homeworld-custody';
|
||||
import { homeworldPopulations } from '../game/homeworld-population';
|
||||
import { emperorHomeworldMoveAction } from '../game/homeworld-options';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
|
||||
const aliases = registerHooks({
|
||||
resolve(specifier, context, next) {
|
||||
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
|
||||
},
|
||||
load(url, context, next) {
|
||||
if (!url.endsWith('.module.css')) return next(url, context);
|
||||
const css = readFileSync(new URL(url), 'utf8');
|
||||
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
|
||||
return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
|
||||
},
|
||||
});
|
||||
const { EmperorHomeworldMovement } = await import('../components/homeworld-table');
|
||||
const { OrnithopterMovement } = await import('../components/ornithopter-movement');
|
||||
aliases.deregister();
|
||||
const card = richeseCards().find(candidate => candidate.effect === 'ornithopter')!;
|
||||
|
||||
/** Controlled public-view rendering and shared-adapter evidence only. The
|
||||
* stateful inputs are rendered at their real zero defaults, not operated;
|
||||
* physical card acquisition/native engine continuation is separate evidence. */
|
||||
function fixture(withCard = true): GameView {
|
||||
const context = { advanced: true, players: [
|
||||
{ id: 'e', faction: 'emperor' as const, reserves: 8, eliteReserves: 3 },
|
||||
{ id: 'a', faction: 'atreides' as const, reserves: 18, eliteReserves: 0 },
|
||||
] };
|
||||
const custody = { salusa: { normal: 2, elite: 2 }, visitors: {} };
|
||||
const populations = homeworldPopulations(context, custody);
|
||||
return { status: 'playing', phase: 5, advanced: true, active: 'e', me: 'e',
|
||||
homeworldMove: { event: 'current-world-event', remaining: 1, blocked: null },
|
||||
homeworlds: { worlds: homeworldForceGroups(context, custody).map(world => ({ ...world,
|
||||
...populations.find(population => population.location === world.id)! })) },
|
||||
players: context.players.map(player => ({ ...player, name: player.faction, ally: null, forces: {},
|
||||
elites: { reserves: player.eliteReserves, forces: {} }, hand: player.id === 'e' && withCard ? [{ ...card }] : [], autopilot: null })),
|
||||
ornithopter: withCard ? { card: { ...card }, blocked: null,
|
||||
modes: [{ mode: 'range3', blocked: null }, { mode: 'twoGroups', blocked: null }], active: null } : null,
|
||||
} as unknown as GameView;
|
||||
}
|
||||
function html(game: GameView, busy = false) {
|
||||
return renderToStaticMarkup(createElement(EmperorHomeworldMovement, { game, busy, act() {} }));
|
||||
}
|
||||
const selected = { origin: 'homeworld:emperor' as const, forces: { normal: 0, elite: 1 } };
|
||||
|
||||
void test('the shared flight panel labels remaining Imperial World cohorts without parsing them as Arrakis sectors', () => {
|
||||
const game = fixture();
|
||||
game.ornithopter!.active = { event: 'active-flight-event', mode: 'twoGroups', completed: 1, remaining: 1,
|
||||
homeworldCohort: true, cohort: { forces: { 'homeworld:emperor': 1, 'homeworld:emperor:salusa': 2 },
|
||||
elites: { 'homeworld:emperor:salusa': 2 } } };
|
||||
const markup = renderToStaticMarkup(createElement(OrnithopterMovement, { game, busy: false, act() {}, move: null }));
|
||||
assert.match(markup, /Kaitain: 1 forces, including 0 Sardaukar/);
|
||||
assert.match(markup, /Salusa Secundus: 2 forces, including 2 Sardaukar/);
|
||||
assert.doesNotMatch(markup, /sector NaN|homeworld, sector/);
|
||||
});
|
||||
|
||||
void test('ordinary Imperial inputs keep zero defaults and their unchanged no-card movement packet', () => {
|
||||
const game = fixture(false), markup = html(game);
|
||||
assert.match(markup, /Move between Imperial Homeworlds/);
|
||||
assert.match(markup, /Costs one movement and no spice/);
|
||||
assert.equal((markup.match(/type="number"[^>]*value="0"/g) ?? []).length, 2);
|
||||
assert.match(markup, /Normal forces \(0–3\)/);
|
||||
assert.match(markup, /Sardaukar \(0–1\)/);
|
||||
assert.doesNotMatch(markup, /Provisional Ornithopter|Play Ornithopter|next Ornithopter group/);
|
||||
assert.deepEqual(emperorHomeworldMoveAction(game, selected), { type: 'emperorHomeworldMove',
|
||||
event: 'current-world-event', origin: selected.origin, normal: 0, elite: 1 });
|
||||
});
|
||||
|
||||
void test('held-card World controls expose both explicit modes and persistently explain their native-route limitation', () => {
|
||||
const game = fixture(), markup = html(game);
|
||||
assert.match(markup, /Play Ornithopter \(range three\) and move to Salusa Secundus/);
|
||||
assert.match(markup, /Play Ornithopter \(two groups\) and move to Salusa Secundus/);
|
||||
assert.match(markup, /Provisional Ornithopter composition/);
|
||||
assert.match(markup, /Range three gives no extra World range or new route/);
|
||||
assert.match(markup, /different original typed counters/);
|
||||
assert.match(markup, /World group may be paired with a ground group/);
|
||||
assert.match(markup, /Existing shipment history and completed movement stay recorded/);
|
||||
for (const mode of ['range3', 'twoGroups'] as const)
|
||||
assert.deepEqual(emperorHomeworldMoveAction(game, selected, mode), { type: 'emperorHomeworldMove',
|
||||
event: 'current-world-event', origin: selected.origin, normal: 0, elite: 1,
|
||||
movementCard: card.id, ornithopter: mode });
|
||||
const busy = html(game, true);
|
||||
assert.match(busy, /<fieldset[^>]*disabled=""/);
|
||||
assert.equal((busy.match(/<button[^>]*disabled=""/g) ?? []).length, 3);
|
||||
});
|
||||
|
||||
void test('active World continuation shows only unmoved typed quotas and uses the current flight event without replaying its card', () => {
|
||||
const game = fixture();
|
||||
game.players[0].hand = [];
|
||||
game.homeworldMove!.remaining = 0;
|
||||
game.ornithopter!.active = { event: 'active-flight-event', mode: 'twoGroups', completed: 1, remaining: 1,
|
||||
homeworldCohort: true, cohort: { forces: { 'homeworld:emperor': 1 }, elites: { 'homeworld:emperor': 1 } } };
|
||||
const markup = html(game);
|
||||
assert.match(markup, /Move next Ornithopter group to Salusa Secundus/);
|
||||
assert.match(markup, /1 group moved · 1 remaining/);
|
||||
assert.match(markup, /Normal forces \(0–0\)/);
|
||||
assert.match(markup, /Sardaukar \(0–1\)/);
|
||||
assert.doesNotMatch(markup, /Play Ornithopter \((range three|two groups)\) and move/);
|
||||
assert.deepEqual(emperorHomeworldMoveAction(game, selected), { type: 'emperorHomeworldMove',
|
||||
event: 'current-world-event', origin: selected.origin, normal: 0, elite: 1, ornithopterEvent: 'active-flight-event' });
|
||||
assert.equal(emperorHomeworldMoveAction(game, { ...selected, forces: { normal: 1, elite: 0 } }), null);
|
||||
game.ornithopter!.active!.event = '';
|
||||
assert.equal(emperorHomeworldMoveAction(game, selected), null);
|
||||
});
|
||||
|
||||
void test('a still-uncompleted on-table flight offers the native replacement route without inventing a cohort or another card play', () => {
|
||||
const game = fixture(); game.players[0].hand = [];
|
||||
game.ornithopter!.active = { event: 'replacement-flight-event', mode: 'twoGroups', completed: 0,
|
||||
remaining: 2, cohort: undefined };
|
||||
const markup = html(game);
|
||||
assert.match(markup, /0 group moved · 2 remaining/);
|
||||
assert.match(markup, /Normal forces \(0–3\)/);
|
||||
assert.match(markup, /Move next Ornithopter group to Salusa Secundus/);
|
||||
assert.doesNotMatch(markup, /Play Ornithopter \((range three|two groups)\) and move/);
|
||||
assert.deepEqual(emperorHomeworldMoveAction(game, selected), { type: 'emperorHomeworldMove',
|
||||
event: 'current-world-event', origin: selected.origin, normal: 0, elite: 1, ornithopterEvent: 'replacement-flight-event' });
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { GameView } from '../game/engine';
|
||||
import { imperialFlightPools, imperialFlightCohort } from '../game/ornithopter-homeworld';
|
||||
import { createRemainingCohort, validateCohortSelection, type MovementCohort } from '../game/ornithopter';
|
||||
import { emperorHomeworldMoveAction, emperorHomeworldMoveActions } from '../game/homeworld-options';
|
||||
import { homeworldForceGroups, type HomeworldCustody } from '../game/homeworld-custody';
|
||||
import { homeworldPopulations } from '../game/homeworld-population';
|
||||
import { quoteEmperorHomeworldMove, type EmperorHomeworldMove } from '../game/homeworld-emperor-move';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
|
||||
const card = richeseCards().find(candidate => candidate.effect === 'ornithopter')!;
|
||||
const kaitain = 'homeworld:emperor', salusa = 'homeworld:emperor:salusa';
|
||||
const order: EmperorHomeworldMove = { origin: kaitain, forces: { normal: 1, elite: 0 } };
|
||||
|
||||
/** Controlled public custody/view supplement, not an acquired-card gameplay
|
||||
* chronology. Actual native route quotes provide the typed World tables. */
|
||||
function fixture(custody: HomeworldCustody = { salusa: { normal: 2, elite: 2 }, visitors: {} }) {
|
||||
const context = { advanced: true, players: [
|
||||
{ id: 'e', faction: 'emperor' as const, reserves: 8, eliteReserves: 3 },
|
||||
{ id: 'a', faction: 'atreides' as const, reserves: 18, eliteReserves: 0 },
|
||||
] };
|
||||
const populations = homeworldPopulations(context, custody);
|
||||
const worlds = homeworldForceGroups(context, custody).map(world => ({ ...world,
|
||||
...populations.find(population => population.location === world.id)! }));
|
||||
const view = { me: 'e', active: 'e', advanced: true, status: 'playing', phase: 5,
|
||||
homeworldMove: { event: 'current-native-world-event', remaining: 1, blocked: null },
|
||||
homeworlds: { worlds },
|
||||
players: context.players.map(player => ({ ...player, name: player.faction, ally: null,
|
||||
forces: player.id === 'e' ? { 'arrakeen:10': 2 } : {},
|
||||
elites: { reserves: player.eliteReserves, forces: player.id === 'e' ? { 'arrakeen:10': 1 } : {} },
|
||||
hand: player.id === 'e' ? [{ ...card }] : [], autopilot: null })),
|
||||
ornithopter: { card: { ...card }, blocked: null,
|
||||
modes: [{ mode: 'range3', blocked: null }, { mode: 'twoGroups', blocked: null }], active: null },
|
||||
} as unknown as GameView;
|
||||
return { context, custody, worlds, view };
|
||||
}
|
||||
function active(view: GameView, cohort: MovementCohort, aware = true) {
|
||||
view.ornithopter!.active = { event: 'actual-active-flight-event', mode: 'twoGroups', completed: 1,
|
||||
remaining: 1, cohort, ...(aware ? { homeworldCohort: true } : {}) };
|
||||
view.players.find(player => player.id === view.me)!.hand = [];
|
||||
view.homeworldMove!.remaining = 0;
|
||||
}
|
||||
|
||||
void test('Imperial flight pools merge only actual native World types under canonical ID keys', () => {
|
||||
const f = fixture(), board = { 'arrakeen:10': 2 }, elite = { 'arrakeen:10': 1 };
|
||||
const before = structuredClone({ board, elite, worlds: f.worlds });
|
||||
const pools = imperialFlightPools(board, elite, f.worlds, 'e');
|
||||
assert.deepEqual(pools, { forces: { 'arrakeen:10': 2, [kaitain]: 4, [salusa]: 4 },
|
||||
elites: { 'arrakeen:10': 1, [kaitain]: 1, [salusa]: 2 } });
|
||||
assert.equal(Object.hasOwn(pools.forces, 'homeworld:atreides'), false);
|
||||
assert.equal(Object.hasOwn(pools.forces, `${kaitain}:0`), false);
|
||||
assert.deepEqual(imperialFlightPools(board, elite, [], 'e'), { forces: board, elites: elite });
|
||||
assert.deepEqual({ board, elite, worlds: f.worlds }, before);
|
||||
});
|
||||
|
||||
void test('aware depleted quotas are cloned without regeneration while legacy board cohorts gain untouched current native Worlds', () => {
|
||||
const f = fixture(), cohort: MovementCohort = { forces: { 'arrakeen:10': 1 }, elites: {},
|
||||
noField: { tokenId: 'old-marker', event: 'old-event', from: 'arrakeen:10' } };
|
||||
const aware = imperialFlightCohort(cohort, true, f.worlds, 'e');
|
||||
assert.deepEqual(aware, cohort);
|
||||
assert.notEqual(aware.forces, cohort.forces);
|
||||
assert.notEqual(aware.noField, cohort.noField);
|
||||
const legacy = imperialFlightCohort(cohort, false, f.worlds, 'e');
|
||||
assert.equal(legacy.forces[kaitain], 4);
|
||||
assert.equal(legacy.elites[salusa], 2);
|
||||
assert.equal(legacy.forces['arrakeen:10'], 1);
|
||||
const current = imperialFlightPools({ 'arrakeen:10': 2 }, {}, f.worlds, 'e');
|
||||
assert.throws(() => validateCohortSelection(aware, current.forces, current.elites, { [kaitain]: 1 }, {}), /original unmoved/);
|
||||
assert.doesNotThrow(() => validateCohortSelection(legacy, current.forces, current.elites, { [kaitain]: 1 }, {}));
|
||||
});
|
||||
|
||||
void test('ordinary Imperial packets stay unchanged and either held-card mode uses exactly the native route', () => {
|
||||
const f = fixture();
|
||||
const ordinary = { type: 'emperorHomeworldMove', event: f.view.homeworldMove!.event,
|
||||
origin: kaitain, normal: 1, elite: 0 };
|
||||
assert.deepEqual(emperorHomeworldMoveAction(f.view, order), ordinary);
|
||||
for (const mode of ['range3', 'twoGroups'] as const)
|
||||
assert.deepEqual(emperorHomeworldMoveAction(f.view, order, mode), { ...ordinary, movementCard: card.id, ornithopter: mode });
|
||||
const invalid = { origin: 'homeworld:atreides', forces: { normal: 1, elite: 0 } } as unknown as EmperorHomeworldMove;
|
||||
assert.equal(emperorHomeworldMoveAction(f.view, invalid, 'range3'), null);
|
||||
assert.equal(emperorHomeworldMoveAction(f.view, { ...order, forces: { normal: 4, elite: 0 } }, 'twoGroups'), null);
|
||||
assert.equal(emperorHomeworldMoveAction(f.view, { ...order, forces: { normal: 0, elite: 0 } }, 'range3'), null);
|
||||
});
|
||||
|
||||
void test('a World first group cannot reuse arriving counters and a remaining group adds only its native flight event', () => {
|
||||
const f = fixture();
|
||||
const pools = imperialFlightPools({ 'arrakeen:10': 2 }, { 'arrakeen:10': 1 }, f.worlds, 'e');
|
||||
const cohort = createRemainingCohort(pools.forces, pools.elites, { [kaitain]: 1 }, {});
|
||||
const transferred = quoteEmperorHomeworldMove({ ...f.context, status: 'playing', phase: 5,
|
||||
currentPlayer: 'e', movesLeft: 1 }, f.custody, 'e', order);
|
||||
const after = fixture(transferred.state).view;
|
||||
active(after, cohort);
|
||||
const originalNormal = { origin: salusa, forces: { normal: 2, elite: 0 } } as const;
|
||||
assert.deepEqual(emperorHomeworldMoveAction(after, originalNormal), {
|
||||
type: 'emperorHomeworldMove', event: after.homeworldMove!.event, origin: salusa, normal: 2, elite: 0,
|
||||
ornithopterEvent: 'actual-active-flight-event',
|
||||
});
|
||||
assert.equal(emperorHomeworldMoveAction(after, { ...originalNormal, forces: { normal: 3, elite: 0 } }), null);
|
||||
assert.equal(emperorHomeworldMoveAction(after, originalNormal, 'range3'), null);
|
||||
after.ornithopter!.active!.event = '';
|
||||
assert.equal(emperorHomeworldMoveAction(after, originalNormal), null);
|
||||
});
|
||||
|
||||
void test('continuation candidates honor original normal and elite quotas separately and support old board-only flights', () => {
|
||||
const f = fixture();
|
||||
active(f.view, { forces: { [kaitain]: 1 }, elites: { [kaitain]: 1 } });
|
||||
assert.equal(emperorHomeworldMoveAction(f.view, order), null);
|
||||
assert.ok(emperorHomeworldMoveAction(f.view, { ...order, forces: { normal: 0, elite: 1 } }));
|
||||
const actions = emperorHomeworldMoveActions(f.view);
|
||||
assert.ok(actions.length);
|
||||
assert.ok(actions.every(action => action.origin === kaitain && action.normal === 0 && action.elite === 1));
|
||||
const normal = fixture(); active(normal.view, { forces: { [kaitain]: 1 }, elites: {} });
|
||||
assert.equal(emperorHomeworldMoveAction(normal.view, { ...order, forces: { normal: 0, elite: 1 } }), null);
|
||||
const legacy = fixture(); active(legacy.view, { forces: { 'arrakeen:10': 1 }, elites: {} }, false);
|
||||
assert.ok(emperorHomeworldMoveAction(legacy.view, order));
|
||||
assert.ok(emperorHomeworldMoveActions(legacy.view).length);
|
||||
});
|
||||
|
||||
void test('an uncompleted on-table flight can replace a prevented first ground group with native World movement before a cohort exists', () => {
|
||||
for (const mode of ['range3', 'twoGroups'] as const) {
|
||||
const f = fixture();
|
||||
f.view.players[0].hand = [];
|
||||
f.view.ornithopter!.active = { event: 'uncompleted-flight', mode, completed: 0,
|
||||
remaining: mode === 'twoGroups' ? 2 : 1, cohort: undefined };
|
||||
assert.deepEqual(emperorHomeworldMoveAction(f.view, order), {
|
||||
type: 'emperorHomeworldMove', event: f.view.homeworldMove!.event, origin: kaitain, normal: 1, elite: 0,
|
||||
ornithopterEvent: 'uncompleted-flight',
|
||||
});
|
||||
assert.ok(emperorHomeworldMoveActions(f.view).length);
|
||||
f.view.homeworldMove!.blocked = 'The original prevention response is pending.';
|
||||
assert.equal(emperorHomeworldMoveAction(f.view, order), null);
|
||||
}
|
||||
});
|
||||
|
||||
void test('missing held physical card blocked mode wrong owner and native modal guards refuse new flight declarations', () => {
|
||||
const missing = fixture().view; missing.players[0].hand = [];
|
||||
assert.equal(emperorHomeworldMoveAction(missing, order, 'twoGroups'), null);
|
||||
assert.ok(emperorHomeworldMoveAction(missing, order));
|
||||
const mode = fixture().view; mode.ornithopter!.modes[0].blocked = 'Card is committed.';
|
||||
assert.equal(emperorHomeworldMoveAction(mode, order, 'range3'), null);
|
||||
assert.ok(emperorHomeworldMoveAction(mode, order, 'twoGroups'));
|
||||
const basic = fixture().view; basic.advanced = false;
|
||||
const foreign = fixture().view; foreign.active = 'a';
|
||||
const closed = fixture().view; closed.homeworldMove!.blocked = 'Finish the pending native interaction.';
|
||||
for (const view of [basic, foreign, closed]) {
|
||||
assert.equal(emperorHomeworldMoveAction(view, order, 'twoGroups'), null);
|
||||
assert.deepEqual(emperorHomeworldMoveActions(view), []);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user