Integrate Imperial Homeworld movement with Ornithopter

This commit is contained in:
Quintine committed 2026-10-27 14:06:29 +11:00
1 parent 7f999f4573
commit 97edc3eac8
16 files changed
+1043 -114

No files matched your search

+39 -15
View File
@@ -5,6 +5,9 @@ import type { Action, GameView } from '@/game/engine';
import { faction } from '@/game/catalog';
import { HOMEWORLD_CARDS } from '@/game/homeworld-cards';
import type { NativeReserveSelections } from '@/game/homeworld-native-reserves';
import { emperorHomeworldMoveAction } from '@/game/homeworld-options';
import type { EmperorHomeworldMove } from '@/game/homeworld-emperor-move';
import { imperialFlightCohort } from '@/game/ornithopter-homeworld';
import { HomeworldFace } from './homeworld-cards';
import { EcazPoisonIncome } from './ecaz-poison-income';
import { Button } from './ui/button';
@@ -148,7 +151,7 @@ export function EmperorHomeworldMovement({
act: (action: Action) => void;
busy: boolean;
}) {
const [origin, setOrigin] = useState('homeworld:emperor');
const [origin, setOrigin] = useState<EmperorHomeworldMove['origin']>('homeworld:emperor');
const [normal, setNormal] = useState(0);
const [elite, setElite] = useState(0);
const option = g.homeworldMove;
@@ -156,9 +159,22 @@ export function EmperorHomeworldMovement({
if (!option || !homes || homes.length !== 2) return null;
const source = homes.find((w) => w.id === origin)!;
const destination = homes.find((w) => w.id !== origin)!;
const available = source.forces[g.me];
const active = g.ornithopter?.active;
let available = source.forces[g.me];
if (active?.mode === 'twoGroups' && active.completed === 1 && active.cohort) {
try {
const cohort = imperialFlightCohort(active.cohort, active.homeworldCohort === true, homes, g.me);
available = { normal: Math.min(available.normal, (cohort.forces[origin] ?? 0) - (cohort.elites[origin] ?? 0)),
elite: Math.min(available.elite, cohort.elites[origin] ?? 0) };
} catch { available = { normal: 0, elite: 0 }; }
}
const order: EmperorHomeworldMove = { origin, forces: { normal, elite } };
const move = emperorHomeworldMoveAction(g, order);
const cardMoves = active ? [] : (['range3', 'twoGroups'] as const).map(mode => ({ mode,
action: emperorHomeworldMoveAction(g, order, mode) }));
const name = (id: string) =>
id === 'homeworld:emperor' ? 'Kaitain' : 'Salusa Secundus';
const disabled = busy || !!option.blocked || !!g.players.find(player => player.id === g.me)?.autopilot;
const invalid =
!Number.isSafeInteger(normal) ||
!Number.isSafeInteger(elite) ||
@@ -177,16 +193,27 @@ export function EmperorHomeworldMovement({
Costs one movement and no spice. Moving first passes your unused
shipment.
</p>
{g.ornithopter && <p className="fine">
Provisional Ornithopter composition: the native Kaitain–Salusa route and no-spice cost stay unchanged.
Range three gives no extra World range or new route. Two groups must use different original typed
counters; a World group may be paired with a ground group. Existing shipment history and completed
movement stay recorded. {active ? 'The card is already on the table; continue its current flight.'
: 'Play the held physical card with the first group.'}
</p>}
{active && <p className="fine">
Ornithopter: {active.completed} group moved · {active.remaining} remaining.
Select only original unmoved normal forces or Sardaukar for the next group.
</p>}
{option.blocked && (
<output className="notice block">{option.blocked}</output>
)}
<fieldset disabled={busy || !!option.blocked} className="space-y-3">
<fieldset disabled={disabled} className="space-y-3">
<label className="block">
From
<select
value={origin}
onChange={(e) => {
setOrigin(e.target.value);
setOrigin(e.target.value as EmperorHomeworldMove['origin']);
setNormal(0);
setElite(0);
}}
@@ -227,19 +254,16 @@ export function EmperorHomeworldMovement({
</p>
<Button
className="game-action"
disabled={invalid || busy || !!option.blocked}
onClick={() =>
act({
type: 'emperorHomeworldMove',
event: option.event,
origin,
normal,
elite,
})
}
disabled={invalid || disabled || !move}
onClick={() => { if (!disabled && !invalid && move) act(move); }}
>
Move to {name(destination.id)}
{active ? 'Move next Ornithopter group to' : 'Move to'} {name(destination.id)}
</Button>
{!active && g.ornithopter && cardMoves.map(({ mode, action }) => <Button key={mode}
className="game-action" disabled={invalid || disabled || !action}
onClick={() => { if (!disabled && !invalid && action) act(action); }}>
Play Ornithopter ({mode === 'range3' ? 'range three' : 'two groups'}) and move to {name(destination.id)}
</Button>)}
</fieldset>
</details>
);
+8
View File
@@ -124,6 +124,14 @@ export function OrnithopterMovement({
{Object.entries(active.cohort.forces)
.filter(([, n]) => n > 0)
.map(([key, n]) => {
const imperial = key === 'homeworld:emperor' ? 'Kaitain'
: key === 'homeworld:emperor:salusa' ? 'Salusa Secundus' : null;
if (imperial) return (
<li key={key}>
{imperial}: {n} forces, including {active.cohort!.elites[key] ?? 0} Sardaukar.
{' '}Use the Imperial movement panel for this group.
</li>
);
const source = splitLocation(key);
return (
<li key={key}>
+41
View File
@@ -20,6 +20,47 @@ open mode gates.
Keep identified Basic-function gaps ahead of Advanced and wider interactions.
Prototype labels and final acceptance gates remain.
**Imperial Ornithopter / Homeworld movement — 27 October. Prototyped / Partial.**
Native Kaitain/Salusa transfers may now be first/second groups alongside ground
movement or another distinct World group. Actual normal/Sardaukar pools and
original quotas prevent reuse after merging. Fresh flights stamp World-aware
cohorts; the recorded older-flight inference preserves original board quotas.
Range3 adds no World route; spending its replacement entitlement on an already
permitted transfer is explicitly provisional. Native costs/permissions remain.
Only completed two-group use earns the extra movement credit. One physical card
is discarded through the appropriate arrival/no-arrival continuation; early end
and actual later Hajr retain their own accounting. Imperial controls and minimal
policies share the quoted adapter; full AI refinement remains deferred.
[Source and contract](RICHESE_ORNITHOPTER_ENGINE_AUDIT.md#imperial-homeworld-movement--27-october-2026).
Frozen **12-file union, 109/109**, types/lint/build and seven original programmes
pass. Public Advanced R/E/A +CHOAM starts enable Homeworlds before Ready/start.
Emperor actually buys the original cache Ornithopter for1, optionally ships one
normal from Kaitain to Basin9 for2, and retains actual typed World stocks.
An original dealt Hajr supplies the separate later move. World/ground in either
order, two typed World groups, range3 native transfer, early ending and star-first
movement preserve aggregate reserves/spice and dispose the card once.
Runtime records28 GameTable SSR,14 Imperial panel renders,7 actual Finish
callbacks and10 shared-adapter World packets; source/further arrays276/21.
World selectors/count inputs/card buttons were not operated; those actions use
the shared adapter/native packets. The stateful panels retain their actual zero
defaults. No browser hydration/navigation or deployed claim. Shared flight rows
now label World cohorts by name, avoiding Arrakis-sector parsing. No played
hand, purse, army, World stock, phase, card or progress is installed. Legacy
inference/replacement-first-group cases are controlled supplements; no old saved
flight or native World-final Semuta/Skills journey is certified.
Feedback added later legal World-card bot candidates while retaining shipment
priority, repaired the rejection test's lossless snapshot and type annotations.
Failed v1/v2 remains preserved; v3 verifies one frozen source then a recorded
three-document evidence overlay. Proof:
`/home/quintine/.local/share/dune/checkpoints/20261027-ornithopter-emperor-world-proof-v3/`;
journeys/HTML: adjacent `20261027-ornithopter-emperor-world-runtime/run-v2/`.
Existing server HTTP200, no restart or saved-game/database operation. Combined
interpretations remain provisional. Broader modules, full modes and final
rules/recovery/privacy/custody/browser/deployed acceptance remain open.
**No-Field free Discovery entry — 27 October. Prototyped / Partial.**
An owned concealed marker may now enter a newly revealed location at the
original next-turn opportunity, alone or with separately selected physical
+11
View File
@@ -145,6 +145,17 @@ No saved-room/D1/server, reset/restart, deployment or separate assurance campaig
SSR/public actions do not certify browser/mobile/deployed acceptance, wider
module combinations or complete modes; those remain open.
## Physical Ornithopter and Imperial transfers — 27 October 2026
The selected provisional composition now admits native Advanced Emperor
Kaitain/Salusa Force Movement as either group of the physical card, alongside
ground movement or another distinct typed World group. Range3 grants no World
route; its use on an already-permitted native transfer is a separate provisional
replacement-entitlement interpretation. Exact current and original normal/starred
pools, native permission, zero spice cost, one movement per actual group and
single physical discard remain. Early ending earns no extra group credit;
actual later Hajr is independent. [Source, controls and limits](RICHESE_ORNITHOPTER_ENGINE_AUDIT.md#imperial-homeworld-movement--27-october-2026).
## Clean Homeworlds and phase modules — 26 October 2026
Fresh clean Homeworld starts now use registered classic or selected-family
+43
View File
@@ -1,5 +1,48 @@
# Dune implementation status
## 27 October 2026 — Imperial Ornithopter / Homeworld movement
**Imperial Ornithopter / Homeworld movement — 27 October. Prototyped / Partial.**
Native Kaitain/Salusa transfers may now be first/second groups alongside ground
movement or another distinct World group. Actual normal/Sardaukar pools and
original quotas prevent reuse after merging. Fresh flights stamp World-aware
cohorts; the recorded older-flight inference preserves original board quotas.
Range3 adds no World route; spending its replacement entitlement on an already
permitted transfer is explicitly provisional. Native costs/permissions remain.
Only completed two-group use earns the extra movement credit. One physical card
is discarded through the appropriate arrival/no-arrival continuation; early end
and actual later Hajr retain their own accounting. Imperial controls and minimal
policies share the quoted adapter; full AI refinement remains deferred.
[Source and contract](RICHESE_ORNITHOPTER_ENGINE_AUDIT.md#imperial-homeworld-movement--27-october-2026).
Frozen **12-file union, 109/109**, types/lint/build and seven original programmes
pass. Public Advanced R/E/A +CHOAM starts enable Homeworlds before Ready/start.
Emperor actually buys the original cache Ornithopter for1, optionally ships one
normal from Kaitain to Basin9 for2, and retains actual typed World stocks.
An original dealt Hajr supplies the separate later move. World/ground in either
order, two typed World groups, range3 native transfer, early ending and star-first
movement preserve aggregate reserves/spice and dispose the card once.
Runtime records28 GameTable SSR,14 Imperial panel renders,7 actual Finish
callbacks and10 shared-adapter World packets; source/further arrays276/21.
World selectors/count inputs/card buttons were not operated; those actions use
the shared adapter/native packets. The stateful panels retain their actual zero
defaults. No browser hydration/navigation or deployed claim. Shared flight rows
now label World cohorts by name, avoiding Arrakis-sector parsing. No played
hand, purse, army, World stock, phase, card or progress is installed. Legacy
inference/replacement-first-group cases are controlled supplements; no old saved
flight or native World-final Semuta/Skills journey is certified.
Feedback added later legal World-card bot candidates while retaining shipment
priority, repaired the rejection test's lossless snapshot and type annotations.
Failed v1/v2 remains preserved; v3 verifies one frozen source then a recorded
three-document evidence overlay. Proof:
`/home/quintine/.local/share/dune/checkpoints/20261027-ornithopter-emperor-world-proof-v3/`;
journeys/HTML: adjacent `20261027-ornithopter-emperor-world-runtime/run-v2/`.
Existing server HTTP200, no restart or saved-game/database operation. Combined
interpretations remain provisional. Broader modules, full modes and final
rules/recovery/privacy/custody/browser/deployed acceptance remain open.
## 27 October 2026 — No-Field free Discovery entry
**No-Field free Discovery entry — 27 October. Prototyped / Partial.**
+65
View File
@@ -1,5 +1,70 @@
# Richese Ornithopter: movement integration audit
## Imperial Homeworld movement — 27 October 2026
E3printed10 permits Advanced Emperor to use its Movement between Kaitain and
Salusa; supplied Advanced physical22/printed20 explicitly calls it Force
Movement of any number of own forces. The recorded physical card grants two
normal movements with different forces. Under standing recommended-option
authority, provisionally compose them: either group may be an independently
legal Imperial transfer or ground move, in either order; both may be transfers.
Normal and Sardaukar cohorts remain distinct across all three stores.
Homeworlds are not territories. Range3 grants no route or range to them; spending
its replacement entitlement on the independently permitted Imperial transfer is
a separate explicit provisional composition. Targeted GF9/designer searching
found no dedicated combined answer. The earlier photographed card contract is
retained; its old crop was unavailable, so no fresh card-face reread is claimed.
Native route permission and Homeworld Karama immunity stay (E3p10); no shipment
or spice cost is added. Moving first retains the native passed-shipment rule.
Fresh Imperial flights stamp `homeworldCohort:true` and snapshot actual typed
board and native-World custody. Arriving moved counters never increase remaining
quotas. An older unstamped active flight was created while World transfers were
blocked; the selected compatibility interpretation treats its then-untouched
native World pools as unmoved, without rebuilding its recorded board cohort.
That inference is not historical-save certification. No reserve total creates a
World force. An uncompleted first attempt needs no cohort until an actual first
group commits; its current card event may use the native transfer once controls
are clear.
Both groups completing earns one existing extra-move credit; early ending earns
none. A later actual Hajr has its own allowance and may reuse moved counters.
Final World movement uses a distinct `homeworld` discard continuation, binds
the actual World custody and discards the original card once without an Arrakis
arrival replay or automatic turn end. Ground-final disposal retains its native
arrival continuation. The Imperial panel and shared legal AI adapter expose
both modes/current continuation with provisional guidance and typed maxima.
Broader modules, native Semuta reaction journeys and full acceptance remain open.
### Scoped native evidence
Frozen12-file union109/109, types/lint/build and seven original programmes pass.
Public Advanced R/E/A +CHOAM with Homeworld opt-in before Ready/start retains
printed15 normal Kaitain/5 starred Salusa. Emperor buys original cache card1;
optional real normal1 shipment2 creates Basin9 army and leaves14/5 native stocks.
Original Hajr is actually dealt when selected. World→ground, ground→World,
two typed Worlds, range3 native World, early end, later Hajr and star-first
World→ground preserve aggregate stocks/spice, actual movement and single discard.
Runtime records28 GameTable SSR,14 Imperial panels,7 actual Finish callbacks and
10 shared-adapter World packets; source/further arrays276/21. World selectors,
count inputs and card buttons were not operated; panels use their actual zero
defaults, with World/ground/Hajr actions applied through adapter/native packets.
Shared flight quota rows label Worlds by name. No played hand/purse/army/World
stock/phase/card/progress is installed. Controlled cases separately cover older
cohort inference, replacement first group and exact typed limits. Existing
related Semuta paths pass; no native World-final Semuta/Skills or old saved-flight
journey is claimed. Browser hydration/navigation, deployed and full combined-mode
acceptance remain open.
Feedback preserved shipment priority while adding later legal World-card bot
choices, repaired lossless rejection snapshots and type annotations. Failed
v1/v2 is preserved. V3 uses one frozen source followed by three-document evidence
prose only. Existing server HTTP200, no restart or saved-game/database operation.
Proof: `/home/quintine/.local/share/dune/checkpoints/20261027-ornithopter-emperor-world-proof-v3/`;
runtime: adjacent `20261027-ornithopter-emperor-world-runtime/run-v2/`.
## Planetologist movement composition — 26 October 2026
Both physical modes now compose provisionally with either Planetologist
+15
View File
@@ -1,5 +1,20 @@
# Rule decision index
## Physical Ornithopter and Imperial Homeworld movement — 27 October 2026
E3printed10/supplied Advanced physical22/printed20 grant Emperor native Force
Movement between Kaitain and Salusa. Under standing recommended-option authority,
compose it provisionally with the recorded physical card's two normal moves:
first/second groups may be ground or native World transfers with distinct
original typed counters. Homeworlds are not territories, so range3 grants no
World route; spending its replacement entitlement on an independently permitted
transfer is a separate provisional interpretation. Native Karama immunity and
cost/allowance rules stay. Completion discards once and earns only the actual
two-group extra credit. Fresh World-aware cohorts and a scoped older-flight
inference retain original board quotas. Targeted publisher/designer searching
found no dedicated combined ruling; no fresh unavailable card-crop inspection
is claimed. [Contract](RICHESE_ORNITHOPTER_ENGINE_AUDIT.md#imperial-homeworld-movement--27-october-2026).
## Concealed No-Field free Discovery entry — 27 October 2026
E3printed12 and supplied Advanced physical23/printed21 grant a separate
+3 -1
View File
@@ -3944,8 +3944,10 @@ function policyActions(g: GameView): Action[] {
actions.push(...skillCardMoves);
actions.push(...cardMoves.filter(action => action.sandmaster !== undefined));
if (reveal && !actions.length) actions.push(reveal);
const imperialMoves = emperorHomeworldMoveActions(g);
if (me.shipped || !actions.some((action) => action.type === 'ship' || action.type === 'homeworldShip'))
actions.unshift(...emperorHomeworldMoveActions(g));
actions.unshift(...imperialMoves);
else actions.push(...imperialMoves.filter(action => action.movementCard !== undefined || action.ornithopterEvent !== undefined));
const activeCunning = nexusGuildCunningActive(g);
if (activeCunning?.stage === 'secondShipment' && activeCunning.hajrAvailable &&
me.hand?.some(card => card.effect === 'hajr') &&
+99 -58
View File
@@ -496,6 +496,7 @@ import {
type MovementCohort,
type OrnithopterMode,
} from './ornithopter';
import { imperialFlightPools, imperialFlightCohort } from './ornithopter-homeworld';
import {
eligibleNullentropyCards,
resolveNullentropyBox,
@@ -1805,7 +1806,7 @@ export type Game = {
}
| {
kind: 'ornithopterDiscard';
source: 'move' | 'end' | 'cunning';
source: 'move' | 'end' | 'cunning' | 'homeworld';
flight: NonNullable<Game['ornithopter']>;
movement: CompletedMovement | null;
resume: NonNullable<Game['pendingNullentropy']>['resume'];
@@ -1873,6 +1874,8 @@ export type Game = {
startingMove: number;
completed: number;
cohort?: MovementCohort;
/** Fresh Imperial flights snapshot both native Homeworld pools. */
homeworldCohort?: true;
} | null;
pendingNullentropy?: {
event: string;
@@ -7155,6 +7158,8 @@ function ornithopterDiscardSignature(
source: next.source,
flight: next.flight,
movement: next.movement,
...(next.source === 'homeworld' || next.flight.homeworldCohort
? {homeworlds:g.homeworlds?.custody} : {}),
controls: nullentropyParentSignature(g, next.resume, next.flight.event),
active: g.active,
order: g.order,
@@ -8136,8 +8141,8 @@ function treacheryDiscardIntegrity(g: Game) {
Number.isSafeInteger(flight.completed) &&
flight.completed >= 0 &&
owner.moved === flight.startingMove + flight.completed &&
['move', 'end', 'cunning'].includes(c.source) &&
(c.source === 'move'
['move', 'end', 'cunning', 'homeworld'].includes(c.source) &&
(c.source === 'move' || c.source === 'homeworld'
? flight.completed === total
: flight.completed < total) &&
batch.cause === `ornithopter:${c.source}` &&
@@ -8184,6 +8189,9 @@ function treacheryDiscardIntegrity(g: Game) {
'The ended Ornithopter must resume its original Guild Cunning response.');
validateGuildCunningResponse(g, c.resume.response);
}
if (c.source === 'homeworld') requireRule(owner.faction === 'emperor' && g.advanced &&
!!g.homeworlds?.custody && owner.shipped && !g.pendingArrivalOverlap,
'The completed Imperial Ornithopter needs its native Homeworld movement.');
requireRule(
c.stateSignature === ornithopterDiscardSignature(g, c),
'The retired Ornithopter no longer matches its saved movement obligations.',
@@ -8743,7 +8751,7 @@ function semutaOfferSupported(
continuation.kind !== 'winnerMandatoryDiscard' &&
continuation.kind !== 'battleResolved' &&
!(continuation.kind === 'ornithopterDiscard' &&
(continuation.source === 'end' || continuation.source === 'cunning' ||
(continuation.source === 'end' || continuation.source === 'cunning' || continuation.source === 'homeworld' ||
(continuation.source === 'move' &&
(!g.players.some(p => p.faction === 'ecaz' || p.faction === 'moritani') ||
bareBasicSemutaProfile(g)) &&
@@ -18441,8 +18449,6 @@ function emperorHomeworldMoveBlock(g: Game, p: Player): string | null {
g.battle
)
return 'Finish the current interaction before moving between Homeworlds.';
if (g.ornithopter)
return 'Homeworld transfers during an active Ornithopter card await their movement-group integration.';
const richese = byFaction(g, 'richese');
if (
richese &&
@@ -18516,6 +18522,8 @@ function ornithopterIntegrity(g: Game) {
flight.completed < (flight.mode === 'twoGroups' ? 2 : 1) &&
owner.moved === flight.startingMove + flight.completed &&
richeseCardDefinition(flight.card)?.card.effect === 'ornithopter' &&
(flight.homeworldCohort === undefined || flight.homeworldCohort === true &&
owner.faction === 'emperor' && g.advanced && !!g.homeworlds?.custody) &&
![
...g.players.flatMap((p) => p.hand),
...g.deck,
@@ -18542,7 +18550,7 @@ function ornithopterIntegrity(g: Game) {
}
function finishOrnithopter(
g: Game,
source: 'move' | 'end' | 'cunning',
source: 'move' | 'end' | 'cunning' | 'homeworld',
movement: CompletedMovement | null = null,
) {
const flight = g.ornithopter;
@@ -18589,6 +18597,63 @@ function finishOrnithopter(
continuation,
);
}
function declareOrnithopterMovement(g: Game, p: Player, action: Action) {
const id = p.id;
let flight = g.ornithopter?.player === id ? g.ornithopter : null;
if (action.movementCard !== undefined) {
const mode = action.ornithopter;
requireRule(
mode === 'range3' || mode === 'twoGroups',
'Choose an Ornithopter movement mode.',
);
const blocked = ornithopterBlock(g, p);
requireRule(!blocked, blocked ?? 'Ornithopter cannot be played.');
const card = p.hand.find((c) => c.id === action.movementCard);
requireRule(
card && richeseCardDefinition(card)?.card.effect === 'ornithopter',
'Choose the canonical Ornithopter in your hand.',
);
const reserved = transferCardBlock(g, p, card);
requireRule(!reserved, reserved ?? 'The card is committed.');
requireRule(
action.ornithopterEvent === undefined,
'A new card movement cannot name an old event.',
);
flight = {
event: crypto.randomUUID(),
player: id,
card,
turn: g.turn,
mode,
startingMove: p.moved,
completed: 0,
...(p.faction === 'emperor' && g.advanced && g.homeworlds?.custody
? {homeworldCohort:true as const} : {}),
};
g.ornithopter = flight;
p.hand = p.hand.filter((c) => c.id !== card.id);
log(
g,
`${p.name} played Ornithopter: ${mode === 'range3' ? 'one group with a maximum three-territory route' : 'two different groups using normal movement'}. The card remains on the table until its use finishes.`,
);
} else if (flight)
requireRule(
action.ornithopterEvent === flight.event,
'Use the current Ornithopter movement event.',
);
else
requireRule(
action.ornithopterEvent === undefined &&
action.ornithopter === undefined,
'No matching Ornithopter movement is active.',
);
return flight;
}
function ornithopterMovementPools(g: Game, p: Player) {
return p.faction === 'emperor' && g.advanced && g.homeworlds?.custody
? noFieldRule(() => imperialFlightPools(p.forces,p.elites?.forces ?? {},homeworldTable(g)!,p.id))
: {forces:p.forces,elites:p.elites?.forces ?? {}};
}
function ornithopterView(g: Game, p: Player) {
const flight = g.ornithopter?.player === p.id ? g.ornithopter : null;
const card =
@@ -18614,6 +18679,7 @@ function ornithopterView(g: Game, p: Player) {
completed: flight.completed,
remaining: (flight.mode === 'twoGroups' ? 2 : 1) - flight.completed,
cohort: flight.cohort,
...(flight.homeworldCohort ? {homeworldCohort:true as const} : {}),
}
: null,
};
@@ -21227,52 +21293,7 @@ function declareGroundMovement(g: Game, p: Player, action: Action, quoteOnly = f
player: id, group, eliteGroup, elite, origin, origins, total: n,
to, sector: s, advisors, wantsFighters, lockedTurn,
});
let flight = g.ornithopter?.player === id ? g.ornithopter : null;
if (action.movementCard !== undefined) {
const mode = action.ornithopter;
requireRule(
mode === 'range3' || mode === 'twoGroups',
'Choose an Ornithopter movement mode.',
);
const blocked = ornithopterBlock(g, p);
requireRule(!blocked, blocked ?? 'Ornithopter cannot be played.');
const card = p.hand.find((c) => c.id === action.movementCard);
requireRule(
card && richeseCardDefinition(card)?.card.effect === 'ornithopter',
'Choose the canonical Ornithopter in your hand.',
);
const reserved = transferCardBlock(g, p, card);
requireRule(!reserved, reserved ?? 'The card is committed.');
requireRule(
action.ornithopterEvent === undefined,
'A new card movement cannot name an old event.',
);
flight = {
event: crypto.randomUUID(),
player: id,
card,
turn: g.turn,
mode,
startingMove: p.moved,
completed: 0,
};
g.ornithopter = flight;
p.hand = p.hand.filter((c) => c.id !== card.id);
log(
g,
`${p.name} played Ornithopter: ${mode === 'range3' ? 'one group with a maximum three-territory route' : 'two different groups using normal movement'}. The card remains on the table until its use finishes.`,
);
} else if (flight)
requireRule(
action.ornithopterEvent === flight.event,
'Use the current Ornithopter movement event.',
);
else
requireRule(
action.ornithopterEvent === undefined &&
action.ornithopter === undefined,
'No matching Ornithopter movement is active.',
);
const flight = declareOrnithopterMovement(g,p,action);
const move: MovementOrder = {
player: id,
group,
@@ -21387,11 +21408,11 @@ function commitGroundMovementPieces(g: Game, move: MovementOrder) {
flight.mode === 'twoGroups' &&
flight.completed === 0
) {
const marker = p.noField?.deployed;
const marker = p.noField?.deployed, pools = ornithopterMovementPools(g,p);
flight.cohort = noFieldRule(() =>
createRemainingCohort(
p.forces,
p.elites?.forces ?? {},
pools.forces,
pools.elites,
Object.fromEntries(group),
eliteGroup,
marker && p.noFieldEvent
@@ -42482,7 +42503,7 @@ function applyActionInner(
requireRule(!blocked, blocked ?? 'Homeworld movement is unavailable.');
requireRule(
Object.keys(action).every((key) =>
['type', 'event', 'origin', 'normal', 'elite'].includes(key),
['type', 'event', 'origin', 'normal', 'elite','movementCard','ornithopter','ornithopterEvent'].includes(key),
),
'Choose only the source Homeworld and physical force types.',
);
@@ -42510,6 +42531,19 @@ function applyActionInner(
},
),
);
const flight = declareOrnithopterMovement(g,p,action);
if (flight?.mode === 'twoGroups') {
const pools = ornithopterMovementPools(g,p);
const selected = {[quote.origin]:quote.forces.normal+quote.forces.elite};
const elites = {[quote.origin]:quote.forces.elite};
if (flight.completed === 0) {
flight.cohort = noFieldRule(() => createRemainingCohort(pools.forces,pools.elites,selected,elites));
} else {
requireRule(flight.cohort, 'The original unmoved Ornithopter cohort is missing.');
const cohort = noFieldRule(() => imperialFlightCohort(flight.cohort!,flight.homeworldCohort === true,homeworldTable(g)!,id));
noFieldRule(() => validateCohortSelection(cohort,pools.forces,pools.elites,selected,elites));
}
}
g.homeworlds!.custody = quote.state;
observeOccupation(g);
finishShipmentPromises(g, p, null);
@@ -42527,8 +42561,15 @@ function applyActionInner(
.join('; ');
log(
g,
`${p.name} moved ${quote.forces.normal} normal forces and ${quote.forces.elite} Sardaukar from ${name(quote.origin)} to ${name(quote.destination)}. This spends one movement and no spice; total reserves are unchanged. ${shifts}.`,
`${p.name} moved ${quote.forces.normal} normal forces and ${quote.forces.elite} Sardaukar from ${name(quote.origin)} to ${name(quote.destination)}. This spends one movement and no spice; total reserves are unchanged. ${shifts}.${flight ? ' Provisional Ornithopter composition uses the independently permitted Imperial route; the card grants no Homeworld route or range.' : ''}`,
);
if (flight) {
flight.completed++;
if (flight.completed === (flight.mode === 'twoGroups' ? 2 : 1)) {
if (flight.mode === 'twoGroups') p.ornithopterExtraMoves = (p.ornithopterExtraMoves ?? 0) + 1;
finishOrnithopter(g,'homeworld');
}
}
return g;
}
if (t === 'move') {
+89 -36
View File
@@ -17,6 +17,9 @@ import {
quoteNativeReserveWithdrawal,
type NativeReserveSelections,
} from './homeworld-native-reserves';
import { imperialFlightPools, imperialFlightCohort } from './ornithopter-homeworld';
import { validateCohortSelection, type OrnithopterMode } from './ornithopter';
import { richeseCardDefinition } from './richese-cards';
/** Reconstitute only the public physical custody accepted by the shared quotes.
* Private hands, plans, reserve decks and random state are never consulted. */
@@ -150,21 +153,74 @@ export function withNativeShipmentSources(
}
}
/** Spend at most one movement restoring a printed high threshold. Salusa has
* priority; normal-only returns to Kaitain cannot undo its Sardaukar repair. */
export function emperorHomeworldMoveActions(view: GameView): Action[] {
/** One exact native World route, optionally declaring or continuing the actual
* physical flight. Ornithopter supplies no new route or World range benefit. */
export function emperorHomeworldMoveAction(
view: GameView,
order: EmperorHomeworldMove,
mode?: OrnithopterMode,
): Action | null {
const movement = view.homeworldMove;
const me = view.players.find(p => p.id === view.me);
const flight = view.ornithopter?.active;
if (
!movement ||
movement.blocked ||
movement.remaining <= 0 ||
(flight ? flight.remaining <= 0 : movement.remaining <= 0) ||
!view.advanced ||
view.status !== 'playing' ||
view.phase !== 5 ||
view.active !== view.me ||
view.players.find((p) => p.id === view.me)?.faction !== 'emperor'
me?.faction !== 'emperor' || view.response || view.decision || view.truthtrance ||
view.phaseOpening || view.automaticContinuationPending || view.nexusTraitors?.pending ||
view.roomControl?.paused || view.roomControl?.closed
)
return [];
return null;
const physical = publicCustody(view);
if (!physical) return null;
let envelope: Partial<Action> = {};
if (flight) {
const needsCohort = flight.mode === 'twoGroups' && flight.completed === 1;
if ((needsCohort && !flight.cohort) || !flight.event || (mode !== undefined && mode !== flight.mode) ||
richeseCardDefinition(view.ornithopter!.card)?.card.effect !== 'ornithopter') return null;
if (needsCohort) {
try {
const pools = imperialFlightPools(me.forces, me.elites?.forces ?? {}, physical.worlds, me.id);
const cohort = imperialFlightCohort(flight.cohort!, flight.homeworldCohort === true, physical.worlds, me.id);
validateCohortSelection(cohort, pools.forces, pools.elites,
{ [order.origin]: order.forces.normal + order.forces.elite }, { [order.origin]: order.forces.elite });
} catch (error) {
if (error instanceof RangeError || error instanceof TypeError) return null;
throw error;
}
}
envelope = { ornithopterEvent: flight.event };
} else if (mode !== undefined) {
const card = view.ornithopter;
if (!card || card.blocked || !card.modes.some(option => option.mode === mode && !option.blocked) ||
!me.hand?.some(held => held.id === card.card.id && richeseCardDefinition(held)?.card.effect === 'ornithopter'))
return null;
envelope = { movementCard: card.card.id, ornithopter: mode };
}
try {
quoteEmperorHomeworldMove({ ...physical.context, status: view.status, phase: view.phase,
currentPlayer: view.active, movesLeft: Math.max(movement.remaining, flight?.remaining ?? 0) },
physical.custody, view.me, order);
return { type: 'emperorHomeworldMove', event: movement.event, origin: order.origin,
normal: order.forces.normal, elite: order.forces.elite, ...envelope };
} catch (error) {
if (error instanceof EmperorHomeworldMoveError || error instanceof HomeworldCustodyError)
return null;
throw error;
}
}
/** Keep the ordinary threshold repair first. Actual card candidates merely
* supply legal participation; original typed quotas cap a continuing group. */
export function emperorHomeworldMoveActions(view: GameView): Action[] {
if (!view.homeworldMove || view.homeworldMove.blocked || !view.advanced ||
view.status !== 'playing' || view.phase !== 5 || view.active !== view.me ||
view.players.find(p => p.id === view.me)?.faction !== 'emperor') return [];
const physical = publicCustody(view);
if (!physical) return [];
const kaitain = physical.worlds.find(
@@ -197,35 +253,32 @@ export function emperorHomeworldMoveActions(view: GameView): Action[] {
origin: 'homeworld:emperor:salusa',
forces: { normal: kaitainDeficit, elite: 0 },
};
if (!order) return [];
try {
quoteEmperorHomeworldMove(
{
...physical.context,
status: view.status,
phase: view.phase,
currentPlayer: view.active,
movesLeft: movement.remaining,
},
physical.custody,
view.me,
order,
);
return [
{
type: 'emperorHomeworldMove',
event: movement.event,
origin: order.origin,
normal: order.forces.normal,
elite: order.forces.elite,
},
];
} catch (error) {
if (
error instanceof EmperorHomeworldMoveError ||
error instanceof HomeworldCustodyError
)
return [];
throw error;
const active = view.ornithopter?.active;
const actions: Action[] = [];
if (order && !active) {
const ordinary = emperorHomeworldMoveAction(view, order);
if (ordinary) actions.push(ordinary);
}
if (!view.ornithopter) return actions;
const needsCohort = active?.mode === 'twoGroups' && active.completed === 1;
let cohort = needsCohort ? active.cohort : undefined;
if (needsCohort && !cohort) return actions;
if (cohort) cohort = imperialFlightCohort(cohort, active?.homeworldCohort === true, physical.worlds, view.me);
const orders: EmperorHomeworldMove[] = [];
for (const home of [salusa, kaitain]) {
const group = home.forces[view.me];
const normal = cohort ? Math.min(group.normal, (cohort.forces[home.id] ?? 0) - (cohort.elites[home.id] ?? 0)) : group.normal;
const elite = cohort ? Math.min(group.elite, cohort.elites[home.id] ?? 0) : group.elite;
if (order?.origin === home.id) {
const capped = { normal: Math.min(order.forces.normal, normal), elite: Math.min(order.forces.elite, elite) };
if (capped.normal + capped.elite > 0) orders.push({ origin: order.origin, forces: capped });
}
if (normal > 0) orders.push({ origin: home.id as EmperorHomeworldMove['origin'], forces: { normal: 1, elite: 0 } });
if (elite > 0) orders.push({ origin: home.id as EmperorHomeworldMove['origin'], forces: { normal: 0, elite: 1 } });
}
for (const candidate of orders) for (const mode of active ? [undefined] : ['range3', 'twoGroups'] as const) {
const action = emperorHomeworldMoveAction(view, candidate, mode);
if (action && !actions.some(earlier => JSON.stringify(earlier) === JSON.stringify(action))) actions.push(action);
}
return actions;
}
+45
View File
@@ -0,0 +1,45 @@
import type { MovementCohort } from './ornithopter';
export type ImperialFlightWorld = {
id: string;
native: string;
forces: Record<string, { normal: number; elite: number }>;
};
const imperialWorlds = ['homeworld:emperor', 'homeworld:emperor:salusa'];
/** Native World counters come only from their actual typed custody, never the
* aggregate reserve total. World IDs are cohort keys without a sector suffix. */
export function imperialFlightPools(
boardForces: Record<string, number>,
boardElites: Record<string, number>,
worlds: readonly ImperialFlightWorld[],
owner: string,
): { forces: Record<string, number>; elites: Record<string, number> } {
const forces = { ...boardForces }, elites = { ...boardElites };
for (const id of imperialWorlds) {
delete forces[id]; delete elites[id];
const world = worlds.find(candidate => candidate.id === id && candidate.native === owner);
if (!world) continue;
const group = world.forces[owner];
if (!group || !Number.isSafeInteger(group.normal) || group.normal < 0 ||
!Number.isSafeInteger(group.elite) || group.elite < 0 ||
!Number.isSafeInteger(group.normal + group.elite))
throw new RangeError('Imperial flight needs actual nonnegative typed Homeworld custody.');
forces[id] = group.normal + group.elite;
elites[id] = group.elite;
}
return { forces, elites };
}
/** Older flights blocked all World moves, so their then-current World pools
* are still unmoved. A fresh aware cohort must retain its depleted quotas. */
export function imperialFlightCohort(
cohort: MovementCohort,
aware: boolean,
worlds: readonly ImperialFlightWorld[],
owner: string,
): MovementCohort {
const pools = aware ? { forces: { ...cohort.forces }, elites: { ...cohort.elites } }
: imperialFlightPools(cohort.forces, cohort.elites, worlds, owner);
return { ...pools, ...(cohort.noField ? { noField: { ...cohort.noField } } : {}) };
}
+7 -4
View File
@@ -963,6 +963,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Under the provisional composition, Ornithopter replaces one unused ordinary or Hajr movement. A prior move stays spent and cannot become the card’s first group retrospectively. One Hajr play plus a two-group flight permits three group movements; Hajr may reuse a card-moved group. Ending early abandons the unused card group and ends your turn. The single physical Hajr may grant another move after legitimate recovery and a new legal play.',
'Advanced Hajr must be played immediately after a completed Force Movement and its required arrival decisions, before your next voluntary game action. A completed card group provisionally supplies this opportunity. Basic retains broader own-turn timing. Kulon leaves the fixed card maximum at three and extends both normal-range groups. Advanced advisors may use both physical card modes. They do not grant city access themselves; two-group mode uses current fighter city access at each move, while range3 is independent. Empty arrivals flip normally, and fresh-advisor locks and original-group limits still apply. After Guild Cunning, the physical card may provisionally replace one earned unused Hajr allowance. Cunning itself adds no ordinary move; an active two-group flight keeps its original remaining group. Declaring Cunning abandons an unfinished earlier flight without extra credit. Wider optional combinations remain unfinished.',
'All four AI profiles use their own projected movement choices and original-group limits. They can use a longer route, split distinct groups, finish interrupted movement and end optional movement when no legal second group remains. Complete Richese-game calibration is not yet certified.',
'With Advanced Emperor Homeworlds, provisional composition permits a native Kaitain/Salusa transfer as either group, alongside a ground move or another different typed World group. The physical card grants no World route or range; using its one-move mode on the independently allowed transfer is a separate provisional replacement. Current and original normal/Sardaukar quotas prevent reusing arriving counters. Complete two groups for the actual extra-move credit, or finish early without it. One card is discarded; later Hajr remains independent. Use the Imperial movement panel for World groups.',
'In supported Basic Ecaz/Moritani tables with Richese, Semuta may recover the completed movement card before the original arrival reaction. The forces and counters stay moved; the original player retains their actual turn. Later Ambassador or Terror discards form separate fresh groups and never repeat the movement.',
],
related: [
@@ -976,25 +977,26 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Implementation',
status: 'Partial',
detail:
'Both modes retain original event/cohort accounting. One unused normal/Hajr entitlement can start a flight; completed two-group credit preserves unused Hajr afterward. Each genuinely recovered Hajr replay has its own grant. Advanced uses supplied immediate-after-play timing with an explicit online/card-group convention. Both modes now accept Advanced advisors through native stance/entry and original-group rules. Advisors do not grant city access; current fighter-city range is recomputed for each normal group. Guild Cunning now retains actual earlier credits and permits a physical card to replace an unused Hajr grant, with active-flight and early-end suffixes. New v3 receipts preserve old formats. Wider combinations and broader acceptance remain unfinished.',
'Native Imperial World/ground sequences now use original typed pools and one physical disposal; fresh World-aware cohorts distinguish arrived counters, with a recorded legacy inference for older ground-only flights. Both modes retain original event/cohort accounting. One unused normal/Hajr entitlement can start a flight; completed two-group credit preserves unused Hajr afterward. Each genuinely recovered Hajr replay has its own grant. Advanced uses supplied immediate-after-play timing with an explicit online/card-group convention. Both modes now accept Advanced advisors through native stance/entry and original-group rules. Advisors do not grant city access; current fighter-city range is recomputed for each normal group. Guild Cunning now retains actual earlier credits and permits a physical card to replace an unused Hajr grant, with active-flight and early-end suffixes. New v3 receipts preserve old formats. Wider combinations and broader acceptance remain unfinished.',
evidence: ['game/ornithopter-homeworld.ts', 'game/homeworld-options.ts', 'game/engine.ts', 'components/homeworld-table.tsx', 'tests/fixture-ornithopter-emperor-world.ts', 'tests/ornithopter-emperor-world-engine.test.ts', 'tests/ornithopter-homeworld-quote.test.ts', 'tests/ornithopter-homeworld-controls.test.tsx', 'docs/RICHESE_ORNITHOPTER_ENGINE_AUDIT.md'],
},
{
area: 'Player controls',
status: 'Partial',
detail:
'Existing source/destination, typed group, mode and inspector controls consume current shared movement availability. Persistent text explains Hajr composition, Guild Cunning prerequisites/early ending, Advanced timing and advisor/city access; automatic disposal adds no confirmation.',
'Imperial controls add actual held-card modes/current continuation, typed unmoved maxima and persistent provisional route/credit guidance. Existing source/destination, typed group, mode and inspector controls consume current shared movement availability. Persistent text explains Hajr composition, Guild Cunning prerequisites/early ending, Advanced timing and advisor/city access; automatic disposal adds no confirmation.',
},
{
area: 'AI',
status: 'Partial',
detail:
'Four profiles use projected ranges, advisor-aware routes/native entry, distinct group quotas and earned Hajr movement, including post-Cunning card groups. Actual advisor and owned second-group candidates are legal; strategic tuning remains deferred.',
'Minimal Imperial candidates use the shared World action adapter and exact typed quotas without strategic refinement. Four profiles use projected ranges, advisor-aware routes/native entry, distinct group quotas and earned Hajr movement, including post-Cunning card groups. Actual advisor and owned second-group candidates are legal; strategic tuning remains deferred.',
},
{
area: 'Documentation',
status: 'Partial',
detail:
'Explains unused entitlement replacement, per-flight distinctness, later Hajr reuse, fixed/normal range, Advanced source timing and explicit unfinished multiple-card/module combinations.',
'Imperial composition, source/range boundaries, World-aware and legacy cohorts and distinct no-arrival disposal are explicit. Explains unused entitlement replacement, per-flight distinctness, later Hajr reuse, fixed/normal range, Advanced source timing and explicit unfinished multiple-card/module combinations.',
},
{
area: 'Verification',
@@ -5387,6 +5389,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Fresh clean registered World/all14 starts also include original occupation benefit state beside normal Banker. Admission and native role limits remain. Real other-payer Bank payments can earn a deferred skill grant; occupied Collection and phase tokens do not become immediate Banker cash. Actual Mentat settles the original skill receipt once. Nexus/Discovery/Semuta contexts retain their own scope; played games are not retrofitted.',
'Existing admitted World starts with Nexus and/or Discoveries also initialize original occupation benefits through fresh setup, optionally with Skills and original phase modules. Occupied Collection, ordinary deposits, nested entry, earned Nexus/Stronghold cards and deferred Banker retain their separate events. Original start/role limits remain; existing occupation history is never replaced and played games are not converted.',
'Each faction uses its own card and matching world token. In Basic play the Emperor uses Kaitain alone. Advanced play adds Salusa Secundus for its Sardaukar; movement can transfer both force types between the two Emperor worlds.',
'Provisional physical Ornithopter composition permits independently legal Imperial transfers as first or second groups, mixed with ground movement or another distinct typed World group. Original normal/starred pools prevent counter reuse after merging; fresh World-aware snapshots and the recorded older-flight inference retain the card event. Range3 grants no new World route. Only completed two-group use earns the extra movement credit; one card is discarded, and actual later Hajr is independent.',
'Registered selected-family World all14 composes independent36/38/50-card setup and original Tech3+/Advanced Strongholds2+, including mixed E3. Clean bare World cards now use the registered selected-family profile with optional phase modules and original occupation state. Recruits uses current native rates/typed limits and recorded paid/free use; Banker and phase-end Axlotl remain separate. Reinforcements seals exact normal-first sources and excludes an own-World fee from dial/casualties while preserving original face/support. Suk rescues committed losses, not fees. Harass returns actual visitors through native custody and is forbidden on every own World. Original Nexus/Discovery/Semuta and role/effect guards remain.',
'Native reserves determine high or low population. Salusa counts Sardaukar, while the other cards count the faction’s reserves there. Reaching the high face’s minimum restores high population. Occupation also applies the low penalty.',
'Low population normally grants one additional free force revival and one additional bank spice when collecting CHOAM Charity. Salusa grants neither bonus; CHOAM’s Tupile reverses the usual pattern by granting an advantage while low and a penalty while high.',
+156
View File
@@ -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' });
});
+150
View File
@@ -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), []);
}
});