Allow replay of the recovered physical Hajr

Count one movement grant per accepted Hajr play after original paid Box recovery. Preserve physical card custody, Basic timing and fresh Advanced post-movement opportunities. Carry unused counts through Guild Cunning with counted declarations and signed final history while retaining legacy receipt compatibility.

Validation: frozen12-file102/102, types/lint/build and two original-action/rendered-table programmes; four minimal policies retain third moves in both modes. Real cache and normal purchases, paid shipment and Box2 recovery reuse the single original Hajr. Replay is an explicit source interpretation. Native Cunning replay has pure/related evidence; wider combinations, final assurance and deployed acceptance remain open. No saved-game, reset, restart or deployment operation.
This commit is contained in:
Quintine committed 2026-10-18 12:33:50 +11:00
1 parent 72e77d7f4d
commit b82441c834
17 files changed
+679 -65

No files matched your search

+3
View File
@@ -39,6 +39,9 @@ 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.
The single physical Hajr can be played again after legitimate
[paid Box recovery](docs/RICHESE_ORNITHOPTER_ENGINE_AUDIT.md#recovered-hajr-replay--18-october-2026).
Advanced still requires a fresh post-movement opportunity.
Before starting, open **Configure** on an AI seat to change its difficulty,
faction or player circle without replacing it. Changes save immediately and
+1
View File
@@ -5005,6 +5005,7 @@ export function GameTable({
</p>
{g.ornithopter && <p className="fine">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.</p>}
{g.advanced && me.hand?.some(card => card.effect === 'hajr') && <p className="fine">Play Hajr immediately after a completed Force Movement and its required arrival decisions, before your next voluntary action.</p>}
{!!me.hajrPlays && <p className="fine">{me.hajrPlays} Hajr play{me.hajrPlays === 1 ? '' : 's'} this turn. Each legal play grants one additional movement. A recovered Hajr must return to your hand; Advanced requires a fresh post-move opportunity.</p>}
{transportPreview && (
<>
<ShipmentQuote
+20
View File
@@ -16,6 +16,26 @@ open mode gates.
## Current checkpoint and work
**Recovered Hajr replay — 18 October. Prototyped / Partial.** The deck contains
one Hajr. Real paid Box recovery now permits playing that same held card again,
counting one extra allowance per accepted use. Advanced needs a fresh completed
movement; Basic retains broader timing. Legacy used markers/receipt bytes remain
compatible, while new Cunning declarations/count snapshots preserve unused Hajr
allowances without another ordinary move. Replay is an explicit interpretation.
Two disjoint lanes plus owner integration/review pass frozen **12 files102/102**,
types/lint/build and two original-action/rendered-table programmes. Actual cards
and shipment use38 Basic/45 Advanced source actions plus seven continuations.
The original sole-discard search pays2 and retrieves automatically; no fabricated
copy or manual selection. Both finish three ordinary moves, with four legal
policies in each mode. A historical Advanced Cunning case now earns Hajr before
its stopped second shipment, and respects the resulting normal range.
Native Cunning replay has pure/related regression evidence, not an original
paid-Box Nexus programme. Wider combinations, full modes and final assurance
remain open; no visual/browser/deployed acceptance, saved-room/reset/restart or
deployment operation.
[Source, counted protocol and proof](RICHESE_ORNITHOPTER_ENGINE_AUDIT.md#recovered-hajr-replay--18-october-2026).
**Ornithopter / Hajr — 18 October. Prototyped / Partial.** New card uses replace
one unused movement by the selected mode and retain the independent Hajr move.
Prior ordinary moves stay spent; groups are distinct within the flight and may
+20
View File
@@ -1,5 +1,25 @@
# Dune implementation status
## 18 October 2026 — Recovered physical Hajr replay
The original inventory has one Hajr; genuine paid Box retrieval now allows that
same physical card to grant another move when legally replayed. Counts preserve
Basic timing and Advanced's fresh completed-movement opportunity. Cunning
forfeits the ordinary slot while carrying counted unused grants; new v2 receipts
and signed final counts retain old v1 compatibility. Replay remains an explicit
composition interpretation, not a new publisher FAQ.
Two exclusive lanes plus owner integration/review pass frozen12-file102/102,
types/lint/build and two actual-source/rendered-table programmes. Actual cache/
normal purchases, paid shipment and Box2 retrieval use38/45 source plus7 actions;
one original Hajr finishes three moves. All four policies retain a third move in
both modes. A historical Advanced Cunning case now earns its allowance at the
proper movement and uses a legal post-city route; nullable search typing fixed.
[Canonical source, exact proof and limits](RICHESE_ORNITHOPTER_ENGINE_AUDIT.md#recovered-hajr-replay--18-october-2026).
Native Nexus replay has quote/regression rather than complete original-Box proof;
other producers, full combinations/assurance/modes and deployed acceptance stay
open. No saved-room/reset/restart/deployment operation.
## 18 October 2026 — Ornithopter and unused Hajr movement
One unused normal/Hajr entitlement now supports either card mode without losing
+13 -1
View File
@@ -83,7 +83,19 @@ Truthtrance questions and fulfillment searches recognize the second shipment as
## Hajr and bot policy
No ordinary movement follows the extra shipment. Only an unused Hajr move is available, and a Hajr move already consumed cannot create a third move. The source's exception retains the same active Guild player through the optional extra move. Native city ornithopter range still applies normally; combining this with the separate physical Ornithopter Treachery Card remains unavailable.
No ordinary movement follows the extra shipment. Every remaining movement must
come from an actually earned unused Hajr allowance. A consumed grant cannot be
spent twice; a legally recovered and replayed physical Hajr adds a new grant.
The same native Guild player retains that continuation. Native city range still
applies normally; physical Ornithopter card composition remains unavailable.
The [18 October replay checkpoint](RICHESE_ORNITHOPTER_ENGINE_AUDIT.md#recovered-hajr-replay--18-october-2026)
adds counted v2 declarations and current/final count snapshots, retaining exact
legacy v1 receipt compatibility. Remaining movement subtracts the original
ordinary slot even when it was forgone. Advanced uses the supplied fresh
post-movement play window; holding or recovering Hajr alone cannot open it.
Count composition has focused pure and related native regression evidence;
the original paid-Box replay programme itself is a non-Nexus table.
The server supplies `hajrAvailable` independently of the displayed movement ceiling. [Ordinary card availability](../game/card-availability.ts) uses this permission during the Cunning continuation, avoiding a false second Hajr offer when a derived ceiling happens to equal one. Controls disable movement during the second-shipment step and explain the remaining Hajr allowance afterward.
+63
View File
@@ -2,6 +2,69 @@
Audit: 2026-09-06. Document-only source and engine review. The canonical component is `richese-ornithopter`; this is distinct from ordinary Arrakeen/Carthag ornithopter access and the later Ecaz discovery token. No runtime effect or complete expansion compliance is claimed here.
## Recovered Hajr replay — 18 October 2026
**Prototyped / Partial.** The earlier multiple-physical-Hajr boundary had an
incorrect premise: the original inventory has only one Hajr. A real missing
case is recovering that discarded card with the original paid
[Nullentropy Box](NULLENTROPY_BOX_ENGINE_AUDIT.md) and playing it again.
The supplied physical p39/logical p37 paragraph gives each Hajr play one extra
normal move and immediate-after-Force-Movement timing, but establishes no
once-per-turn cap. Under choose-recommended, a genuinely recovered held card
may be played again for one further allowance. This replay interpretation is
explicit; it is not a newly retrieved publisher FAQ or permission to duplicate
the printed card. Basic retains broader timing; Advanced requires a fresh
completed movement after the preceding Hajr consumed its opportunity.
Actual accepted Hajr plays are counted separately from the unique legacy
has-used marker. The physical card must be back in hand: repeating a consumed
first-use ID before recovery, or after its second discard, grants nothing.
The original Box pays two bank spice and retrieves the one eligible discard
automatically when no selection is needed. Searching grants no movement or
Advanced timing opportunity. Basic may replay after retrieval; Advanced's
programme retrieves while one earned move remains, completes that movement,
then plays the same Hajr at the fresh opportunity. Both finish three ordinary
movements with one physical Hajr and one paid Box.
The shared movement ceiling and owned control count accepted plays, preserving
Ornithopter's separate completed-group credit. Ordinary/flight disposal binds
the new count without altering legacy signatures when the field is absent.
The count resets with the original turn/movement counters; old marker-only
positions mean one play, without reconstructing saved history. Guild Cunning
now carries counted unused Hajr allowances while forfeiting the ordinary slot.
New declarations use v2 count receipts; legacy v1 boolean bytes stay valid.
Signed current/final counts bound the native continuation after the second
shipment and remain in completed history. Advanced Cunning now uses the same
held-card/fresh-window timing. Physical Ornithopter/Cunning remains separate.
Two exclusive fixture/pure lanes plus owner integration and bounded review pass
frozen **12 files102/102**, types/lint/build and **two** original-action/
actual-GameTable programmes. Real cache Box1, ordinary Hajr1 and paid three-counter
desert shipment reach the source in38 Basic/45 Advanced actions; seven subsequent
actions include first use, the original two-spice search, same-ID second use,
three ordinary moves and actual turn ending. Advanced retains two originally
shipped Sardaukar. Both sole-discard searches are automatic, with no fabricated
second card or manual selection claim. Four minimal policies retain the third
move in each mode (eight proposals); strategy/calibration is deferred.
Pure cases additionally cover old receipt bytes, counted before/after grants,
ordinary forfeiture, spent counters and invalid mixed formats. Related Cunning,
Box, disposal and prior flight regressions pass. One historical Advanced Cunning
case now earns Hajr after the real first move before carrying it through a
stopped shipment; its later route respects lost city access. Fixture type
feedback corrected a nullable private search reference. No printed number changed.
Proof base `72e77d7f4d96a3eca5987eba5287cc36dea3e99c`, frozen tree
`8c1122bd35446db71d09245dad128be1c8df440392c7d1ac7b0014fca3cadecc`;
private reports/HTML are under
`/home/quintine/.local/share/dune/checkpoints/20261018-hajr-replay/`.
Cunning replay composition has pure/related regression evidence, not a genuine
paid-Box native Nexus replay programme. Framework aliases/CSS stubs mean no
visual/browser/deployed acceptance. Other recovery producers, advisor/card and
wider module combinations, comprehensive assurance and full modes remain open.
No saved room/database mutation, reset, restart or deployment. Earlier one-Hajr
and multiple-copy descriptions below remain dated implementation history.
## Hajr and unused movement entitlements — 18 October 2026
**Prototyped / Partial.** The photographed Ornithopter face supplies its two
+1 -1
View File
@@ -873,7 +873,7 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t
| Boundary | Still unresolved | Existing record |
| --- | --- | --- |
| Ornithopter / Hajr | Standing choose-recommended provisionally replaces one unused ordinary/Hajr entitlement by range-three or two distinct normal groups, with no retroactive credit. A later Hajr may reuse card-moved forces. Advanced adopts supplied physical39/logical37 immediately-after-play timing; treating a completed card group as Force Movement and preserving its window through required arrivals are explicit composition choices. Basic retains broader face timing. Multiple physical Hajr stacking remains an existing unsupported boundary: the supplied paragraph establishes neither permission nor a once-per-turn prohibition. | [Source cutover and exact procedure](RICHESE_ORNITHOPTER_ENGINE_AUDIT.md#hajr-and-unused-movement-entitlements--18-october-2026) |
| Ornithopter / Hajr | Standing choose-recommended provisionally replaces one unused ordinary/Hajr entitlement by range-three or two distinct normal groups, with no retroactive credit; a later Hajr may reuse those forces. Advanced adopts supplied physical39/logical37 immediately-after-play timing with explicit online/card-group interpretation; Basic retains broader face timing. The inventory has one physical Hajr, not several copies. A genuine paid Box retrieval now permits replaying that held card for another allowance because no once-per-turn cap is established. This is an explicit replay interpretation; other recovery producers and full combinations remain open. | [Original source cutover](RICHESE_ORNITHOPTER_ENGINE_AUDIT.md#hajr-and-unused-movement-entitlements--18-october-2026) · [Physical replay and counted Cunning](RICHESE_ORNITHOPTER_ENGINE_AUDIT.md#recovered-hajr-replay--18-october-2026) |
| Reinforcements cost | The earlier all-outcome versus sole-Traitor-winner question remains unanswered. The bounded prototype provisionally charges on every revealed outcome, chooses normal reserves first and counts the transfer as battle losses. Same-plan Harass pays the original quote before returns; future returns cannot fund or change it. Native Advanced Occupy Stone/Mirror composition keeps the fee but ignores +2 normal score in physical-token comparison. These are product interpretations, not a new publisher ruling or user answer. Other profile/module gates remain; eligibility never depends on concealed Traitors. | [Source review](ECAZ_TREACHERY_RULES.md#14-september-reinforcements-cost-review) · [Combined policy](REINFORCEMENTS_RUNTIME.md#combined-utility-plan--16-october-2026) · [Stone composition](REINFORCEMENTS_RUNTIME.md#native-stone-composition--16-october-2026) |
| Harass / Stone Burner | The earlier keep-gated versus compare-after-withdrawal question is unanswered. Standing choose-recommended provisionally compares counters left after successful withdrawal. Opposing Traitor cancels the return; original fixed Occupy commitment stays. Public preflight considers possible fixed Ecaz withdrawal without hidden custody. Clean Ix priority follows its separate explicitly provisional contract; new physical commitments do not remove other profile/module limits. This is implementation interpretation, not a new publisher ruling or user answer. | [Withdrawal](HARASS_WITHDRAW_RUNTIME.md#provisional-stone-burner-timing--16-october-2026) · [Clean Ix priority](MIRROR_WEAPON_ENGINE_AUDIT.md#clean-selected-ix-composition--16-october-2026) |
| Stone mixed-force allocation timing | The older timing question remains unanswered by a source. Under 14 October choose-recommended, new original outcome-ambiguous Stone/Mirror battles provisionally commit each ambiguous side's undialed physical total after revealed modes/actual withdrawal and decisive Traitor/explosion branches. Storm-order owners choose privately; every original required value stays sealed until all commit. The eventual winner retains all legal typed casualty choices with the count. Ordinary/invariant and unstamped legacy frames keep their existing path. Publisher physical-count text and supplied Advanced physical12/logical10 casualty timing provide the numerical rules, not this online commitment convention. | [Source, versioned procedure and bounded proof](STONE_BURNER_ENGINE_AUDIT.md#outcome-changing-physical-totals--18-october-2026) |
+2 -1
View File
@@ -19,6 +19,7 @@ export type OrdinaryCardPlayer = {
movesAllowed?: number;
moved?: number;
hajrUsed?: boolean;
hajrPlays?: number;
hajrAvailable?: boolean;
};
/** Only public state and the current viewer's own hand are inspected. */
@@ -110,7 +111,7 @@ export function ordinaryCardAvailability(
own.hajrUsed === undefined ? own.movesAllowed !== 1 :
own.hajrUsed || (own.moved ?? 0) > (own.movesAllowed ?? 1))
)
return blocked('Use one Hajr during your movement; Advanced requires the immediate opportunity after a completed Force Movement.');
return blocked('Play a held Hajr during your movement; Advanced requires a fresh immediate opportunity after a completed Force Movement.');
break;
case 'harvester':
if (state.phase !== 1 || !harvesterAvailable(state.spiceWindow))
+39 -14
View File
@@ -682,6 +682,8 @@ export type Player = {
ecazRevivalCohort?: EcazRevivalCohort;
shipped: boolean;
moved: number;
/** One allowance per actual physical Hajr play; old marker-only saves mean one. */
hajrPlays?: number;
/** Completed second groups replace one entitlement without consuming Hajr. */
ornithopterExtraMoves?: number;
/** Supplied Advanced Hajr timing: the owner's next voluntary action. */
@@ -2026,7 +2028,7 @@ export type Game = {
nexusSardaukarLast?: {event: string; stage: 'pending' | 'active' | 'canceled'};
nexusChoamHistory?: {receipt: NexusChoamReceipt; stage: 'pending' | 'canceled' | 'complete' | 'fizzled'; frame: string; parent: string; signature: string}[];
nexusMoritaniHistory?: {receipt:NexusMoritaniReceipt;stage:'pending'|'complete'|'canceled';before:string;frame:string;signature:string}[];
nexusGuildCunningHistory?: {receipt:NexusGuildCunningReceipt;stage:'pending'|'secondShipment'|'extraMove'|'completed'|'canceled';parent:string;movesAfter:number;shipment?:{kind:'reserve'|'guild'|'homeworld';frame:string;stage:'pending'|'complete'|'stopped'}|{kind:'declined';stage:'declined'};signature:string}[];
nexusGuildCunningHistory?: {receipt:NexusGuildCunningReceipt;stage:'pending'|'secondShipment'|'extraMove'|'completed'|'canceled';parent:string;movesAfter:number;hajrPlays?:number;shipment?:{kind:'reserve'|'guild'|'homeworld';frame:string;stage:'pending'|'complete'|'stopped'}|{kind:'declined';stage:'declined'};signature:string}[];
nexusGuildCunningLast?: {event:string;stage:'pending'|'secondShipment'|'extraMove'|'completed'|'canceled'};
nexusGuildSecretHistory?: {receipt:NexusGuildSecretAllyReceipt;kind:'reserve'|'cross'|'homeworld';frame:string;signature:string}[];
nexusGuildSecretLast?: {event:string};
@@ -5357,6 +5359,7 @@ function ordinaryDiscardSignature(g: Game, next: OrdinaryDiscardContinuation) {
tanks: p.tanks,
elites: p.elites,
advisors: p.advisors,
hajrPlays: p.hajrPlays,
moved: p.moved,
shipped: p.shipped,
revived: p.revived,
@@ -6090,6 +6093,7 @@ function ornithopterDiscardSignature(
advisors: p.advisors,
noField: p.noField,
noFieldEvent: p.noFieldEvent,
hajrPlays: p.hajrPlays,
ornithopterExtraMoves: p.ornithopterExtraMoves,
hajrWindow: p.hajrWindow,
moved: p.moved,
@@ -15569,16 +15573,22 @@ function forceGroup(g: Game, p: Player, action: Action, maxOrigins = 1) {
],
};
}
function hajrPlayCount(g: Game, p: Player) {
const used = g.hajr.includes(p.id), count = p.hajrPlays ?? Number(used);
requireRule(Number.isSafeInteger(count) && count >= 0 && (count > 0) === used,
'The Hajr movement count must retain its actual played-card marker.');
return count;
}
function movesAllowed(g: Game, p: Player) {
const cunning = currentGuildCunning(g,p.id);
if (cunning) return p.moved + (cunning.stage === 'extraMove' ? nexusRule(() => nexusGuildCunningMoves(cunning.receipt,g.hajr.includes(p.id),cunning.movesAfter)) : 0);
if (cunning) return p.moved + (cunning.stage === 'extraMove' ? nexusRule(() => nexusGuildCunningMoves(cunning.receipt,hajrPlayCount(g,p),cunning.movesAfter)) : 0);
const flight = g.ornithopter;
return flight?.player === p.id && flight.turn === g.turn
? flight.startingMove + (flight.mode === 'twoGroups' ? 2 : 1)
: ordinaryMovementAllowance(g.hajr.includes(p.id), p.ornithopterExtraMoves);
: ordinaryMovementAllowance(hajrPlayCount(g,p), p.ornithopterExtraMoves);
}
function hajrAvailable(g: Game, p: Player) {
return !g.hajr.includes(p.id) && p.moved <= movesAllowed(g,p) &&
return p.hand.some(card => card.kind === 'special' && card.effect === 'hajr') && 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 {
@@ -19651,6 +19661,7 @@ function commitMovementPhaseStart(g: Game, quote: MovementPhaseQuote) {
for (const p of g.players) {
p.shipped = false;
p.moved = 0;
delete p.hajrPlays;
delete p.ornithopterExtraMoves;
delete p.hajrWindow;
}
@@ -20203,6 +20214,7 @@ function beginStormTurn(g: Game) {
}
g.hajr = [];
for (const player of g.players) delete player.hajrPlays;
g.nexus = false;
log(g, `Turn ${g.turn} begins.`);
}
@@ -28345,7 +28357,8 @@ function guildCunningParent(g: Game) {
return JSON.stringify([g.active,g.order,g.movementRemaining,g.guildTimingGranted,g.guildTimingLocked,g.saphoMovementLast ?? null]);
}
function guildCunningSignature(record: NonNullable<Game['nexusGuildCunningHistory']>[number]) {
return JSON.stringify([record.receipt.signature,record.stage,record.parent,record.movesAfter,record.shipment ?? null]);
return JSON.stringify([record.receipt.signature,record.stage,record.parent,record.movesAfter,record.shipment ?? null,
...(record.hajrPlays === undefined ? [] : [record.hajrPlays])]);
}
function saveGuildCunning(g: Game, record: NonNullable<Game['nexusGuildCunningHistory']>[number]) {
record.signature = guildCunningSignature(record);
@@ -28356,8 +28369,8 @@ function projectedNexusGuildCunning(g: Game, owner: string) {
if (!p || p.faction !== 'guild') return null;
const record = currentGuildCunning(g,owner);
if (record) return {offer:null,active:{event:record.receipt.event,stage:record.stage as 'pending'|'secondShipment'|'extraMove',
movesLeft:record.stage === 'extraMove' ? nexusRule(() => nexusGuildCunningMoves(record.receipt,g.hajr.includes(owner),record.movesAfter)) : 0,
hajrAvailable:record.stage !== 'pending' && !g.hajr.includes(owner)}};
movesLeft:record.stage === 'extraMove' ? nexusRule(() => nexusGuildCunningMoves(record.receipt,hajrPlayCount(g,p),record.movesAfter)) : 0,
hajrAvailable:record.stage !== 'pending' && hajrAvailable(g,p)}};
if (g.nexusCards?.cards?.hands[owner] !== 'guild') return null;
let blocked: string | null = null;
if (g.guildBetrayalPreview && !nexusCoreProfile(g)) blocked = 'This payment audit profile supports the original native shipment, not a Cunning second-shipment overlay.';
@@ -28367,7 +28380,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.ornithopterExtraMoves || p.moved > (g.hajr.includes(owner) ? 2 : 1))
else if (g.ornithopter || p.ornithopterExtraMoves || p.moved > ordinaryMovementAllowance(hajrPlayCount(g,p)))
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.';
@@ -28376,8 +28389,8 @@ function projectedNexusGuildCunning(g: Game, owner: string) {
function declareGuildCunning(g: Game, p: Player, event: unknown) {
const offer = projectedNexusGuildCunning(g,p.id)?.offer;
requireRule(offer && !offer.blocked && event === offer.event,offer?.blocked ?? 'This Guild Cunning opportunity is stale.');
const receipt = nexusRule(() => createNexusGuildCunning(g,p.id,{shipped:p.shipped,moved:p.moved,hajrUsed:g.hajr.includes(p.id)}));
const record = {receipt,stage:'pending' as const,parent:guildCunningParent(g),movesAfter:0,signature:''};
const receipt = nexusRule(() => createNexusGuildCunning(g,p.id,{shipped:p.shipped,moved:p.moved,hajrPlays:hajrPlayCount(g,p)}));
const record = {receipt,stage:'pending' as const,parent:guildCunningParent(g),movesAfter:0,hajrPlays:hajrPlayCount(g,p),signature:''};
g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!,p.id,g.players));
(g.nexusGuildCunningHistory ??= []).push(record); saveGuildCunning(g,record);
g.karamaShipping = null;
@@ -28436,12 +28449,18 @@ function nexusGuildCunningIntegrity(g: Game) {
requireRule(g.nexusCards?.cards && Array.isArray(history) && history.length > 0,'Guild Cunning requires its original Nexus module and history.');
const events = new Set<string>();
for (const [index,record] of history.entries()) {
requireRule(record && typeof record === 'object' && Object.keys(record).every(k => ['receipt','stage','parent','movesAfter','shipment','signature'].includes(k)) &&
requireRule(record && typeof record === 'object' && Object.keys(record).every(k => ['receipt','stage','parent','movesAfter','hajrPlays','shipment','signature'].includes(k)) &&
['pending','secondShipment','extraMove','completed','canceled'].includes(record.stage) && typeof record.parent === 'string' &&
record.signature === guildCunningSignature(record) && !events.has(record.receipt?.event) &&
Number.isSafeInteger(record.movesAfter) && record.movesAfter >= 0 && record.movesAfter <= 1 &&
Number.isSafeInteger(record.movesAfter) && record.movesAfter >= 0 &&
(['completed','canceled'].includes(record.stage) || index === history.length - 1),'Guild Cunning has a changed or duplicated allowance record.');
nexusRule(() => validateNexusGuildCunning(g,record.receipt));
if (record.hajrPlays !== undefined) {
requireRule(Number.isSafeInteger(record.hajrPlays) && record.hajrPlays >= 0,
'Guild Cunning needs its actual earned Hajr count.');
nexusRule(() => nexusGuildCunningMoves(record.receipt,record.hajrPlays!,record.movesAfter));
} else requireRule(record.receipt.version === 1 && record.movesAfter <= 1,
'The legacy Guild Cunning history has lost its original Hajr bound.');
if (record.shipment?.kind === 'declined') requireRule(Object.keys(record.shipment).sort().join(',') === 'kind,stage' && record.shipment.stage === 'declined' && ['extraMove','completed'].includes(record.stage), 'Guild Cunning has changed its declined second shipment.');
else if (record.shipment) requireRule(Object.keys(record.shipment).sort().join(',') === 'frame,kind,stage' &&
['reserve','guild','homeworld'].includes(record.shipment.kind) && typeof record.shipment.frame === 'string' &&
@@ -28464,9 +28483,10 @@ function nexusGuildCunningIntegrity(g: Game) {
const owner = getPlayer(g,active.receipt.owner);
requireRule(g.status === 'playing' && g.phase === 5 && g.active === owner.id &&
active.parent === guildCunningParent(g) && owner.moved === active.receipt.before.moved + active.movesAfter &&
(active.hajrPlays === undefined || active.hajrPlays === hajrPlayCount(g,owner)) &&
owner.shipped === (active.stage === 'extraMove' && (active.shipment?.kind !== 'declined' || active.movesAfter > 0) ? true : active.receipt.before.shipped) && g.nexusCards.cards.discard.includes('guild'),
'Guild Cunning changed its original active player, queue, shipment or movement allowance.');
nexusRule(() => nexusGuildCunningMoves(active.receipt,g.hajr.includes(owner.id),active.movesAfter));
nexusRule(() => nexusGuildCunningMoves(active.receipt,hajrPlayCount(g,owner),active.movesAfter));
for (const response of responses) validateGuildCunningResponse(g,response!);
if (active.shipment?.stage === 'pending') {
const suspended = g.pendingGuildBetrayal?.continuation;
@@ -34613,6 +34633,7 @@ function applyActionInner(
hand: p.hand,
movesAllowed: movesAllowed(g, p),
hajrUsed: g.hajr.includes(p.id),
hajrPlays: hajrPlayCount(g,p),
hajrAvailable: hajrAvailable(g,p),
},
],
@@ -34623,8 +34644,11 @@ function applyActionInner(
if (availability && !availability.available)
throw new RuleError(availability.reason);
if (c.effect === 'hajr') {
g.hajr.push(id);
p.hajrPlays = hajrPlayCount(g,p) + 1;
if (!g.hajr.includes(id)) g.hajr.push(id);
delete p.hajrWindow;
const cunning = currentGuildCunning(g,id);
if (cunning) { cunning.hajrPlays = p.hajrPlays; saveGuildCunning(g,cunning); }
} else if (c.effect === 'ghola') {
applyGholaEffect(g, p, action, c.id);
} else if (c.effect === 'harvester') {
@@ -35605,6 +35629,7 @@ export function viewGame(state: Game, id: string) {
moved: p.moved,
movesAllowed: movesAllowed(g, p),
hajrUsed: g.hajr.includes(p.id),
hajrPlays: hajrPlayCount(g,p),
hajrAvailable: hajrAvailable(g,p),
revived: p.revived,
leaderRevived: p.leaderRevived,
+65 -27
View File
@@ -8,18 +8,35 @@ export type NexusGuildCunningBefore = {
shipped: boolean;
moved: number;
hajrUsed: boolean;
hajrPlays?: never;
};
export type NexusGuildCunningReceipt = {
version: 1;
export type NexusGuildCunningCountedBefore = {
shipped: boolean;
moved: number;
hajrPlays: number;
hajrUsed?: never;
};
type NexusGuildCunningReceiptFields = {
event: string;
owner: string;
turn: number;
phase: 5;
mode: 'cunning';
roster: { id: string; faction: FactionId }[];
before: NexusGuildCunningBefore;
signature: string;
};
export type NexusGuildCunningLegacyReceipt = NexusGuildCunningReceiptFields & {
version: 1;
before: NexusGuildCunningBefore;
};
export type NexusGuildCunningCountedReceipt = NexusGuildCunningReceiptFields & {
version: 2;
before: NexusGuildCunningCountedBefore;
};
export type NexusGuildCunningReceipt =
| NexusGuildCunningLegacyReceipt
| NexusGuildCunningCountedReceipt;
type Before = NexusGuildCunningBefore | NexusGuildCunningCountedBefore;
function requireGuild(value: unknown, message: string): asserts value {
if (!value) throw new Error(message);
@@ -54,15 +71,20 @@ function roster(context: NexusGuildCunningContext) {
.map(({ id, faction }) => ({ id, faction }))
.sort((a, b) => a.id.localeCompare(b.id));
}
function validateBefore(before: NexusGuildCunningBefore) {
function validPlayCount(value: unknown): value is number {
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 &&
Number.isSafeInteger(value + 1);
}
function validateBefore(before: Before, version: 1 | 2) {
requireGuild(
plain(before) &&
Object.keys(before).sort().join(',') === 'hajrUsed,moved,shipped' &&
Object.keys(before).sort().join(',') ===
(version === 1 ? 'hajrUsed,moved,shipped' : 'hajrPlays,moved,shipped') &&
typeof before.shipped === 'boolean' &&
typeof before.hajrUsed === 'boolean' &&
(version === 1 ? typeof before.hajrUsed === 'boolean' : validPlayCount(before.hajrPlays)) &&
Number.isSafeInteger(before.moved) &&
before.moved >= 0 &&
before.moved <= (before.hajrUsed ? 2 : 1),
before.moved <= 1 + (version === 1 ? Number(before.hajrUsed) : before.hajrPlays!),
'Guild Cunning needs the original ordinary shipment and Hajr movement allowance.',
);
}
@@ -76,7 +98,8 @@ function signature(receipt: NexusGuildCunningReceipt) {
receipt.phase,
receipt.mode,
receipt.roster.map((p) => [p.id, p.faction]),
[receipt.before.shipped, receipt.before.moved, receipt.before.hajrUsed],
[receipt.before.shipped, receipt.before.moved,
receipt.version === 1 ? receipt.before.hajrUsed : receipt.before.hajrPlays],
]);
}
/** Validates historical facts, not current cards, alliances, resources or movement.
@@ -90,7 +113,7 @@ export function validateNexusGuildCunning(
plain(receipt) &&
Object.keys(receipt).sort().join(',') ===
'before,event,mode,owner,phase,roster,signature,turn,version' &&
receipt.version === 1 &&
(receipt.version === 1 || receipt.version === 2) &&
receipt.phase === 5 &&
receipt.mode === 'cunning' &&
Number.isSafeInteger(receipt.turn) &&
@@ -107,7 +130,7 @@ export function validateNexusGuildCunning(
JSON.stringify(receipt.roster) === JSON.stringify(seats),
'Guild Cunning has changed its original owner, turn or faction roster.',
);
validateBefore(receipt.before);
validateBefore(receipt.before, receipt.version);
requireGuild(
receipt.signature === signature(receipt),
'Guild Cunning has changed its original combined turn.',
@@ -117,6 +140,21 @@ export function createNexusGuildCunning(
context: NexusGuildCunningContext & { phase: number; active: string | null },
owner: string,
before: NexusGuildCunningBefore,
): NexusGuildCunningLegacyReceipt;
export function createNexusGuildCunning(
context: NexusGuildCunningContext & { phase: number; active: string | null },
owner: string,
before: NexusGuildCunningCountedBefore,
): NexusGuildCunningCountedReceipt;
export function createNexusGuildCunning(
context: NexusGuildCunningContext & { phase: number; active: string | null },
owner: string,
before: Before,
): NexusGuildCunningReceipt;
export function createNexusGuildCunning(
context: NexusGuildCunningContext & { phase: number; active: string | null },
owner: string,
before: Before,
): NexusGuildCunningReceipt {
const seats = roster(context);
requireGuild(
@@ -127,47 +165,47 @@ export function createNexusGuildCunning(
),
'Guild Cunning requires the unallied native Guild at the end of its own combined turn.',
);
validateBefore(before);
const receipt: NexusGuildCunningReceipt = {
version: 1,
const version = plain(before) && Object.hasOwn(before, 'hajrPlays') ? 2 : 1;
validateBefore(before, version);
const fields: Omit<NexusGuildCunningReceiptFields, 'signature'> = {
event: JSON.stringify(['nexusGuildCunning', context.turn, owner]),
owner,
turn: context.turn,
phase: 5,
mode: 'cunning',
roster: seats,
before: { ...before },
signature: '',
};
const receipt: NexusGuildCunningReceipt = version === 1
? { version: 1, ...fields, before: { ...before as NexusGuildCunningBefore }, signature: '' }
: { version: 2, ...fields, before: { ...before as NexusGuildCunningCountedBefore }, signature: '' };
receipt.signature = signature(receipt);
validateNexusGuildCunning(context, receipt);
return receipt;
}
/** Ending ordinary movement forgoes at least its first slot. Hajr supplies only
* the still-unused extra slot, whether played before or after the shipment. */
/** Ending ordinary movement forgoes at least its first slot. Every actual Hajr
* play supplies one extra slot; the engine owns card custody and play timing.
* Legacy boolean callers/receipts retain their original zero-or-one meaning. */
export function nexusGuildCunningMoves(
receipt: NexusGuildCunningReceipt,
currentHajrUsed: boolean,
currentHajrUsed: boolean | number,
movesAfterSecond = 0,
): 0 | 1 {
): number {
validateNexusGuildCunning(
{ turn: receipt.turn, players: receipt.roster },
receipt,
);
const currentCount = typeof currentHajrUsed === 'boolean' ? Number(currentHajrUsed) : currentHajrUsed;
const beforeCount = receipt.version === 1 ? Number(receipt.before.hajrUsed) : receipt.before.hajrPlays;
requireGuild(
typeof currentHajrUsed === 'boolean' &&
(!receipt.before.hajrUsed || currentHajrUsed) &&
validPlayCount(currentCount) && currentCount >= beforeCount &&
Number.isSafeInteger(movesAfterSecond) &&
movesAfterSecond >= 0 &&
movesAfterSecond <= 1,
movesAfterSecond >= 0,
'Guild Cunning has changed its remaining Hajr movement.',
);
const available = currentHajrUsed
? Math.max(0, 2 - Math.max(1, receipt.before.moved))
: 0;
const available = Math.max(0, 1 + currentCount - Math.max(1, receipt.before.moved));
requireGuild(
movesAfterSecond <= available,
'Guild Cunning cannot supply another ordinary movement.',
);
return (available - movesAfterSecond) as 0 | 1;
return available - movesAfterSecond;
}
+4 -3
View File
@@ -9,9 +9,10 @@ type Counts = Readonly<Record<string, number>>;
/** 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 ||
export function ordinaryMovementAllowance(hajr: boolean | number, extraMoves = 0): number {
const plays = typeof hajr === 'boolean' ? Number(hajr) : hajr;
const limit = 1 + plays + extraMoves;
if (!Number.isSafeInteger(plays) || plays < 0 || !Number.isSafeInteger(extraMoves) || extraMoves < 0 ||
!Number.isSafeInteger(limit)) throw new RangeError('Movement credits must be nonnegative safe integers.');
return limit;
}
+14 -6
View File
@@ -901,7 +901,7 @@ 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.',
'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.',
'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. 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.',
],
@@ -916,7 +916,7 @@ 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. Advanced uses supplied immediate-after-play timing with an explicit online/card-group convention. Multiple Hajr and advisor composition remain unfinished.',
'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. Advisor composition and broader acceptance remain unfinished.',
},
{
area: 'Player controls',
@@ -1223,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 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.'
? '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. A genuinely recovered Hajr may be legally replayed for another grant. Advanced needs a fresh immediate-after-movement opportunity. Kulon retains the fixed maximum three and extends both normal groups. 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'
@@ -2567,10 +2567,18 @@ export const RULE_TOPICS: RuleTopic[] = [
steps: [
'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.',
'Ornithopter replaces one unused movement entitlement, so one Hajr play and a two-group flight permit three group movements. Groups must differ within that flight, while the later Hajr move may reuse them.',
'The deck contains one physical Hajr. If Nullentropy Box genuinely retrieves it after its discard, you may legally play that same card again for another extra move. This replay is an explicit interpretation because the source establishes no once-per-turn cap. A repeated submission while it is still discarded grants nothing. Advanced still needs a fresh completed-movement opportunity after retrieval; the search supplies no movement.',
'A saved play preserves the extra movement allowance. Recovery neither grants another move nor finishes your movement turn.',
],
related: ['movement', 'ornithopter-movement'],
related: ['movement', 'ornithopter-movement', 'nullentropy-search'],
checklist: [
{area:'Implementation',status:'Partial',detail:'Accepted physical plays grant counted extra moves; legitimate same-card Box retrieval preserves cost/custody and Advanced timing. Cunning carries unused grants while forfeiting ordinary movement. Legacy markers and receipts retain compatibility.',evidence:['game/engine.ts','game/ornithopter.ts','game/nexus-guild-cunning.ts']},
{area:'Player controls',status:'Partial',detail:'The existing card selector uses held-card/current timing authority. Persistent table text shows accepted Hajr plays and explains fresh Advanced timing after recovery.',evidence:['game/card-availability.ts','components/game-table.tsx']},
{area:'AI',status:'Partial',detail:'Four profiles retain a legal third ordinary move after real Basic/Advanced paid Box recovery. Strategic refinement and calibration remain deferred.',evidence:['tests/hajr-replay-engine.test.ts']},
{area:'Documentation',status:'Partial',detail:'Explains the single physical card, original paid retrieval, repeated legal grant, unchanged Basic timing and fresh Advanced opportunity. Replay remains an explicit source interpretation.',evidence:['docs/RICHESE_ORNITHOPTER_ENGINE_AUDIT.md']},
{area:'Verification',status:'Partial',detail:'Frozen12-file102/102, types/lint/build and two original-action/rendered-table programmes pass with eight legal policy proposals. Cunning counts have pure/related regressions, not an original paid-Box Nexus programme. No visual/browser/deployed, complete-mode or comprehensive assurance claim.',evidence:['tests/fixture-hajr-replay.ts','tests/hajr-replay-engine.test.ts','tests/nexus-guild-cunning-counted.test.ts','tests/nexus-guild-cunning-engine.test.ts']},
],
},
{
id: 'card-ghola',
@@ -4768,7 +4776,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'When Richese is absent, its Secret Ally replaces your ordinary reserve shipment with up to five physical forces charged as one. Forces, elite identity, destination restrictions, native Homeworld sources and movement remain unchanged. Fremen retain free reinforcement. Native Guild rate cancellation changes the one-counter sand bill from1 to2 without canceling the Nexus source; an already canceled rate is reflected in the quote. Unfunded declared Nexus stays spent without refund/reusable coupon while ordinary shipment remains available under the selected affordability policy. An independently purchased ordinary price remains separate. This is no concealed marker or extra shipment.',
'Native Richese Cunning uses the original signed two-No-Field shipment in supported single/paired E2 or selected mixed Ix+CHOAM native Nexus tables with classics, optional Skills/Homeworlds/Discovery/Tech/Advanced Strongholds. Select two distinct unused physical tokens: one reveals immediately into reserve-capped forces, one remains concealed; pay one marker invoice and spend the Nexus. The previous shipment token cannot be either choice, and the concealed one becomes last-used. Native/Guild response and actual payer/counter sources remain; a nested destination must be revealed. Prevention keeps physical tokens and unpaid spice, after any printed card cost. Sandtrout, arbitrary companions, shared allocation and other pending combinations retain their guards.',
'Richese Betrayal has a separate opt-in auction preview. Before payment, an unallied rival may spend its physical Richese Nexus card to discard a public self-cache win without any purchase debit, or redirect another buyer’s cache/Black Market payment to the bank while preserving delivery and earned bonus. Every publicly possible holder acknowledges the same opportunity; private Use eligibility cannot determine public timing. Normal hidden self-purchases and private special-Karama acquisitions are not connected. See the dedicated guide for its existing local runtime/browser evidence and remaining release gates; that evidence does not verify Nexus Kull.',
'Guild Cunning is declared when finishing the ordinary Shipment and Movement turn. It spends the Nexus and opens its native Karama response. Allowance grants one second native shipment at normal Guild prices, including supported cross, return and Homeworld routes; cancellation preserves the first shipment and movement. Only an unused Hajr move can follow, never another ordinary movement or third move. Classic core retains the actual grant and funded reserve/cross/return through Guild fee acknowledgement; other envelopes keep their own source limits. The physical Ornithopter card combination remains separate.',
'Guild Cunning is declared when finishing the ordinary Shipment and Movement turn. It spends the Nexus and opens its native Karama response. Allowance grants one second native shipment at normal Guild prices, including supported cross, return and Homeworld routes; cancellation preserves the first shipment and movement. Every later movement must spend an earned unused Hajr grant; there is no new ordinary move. A genuinely recovered and legally replayed Hajr adds another grant, with fresh post-movement timing in Advanced. Classic core retains original funding through Guild fee acknowledgement; other envelopes keep their source limits. The physical Ornithopter card combination remains separate.',
'Cunning native cross/return permission and rate use separate unpaid controls while original shipped history stays unchanged. A denial within its second opportunity allows a legal reserve replacement; denial of an earlier ordinary opportunity does not remove that new signed grant. Typed sources, original prices and approval stay bound; neither first payment nor ordinary movement reopens. Independent purchased price, immune Junction and other card sources remain distinct.',
'With Guild absent, its Secret Ally is an explicit optional source for one ordinary shipment at Guild prices. It supports physical reserve shipment, cross-shipment and return to the holder’s own reserves, including the native-Homeworld return when Homeworlds are enabled, plus half-price independently legal world-to-world shipment. Fremen may retain free reinforcement or choose the paid card route beyond its normal range, outside the storm. Native faction, typed sources and normal movement stay unchanged. Arbitrary foreign-world transport and concealed No-Fields are not granted.',
'Borrowed Guild/Richese tariffs do not change native faction or physical reserve origin. Non-Guild/non-Fremen off-planet arrivals can accrue Heighliners once for collection at the original phase end. A paid borrowed Guild route from Fremen southern reserves is still on-planet and does not trigger it; stopped and native-Guild-only shipments likewise remain excluded. The existing Emperor bank-auction purchase retains the actual buyer wallet and creates no industry activity.',
+2 -2
View File
@@ -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 grants only one additional ordinary entitlement', () => {
void test('each held Hajr needs the active movement owner and grants its own ordinary entitlement', () => {
agrees(
'hajr',
(g) => {
@@ -173,7 +173,7 @@ void test('Hajr requires the active movement owner and grants only one additiona
(g) => {
g.hajr = ['a'];
},
false,
true,
);
agrees(
'hajr',
+153
View File
@@ -0,0 +1,153 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { location } from '../game/board';
import { richeseCards } from '../game/richese-cards';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
import {
actOrnithopterHajr, ornithopterHajrClean, ornithopterHajrMove,
ornithopterHajrPlayer, type OrnithopterHajrStep,
} from './fixture-ornithopter-hajr';
const canonicalBox = richeseCards().find(card => card.effect === 'nullentropyBox');
assert.ok(canonicalBox);
export const HAJR_REPLAY_BOX = canonicalBox.id;
export type HajrReplayStep = OrnithopterHajrStep;
export const hajrReplayPlayer = ornithopterHajrPlayer;
export const hajrReplayClean = ornithopterHajrClean;
export const actHajrReplay = actOrnithopterHajr;
export const hajrReplayMove = ornithopterHajrMove;
/** Only live original controls are consumed. The cache card is bought in its
* ordinary native auction; the single Hajr is bought in the normal auction. */
export function nextHajrReplayStep(game: Game): HajrReplayStep | 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_REPLAY_BOX,
method: 'onceAround', direction: 'clockwise',
} };
if (decision.kind === 'richeseUnbid') return { actor: decision.player,
action: { type: 'decision', event: game.richeseBidding!.event, keep: false } };
}
if (hajrReplayClean(game) && game.richeseAuction?.cardId === HAJR_REPLAY_BOX &&
!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 (hajrReplayClean(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 advanceHajrReplay(game: Game, until: (state: Game) => boolean,
actions: HajrReplayStep[] = []): Game {
for (let count = 0; count < 2000; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original Hajr/Box programme ended before its next control');
const next = nextHajrReplayStep(game); assert.ok(next, 'Finish the real owned Hajr/Box choice before continuing');
game = actHajrReplay(game, next.actor, next.action, actions);
}
throw Error('The original Hajr/Box programme did not reach its finite boundary');
}
/** Genuine original host, setup, paid Box/Hajr lots and paid desert shipment.
* Only unopened Hajr/Spice deck positions and scalar lotteries are selected.
* No hand, wallet, force, phase or completed-effect receipt is installed. */
export function createHajrReplayFixture(options: { advanced?: boolean } = {}) {
const advanced = options.advanced ?? false;
let game = createGame('HAJRREPLAY', newPlayer('richese', 'Richese', 'richese'), advanced, ['choam']);
for (const faction of ['emperor', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction));
const actions: HajrReplayStep[] = [];
const act = (actor: string, action: Action) => { game = actHajrReplay(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceHajrReplay(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 && hajrReplayClean(state));
const at = game.deck.findIndex(card => card.effect === 'hajr');
assert.ok(at >= 0, 'The one original Hajr must still be undealt');
const hajr = game.deck[at].id;
game.deck.unshift(game.deck.splice(at, 1)[0]);
const beforePurchases = structuredClone(game);
reach(state => hajrReplayPlayer(state).hand.some(card => card.id === HAJR_REPLAY_BOX) &&
hajrReplayPlayer(state).hand.some(card => card.id === hajr));
const acquired = structuredClone(game);
reach(state => state.phase === 5 && state.active === 'emperor' && hajrReplayClean(state));
const beforeShipment = structuredClone(game), shipmentStep: HajrReplayStep = { actor: 'emperor', action: {
type: 'ship', territory: 'imperial_basin', sector: 9, amount: 3, elite: advanced ? 2 : 0, allyPayment: 0,
} };
act(shipmentStep.actor, shipmentStep.action); reach(hajrReplayClean);
return { game, lobby, setup, afterSetup, beforePurchases, acquired, beforeShipment, shipmentStep,
afterShipment: structuredClone(game), hajr, box: HAJR_REPLAY_BOX, owner: 'emperor',
source: 'imperial_basin:9', actions,
staging: ['Original native setup and paid cache1/normal1 lots acquire Box and the single Hajr. An original paid3-counter desert shipment leaves the two-spice Box fee. Only unopened deck positions and scalar lotteries are selected.'] };
}
export type HajrReplayFixture = ReturnType<typeof createHajrReplayFixture>;
/** The real Box pays once and either exposes its actual private search or
* automatically takes the only eligible discard, as the existing rule does. */
export function recoverHajrWithBox(game: Game, fixture: HajrReplayFixture, actions: HajrReplayStep[] = []) {
assert.ok(game.discard.some(card => card.id === fixture.hajr));
const before = structuredClone(game), step: HajrReplayStep = {
actor: fixture.owner, action: { type: 'card', card: fixture.box },
};
game = actHajrReplay(game, step.actor, step.action, actions);
const paid = structuredClone(game);
let selectionStep: HajrReplayStep | null = null;
if (game.pendingNullentropy) {
const search = viewGame(game, fixture.owner).nullentropy?.search;
assert.ok(search && search.cards.some(card => card.id === fixture.hajr), 'Select the genuinely discarded Hajr from the real paid owner view');
selectionStep = { actor: fixture.owner,
action: { type: 'decision', event: search.event, card: fixture.hajr } };
game = actHajrReplay(game, selectionStep.actor, selectionStep.action, actions);
}
game = advanceHajrReplay(game, hajrReplayClean, actions);
assert.ok(hajrReplayPlayer(game).hand.some(card => card.id === fixture.hajr));
return { game, before, step, paid, selectionStep };
}
/** Basic: Hajr, move, Box, recovered Hajr, move, move. Advanced: move,
* Hajr, Box, move, recovered Hajr, move. A Box action supplies no movement
* and no Advanced timing opportunity; the second actual move opens that. */
export function completeHajrReplay(fixture: HajrReplayFixture) {
let game = structuredClone(fixture.game);
const actions: HajrReplayStep[] = [];
const moves: { before: Game; step: HajrReplayStep; after: Game }[] = [];
const play = () => {
game = actHajrReplay(game, fixture.owner, { type: 'card', card: fixture.hajr }, actions);
game = advanceHajrReplay(game, hajrReplayClean, actions);
};
const move = (from: string) => {
const before = structuredClone(game), step: HajrReplayStep = {
actor: fixture.owner, action: hajrReplayMove(game, from),
};
game = actHajrReplay(game, step.actor, step.action, actions);
game = advanceHajrReplay(game, hajrReplayClean, actions);
moves.push({ before, step, after: structuredClone(game) });
return location(String(step.action.territory), Number(step.action.sector));
};
if (!game.advanced) play();
const beforeFirstMove = structuredClone(game), firstDestination = move(fixture.source);
if (game.advanced) play();
const firstHajr = structuredClone(game), recovery = recoverHajrWithBox(game, fixture, actions);
game = recovery.game;
const recovered = structuredClone(game);
let secondDestination = firstDestination;
if (game.advanced) secondDestination = move(firstDestination);
const beforeSecondHajr = structuredClone(game);
play();
const secondHajr = structuredClone(game);
if (!game.advanced) secondDestination = move(firstDestination);
const beforeThirdMove = structuredClone(game);
move(secondDestination);
const completed = structuredClone(game);
game = actHajrReplay(game, fixture.owner, { type: 'endMovement' }, actions);
return { game, completed, beforeFirstMove, firstHajr, recovery, recovered, beforeSecondHajr,
secondHajr, beforeThirdMove, firstDestination, secondDestination, moves, actions };
}
+144
View File
@@ -0,0 +1,144 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { viewGame, type Game } from '../game/engine';
import { baseDeck } from '../game/cards';
import { ordinaryCardAvailability } from '../game/card-availability';
import { botActions } from '../game/bots';
import {
HAJR_REPLAY_BOX, actHajrReplay, completeHajrReplay, createHajrReplayFixture,
hajrReplayPlayer as player, type HajrReplayFixture,
} from './fixture-hajr-replay';
const own = (game: Game) => viewGame(game, 'emperor').players.find(player => player.id === 'emperor')!;
const availability = (game: Game, f: HajrReplayFixture) => ordinaryCardAvailability(viewGame(game, f.owner), f.hajr)?.available;
function physicalHajr(game: Game, f: HajrReplayFixture) {
return [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand)]
.filter(card => card.id === f.hajr);
}
function source(f: HajrReplayFixture) {
assert.equal(baseDeck().filter(card => card.effect === 'hajr').length, 1,
'This programme reuses the one printed Hajr rather than creating a second physical copy');
assert.equal(f.box, HAJR_REPLAY_BOX);
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 === f.box));
assert.ok(player(f.game).hand.some(card => card.id === f.hajr));
assert.equal(physicalHajr(f.game, f).length, 1);
assert.equal(player(f.beforeShipment).spice - player(f.afterShipment).spice, 6);
assert.equal(player(f.game).spice, 2, 'The actual purchases and three-counter shipment preserve the Box fee');
assert.equal(player(f.game).forces[f.source], 3);
assert.equal(own(f.game).moved, 0);
assert.equal(own(f.game).movesAllowed, 1);
assert.equal(own(f.game).hajrUsed, false);
assert.equal(own(f.game).hajrPlays, 0);
assert.equal(availability(f.game, f), !f.game.advanced);
}
function settled(f: HajrReplayFixture, result: ReturnType<typeof completeHajrReplay>) {
const game = result.completed;
assert.equal(result.moves.length, 3);
assert.equal(player(game).moved, 3);
assert.equal(own(game).movesAllowed, 3);
assert.equal(own(game).hajrUsed, true);
assert.equal(own(game).hajrPlays, 2);
assert.equal(game.hajr.filter(actor => actor === f.owner).length, 1,
'The legacy Hajr summary remains a unique marker rather than a second allowance');
assert.deepEqual(result.actions.filter(step => step.action.type === 'card' && step.action.card === f.hajr)
.map(step => step.action.card), [f.hajr, f.hajr]);
assert.equal(physicalHajr(game, f).length, 1);
assert.equal(game.discard.filter(card => card.id === f.hajr).length, 1);
assert.equal(game.discard.filter(card => card.id === f.box).length, 1);
assert.equal(player(game).spice, player(f.game).spice - 2);
assert.equal(player(game).reserves, player(f.game).reserves);
assert.equal(player(game).tanks, player(f.game).tanks);
assert.equal(Object.values(player(game).forces).reduce((sum, count) => sum + count, 0), 3);
assert.notEqual(result.game.active, f.owner, 'The actual final endMovement action finishes the native owner turn');
assert.throws(() => actHajrReplay(game, f.owner, { type: 'card', card: f.hajr }),
'A third submission after the recovered physical Hajr is discarded cannot grant movement');
}
function recovered(f: HajrReplayFixture, result: ReturnType<typeof completeHajrReplay>) {
const { recovery } = result;
assert.equal(player(recovery.before).spice - player(recovery.paid).spice, 2);
assert.equal(player(recovery.paid).moved, player(recovery.before).moved,
'Paying for the Box supplies no Force Movement');
assert.equal(own(recovery.paid).hajrPlays, 1);
assert.equal(own(result.firstHajr).hajrPlays, 1);
assert.equal(result.firstHajr.discard.filter(card => card.id === f.hajr).length, 1);
assert.equal(availability(result.firstHajr, f), false);
assert.throws(() => actHajrReplay(result.firstHajr, f.owner, { type: 'card', card: f.hajr }),
'The consumed first-use ID is illegal until the Box actually returns that card');
assert.equal(player(result.recovered).hand.filter(card => card.id === f.hajr).length, 1);
assert.equal(result.recovered.discard.some(card => card.id === f.hajr), false);
assert.equal(physicalHajr(result.recovered, f).length, 1);
assert.equal(result.recovered.discard.at(-1)!.id, f.box, 'The played Box finishes on top after the real search');
assert.equal(result.recovered.pendingNullentropy ?? null, null);
assert.equal(result.recovered.pendingTreacheryDiscard ?? null, null);
if (recovery.selectionStep) {
const search = viewGame(recovery.paid, f.owner).nullentropy!.search!;
assert.equal(recovery.selectionStep.action.event, search.event);
assert.equal(recovery.selectionStep.action.card, f.hajr);
assert.ok(search.cards.some(card => card.id === f.hajr));
} else assert.ok(player(recovery.paid).hand.some(card => card.id === f.hajr),
'The original Box automatically retrieves its sole eligible discard');
}
void test('genuine Basic paid Box recovers the single discarded Hajr for two grants and three ordinary moves', () => {
const f = createHajrReplayFixture();
source(f);
const result = completeHajrReplay(f);
recovered(f, result);
assert.equal(own(result.beforeFirstMove).hajrPlays, 1);
assert.equal(own(result.beforeFirstMove).movesAllowed, 2);
assert.equal(player(result.beforeSecondHajr).moved, 1);
assert.equal(availability(result.recovered, f), true);
assert.equal(own(result.secondHajr).hajrPlays, 2);
assert.equal(own(result.secondHajr).movesAllowed, 3);
assert.equal(player(result.secondHajr).moved, 1);
assert.equal(player(result.beforeThirdMove).moved, 2);
settled(f, result);
});
void test('genuine Advanced paid Box recovery waits for the second typed Force Movement before replaying the same Hajr', () => {
const f = createHajrReplayFixture({ advanced: true });
source(f);
assert.equal(player(f.game).elites!.forces[f.source], 2);
const result = completeHajrReplay(f);
recovered(f, result);
assert.equal(own(result.beforeFirstMove).hajrPlays, 0);
assert.equal(player(result.firstHajr).moved, 1);
assert.equal(own(result.firstHajr).movesAllowed, 2);
assert.equal(own(result.recovered).hajrAvailable, false,
'A Box payment/search is voluntary and cannot open an immediate-after-movement window');
assert.equal(availability(result.recovered, f), false);
assert.throws(() => actHajrReplay(result.recovered, f.owner, { type: 'card', card: f.hajr }),
/Force Movement|move|movement|immediately/i);
assert.equal(player(result.beforeSecondHajr).moved, 2);
assert.equal(own(result.beforeSecondHajr).hajrAvailable, true);
assert.equal(availability(result.beforeSecondHajr, f), true);
assert.equal(player(result.secondHajr).moved, 2);
assert.equal(own(result.secondHajr).movesAllowed, 3);
assert.ok(result.moves.every(move => move.step.action.elite === 1),
'The same genuinely shipped Sardaukar may take all three ordinary moves');
assert.equal(Object.values(player(result.completed).elites!.forces).reduce((sum, count) => sum + count, 0), 2);
settled(f, result);
});
void test('all four minimal policies propose a legal third movement after genuine Basic and Advanced Hajr recovery', () => {
for (const advanced of [false, true]) {
const f = createHajrReplayFixture({ advanced });
const { beforeThirdMove } = completeHajrReplay(f);
assert.equal(player(beforeThirdMove).moved, 2);
assert.equal(own(beforeThirdMove).hajrPlays, 2);
assert.equal(own(beforeThirdMove).movesAllowed, 3);
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const view = viewGame(beforeThirdMove, 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} retains the original recovered-Hajr third move`);
assert.equal(action.ornithopterEvent, undefined);
assert.equal(action.movementCard, undefined);
const next = actHajrReplay(structuredClone(beforeThirdMove), f.owner, action);
assert.ok(player(next).moved === 3 || next.response || next.decision,
'The actual policy candidate completes the ordinary move or enters its native response');
}
}
});
+126
View File
@@ -0,0 +1,126 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createNexusGuildCunning,
nexusGuildCunningMoves,
validateNexusGuildCunning,
type NexusGuildCunningCountedBefore,
} from '../game/nexus-guild-cunning';
const context = {
turn: 3,
phase: 5,
active: 'g',
players: [
{ id: 'a', faction: 'atreides' as const },
{ id: 'g', faction: 'guild' as const },
{ id: 'f', faction: 'fremen' as const },
],
};
const counted = (moved: number, hajrPlays: number) =>
createNexusGuildCunning(context, 'g', { shipped: true, moved, hajrPlays });
// Counts describe already accepted physical card plays. The engine, rather
// than this allowance quote, proves actual Box retrieval and Advanced timing.
void test('counted Guild Cunning preserves the exact original boolean receipt bytes and allowance', () => {
const receipt = createNexusGuildCunning(context, 'g', {
shipped: true, moved: 1, hajrUsed: true,
});
const original = {
version: 1,
event: '["nexusGuildCunning",3,"g"]',
owner: 'g',
turn: 3,
phase: 5,
mode: 'cunning',
roster: [
{ id: 'a', faction: 'atreides' },
{ id: 'f', faction: 'fremen' },
{ id: 'g', faction: 'guild' },
],
before: { shipped: true, moved: 1, hajrUsed: true },
signature: '["nexusGuildCunningReceipt",1,"[\\"nexusGuildCunning\\",3,\\"g\\"]","g",3,5,"cunning",[["a","atreides"],["f","fremen"],["g","guild"]],[true,1,true]]',
};
assert.equal(JSON.stringify(receipt), JSON.stringify(original));
validateNexusGuildCunning(context, receipt);
assert.equal(nexusGuildCunningMoves(receipt, true), 1);
assert.equal(nexusGuildCunningMoves(receipt, true, 1), 0);
assert.throws(() => nexusGuildCunningMoves(receipt, true, 2));
});
void test('a counted Guild Cunning receipt records all earned Hajr moves before its second shipment', () => {
const before = Object.freeze({ shipped: true, moved: 3, hajrPlays: 2 });
const receipt = createNexusGuildCunning(context, 'g', before);
assert.equal(receipt.version, 2);
assert.deepEqual(receipt.before, before);
assert.notEqual(receipt.before, before);
assert.deepEqual(Object.keys(receipt.before).sort(), ['hajrPlays', 'moved', 'shipped']);
validateNexusGuildCunning(context, receipt);
assert.equal(nexusGuildCunningMoves(receipt, 2), 0);
assert.equal(nexusGuildCunningMoves(receipt, 3), 1,
'Another actual play after the shipment earns one fresh move');
});
void test('Cunning forfeits an unused ordinary move while preserving every unspent counted Hajr move', () => {
for (const [moved, remaining] of [[0, 2], [1, 2], [2, 1], [3, 0]]) {
const receipt = counted(moved, 2);
assert.equal(nexusGuildCunningMoves(receipt, 2), remaining);
assert.equal(nexusGuildCunningMoves(receipt, 2, remaining), 0);
assert.throws(() => nexusGuildCunningMoves(receipt, 2, remaining + 1));
}
for (const moved of [0, 1]) {
const receipt = counted(moved, 0);
assert.equal(nexusGuildCunningMoves(receipt, 0), 0,
'The extra shipment supplies no ordinary movement, even after declining the first');
assert.throws(() => nexusGuildCunningMoves(receipt, 0, 1));
}
});
void test('actual Hajr replays after the second shipment add moves rather than renewing an already consumed grant', () => {
const receipt = counted(1, 0);
assert.equal(nexusGuildCunningMoves(receipt, 1), 1);
assert.equal(nexusGuildCunningMoves(receipt, 1, 1), 0);
assert.equal(nexusGuildCunningMoves(receipt, 2, 1), 1);
assert.equal(nexusGuildCunningMoves(receipt, 2, 2), 0);
assert.equal(nexusGuildCunningMoves(receipt, 3, 2), 1);
assert.throws(() => nexusGuildCunningMoves(receipt, 2, 3));
const legacy = createNexusGuildCunning(context, 'g', {
shipped: true, moved: 2, hajrUsed: true,
});
assert.equal(nexusGuildCunningMoves(legacy, true), 0);
assert.equal(nexusGuildCunningMoves(legacy, 2), 1,
'An original v1 continuation may gain a genuinely new counted play');
assert.equal(nexusGuildCunningMoves(legacy, 2, 1), 0);
});
void test('counted Cunning rejects lost grants and nonintegral or excessive consumed movement', () => {
const receipt = counted(2, 2);
for (const current of [false, true, 0, 1, -1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER])
assert.throws(() => nexusGuildCunningMoves(receipt, current));
for (const moved of [-1, 0.5, NaN, Infinity, 2])
assert.throws(() => nexusGuildCunningMoves(receipt, 2, moved));
assert.equal(nexusGuildCunningMoves(receipt, 2, 1), 0);
assert.equal(nexusGuildCunningMoves(counted(1, 1), true), 1,
'Boolean callers still describe one actual play for a compatible counted receipt');
});
void test('counted Cunning binds its original count and cannot mix the two receipt formats', () => {
const before = { shipped: true, moved: 1, hajrPlays: 2 };
for (const bad of [
{ ...before, hajrPlays: -1 },
{ ...before, hajrPlays: 0.5 },
{ ...before, hajrPlays: NaN },
{ ...before, hajrPlays: Infinity },
{ ...before, hajrPlays: Number.MAX_SAFE_INTEGER },
{ ...before, moved: 4 },
{ ...before, hajrUsed: true },
]) assert.throws(() => createNexusGuildCunning(context, 'g', bad as NexusGuildCunningCountedBefore));
const receipt = counted(1, 2);
assert.throws(() => validateNexusGuildCunning(context, {
...receipt, before: { ...receipt.before, hajrPlays: 1 },
}));
assert.throws(() => validateNexusGuildCunning(context, {
...receipt, version: 1, before: { shipped: true, moved: 1, hajrUsed: true },
}));
});
+9 -10
View File
@@ -88,7 +88,8 @@ void test('Guild special Karama stops its own second shipment without payment or
const f = nexusGuildCunningFixture({ advanced: true, homeworlds });
const karama = holdGuildCunningCard(f.g, f.owner, 'karama');
const hajr = holdGuildCunningCard(f.g, f.owner, 'hajr');
let g = originalGuildCunningTurn(f, false);
let g = originalGuildCunningTurn(f);
g = applyAction(g, f.owner, {type:'card',card:hajr.id});
g = nexusGuildCunningAllow(
applyAction(g, f.owner, nexusGuildCunningRequest(f, g)),
);
@@ -131,22 +132,20 @@ void test('Guild special Karama stops its own second shipment without payment or
);
const move: Action = {
type: 'move',
from: 'arrakeen:10',
territory: 'hagga_basin',
from: 'hagga_basin:12',
territory: 'plastic_basin',
sector: 12,
amount: 5,
};
rejected(g, f.owner, move);
rejected(g, f.owner, shipment);
g = applyAction(nexusGuildCunningReload(g), f.owner, {
type: 'card',
card: hajr.id,
});
assert.equal(viewGame(g,f.owner).nexusGuildCunning!.active!.movesLeft,1);
// Supplied Advanced timing played Hajr after the first actual movement;
// the stopped second shipment preserves that already earned unused move.
g = applyAction(g, f.owner, move);
rejected(g, f.owner, {
type: 'move',
from: 'hagga_basin:12',
territory: 'arsunt',
from: 'plastic_basin:12',
territory: 'hagga_basin',
sector: 12,
amount: 5,
});