Compose Guild Cunning with physical Ornithopter movement

Provisionally let either physical card mode replace an earned unused Hajr movement after the second shipment. Keep active group rights separate from actual completed credits and original movement counts. Add v3 original-count receipts while preserving old formats; abandon partial flights through a bound discard suffix before Cunning resumes. Connect persistent controls and minimal legal bot candidates.

Validation: frozen13-file125/125, types/lint/build and eight original-action/rendered-table continuations. Public ready/start, cache1/Hajr1 purchases, real worm/alliance/Guild Nexus draw and paid first2/second1 shipments cover both modes, earlier flights and Basic early endings. Eight minimal policy proposals are legal. No frozen repair; bounded continuation review found no concrete defect. New Semuta grammar lacks direct combined claim/capture proof. Wider combinations, complete modes, final assurance and browser/deployed acceptance remain open. No saved-room/reset/restart/deployment operation.
This commit is contained in:
Quintine committed 2026-10-18 13:10:12 +11:00
1 parent 7fe84ffbbd
commit 561152d58e
16 files changed
+887 -54

No files matched your search

+1 -1
View File
@@ -3328,7 +3328,7 @@ function policyActions(g: GameView): Action[] {
const movementAvailable = nexusGuildMovementAvailable(g);
const hajr = nexusGuildHajrAction(g);
if (hajr) return [hajr];
const cardMoves = nexusGuildCunningActive(g) ? [] : ornithopterMoves(g);
const cardMoves = ornithopterMoves(g);
const discoveryMoves = g.discoveryOrnithopter && !g.discoveryOrnithopter.blocked
? discoveryFlightBotActions(g,
destinations(g).filter(target => target.score > -1000)
+47 -24
View File
@@ -1556,7 +1556,7 @@ export type Game = {
}
| {
kind: 'ornithopterDiscard';
source: 'move' | 'end';
source: 'move' | 'end' | 'cunning';
flight: NonNullable<Game['ornithopter']>;
movement: CompletedMovement | null;
resume: NonNullable<Game['pendingNullentropy']>['resume'];
@@ -2031,7 +2031,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;hajrPlays?: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;ornithopterExtraMoves?: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};
@@ -6949,7 +6949,7 @@ function treacheryDiscardIntegrity(g: Game) {
Number.isSafeInteger(flight.completed) &&
flight.completed >= 0 &&
owner.moved === flight.startingMove + flight.completed &&
['move', 'end'].includes(c.source) &&
['move', 'end', 'cunning'].includes(c.source) &&
(c.source === 'move'
? flight.completed === total
: flight.completed < total) &&
@@ -6991,6 +6991,12 @@ function treacheryDiscardIntegrity(g: Game) {
c.movement === null,
'An ended Ornithopter has no arrival to replay.',
);
if (c.source === 'cunning') {
requireRule(c.resume.response?.kind === 'nexusGuildCunning' &&
c.resume.response.owner === owner.id,
'The ended Ornithopter must resume its original Guild Cunning response.');
validateGuildCunningResponse(g, c.resume.response);
}
requireRule(
c.stateSignature === ornithopterDiscardSignature(g, c),
'The retired Ornithopter no longer matches its saved movement obligations.',
@@ -7503,7 +7509,7 @@ function semutaOfferSupported(
continuation.kind !== 'winnerMandatoryDiscard' &&
continuation.kind !== 'battleResolved' &&
!(continuation.kind === 'ornithopterDiscard' &&
(continuation.source === 'end' ||
(continuation.source === 'end' || continuation.source === 'cunning' ||
(continuation.source === 'move' &&
!g.players.some(p => p.faction === 'ecaz' || p.faction === 'moritani') &&
!g.pendingArrivalOverlap))) &&
@@ -7645,7 +7651,9 @@ function semutaOfferSupported(
continuation.kind === 'karamaEmperorShareDiscard' ||
continuation.kind === 'karamaIncomeDiscard' ||
continuation.kind === 'karamaShipmentDiscard' ||
(!continuation.resume.response && !continuation.resume.decision &&
((continuation.kind === 'ornithopterDiscard' && continuation.source === 'cunning'
? continuation.resume.response?.kind === 'nexusGuildCunning'
: !continuation.resume.response) && !continuation.resume.decision &&
!continuation.resume.pendingKarama &&
(continuation.kind === 'amalDiscard'
? continuation.resume.phaseOpening?.passed.length === 0
@@ -7982,7 +7990,7 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string
g.pendingKarama = next.resume.pendingKarama;
g.phaseOpening = next.resume.phaseOpening;
if (next.source === 'move') finishMovedGroup(g, next.movement!);
else finishMovementTurn(g, next.flight.player);
else if (next.source === 'end') finishMovementTurn(g, next.flight.player);
} else if (next.kind === 'ixAllyCard') {
const buyer = getPlayer(g, next.player);
const card = draw(g);
@@ -15611,12 +15619,13 @@ function hajrPlayCount(g: Game, p: Player) {
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,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(hajrPlayCount(g,p), p.ornithopterExtraMoves);
if (flight?.player === p.id && flight.turn === g.turn)
return flight.startingMove + (flight.mode === 'twoGroups' ? 2 : 1);
const cunning = currentGuildCunning(g,p.id);
if (cunning) return p.moved + (cunning.stage === 'extraMove' ? nexusRule(() =>
nexusGuildCunningMoves(cunning.receipt,hajrPlayCount(g,p),cunning.movesAfter,p.ornithopterExtraMoves ?? 0)) : 0);
return ordinaryMovementAllowance(hajrPlayCount(g,p), p.ornithopterExtraMoves);
}
function hajrAvailable(g: Game, p: Player) {
return p.hand.some(card => card.kind === 'special' && card.effect === 'hajr') && p.moved <= movesAllowed(g,p) &&
@@ -15686,7 +15695,6 @@ function ornithopterBlock(
return 'Resolve the current interaction before declaring a movement.';
if (g.ornithopter)
return 'Finish the already played Ornithopter movement first.';
if (currentGuildCunning(g,p.id)) return 'Guild Cunning combined with the physical Ornithopter card awaits its timing ruling.';
if (p.moved >= movesAllowed(g,p))
return 'Spend an unused ordinary or Hajr movement on Ornithopter.';
// Provisional composition: Kulon augments normal movement, not the
@@ -15743,7 +15751,7 @@ function ornithopterIntegrity(g: Game) {
}
function finishOrnithopter(
g: Game,
source: 'move' | 'end',
source: 'move' | 'end' | 'cunning',
movement: CompletedMovement | null = null,
) {
const flight = g.ornithopter;
@@ -16322,7 +16330,14 @@ function completeMove(g: Game, move: MovementOrder) {
if (flight?.player === id) {
flight.completed++;
if (flight.completed === (flight.mode === 'twoGroups' ? 2 : 1)) {
if (flight.mode === 'twoGroups') p.ornithopterExtraMoves = (p.ornithopterExtraMoves ?? 0) + 1;
if (flight.mode === 'twoGroups') {
p.ornithopterExtraMoves = (p.ornithopterExtraMoves ?? 0) + 1;
if (cunning) {
cunning.hajrPlays = hajrPlayCount(g,p);
cunning.ornithopterExtraMoves = p.ornithopterExtraMoves;
saveGuildCunning(g,cunning);
}
}
finishOrnithopter(g, 'move', completed);
return;
}
@@ -28391,7 +28406,8 @@ function guildCunningParent(g: Game) {
}
function guildCunningSignature(record: NonNullable<Game['nexusGuildCunningHistory']>[number]) {
return JSON.stringify([record.receipt.signature,record.stage,record.parent,record.movesAfter,record.shipment ?? null,
...(record.hajrPlays === undefined ? [] : [record.hajrPlays])]);
...(record.hajrPlays === undefined ? [] : [record.hajrPlays]),
...(record.ornithopterExtraMoves === undefined ? [] : [['ornithopter',record.ornithopterExtraMoves]])]);
}
function saveGuildCunning(g: Game, record: NonNullable<Game['nexusGuildCunningHistory']>[number]) {
record.signature = guildCunningSignature(record);
@@ -28402,7 +28418,7 @@ 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,hajrPlayCount(g,p),record.movesAfter)) : 0,
movesLeft:record.stage === 'extraMove' ? movesAllowed(g,p) - p.moved : 0,
hajrAvailable:record.stage !== 'pending' && hajrAvailable(g,p)}};
if (g.nexusCards?.cards?.hands[owner] !== 'guild') return null;
let blocked: string | null = null;
@@ -28413,8 +28429,6 @@ 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 > 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.';
return {offer:{event:JSON.stringify(['nexusGuildCunning',g.turn,owner]),blocked},active:null};
@@ -28422,8 +28436,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,hajrPlays:hajrPlayCount(g,p)}));
const record = {receipt,stage:'pending' as const,parent:guildCunningParent(g),movesAfter:0,hajrPlays:hajrPlayCount(g,p),signature:''};
const receipt = nexusRule(() => createNexusGuildCunning(g,p.id,{shipped:p.shipped,moved:p.moved,hajrPlays:hajrPlayCount(g,p),ornithopterExtraMoves:p.ornithopterExtraMoves ?? 0}));
const record = {receipt,stage:'pending' as const,parent:guildCunningParent(g),movesAfter:0,hajrPlays:hajrPlayCount(g,p),ornithopterExtraMoves:p.ornithopterExtraMoves ?? 0,signature:''};
g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!,p.id,g.players));
(g.nexusGuildCunningHistory ??= []).push(record); saveGuildCunning(g,record);
g.karamaShipping = null;
@@ -28482,16 +28496,20 @@ 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','hajrPlays','shipment','signature'].includes(k)) &&
requireRule(record && typeof record === 'object' && Object.keys(record).every(k => ['receipt','stage','parent','movesAfter','hajrPlays','ornithopterExtraMoves','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 &&
(['completed','canceled'].includes(record.stage) || index === history.length - 1),'Guild Cunning has a changed or duplicated allowance record.');
nexusRule(() => validateNexusGuildCunning(g,record.receipt));
requireRule((record.ornithopterExtraMoves === undefined ||
(Number.isSafeInteger(record.ornithopterExtraMoves) && record.ornithopterExtraMoves >= 0)) &&
(record.receipt.version !== 3 || record.ornithopterExtraMoves !== undefined),
'Guild Cunning needs its actual completed Ornithopter count.');
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));
nexusRule(() => nexusGuildCunningMoves(record.receipt,record.hajrPlays!,record.movesAfter,record.ornithopterExtraMoves ?? 0));
} 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.');
@@ -28517,9 +28535,10 @@ function nexusGuildCunningIntegrity(g: Game) {
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)) &&
(active.ornithopterExtraMoves === undefined || active.ornithopterExtraMoves === (owner.ornithopterExtraMoves ?? 0)) &&
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,hajrPlayCount(g,owner),active.movesAfter));
nexusRule(() => nexusGuildCunningMoves(active.receipt,hajrPlayCount(g,owner),active.movesAfter,owner.ornithopterExtraMoves ?? 0));
for (const response of responses) validateGuildCunningResponse(g,response!);
if (active.shipment?.stage === 'pending') {
const suspended = g.pendingGuildBetrayal?.continuation;
@@ -34287,7 +34306,11 @@ function applyActionInner(
g.phase === 5 && g.active === id,
'Wait for your movement turn.',
);
if (action.nexus !== undefined) { declareGuildCunning(g,p,action.nexus); return g; }
if (action.nexus !== undefined) {
declareGuildCunning(g,p,action.nexus);
if (g.ornithopter?.player === id) finishOrnithopter(g, 'cunning');
return g;
}
// Reject unsupported concealed custody before retiring a played movement
// card and parking its native ending continuation.
requireAlliedSeparationCustody(g, p);
+58 -17
View File
@@ -9,12 +9,21 @@ export type NexusGuildCunningBefore = {
moved: number;
hajrUsed: boolean;
hajrPlays?: never;
ornithopterExtraMoves?: never;
};
export type NexusGuildCunningCountedBefore = {
shipped: boolean;
moved: number;
hajrPlays: number;
hajrUsed?: never;
ornithopterExtraMoves?: never;
};
export type NexusGuildCunningMovementBefore = {
shipped: boolean;
moved: number;
hajrPlays: number;
ornithopterExtraMoves: number;
hajrUsed?: never;
};
type NexusGuildCunningReceiptFields = {
event: string;
@@ -33,10 +42,16 @@ export type NexusGuildCunningCountedReceipt = NexusGuildCunningReceiptFields & {
version: 2;
before: NexusGuildCunningCountedBefore;
};
export type NexusGuildCunningMovementReceipt = NexusGuildCunningReceiptFields & {
version: 3;
before: NexusGuildCunningMovementBefore;
};
export type NexusGuildCunningReceipt =
| NexusGuildCunningLegacyReceipt
| NexusGuildCunningCountedReceipt;
type Before = NexusGuildCunningBefore | NexusGuildCunningCountedBefore;
| NexusGuildCunningCountedReceipt
| NexusGuildCunningMovementReceipt;
type Before = NexusGuildCunningBefore | NexusGuildCunningCountedBefore |
NexusGuildCunningMovementBefore;
function requireGuild(value: unknown, message: string): asserts value {
if (!value) throw new Error(message);
@@ -75,17 +90,24 @@ 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) {
function validateBefore(before: Before, version: 1 | 2 | 3) {
const expectedKeys = version === 1 ? 'hajrUsed,moved,shipped' : version === 2
? 'hajrPlays,moved,shipped' : 'hajrPlays,moved,ornithopterExtraMoves,shipped';
requireGuild(
plain(before) &&
Object.keys(before).sort().join(',') ===
(version === 1 ? 'hajrUsed,moved,shipped' : 'hajrPlays,moved,shipped') &&
Object.keys(before).sort().join(',') === expectedKeys &&
typeof before.shipped === 'boolean' &&
(version === 1 ? typeof before.hajrUsed === 'boolean' : validPlayCount(before.hajrPlays)) &&
(version !== 3 || validPlayCount(before.ornithopterExtraMoves)) &&
Number.isSafeInteger(1 + (version === 1 ? Number(before.hajrUsed) : before.hajrPlays!) +
(version === 3 ? before.ornithopterExtraMoves! : 0)) &&
Number.isSafeInteger(before.moved) &&
before.moved >= 0 &&
before.moved <= 1 + (version === 1 ? Number(before.hajrUsed) : before.hajrPlays!),
'Guild Cunning needs the original ordinary shipment and Hajr movement allowance.',
before.moved <= 1 + (version === 1 ? Number(before.hajrUsed) : before.hajrPlays!) +
(version === 3 ? before.ornithopterExtraMoves! : 0),
version === 3
? 'Guild Cunning needs the original ordinary shipment, Hajr and completed Ornithopter movement allowance.'
: 'Guild Cunning needs the original ordinary shipment and Hajr movement allowance.',
);
}
function signature(receipt: NexusGuildCunningReceipt) {
@@ -98,8 +120,11 @@ function signature(receipt: NexusGuildCunningReceipt) {
receipt.phase,
receipt.mode,
receipt.roster.map((p) => [p.id, p.faction]),
[receipt.before.shipped, receipt.before.moved,
receipt.version === 1 ? receipt.before.hajrUsed : receipt.before.hajrPlays],
receipt.version === 3
? [receipt.before.shipped, receipt.before.moved, receipt.before.hajrPlays,
receipt.before.ornithopterExtraMoves]
: [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.
@@ -113,7 +138,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 === 2) &&
(receipt.version === 1 || receipt.version === 2 || receipt.version === 3) &&
receipt.phase === 5 &&
receipt.mode === 'cunning' &&
Number.isSafeInteger(receipt.turn) &&
@@ -146,6 +171,11 @@ export function createNexusGuildCunning(
owner: string,
before: NexusGuildCunningCountedBefore,
): NexusGuildCunningCountedReceipt;
export function createNexusGuildCunning(
context: NexusGuildCunningContext & { phase: number; active: string | null },
owner: string,
before: NexusGuildCunningMovementBefore,
): NexusGuildCunningMovementReceipt;
export function createNexusGuildCunning(
context: NexusGuildCunningContext & { phase: number; active: string | null },
owner: string,
@@ -165,7 +195,8 @@ export function createNexusGuildCunning(
),
'Guild Cunning requires the unallied native Guild at the end of its own combined turn.',
);
const version = plain(before) && Object.hasOwn(before, 'hajrPlays') ? 2 : 1;
const version = plain(before) && Object.hasOwn(before, 'ornithopterExtraMoves') ? 3 :
plain(before) && Object.hasOwn(before, 'hajrPlays') ? 2 : 1;
validateBefore(before, version);
const fields: Omit<NexusGuildCunningReceiptFields, 'signature'> = {
event: JSON.stringify(['nexusGuildCunning', context.turn, owner]),
@@ -177,18 +208,22 @@ export function createNexusGuildCunning(
};
const receipt: NexusGuildCunningReceipt = version === 1
? { version: 1, ...fields, before: { ...before as NexusGuildCunningBefore }, signature: '' }
: { version: 2, ...fields, before: { ...before as NexusGuildCunningCountedBefore }, signature: '' };
: version === 2
? { version: 2, ...fields, before: { ...before as NexusGuildCunningCountedBefore }, signature: '' }
: { version: 3, ...fields, before: { ...before as NexusGuildCunningMovementBefore }, signature: '' };
receipt.signature = signature(receipt);
validateNexusGuildCunning(context, receipt);
return receipt;
}
/** 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. */
/** Ending ordinary movement forgoes at least its first slot. Actual Hajr plays
* and completed two-group Ornithopter uses supply their earned extra slots.
* The engine owns card custody, play timing and any still-active flight's rights;
* no speculative flight credit is supplied here. Legacy defaults are unchanged. */
export function nexusGuildCunningMoves(
receipt: NexusGuildCunningReceipt,
currentHajrUsed: boolean | number,
movesAfterSecond = 0,
currentOrnithopterExtraMoves = 0,
): number {
validateNexusGuildCunning(
{ turn: receipt.turn, players: receipt.roster },
@@ -196,13 +231,19 @@ export function nexusGuildCunningMoves(
);
const currentCount = typeof currentHajrUsed === 'boolean' ? Number(currentHajrUsed) : currentHajrUsed;
const beforeCount = receipt.version === 1 ? Number(receipt.before.hajrUsed) : receipt.before.hajrPlays;
const beforeExtraMoves = receipt.version === 3 ? receipt.before.ornithopterExtraMoves : 0;
requireGuild(
validPlayCount(currentCount) && currentCount >= beforeCount &&
validPlayCount(currentOrnithopterExtraMoves) && currentOrnithopterExtraMoves >= beforeExtraMoves &&
Number.isSafeInteger(1 + currentCount + currentOrnithopterExtraMoves) &&
Number.isSafeInteger(movesAfterSecond) &&
movesAfterSecond >= 0,
'Guild Cunning has changed its remaining Hajr movement.',
receipt.version === 3 || currentOrnithopterExtraMoves !== 0
? 'Guild Cunning has changed its remaining Hajr or Ornithopter movement.'
: 'Guild Cunning has changed its remaining Hajr movement.',
);
const available = Math.max(0, 1 + currentCount - Math.max(1, receipt.before.moved));
const available = Math.max(0, 1 + currentCount + currentOrnithopterExtraMoves -
Math.max(1, receipt.before.moved));
requireGuild(
movesAfterSecond <= available,
'Guild Cunning cannot supply another ordinary movement.',
+11 -6
View File
@@ -902,7 +902,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'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 play plus a two-group flight permits three group movements; Hajr may reuse a card-moved group. Ending early abandons the unused card group and ends your turn. The single physical Hajr may grant another move after legitimate recovery and a new legal play.',
'Advanced Hajr must be played immediately after a completed Force Movement and its required arrival decisions, before your next voluntary game action. A completed card group provisionally supplies this opportunity. Basic retains broader own-turn timing. Kulon leaves the fixed card maximum at three and extends both normal-range groups. Advanced advisors may use both physical card modes. They do not grant city access themselves; two-group mode uses current fighter city access at each move, while range3 is independent. Empty arrivals flip normally, and fresh-advisor locks and original-group limits still apply. Guild Cunning/card and wider optional combinations remain unfinished.',
'Advanced Hajr must be played immediately after a completed Force Movement and its required arrival decisions, before your next voluntary game action. A completed card group provisionally supplies this opportunity. Basic retains broader own-turn timing. Kulon leaves the fixed card maximum at three and extends both normal-range groups. Advanced advisors may use both physical card modes. They do not grant city access themselves; two-group mode uses current fighter city access at each move, while range3 is independent. Empty arrivals flip normally, and fresh-advisor locks and original-group limits still apply. After Guild Cunning, the physical card may provisionally replace one earned unused Hajr allowance. Cunning itself adds no ordinary move; an active two-group flight keeps its original remaining group. Declaring Cunning abandons an unfinished earlier flight without extra credit. Wider optional combinations remain unfinished.',
'All four AI profiles use their own projected movement choices and original-group limits. They can use a longer route, split distinct groups, finish interrupted movement and end optional movement when no legal second group remains. Complete Richese-game calibration is not yet certified.',
],
related: [
@@ -916,19 +916,19 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Implementation',
status: 'Partial',
detail:
'Both modes retain original event/cohort accounting. One unused normal/Hajr entitlement can start a flight; completed two-group credit preserves unused Hajr afterward. Each genuinely recovered Hajr replay has its own grant. Advanced uses supplied immediate-after-play timing with an explicit online/card-group convention. Both modes now accept Advanced advisors through native stance/entry and original-group rules. Advisors do not grant city access; current fighter-city range is recomputed for each normal group. Wider combinations and broader acceptance 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. Both modes now accept Advanced advisors through native stance/entry and original-group rules. Advisors do not grant city access; current fighter-city range is recomputed for each normal group. Guild Cunning now retains actual earlier credits and permits a physical card to replace an unused Hajr grant, with active-flight and early-end suffixes. New v3 receipts preserve old formats. Wider combinations and broader acceptance remain unfinished.',
},
{
area: 'Player controls',
status: 'Partial',
detail:
'Existing source/destination, typed group, mode and inspector controls consume current shared movement availability. Persistent text explains Hajr composition, Advanced timing and advisor/city access; automatic disposal adds no confirmation.',
'Existing source/destination, typed group, mode and inspector controls consume current shared movement availability. Persistent text explains Hajr composition, Guild Cunning prerequisites/early ending, Advanced timing and advisor/city access; automatic disposal adds no confirmation.',
},
{
area: 'AI',
status: 'Partial',
detail:
'Four profiles use projected ranges, advisor-aware routes/native entry, distinct group quotas and earned Hajr movement. Actual advisor and owned second-group candidates are legal; strategic tuning remains deferred.',
'Four profiles use projected ranges, advisor-aware routes/native entry, distinct group quotas and earned Hajr movement, including post-Cunning card groups. Actual advisor and owned second-group candidates are legal; strategic tuning remains deferred.',
},
{
area: 'Documentation',
@@ -940,9 +940,14 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Verification',
status: 'Partial',
detail:
'18 October advisor scope:10-file87/87, types/lint/build and three original-action/rendered-table programmes pass. Actual host/setup, paid cache1, native accompaniment and paid BG reinforcement cover advisor range3, distinct advisor/fighter groups and loss of city access. Eight minimal policy proposals are legal. Controlled geometry and isolated controls remain separately labelled; no select/submit browser click. Earlier Hajr evidence remains separate. Framework aliases/CSS stubs mean no visual/browser/deployed acceptance; full combinations/assurance/modes remain open.',
'18 October Guild Cunning scope:13-file125/125, types/lint/build and eight original-action/rendered-table continuations pass. Public ready/start, cache1/Hajr1 purchases, worm/alliance/Guild draw and paid first2/second1 shipments cover both modes, earlier flights and Basic early ending. Eight minimal policy proposals are legal; unearned/spent Hajr rejects. Old receipt formats remain covered. Earlier advisor/Hajr evidence is separate. Actual accepted drafts render without select/submit browser clicks. New Semuta grammar lacks direct combined claim/capture proof; wider combinations, full modes, final assurance and visual/browser/deployed acceptance remain open.',
evidence: [
'game/ornithopter.ts',
'game/nexus-guild-cunning.ts',
'tests/nexus-guild-cunning-ornithopter.test.ts',
'tests/fixture-guild-cunning-ornithopter.ts',
'tests/guild-cunning-ornithopter-engine.test.ts',
'docs/NEXUS_GUILD_CUNNING_RUNTIME.md',
'components/ornithopter-movement.tsx',
'tests/ornithopter-advisor-rules.test.ts',
'tests/ornithopter-advisors-engine.test.ts',
@@ -4781,7 +4786,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. 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.',
'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 may provisionally replace one unused Hajr grant. Both original card groups remain legal until completed or abandoned, with extra credit only after both actually move. Earlier completed flight credits remain counted; declaring Cunning during an unfinished flight abandons its remaining group before the original Nexus response resumes. This adds no ordinary move.',
'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.',