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

+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.',