diff --git a/README.md b/README.md
index c3b0edbc..9acaa6dc 100644
--- a/README.md
+++ b/README.md
@@ -35,6 +35,10 @@ advisors, Polar Sink and native Ecaz coexistence retain their exceptions.
Basic and an already recorded current Storm keep their existing protocol.
See [Advanced source boundaries](docs/BASE_ADVANCED_READINESS_20260907.md#authorized-advanced-source-follow-up--1-october-2026)
and [allied separation](docs/ALLIED_TRANSIT_RULES.md).
+Advanced Hajr is played immediately after a completed Force Movement and its
+required arrival decisions. Basic keeps its broader own-turn timing. The
+[Ornithopter combination](docs/RICHESE_ORNITHOPTER_ENGINE_AUDIT.md#hajr-and-unused-movement-entitlements--18-october-2026)
+uses one unused movement entitlement and retains Hajr's separate move.
Before starting, open **Configure** on an AI seat to change its difficulty,
faction or player circle without replacing it. Changes save immediately and
diff --git a/components/game-table.tsx b/components/game-table.tsx
index 67531bdb..40561713 100644
--- a/components/game-table.tsx
+++ b/components/game-table.tsx
@@ -5003,6 +5003,8 @@ export function GameTable({
? `${(me.movesAllowed ?? 1) - (me.moved ?? 0)} movement remaining.`
: 'Movement complete. Finish your turn when your shipment is also settled.'}
+ {g.ornithopter && Provisional card composition: Ornithopter replaces one unused movement. Its two groups must be different; Hajr adds one separate normal move and may move those forces again.
}
+ {g.advanced && me.hand?.some(card => card.effect === 'hajr') && Play Hajr immediately after a completed Force Movement and its required arrival decisions, before your next voluntary action.
}
{transportPreview && (
<>
;
hand?: readonly Card[];
movesAllowed?: number;
+ moved?: number;
+ hajrUsed?: boolean;
+ hajrAvailable?: boolean;
};
/** Only public state and the current viewer's own hand are inspected. */
export type OrdinaryCardState = {
@@ -102,9 +105,12 @@ export function ordinaryCardAvailability(
if (
state.phase !== 5 ||
state.active !== state.me ||
- (state.nexusGuildCunning?.active ? !state.nexusGuildCunning.active.hajrAvailable : own.movesAllowed !== 1)
+ (state.nexusGuildCunning?.active ? !state.nexusGuildCunning.active.hajrAvailable :
+ own.hajrAvailable !== undefined ? !own.hajrAvailable :
+ own.hajrUsed === undefined ? own.movesAllowed !== 1 :
+ own.hajrUsed || (own.moved ?? 0) > (own.movesAllowed ?? 1))
)
- return blocked('Use Hajr during your movement.');
+ return blocked('Use one Hajr during your movement; Advanced requires the immediate opportunity after a completed Force Movement.');
break;
case 'harvester':
if (state.phase !== 1 || !harvesterAvailable(state.spiceWindow))
diff --git a/game/engine.ts b/game/engine.ts
index 62e1a853..f7afea0f 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -400,6 +400,7 @@ import {
} from './residual-poison';
import {
createRemainingCohort,
+ ordinaryMovementAllowance,
validateCohortSelection,
type MovementCohort,
type OrnithopterMode,
@@ -681,6 +682,10 @@ export type Player = {
ecazRevivalCohort?: EcazRevivalCohort;
shipped: boolean;
moved: number;
+ /** Completed second groups replace one entitlement without consuming Hajr. */
+ ornithopterExtraMoves?: number;
+ /** Supplied Advanced Hajr timing: the owner's next voluntary action. */
+ hajrWindow?: {turn: number; move: number};
battleLosses: number;
kwisatz?: { dead: boolean; usedAt?: string; revivalCycle?: number };
prediction?: { faction: FactionId; turn: number };
@@ -6085,6 +6090,8 @@ function ornithopterDiscardSignature(
advisors: p.advisors,
noField: p.noField,
noFieldEvent: p.noFieldEvent,
+ ornithopterExtraMoves: p.ornithopterExtraMoves,
+ hajrWindow: p.hajrWindow,
moved: p.moved,
shipped: p.shipped,
})),
@@ -15568,9 +15575,11 @@ function movesAllowed(g: Game, p: Player) {
const flight = g.ornithopter;
return flight?.player === p.id && flight.turn === g.turn
? flight.startingMove + (flight.mode === 'twoGroups' ? 2 : 1)
- : g.hajr.includes(p.id)
- ? 2
- : 1;
+ : ordinaryMovementAllowance(g.hajr.includes(p.id), p.ornithopterExtraMoves);
+}
+function hajrAvailable(g: Game, p: Player) {
+ return !g.hajr.includes(p.id) && p.moved <= movesAllowed(g,p) &&
+ (!g.advanced || (p.hajrWindow?.turn === g.turn && p.hajrWindow.move === p.moved));
}
function emperorHomeworldMoveBlock(g: Game, p: Player): string | null {
if (!g.homeworlds?.custody || !g.advanced || p.faction !== 'emperor')
@@ -15637,8 +15646,8 @@ function ornithopterBlock(
if (g.ornithopter)
return 'Finish the already played Ornithopter movement first.';
if (currentGuildCunning(g,p.id)) return 'Guild Cunning combined with the physical Ornithopter card awaits its timing ruling.';
- if (p.moved || g.hajr.includes(p.id))
- return 'Ornithopter combined with prior moves or Hajr awaits its timing ruling.';
+ if (p.moved >= movesAllowed(g,p))
+ return 'Spend an unused ordinary or Hajr movement on Ornithopter.';
// Provisional composition: Kulon augments normal movement, not the
// Ornithopter card's explicit maximum of three territories.
return null;
@@ -16252,6 +16261,7 @@ function completeMove(g: Game, move: MovementOrder) {
finishShipmentPromises(g, p, null);
p.shipped = true;
p.moved++;
+ p.hajrWindow = {turn:g.turn,move:p.moved};
if (move.discoveryFlight) {
g.discoveries = nexusRule(() => completeDiscoveryFlight(g, move.discoveryFlight!, move));
log(g, `${p.name} used the carried Discovery Ornithopter for this movement and removed its token.`);
@@ -16271,6 +16281,7 @@ function completeMove(g: Game, move: MovementOrder) {
if (flight?.player === id) {
flight.completed++;
if (flight.completed === (flight.mode === 'twoGroups' ? 2 : 1)) {
+ if (flight.mode === 'twoGroups') p.ornithopterExtraMoves = (p.ornithopterExtraMoves ?? 0) + 1;
finishOrnithopter(g, 'move', completed);
return;
}
@@ -19640,6 +19651,8 @@ function commitMovementPhaseStart(g: Game, quote: MovementPhaseQuote) {
for (const p of g.players) {
p.shipped = false;
p.moved = 0;
+ delete p.ornithopterExtraMoves;
+ delete p.hajrWindow;
}
applyAdvisorReleases(g, quote.released);
g.decision = quote.decision;
@@ -28354,7 +28367,7 @@ function projectedNexusGuildCunning(g: Game, owner: string) {
else if (g.response || g.decision || g.truthtrance || g.phaseOpening || g.pendingKarama || g.pendingTreacheryDiscard ||
g.pendingNullentropy || g.pendingShipment || g.pendingHomeworldShipment || g.nexusTraitorPending || g.nexusCards.phase?.stage === 'drawing')
blocked = 'Finish the current interaction before declaring a second shipment.';
- else if (g.ornithopter || p.moved > (g.hajr.includes(owner) ? 2 : 1))
+ else if (g.ornithopter || p.ornithopterExtraMoves || p.moved > (g.hajr.includes(owner) ? 2 : 1))
blocked = 'Cunning combined with the physical Ornithopter movement card awaits its timing ruling.';
else if (g.nexusGuildCunningHistory?.some(r => r.receipt.turn === g.turn && r.receipt.owner === owner))
blocked = 'This turn’s Guild Cunning opportunity is already used.';
@@ -30730,6 +30743,12 @@ function applyActionInner(
);
requireRule(g.status !== 'finished', 'This game has ended.');
const t = action.type;
+ // Mandatory movement reactions and other seats' actions do not close the
+ // Advanced owner's immediate Hajr opportunity. Its next voluntary action does.
+ if (g.advanced && p.hajrWindow && !g.response && !g.decision && !g.truthtrance &&
+ !g.phaseOpening && !g.pendingTreacheryDiscard && t !== 'advanceBots' &&
+ !(t === 'card' && p.hand.some(card => card.id === action.card && card.effect === 'hajr')))
+ delete p.hajrWindow;
requireRule(t !== 'nexusIxianReplacementPass' && t !== 'nexusIxianReplacementUse',
'This purchased-card choice is no longer pending.');
requireRule(t !== 'guildBetrayalPass' && t !== 'guildBetrayalUse',
@@ -33995,6 +34014,7 @@ function applyActionInner(
finishShipmentPromises(g, p, null);
p.shipped = true;
p.moved++;
+ p.hajrWindow = {turn:g.turn,move:p.moved};
const name = (world: string) =>
world === 'homeworld:emperor' ? 'Kaitain' : 'Salusa Secundus';
const shifts = quote.populations.after
@@ -34592,6 +34612,8 @@ function applyActionInner(
advisors: p.advisors,
hand: p.hand,
movesAllowed: movesAllowed(g, p),
+ hajrUsed: g.hajr.includes(p.id),
+ hajrAvailable: hajrAvailable(g,p),
},
],
},
@@ -34601,11 +34623,8 @@ function applyActionInner(
if (availability && !availability.available)
throw new RuleError(availability.reason);
if (c.effect === 'hajr') {
- requireRule(
- g.ornithopter?.player !== id,
- 'Hajr combined with Ornithopter awaits its timing ruling.',
- );
g.hajr.push(id);
+ delete p.hajrWindow;
} else if (c.effect === 'ghola') {
applyGholaEffect(g, p, action, c.id);
} else if (c.effect === 'harvester') {
@@ -35585,6 +35604,8 @@ export function viewGame(state: Game, id: string) {
shipped: p.shipped,
moved: p.moved,
movesAllowed: movesAllowed(g, p),
+ hajrUsed: g.hajr.includes(p.id),
+ hajrAvailable: hajrAvailable(g,p),
revived: p.revived,
leaderRevived: p.leaderRevived,
gholaBlocked: Object.entries(p.gholaBlocked ?? {})
diff --git a/game/ornithopter.ts b/game/ornithopter.ts
index 8f380778..3ecdbe7c 100644
--- a/game/ornithopter.ts
+++ b/game/ornithopter.ts
@@ -7,6 +7,15 @@ export type MovementCohort = {
};
type Counts = Readonly>;
+/** A two-group flight replaces one ordinary entitlement. Only its completed
+ * extra group earns a physical-move credit; Hajr remains an independent move. */
+export function ordinaryMovementAllowance(hajrUsed: boolean, extraMoves = 0): number {
+ const limit = 1 + Number(hajrUsed) + extraMoves;
+ if (typeof hajrUsed !== 'boolean' || !Number.isSafeInteger(extraMoves) || extraMoves < 0 ||
+ !Number.isSafeInteger(limit)) throw new RangeError('Movement credits must be nonnegative safe integers.');
+ return limit;
+}
+
function validateCounts(counts: Counts, label: string) {
if (!counts || typeof counts !== 'object' || Array.isArray(counts))
throw new TypeError(`${label} must be a location count record.`);
diff --git a/game/reference.ts b/game/reference.ts
index 09c620b8..fb1e8d76 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -901,7 +901,8 @@ export const RULE_TOPICS: RuleTopic[] = [
'A concealed No-Field may move as a group member without exposing its value. A marker moved in the first group cannot also move in the second. Revealing an unmoved marker converts its remaining entitlement to its actual placed forces; revealing a moved marker does not give those forces another move.',
'A validated declaration moves the physical card from your hand to the public played-card area. It remains there through genuine CHOAM, Ixian or other arrival decisions and is discarded once when the chosen use ends. Random hand effects act on cards still in hand. Ending your movement closes any remaining card movement without undoing a completed group.',
'The saved event binds retries and the remaining original-group data. Refresh and reconnect preserve that event. After disposal, recovery preserves the completed forces and marker event before opening arrival decisions or advancing an early-ended turn; it does not move the group again. Missing saved group data cannot be reconstructed from merged forces and blocks the damaged continuation instead of allowing reuse.',
- 'Kulon and the fixed-range card now coexist under a provisional policy: the one-group move stays capped at three territories, while each normal-range group receives Kulon’s bonus. Hajr/prior-move composition and advanced advisor use remain guarded; these development guards are not printed prohibitions. Other unfinished entry and optional-module interactions remain unfinished; full Richese starts are still disabled.',
+ '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 plus a two-group flight permits three group movements; the Hajr move may reuse a card-moved group. Ending early abandons the unused card group and ends your turn. Multiple physical Hajr cards remain unfinished.',
+ '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. Advisor/card use, Guild Cunning/card and 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.',
],
related: [
@@ -915,32 +916,37 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Implementation',
status: 'Partial',
detail:
- 'Both movement modes, played-card custody, exact event continuation, typed original-group quotas, concealed-marker conversion and separate disposal recovery are integrated. Kulon coexistence uses a labeled provisional three-territory cap; Hajr/prior moves and advisor composition remain guarded.',
+ 'Both modes retain original event/cohort accounting. One unused normal/Hajr entitlement can start a flight; completed two-group credit preserves unused Hajr afterward. Advanced uses supplied immediate-after-play timing with an explicit online/card-group convention. Multiple Hajr and advisor composition remain unfinished.',
},
{
area: 'Player controls',
status: 'Partial',
detail:
- 'Uses the existing board source/destination and multi-sector controls, a mode selector, an inspector and remaining-group guidance. Automatic disposal adds no confirmation.',
+ 'Existing source/destination, typed group, mode and inspector controls consume current shared movement availability. Persistent text explains Hajr composition and Advanced timing; automatic disposal adds no confirmation.',
},
{
area: 'AI',
status: 'Partial',
detail:
- 'Four profiles use projected ranges and remaining ordinary/elite/marker quotas without hidden-value access. Complete expansion strength calibration remains unfinished.',
+ 'Four profiles use projected ranges, distinct group quotas and earned Hajr movement. Actual owned second-flight and extra-move candidates are legal; strategic tuning remains deferred.',
},
{
area: 'Documentation',
status: 'Partial',
detail:
- 'Explains both choices, independent card range, current normal range, distinct groups, public played-card custody and explicit unresolved combinations.',
+ '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',
status: 'Partial',
detail:
- 'Pure quota and engine checks cover twelve holders, both game modes, merged groups, exact marker events, stale state and interrupted faction decisions. Full expansion acceptance remains unverified.',
+ 'Hajr checkpoint adds frozen11-file132/132, types/lint/build and12 original-action/rendered-table programmes: actual paid cards/shipments, Basic and Advanced timing, prior normal movement, typed groups, native Baliset, voluntary window closure/reopening and early end. Four minimal policies retain both continuations. Framework aliases/CSS stubs mean no visual/browser/deployed acceptance; full combinations/assurance/modes remain open.',
evidence: [
+ 'game/ornithopter.ts',
+ 'game/card-availability.ts',
+ 'tests/fixture-ornithopter-hajr.ts',
+ 'tests/ornithopter-hajr-engine.test.ts',
+ 'docs/RICHESE_ORNITHOPTER_ENGINE_AUDIT.md',
'tests/ornithopter.test.ts',
'tests/ornithopter-engine.test.ts',
'tests/ornithopter-engine-review.test.ts',
@@ -1217,7 +1223,7 @@ export const RULE_TOPICS: RuleTopic[] = [
: definition.card.effect === 'nullentropyBox'
? 'Use the Nullentropy Box panel to pay two bank spice for a private discard search. Both modes provisionally permit full-hand retrieval as an atomic exchange that discards the Box and leaves the final hand within its current limit. Provisional Guild refund claims remain guarded.'
: definition.card.effect === 'ornithopter'
- ? 'Use the Ornithopter movement controls with a selected group and destination. Provisionally, Kulon coexists without raising the one-group maximum of three; both normal-range groups retain its bonus. Hajr/prior-move composition and advanced advisors remain guarded.'
+ ? 'Use the Ornithopter controls with one unused normal/Hajr movement and a selected group/destination. Two card groups stay distinct, while Hajr may reuse their forces afterward. Advanced Hajr follows immediate-after-movement timing. Kulon retains the fixed maximum three and extends both normal groups. Multiple Hajr, advisor/card and wider module combinations remain unfinished.'
: definition.card.effect === 'residualPoison'
? 'Use the Residual Poison panel before either combatant commits a leader. A shared preparation step provides an opportunity regardless of hand contents; advanced Harkonnen tables remain guarded pending the secret-captive ruling. In the explicit Semuta preview, a clean death with no readiness or inspection commitments pauses after public disposal for the neutral offer, then resumes the same preparation.'
: definition.card.effect === 'stoneBurner'
@@ -1251,7 +1257,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'The private semuta --discoveries entry composes original Richese/CHOAM and optional Ix Discovery setup, optionally unused Tech3+ and Advanced Strongholds, without Skills, Homeworlds, Nexus or E3. An entitled physical Truthtrance used as Karama offers its original fresh cost before the same price, cancellation, normal-auction or native special effect resumes. Emperor revival already happened and is not repeated. A physical Karama used as Truthtrance offers after its single definite answer. Recovery changes custody, never the printed face or original effect. Controlled Shrine setup evidence is not natural draw/entry or complete-mode acceptance.',
'The separate fresh semuta --homeworlds entry retains original Richese/CHOAM and classic Homeworld occupation setup with exact CHOAM and no other modules. Paid Box search, clean Distrans transfer, definite Truthtrance answers, used ordinary specials, paid Kaitain disposal, Tupile cleanup and printed Karama canceling native Guild/Emperor/Richese-purchase income offer Semuta. Native printed Kulon, Baliset, La La La, Trip to Gamont and Jubba costs also pause before their original effect: claim/pass adds one movement bonus, denies free allowance without charging/moving forces, stops the uncommitted Baliset move, returns one original board counter before the same CHOAM close, or applies protection before the same recorded storm moves. Printed Karama canceling these powers pauses separately before denial; the retained Worthless fuel is not a candidate. A canceled reactive Baliset resumes its original declared move once; canceled Kulon/La La La/Gamont/Jubba do not apply their effects. A recovered card grants no other faction CHOAM power. Kaitain private groups require commitment before selection; each Tupile holder has a separate cost. Native income denial retains payment and the original occupied-share allocation. Basic unresolved epochs, Kull, other producers and public starts remain guarded.',
'Legal completed CHOAM market sales also offer their used card in the Homeworld profile. Worthless cashout keeps its original two-spice credit; a non-Worthless duplicate keeps its three-spice credit and the original same-name witness stays held. The high-Tupile Worthless-sale prohibition still applies, including duplicate Worthless sales. Claim/pass cannot repeat payout, undo the actual reserve shipment or replace the original market close; no new bank-income interpretation is made.',
- 'Final Ornithopter disposal also offers its used card in the Homeworld profile after the one-group range-three or original two-group movement completes, including an early end with no useful original group. Claim/pass cannot move those counters again or replay the paid native shipment; prior-move/Hajr, advisor and Homeworld-transfer boundaries remain. Clean Sapho Once Around and unstarted Movement first/last costs likewise offer after reordering the finite unacted queue. Earlier bids/funding and physical storm order stay committed, and the same next bidder or combined turn resumes once. Cyclic auctions, partly completed turns, first-versus-Guild and late battle timing are not opened.',
+ 'Final Ornithopter disposal also offers its used card in the Homeworld profile after the one-group range-three or original two-group movement completes, including an early end with no useful original group. Claim/pass cannot move those counters again or replay the paid native shipment; unused movement entitlements retain the shared Hajr composition, while advisor and Homeworld-transfer boundaries remain. Clean Sapho Once Around and unstarted Movement first/last costs likewise offer after reordering the finite unacted queue. Earlier bids/funding and physical storm order stay committed, and the same next bidder or combined turn resumes once. Cyclic auctions, partly completed turns, first-versus-Guild and late battle timing are not opened.',
'Original Homeworld battle disposal offers accepted losing cards, later mandatory winning cards and optional winner-selected cards as separate fresh events. Commit and exact selection recover at most one other player’s newly discarded card; unselected cards stay in the pile. The original paid deployments, accepted result, leader deaths and casualty obligations remain committed, with the same native winner cleanup and rewards afterward. A recovered card cannot replay combat or consume another original opportunity.',
'Clean Homeworld Residual Poison disposal offers its used card after exactly one native random eligible leader enters the Tanks, with no spice bounty or force loss; claim/pass resumes the original shared preparation and does not redraw the victim. Early Sapho aggressor disposal likewise keeps the original participants/chooser and changed ordinary tie priority, while clean between-battle first/last disposal keeps earlier resolved battles and only reorders remaining chooser opportunities. Physical storm order is unchanged. Readied or inspected Poison costs, late Sapho timing and Advanced Harkonnen captive disclosure remain outside these clean reactions.',
'Printed Karama canceling original CHOAM opening income or native Inflation placement also offers its used card in the Homeworld profile. Claim/pass completes the same denial once: unpaid income stays withheld, and the original placement attempt remains spent with no token. Clean normal-lot printed Karama purchase and free winning-bid payment likewise offer their costs before exactly one original reserved card arrives without spice payment. Earlier bids, the normal pool, Richese cache, original wallets and later native controls stay committed. Richese lots, BG Worthless conversion, Kull and other cancellations remain separate.',
@@ -2556,14 +2562,15 @@ export const RULE_TOPICS: RuleTopic[] = [
id: 'card-hajr',
title: 'Hajr',
category: 'Cards',
- coverage: 'Implemented',
+ coverage: 'Partial',
summary: 'Make an additional force move during your movement turn.',
steps: [
- 'Play during your shipment and movement turn.',
- 'Make a second legal group move. Normal source, range and destination restrictions still apply.',
+ 'In Basic, play during your shipment and movement turn. Advanced requires the immediate opportunity after a completed Force Movement and its required arrival decisions, before your next voluntary game action.',
+ 'Make one additional legal group move, using the same or another group. Normal source, range and destination restrictions still apply. A completed Ornithopter group provisionally supplies the Advanced timing opportunity.',
+ 'Ornithopter replaces one unused movement entitlement, so one Hajr and a two-group flight permit three group movements. Groups must differ within that flight, while the later Hajr move may reuse them. Multiple physical Hajr plays in one turn remain an unfinished prototype boundary; the supplied passage does not establish a once-per-turn prohibition.',
'A saved play preserves the extra movement allowance. Recovery neither grants another move nor finishes your movement turn.',
],
- related: ['movement'],
+ related: ['movement', 'ornithopter-movement'],
},
{
id: 'card-ghola',
diff --git a/tests/card-availability.test.ts b/tests/card-availability.test.ts
index de0dc9a1..4d1382f9 100644
--- a/tests/card-availability.test.ts
+++ b/tests/card-availability.test.ts
@@ -153,7 +153,7 @@ void test('Weather Control stays available after dials and confirmations, but va
'omitting selection asks about timing only',
);
});
-void test('Hajr requires the active movement owner and cannot grant a third move', () => {
+void test('Hajr requires the active movement owner and grants only one additional ordinary entitlement', () => {
agrees(
'hajr',
(g) => {
diff --git a/tests/fixture-ornithopter-hajr.ts b/tests/fixture-ornithopter-hajr.ts
new file mode 100644
index 00000000..90f85fcf
--- /dev/null
+++ b/tests/fixture-ornithopter-hajr.ts
@@ -0,0 +1,160 @@
+import assert from 'node:assert/strict';
+import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
+import { gameDistance, location, splitLocation, TERRITORIES } from '../game/board';
+import type { OrnithopterMode } from '../game/ornithopter';
+import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
+import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
+
+export const HAJR_ORNITHOPTER = 'richese-ornithopter';
+export type OrnithopterHajrStep = { actor: string; action: Action };
+export type OrnithopterHajrOptions = { advanced?: boolean; interruption?: boolean; amount?: 3 | 4 };
+export const ornithopterHajrPlayer = (game: Game, actor = 'emperor') => {
+ const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
+};
+export const ornithopterHajrClean = (game: Game) => !game.response && !game.phaseOpening &&
+ !game.decision && !game.truthtrance && !game.pendingTreacheryDiscard;
+export function actOrnithopterHajr(game: Game, actor: string, action: Action,
+ actions: OrnithopterHajrStep[] = []): Game {
+ actions.push({ actor, action: structuredClone(action) });
+ return withClassicDiscoveryNexusLottery(() => applyAction(game, actor, action));
+}
+function nextOrnithopterHajrStep(game: Game): OrnithopterHajrStep | null {
+ if (!game.response && !game.phaseOpening && game.decision) {
+ const decision = game.decision;
+ if (decision.kind === 'richeseCache') return { actor: decision.player, action: { type: 'decision',
+ event: game.richeseBidding!.event, card: HAJR_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 === 'choamMovement') return { actor: decision.player, action: { type: 'decision', decline: true } };
+ }
+ if (ornithopterHajrClean(game) && game.richeseAuction?.cardId === HAJR_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 } };
+ if (ornithopterHajrClean(game) && game.auction?.active === 'emperor' && game.auction.bid === 0 &&
+ game.auction.cards[game.auction.index].effect === 'hajr')
+ return { actor: 'emperor', action: { type: 'bid', amount: 1, allyPayment: 0 } };
+ return nextRicheseStrongholdsNativeStep(game);
+}
+export function advanceOrnithopterHajr(game: Game, until: (state: Game) => boolean,
+ actions: OrnithopterHajrStep[] = []): Game {
+ for (let count = 0; count < 2000; count++) {
+ if (until(game)) return game;
+ assert.notEqual(game.status, 'finished', 'The original Ornithopter/Hajr programme ended before its next owned control');
+ const step = nextOrnithopterHajrStep(game); assert.ok(step, 'Complete the real private plan before continuing');
+ game = actOrnithopterHajr(game, step.actor, step.action, actions);
+ }
+ throw Error('The original Ornithopter/Hajr programme did not reach its finite boundary');
+}
+
+/** Original host, native cache/ordinary paid lots and actual reserve shipments.
+ * An optional native CHOAM arrival interruption is funded before the pilot's
+ * turn; no dealt card, board counter, wallet, clock or reaction is installed. */
+export function createOrnithopterHajrFixture(options: OrnithopterHajrOptions = {}) {
+ const advanced = options.advanced ?? false;
+ let game = createGame('ORNITHOPTERHAJR', newPlayer('richese', 'Richese', 'richese'), advanced, ['choam']);
+ const seats = options.interruption ? ['choam', 'emperor', 'guild'] as const : ['emperor', 'guild'] as const;
+ for (const faction of seats) joinGame(game, newPlayer(faction, faction, faction));
+ const actions: OrnithopterHajrStep[] = [];
+ const act = (actor: string, action: Action) => { game = actOrnithopterHajr(game, actor, action, actions); };
+ const reach = (until: (state: Game) => boolean) => { game = advanceOrnithopterHajr(game, until, actions); };
+ for (const player of game.players) act(player.id, { type: 'ready' });
+ const lobby = structuredClone(game);
+ act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
+ const setup = structuredClone(game);
+ reach(state => state.status === 'playing');
+ const afterSetup = structuredClone(game), front: Game['spiceDeck'] = [];
+ for (let count = 0; count < (advanced ? 2 : 1); count++) {
+ const at = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(at >= 0);
+ front.push(game.spiceDeck.splice(at, 1)[0]);
+ }
+ game.spiceDeck.unshift(...front);
+ reach(state => state.phase === 2 && ornithopterHajrClean(state));
+ const at = game.deck.findIndex(card => card.effect === 'hajr'); assert.ok(at >= 0, 'The original Hajr must remain undealt');
+ const hajr = game.deck[at].id; game.deck.unshift(game.deck.splice(at, 1)[0]);
+ const beforePurchases = structuredClone(game);
+ reach(state => ornithopterHajrPlayer(state).hand.some(card => card.id === HAJR_ORNITHOPTER) &&
+ ornithopterHajrPlayer(state).hand.some(card => card.id === hajr));
+ const acquired = structuredClone(game), shipments: { before: Game; step: OrnithopterHajrStep; after: Game }[] = [];
+ const ship = (actor: string, to: string, sector: number, amount: number, elite = 0) => {
+ const before = structuredClone(game), step: OrnithopterHajrStep = { actor,
+ action: { type: 'ship', territory: to, sector, amount, elite, allyPayment: 0 } };
+ act(actor, step.action); reach(ornithopterHajrClean);
+ shipments.push({ before, step, after: structuredClone(game) });
+ };
+ if (options.interruption) {
+ reach(state => state.phase === 5 && state.active === 'choam' && ornithopterHajrClean(state));
+ ship('choam', 'oh_gap', 9, 1);
+ }
+ reach(state => state.phase === 5 && state.active === 'emperor' && ornithopterHajrClean(state));
+ ship('emperor', 'imperial_basin', 9, options.amount ?? 4, advanced ? 2 : 0);
+ return { game, lobby, setup, afterSetup, beforePurchases, acquired, hajr, actions, shipments,
+ owner: 'emperor', source: 'imperial_basin:9', interruption: options.interruption ?? false,
+ staging: ['Original native setup and paid cache/ordinary auction acquire both cards. Only unopened Hajr/Spice positions and scalar lotteries are selected; original funded shipments provide every physical source.'] };
+}
+export type OrnithopterHajrFixture = ReturnType;
+
+/** Use current physical typed custody and real storm geography, without city
+ * access or any staged speed bonus. A forced destination supports the genuine
+ * native CHOAM interruption; other targets are empty printed sand or rock. */
+export function ornithopterHajrMove(game: Game, from: string, options: {
+ range?: number; mode?: OrnithopterMode; to?: string; elite?: number;
+} = {}): Action {
+ const own = ornithopterHajrPlayer(game), range = options.range ?? 1;
+ assert.ok((own.forces[from] ?? 0) > 0);
+ const to = options.to ?? TERRITORIES.filter(place => place.type === 'sand' || place.type === 'rock')
+ .flatMap(place => place.sectors.map(sector => location(place.id, sector)))
+ .find(to => game.players.every(player => Object.entries(player.forces).every(([key, count]) =>
+ count === 0 || splitLocation(key).territory !== splitLocation(to).territory)) &&
+ gameDistance(game, from, to, key => splitLocation(key).sector === game.storm) === range);
+ assert.ok(to, `The original group needs an empty printed range-${range} destination`);
+ const flight = viewGame(game, 'emperor').ornithopter?.active;
+ return { type: 'move', from, amount: 1, elite: options.elite ?? ((own.elites?.forces[from] ?? 0) > 0 ? 1 : 0),
+ ...splitLocation(to), ...(options.mode ? { movementCard: HAJR_ORNITHOPTER, ornithopter: options.mode }
+ : flight ? { ornithopterEvent: flight.event } : {}) };
+}
+export function playOrnithopterHajr(game: Game, fixture: OrnithopterHajrFixture,
+ actions: OrnithopterHajrStep[] = []): Game {
+ game = actOrnithopterHajr(game, fixture.owner, { type: 'card', card: fixture.hajr }, actions);
+ return advanceOrnithopterHajr(game, ornithopterHajrClean, actions);
+}
+
+/** Complete actual card groups then the independent Hajr continuation. Before
+ * is Basic face timing; Advanced uses Hajr after a completed Force Movement.
+ * A prior ordinary move is never reclassified as part of the later flight. */
+export function completeOrnithopterHajr(fixture: OrnithopterHajrFixture, options: {
+ mode?: OrnithopterMode; timing?: 'before' | 'during' | 'after'; priorNormal?: boolean;
+} = {}) {
+ const mode = options.mode ?? 'twoGroups', timing = options.timing ?? 'after';
+ assert.ok(mode === 'twoGroups' || timing !== 'during', 'A one-group flight completes before another timing window');
+ assert.ok(!fixture.game.advanced || timing !== 'before', 'Advanced Hajr follows an actual completed Force Movement');
+ const actions: OrnithopterHajrStep[] = [], moves: { before: Game; step: OrnithopterHajrStep; after: Game }[] = [];
+ let game = structuredClone(fixture.game), prior: Game | null = null, pendingArrival: Game | null = null;
+ const move = (from: string, settings: Parameters[2] = {}) => {
+ const before = structuredClone(game), step: OrnithopterHajrStep = { actor: fixture.owner, action: ornithopterHajrMove(game, from, settings) };
+ game = actOrnithopterHajr(game, step.actor, step.action, actions);
+ if (game.decision?.kind === 'choamMovement') pendingArrival = structuredClone(game);
+ game = advanceOrnithopterHajr(game, ornithopterHajrClean, actions);
+ moves.push({ before, step, after: structuredClone(game) });
+ return location(String(step.action.territory), Number(step.action.sector));
+ };
+ let firstSource = fixture.source;
+ if (options.priorNormal) {
+ firstSource = move(fixture.source); prior = structuredClone(game);
+ game = playOrnithopterHajr(game, fixture, actions);
+ } else if (timing === 'before') game = playOrnithopterHajr(game, fixture, actions);
+ const beforeFlight = structuredClone(game), firstDestination = move(firstSource, {
+ mode, range: mode === 'range3' ? 3 : 1,
+ ...(fixture.interruption ? { to: 'oh_gap:9' } : {}),
+ });
+ const first = structuredClone(game);
+ if (!options.priorNormal && timing === 'during') game = playOrnithopterHajr(game, fixture, actions);
+ if (mode === 'twoGroups') move(fixture.source);
+ const flightComplete = structuredClone(game);
+ if (!options.priorNormal && timing === 'after') game = playOrnithopterHajr(game, fixture, actions);
+ const beforeExtra = structuredClone(game);
+ if (!options.priorNormal) move(firstDestination);
+ const completed = structuredClone(game);
+ game = actOrnithopterHajr(game, fixture.owner, { type: 'endMovement' }, actions);
+ return { game, completed, prior, beforeFlight, first, flightComplete, beforeExtra, firstDestination,
+ pendingArrival, moves, actions, mode, timing };
+}
diff --git a/tests/ornithopter-bots.test.ts b/tests/ornithopter-bots.test.ts
index 19911aeb..3ce22f78 100644
--- a/tests/ornithopter-bots.test.ts
+++ b/tests/ornithopter-bots.test.ts
@@ -207,6 +207,7 @@ void test('all profiles honor mode/ownership guards, existing interaction priori
},
(g: Game) => {
g.hajr = ['p'];
+ g.players[0].moved = 2;
},
(g: Game) => {
g.active = 'o';
diff --git a/tests/ornithopter-engine.test.ts b/tests/ornithopter-engine.test.ts
index cc6cd205..bb71510a 100644
--- a/tests/ornithopter-engine.test.ts
+++ b/tests/ornithopter-engine.test.ts
@@ -67,11 +67,11 @@ void test('played card escrow, owner-only remaining quotas and ended movement su
assert.equal(ended.active,'q');
});
void test('invalid source, blocked route, committed card and explicitly unresolved combinations are immutable',()=>{
- for(const change of ['range','storm','prior','hajr','advisor','gift','wrongMode'] as const){
+ for(const change of ['range','storm','prior','advisor','gift','wrongMode'] as const){
const g=fixture(change==='advisor'?'beneGesserit':'emperor',change==='advisor');
let key=destination(g,origin,3);const extra:Partial={movementCard:card,ornithopter:'range3'};
if(change==='range')key=destination(g,origin,4);if(change==='storm')g.storm=splitLocation(key).sector;
- if(change==='prior')g.players[0].moved=1;if(change==='hajr')g.hajr=['p'];
+ if(change==='prior')g.players[0].moved=1;
if(change==='advisor'){g.players[0].advisors={red_chasm:{}};g.players[1].forces={[origin]:1};}
if(change==='wrongMode')extra.ornithopter='both';
if(change==='gift')g.pendingRicheseGift={event:'reserved',intent:{owner:'p',recipient:'q',cardId:card},turn:g.turn,phase:g.phase,resume:{response:null,decision:null,pendingKarama:null,phaseOpening:null}} as Game['pendingRicheseGift'];
diff --git a/tests/ornithopter-hajr-engine.test.ts b/tests/ornithopter-hajr-engine.test.ts
new file mode 100644
index 00000000..deafe0ab
--- /dev/null
+++ b/tests/ornithopter-hajr-engine.test.ts
@@ -0,0 +1,172 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import { viewGame, type Game } from '../game/engine';
+import { botActions } from '../game/bots';
+import { location } from '../game/board';
+import {
+ HAJR_ORNITHOPTER, actOrnithopterHajr, advanceOrnithopterHajr, completeOrnithopterHajr,
+ createOrnithopterHajrFixture, ornithopterHajrClean, ornithopterHajrMove,
+ ornithopterHajrPlayer as player, playOrnithopterHajr, type OrnithopterHajrFixture,
+} from './fixture-ornithopter-hajr';
+
+const ownView = (game: Game) => viewGame(game, 'emperor').players.find(player => player.id === 'emperor')!;
+function source(f: OrnithopterHajrFixture) {
+ assert.ok(f.actions.some(step => step.actor === f.owner && step.action.type === 'richeseBid' && step.action.amount === 1));
+ assert.ok(f.actions.some(step => step.actor === f.owner && step.action.type === 'bid' && step.action.amount === 1));
+ assert.ok(player(f.game).hand.some(card => card.id === HAJR_ORNITHOPTER));
+ assert.ok(player(f.game).hand.some(card => card.id === f.hajr));
+ const shipment = f.shipments.find(shipment => shipment.step.actor === f.owner)!;
+ assert.equal(player(shipment.before).spice - player(shipment.after).spice, Number(shipment.step.action.amount) * 2,
+ 'The real source is an originally funded desert shipment, not a staged board group');
+ assert.equal(player(f.game).forces[f.source], Number(shipment.step.action.amount));
+ assert.equal(ownView(f.game).moved, 0);
+ assert.equal(ownView(f.game).movesAllowed, 1);
+ assert.equal(ownView(f.game).hajrUsed, false);
+ assert.equal(ownView(f.game).hajrAvailable, !f.game.advanced);
+}
+function settled(f: OrnithopterHajrFixture, completed: Game, moves: number) {
+ assert.equal(player(completed).moved, moves);
+ assert.equal(ownView(completed).movesAllowed, moves);
+ assert.equal(ownView(completed).hajrUsed, true);
+ assert.equal(ownView(completed).hajrAvailable, false);
+ assert.equal(completed.ornithopter ?? null, null);
+ assert.equal(completed.discard.filter(card => card.id === HAJR_ORNITHOPTER).length, 1);
+ assert.equal(completed.discard.filter(card => card.id === f.hajr).length, 1);
+ assert.equal(player(completed).reserves, player(f.game).reserves);
+ assert.equal(player(completed).spice, player(f.game).spice);
+ assert.equal(player(completed).tanks, player(f.game).tanks);
+ assert.equal(Object.values(player(completed).forces).reduce((sum, count) => sum + count, 0),
+ Object.values(player(f.game).forces).reduce((sum, count) => sum + count, 0));
+ assert.throws(() => actOrnithopterHajr(completed, f.owner, { type: 'card', card: f.hajr }),
+ 'The consumed physical Hajr cannot be replayed to add a fourth move');
+}
+
+void test('genuine Basic paid Ornithopter composes with Hajr before, during or after two distinct card groups', () => {
+ for (const timing of ['before', 'during', 'after'] as const) {
+ const f = createOrnithopterHajrFixture();
+ source(f);
+ const result = completeOrnithopterHajr(f, { timing });
+ assert.equal(result.moves.length, 3);
+ assert.equal(player(result.first).moved, 1);
+ assert.equal(result.first.ornithopter!.completed, 1);
+ assert.equal(player(result.flightComplete).moved, 2);
+ assert.equal(result.flightComplete.ornithopter ?? null, null);
+ assert.equal(ownView(result.beforeExtra).movesAllowed, 3);
+ assert.equal(result.moves[2].step.action.from, result.firstDestination,
+ 'The independent Hajr move may reuse the first card group after the flight ends');
+ assert.equal(result.moves[2].step.action.ornithopterEvent, undefined);
+ assert.notEqual(result.game.active, f.owner);
+ settled(f, result.completed, 3);
+ }
+});
+
+void test('genuine Advanced Hajr follows a completed typed card group and retains its later ordinary move', () => {
+ const f = createOrnithopterHajrFixture({ advanced: true });
+ source(f);
+ assert.throws(() => actOrnithopterHajr(f.game, f.owner, { type: 'card', card: f.hajr }), /Force Movement|move|movement|immediately/i);
+ const result = completeOrnithopterHajr(f, { timing: 'during' });
+ assert.equal(ownView(result.first).hajrAvailable, true);
+ assert.equal(result.moves[0].step.action.elite, 1);
+ assert.equal(result.moves[1].step.action.elite, 1);
+ assert.equal(result.moves[2].step.action.elite, 1);
+ assert.equal(Object.values(player(result.completed).elites!.forces).reduce((sum, count) => sum + count, 0), 2);
+ settled(f, result.completed, 3);
+});
+
+void test('a real ordinary move followed by Hajr grants a fresh two-group flight without retroactively counting that move', () => {
+ for (const advanced of [false, true]) {
+ const f = createOrnithopterHajrFixture({ advanced });
+ source(f);
+ const result = completeOrnithopterHajr(f, { timing: 'during', priorNormal: true });
+ assert.equal(player(result.prior!).moved, 1);
+ assert.equal(result.prior!.ornithopter ?? null, null);
+ assert.equal(ownView(result.beforeFlight).movesAllowed, 2);
+ assert.equal(result.first.ornithopter!.startingMove, 1);
+ assert.equal(result.first.ornithopter!.completed, 1);
+ assert.equal(player(result.first).moved, 2);
+ assert.equal(result.moves[1].step.action.from,
+ location(String(result.moves[0].step.action.territory), Number(result.moves[0].step.action.sector)),
+ 'Units moved by the earlier ordinary entitlement may join a new flight');
+ assert.equal(result.moves.length, 3);
+ settled(f, result.completed, 3);
+ }
+});
+
+void test('a genuine range-three card flight replaces one entitlement while same-flight repeated groups remain illegal', () => {
+ const f = createOrnithopterHajrFixture();
+ source(f);
+ const ranged = completeOrnithopterHajr(f, { mode: 'range3', timing: 'after' });
+ assert.equal(ranged.moves.length, 2);
+ assert.equal(player(ranged.flightComplete).moved, 1);
+ settled(f, ranged.completed, 2);
+ const firstMove = ornithopterHajrMove(f.game, f.source, { mode: 'twoGroups' });
+ let game = actOrnithopterHajr(structuredClone(f.game), f.owner, firstMove);
+ game = advanceOrnithopterHajr(game, ornithopterHajrClean);
+ const firstDestination = location(String(firstMove.territory), Number(firstMove.sector));
+ const repeated = ornithopterHajrMove(game, firstDestination);
+ assert.throws(() => actOrnithopterHajr(game, f.owner, repeated), /cohort|unmoved|original|force/i);
+ game = playOrnithopterHajr(game, f);
+ game = actOrnithopterHajr(game, f.owner, ornithopterHajrMove(game, f.source));
+ game = advanceOrnithopterHajr(game, ornithopterHajrClean);
+ assert.equal(game.ornithopter ?? null, null);
+ assert.equal(ownView(game).movesAllowed, 3);
+ game = actOrnithopterHajr(game, f.owner, ornithopterHajrMove(game, firstDestination));
+ game = advanceOrnithopterHajr(game, ornithopterHajrClean);
+ settled(f, game, 3);
+});
+
+void test('a real Advanced Baliset pause preserves Hajr timing until an owner action closes it and the next group reopens it', () => {
+ const f = createOrnithopterHajrFixture({ advanced: true, interruption: true, amount: 3 });
+ source(f);
+ assert.equal(player(f.game, 'choam').forces['oh_gap:9'], 1);
+ assert.ok(player(f.game).spice >= 1, 'The smaller real shipment leaves its original bribe funding');
+ const firstMove = ornithopterHajrMove(f.game, f.source, { mode: 'twoGroups', to: 'oh_gap:9' });
+ let game = actOrnithopterHajr(structuredClone(f.game), f.owner, firstMove);
+ assert.equal(game.decision?.kind, 'choamMovement');
+ assert.equal(game.decision?.player, 'choam');
+ assert.equal(player(game).moved, 0);
+ assert.equal(game.ornithopter!.completed, 0);
+ assert.throws(() => actOrnithopterHajr(game, f.owner, { type: 'card', card: f.hajr }));
+ game = actOrnithopterHajr(game, 'choam', { type: 'decision', decline: true });
+ game = advanceOrnithopterHajr(game, ornithopterHajrClean);
+ assert.equal(player(game).moved, 1);
+ assert.equal(ownView(game).hajrAvailable, true);
+ game = actOrnithopterHajr(game, f.owner, { type: 'bribe', target: 'guild', amount: 1 });
+ assert.equal(ownView(game).hajrAvailable, false);
+ assert.throws(() => actOrnithopterHajr(game, f.owner, { type: 'card', card: f.hajr }), /Force Movement|move|movement|immediately/i);
+ game = actOrnithopterHajr(game, f.owner, ornithopterHajrMove(game, f.source));
+ game = advanceOrnithopterHajr(game, ornithopterHajrClean);
+ assert.equal(player(game).moved, 2);
+ assert.equal(ownView(game).hajrAvailable, true);
+ game = playOrnithopterHajr(game, f);
+ game = actOrnithopterHajr(game, f.owner, ornithopterHajrMove(game, 'oh_gap:9'));
+ game = advanceOrnithopterHajr(game, ornithopterHajrClean);
+ assert.equal(player(game).moved, 3);
+ assert.equal(ownView(game).movesAllowed, 3);
+ assert.equal(player(game).spice, player(f.game).spice - 1);
+ assert.equal(game.discard.filter(card => card.id === f.hajr).length, 1);
+ assert.equal(game.discard.filter(card => card.id === HAJR_ORNITHOPTER).length, 1);
+});
+
+void test('all four minimal policies retain legal second-flight and earned Hajr movement candidates from original pending views', () => {
+ const f = createOrnithopterHajrFixture();
+ let first = actOrnithopterHajr(structuredClone(f.game), f.owner,
+ ornithopterHajrMove(f.game, f.source, { mode: 'twoGroups' }));
+ first = advanceOrnithopterHajr(first, ornithopterHajrClean);
+ first = playOrnithopterHajr(first, f);
+ const complete = actOrnithopterHajr(first, f.owner, ornithopterHajrMove(first, f.source));
+ const ready = advanceOrnithopterHajr(complete, ornithopterHajrClean);
+ assert.equal(ownView(ready).movesAllowed, 3);
+ for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
+ for (const game of [first, ready]) {
+ const view = viewGame(game, f.owner);
+ view.players.find(player => player.id === f.owner)!.bot = difficulty;
+ const action = botActions(view).find(action => action.type === 'move');
+ assert.ok(action, `${difficulty} must keep an owned legal ${game.ornithopter ? 'second flight' : 'Hajr'} movement`);
+ assert.equal(typeof action.ornithopterEvent === 'string', !!game.ornithopter);
+ const moved = actOrnithopterHajr(structuredClone(game), f.owner, action);
+ assert.ok(player(moved).moved === player(game).moved + 1 || moved.response || moved.decision,
+ 'Each real policy candidate completes its group or enters its original reaction');
+ }
+ }
+});
diff --git a/tests/semuta-homeworld-ornithopter.test.ts b/tests/semuta-homeworld-ornithopter.test.ts
index bcf3a793..321ad8b6 100644
--- a/tests/semuta-homeworld-ornithopter.test.ts
+++ b/tests/semuta-homeworld-ornithopter.test.ts
@@ -163,7 +163,7 @@ for (const advanced of [false, true]) for (const { mode, early } of paths) {
});
}
-for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} Homeworld original prior-move, Hajr and fixed maximum-three guards remain real`, () => {
+for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} Homeworld prior move needs an unused entitlement and Hajr preserves the fixed maximum three`, () => {
const f = createSemutaHomeworldOrnithopterFixture('range3', advanced);
const before = structuredClone(f.beforeFirstMove);
assert.throws(() => applyAction(before, f.owner, { ...f.firstMove, ...splitLocation(f.tooFarDestination) }));
@@ -174,14 +174,20 @@ for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basi
assert.equal(player(moved, f.owner).hand.some(c => c.id === f.card), true);
assert.throws(() => applyAction(moved, f.owner, { ...f.firstMove, from: f.secondDestination,
...splitLocation(f.origin) }));
- const hajrGame = structuredClone(before);
+ const hajrGame = structuredClone(advanced ? moved : before);
const hajr = holdSemutaHomeworldChoamCard(hajrGame, f.owner, 'Hajr');
const played = applyAction(hajrGame, f.owner, { type: 'card', card: hajr });
const allowed = advanceSemutaHomeworldChoam(played,
g => !g.pendingTreacheryDiscard && !g.response && !g.decision && !g.phaseOpening);
- assert.equal(player(allowed, f.owner).moved, 0);
+ assert.equal(player(allowed, f.owner).moved, advanced ? 1 : 0);
assert.ok(allowed.hajr.includes(f.owner));
- assert.throws(() => applyAction(allowed, f.owner, f.firstMove));
assert.equal(player(allowed, f.owner).hand.some(c => c.id === f.card), true);
+ const declared = applyAction(allowed, f.owner, advanced
+ ? { ...f.firstMove, from: f.secondDestination, ...splitLocation(f.origin) }
+ : f.firstMove);
+ const completed = advanceSemutaHomeworldChoam(declared,
+ g => !g.pendingTreacheryDiscard && !g.response && !g.decision && !g.phaseOpening);
+ assert.equal(player(completed, f.owner).moved, advanced ? 2 : 1);
assertCounters(allowed, f.setup);
+ assertCounters(completed, f.setup);
});