Implement Atreides Nexus inspections with durable battle commitments
This commit is contained in:
1 parent
d2a1d63517
commit
d820c8a414
25 files changed
+3171
-182
No files matched your search
@@ -0,0 +1,487 @@
|
||||
import { FACTIONS, type FactionId } from './catalog';
|
||||
import type { PlanField } from './engine';
|
||||
|
||||
export type InspectionValue = string | number | null;
|
||||
export type NativeInspection = {
|
||||
player: string;
|
||||
field: PlanField;
|
||||
value?: InspectionValue;
|
||||
};
|
||||
export type NexusInspection = {
|
||||
version: 1;
|
||||
event: string;
|
||||
mode: 'cunning' | 'secretAlly' | 'betrayal';
|
||||
owner: string;
|
||||
target: string;
|
||||
field: PlanField;
|
||||
stage: 'response' | 'answer' | 'answered' | 'canceled';
|
||||
answers: InspectionValue[];
|
||||
/** Original first disclosure remains evidence even after Residual reopens it. */
|
||||
first?: { player: string; field: PlanField; value: InspectionValue };
|
||||
/** Current native commitment; absent only after its recorded reopening. */
|
||||
firstCurrent?: { value: InspectionValue };
|
||||
/** Original native attempt can disappear when Betrayal cancels it. */
|
||||
nativeProvider?: string;
|
||||
nativeBeneficiary?: string;
|
||||
signature: string;
|
||||
};
|
||||
export type BattleInspectionContext = {
|
||||
event: string;
|
||||
attacker: string;
|
||||
defender: string;
|
||||
players: readonly { id: string; faction: FactionId }[];
|
||||
native?: NativeInspection;
|
||||
};
|
||||
export type CommittedPlanElement = {
|
||||
source: 'native' | 'nexus';
|
||||
beneficiary: string;
|
||||
target: string;
|
||||
field: PlanField;
|
||||
value: InspectionValue;
|
||||
};
|
||||
type InspectionBattle = {
|
||||
event?: string;
|
||||
attacker: string;
|
||||
defender: string;
|
||||
prescience?: NativeInspection;
|
||||
nexusInspection?: NexusInspection;
|
||||
};
|
||||
const has = (object: object, key: string) =>
|
||||
Object.prototype.hasOwnProperty.call(object, key);
|
||||
const fields: PlanField[] = ['leader', 'weapon', 'defense', 'dial'];
|
||||
function requireInspection(
|
||||
condition: unknown,
|
||||
message: string,
|
||||
): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
function validId(id: unknown): id is string {
|
||||
return typeof id === 'string' && id.trim().length > 0;
|
||||
}
|
||||
function validateValue(field: PlanField, value: unknown): void {
|
||||
requireInspection(
|
||||
field === 'dial'
|
||||
? typeof value === 'number' &&
|
||||
value >= 0 &&
|
||||
Number.isSafeInteger(value * 2)
|
||||
: value === null || validId(value),
|
||||
'Invalid battle inspection value.',
|
||||
);
|
||||
}
|
||||
function validateNative(native: NativeInspection): void {
|
||||
requireInspection(
|
||||
native && validId(native.player) && fields.includes(native.field),
|
||||
'Invalid native inspection.',
|
||||
);
|
||||
if (has(native, 'value')) validateValue(native.field, native.value);
|
||||
}
|
||||
function opponent(
|
||||
context: { attacker: string; defender: string },
|
||||
player: string,
|
||||
): string {
|
||||
requireInspection(
|
||||
player === context.attacker || player === context.defender,
|
||||
'An inspection must belong to a combatant.',
|
||||
);
|
||||
return player === context.attacker ? context.defender : context.attacker;
|
||||
}
|
||||
function validateContext(context: BattleInspectionContext): void {
|
||||
requireInspection(
|
||||
validId(context.event) &&
|
||||
validId(context.attacker) &&
|
||||
validId(context.defender) &&
|
||||
context.attacker !== context.defender &&
|
||||
Array.isArray(context.players) &&
|
||||
context.players.length >= 2 &&
|
||||
context.players.length <= 6,
|
||||
'Invalid inspection battle.',
|
||||
);
|
||||
const ids = new Set<string>();
|
||||
const factions = new Set<FactionId>();
|
||||
for (const player of context.players) {
|
||||
requireInspection(
|
||||
validId(player.id) &&
|
||||
!ids.has(player.id) &&
|
||||
!factions.has(player.faction) &&
|
||||
FACTIONS.some((faction) => faction.id === player.faction),
|
||||
'Invalid inspection seats.',
|
||||
);
|
||||
ids.add(player.id);
|
||||
factions.add(player.faction);
|
||||
}
|
||||
requireInspection(
|
||||
ids.has(context.attacker) && ids.has(context.defender),
|
||||
'Inspection combatants must be seated.',
|
||||
);
|
||||
if (context.native) {
|
||||
validateNative(context.native);
|
||||
opponent(context, context.native.player);
|
||||
}
|
||||
}
|
||||
function signature(inspection: Omit<NexusInspection, 'signature'>): string {
|
||||
return JSON.stringify([
|
||||
'nexusInspection',
|
||||
inspection.version,
|
||||
inspection.event,
|
||||
inspection.mode,
|
||||
inspection.owner,
|
||||
inspection.target,
|
||||
inspection.field,
|
||||
inspection.stage,
|
||||
inspection.answers,
|
||||
inspection.first
|
||||
? [
|
||||
inspection.first.player,
|
||||
inspection.first.field,
|
||||
inspection.first.value,
|
||||
]
|
||||
: null,
|
||||
inspection.firstCurrent ? [inspection.firstCurrent.value] : null,
|
||||
inspection.nativeProvider ?? null,
|
||||
inspection.nativeBeneficiary ?? null,
|
||||
]);
|
||||
}
|
||||
function validateRecord(inspection: NexusInspection): void {
|
||||
requireInspection(
|
||||
inspection &&
|
||||
typeof inspection === 'object' &&
|
||||
!Array.isArray(inspection) &&
|
||||
inspection.version === 1 &&
|
||||
validId(inspection.event) &&
|
||||
validId(inspection.owner) &&
|
||||
validId(inspection.target) &&
|
||||
['cunning', 'secretAlly', 'betrayal'].includes(inspection.mode) &&
|
||||
fields.includes(inspection.field) &&
|
||||
['response', 'answer', 'answered', 'canceled'].includes(
|
||||
inspection.stage,
|
||||
) &&
|
||||
Array.isArray(inspection.answers),
|
||||
'Invalid Nexus inspection record.',
|
||||
);
|
||||
const keys = [
|
||||
'version',
|
||||
'event',
|
||||
'mode',
|
||||
'owner',
|
||||
'target',
|
||||
'field',
|
||||
'stage',
|
||||
'answers',
|
||||
'signature',
|
||||
];
|
||||
if (inspection.mode === 'cunning') {
|
||||
keys.push('first');
|
||||
requireInspection(
|
||||
inspection.first &&
|
||||
Object.keys(inspection.first).sort().join(',') === 'field,player,value',
|
||||
'Cunning requires the original first disclosure.',
|
||||
);
|
||||
validateNative(inspection.first);
|
||||
if (has(inspection, 'firstCurrent')) {
|
||||
keys.push('firstCurrent');
|
||||
requireInspection(
|
||||
inspection.firstCurrent &&
|
||||
Object.keys(inspection.firstCurrent).join(',') === 'value',
|
||||
'Invalid current native inspection receipt.',
|
||||
);
|
||||
validateValue(inspection.first.field, inspection.firstCurrent.value);
|
||||
}
|
||||
}
|
||||
if (inspection.mode === 'betrayal') {
|
||||
keys.push('nativeProvider', 'nativeBeneficiary');
|
||||
requireInspection(
|
||||
validId(inspection.nativeProvider) &&
|
||||
validId(inspection.nativeBeneficiary) &&
|
||||
inspection.stage === 'canceled' &&
|
||||
inspection.answers.length === 0,
|
||||
'Betrayal records a canceled native attempt without an answer.',
|
||||
);
|
||||
} else {
|
||||
requireInspection(
|
||||
(inspection.stage !== 'response' ||
|
||||
(inspection.mode === 'cunning' && inspection.answers.length === 0)) &&
|
||||
(inspection.stage !== 'canceled' ||
|
||||
(inspection.mode === 'cunning' && inspection.answers.length === 0)) &&
|
||||
(inspection.stage !== 'answered' || inspection.answers.length > 0) &&
|
||||
(inspection.field !== 'leader' || inspection.answers.length <= 1) &&
|
||||
!(
|
||||
inspection.field === 'leader' &&
|
||||
inspection.stage === 'answer' &&
|
||||
inspection.answers.length
|
||||
),
|
||||
'Invalid Nexus inspection answer history.',
|
||||
);
|
||||
}
|
||||
requireInspection(
|
||||
Object.keys(inspection).sort().join(',') === keys.sort().join(','),
|
||||
'Invalid Nexus inspection fields.',
|
||||
);
|
||||
for (const value of inspection.answers)
|
||||
validateValue(inspection.field, value);
|
||||
requireInspection(
|
||||
inspection.signature === signature(inspection),
|
||||
'The Nexus inspection has lost its original disclosure history.',
|
||||
);
|
||||
}
|
||||
export function validateNexusInspection(
|
||||
context: BattleInspectionContext,
|
||||
inspection: NexusInspection,
|
||||
): void {
|
||||
validateContext(context);
|
||||
validateRecord(inspection);
|
||||
requireInspection(
|
||||
inspection.event === context.event,
|
||||
'The Nexus inspection belongs to another battle.',
|
||||
);
|
||||
const owner = context.players.find((p) => p.id === inspection.owner);
|
||||
const atreides = context.players.find((p) => p.faction === 'atreides');
|
||||
requireInspection(owner, 'The Nexus inspection owner must be seated.');
|
||||
if (inspection.mode === 'betrayal') {
|
||||
requireInspection(
|
||||
atreides &&
|
||||
owner.id !== atreides.id &&
|
||||
inspection.nativeProvider === atreides.id &&
|
||||
inspection.target === opponent(context, inspection.nativeBeneficiary!),
|
||||
'Betrayal must bind the native Atreides attempt.',
|
||||
);
|
||||
if (context.native)
|
||||
requireInspection(
|
||||
context.native.player === inspection.nativeBeneficiary &&
|
||||
context.native.field === inspection.field &&
|
||||
!has(context.native, 'value'),
|
||||
'Betrayal must cancel the original unanswered attempt.',
|
||||
);
|
||||
} else {
|
||||
requireInspection(
|
||||
inspection.target === opponent(context, owner.id),
|
||||
'An inspection must target its owner’s battle opponent.',
|
||||
);
|
||||
if (inspection.mode === 'secretAlly')
|
||||
requireInspection(
|
||||
!atreides,
|
||||
'Secret Ally requires Atreides to be absent.',
|
||||
);
|
||||
else
|
||||
requireInspection(
|
||||
owner.faction === 'atreides' &&
|
||||
inspection.first!.player === owner.id &&
|
||||
context.native?.player === inspection.first!.player &&
|
||||
context.native.field === inspection.first!.field &&
|
||||
inspection.field !== inspection.first!.field &&
|
||||
has(context.native, 'value') === has(inspection, 'firstCurrent') &&
|
||||
(!has(inspection, 'firstCurrent') ||
|
||||
context.native.value === inspection.firstCurrent!.value),
|
||||
'Cunning must preserve a distinct original native disclosure.',
|
||||
);
|
||||
}
|
||||
}
|
||||
export function createNexusInspection(
|
||||
context: BattleInspectionContext,
|
||||
input: Pick<NexusInspection, 'mode' | 'owner' | 'target' | 'field'>,
|
||||
): NexusInspection {
|
||||
validateContext(context);
|
||||
const inspection: NexusInspection = {
|
||||
version: 1,
|
||||
event: context.event,
|
||||
mode: input.mode,
|
||||
owner: input.owner,
|
||||
target: input.target,
|
||||
field: input.field,
|
||||
stage:
|
||||
input.mode === 'betrayal'
|
||||
? 'canceled'
|
||||
: input.mode === 'cunning'
|
||||
? 'response'
|
||||
: 'answer',
|
||||
answers: [],
|
||||
signature: '',
|
||||
};
|
||||
if (input.mode === 'cunning') {
|
||||
requireInspection(
|
||||
context.native && has(context.native, 'value'),
|
||||
'Answer native prescience before using Cunning.',
|
||||
);
|
||||
inspection.firstCurrent = { value: context.native.value! };
|
||||
inspection.first = {
|
||||
player: context.native.player,
|
||||
field: context.native.field,
|
||||
value: context.native.value!,
|
||||
};
|
||||
}
|
||||
if (input.mode === 'betrayal') {
|
||||
requireInspection(
|
||||
context.native &&
|
||||
!has(context.native, 'value') &&
|
||||
context.native.field === input.field,
|
||||
'Betrayal needs an unanswered native attempt.',
|
||||
);
|
||||
inspection.nativeProvider = context.players.find(
|
||||
(p) => p.faction === 'atreides',
|
||||
)?.id;
|
||||
inspection.nativeBeneficiary = context.native.player;
|
||||
}
|
||||
inspection.signature = signature(inspection);
|
||||
validateNexusInspection(context, inspection);
|
||||
return inspection;
|
||||
}
|
||||
/** Record a native re-answer obligation before the engine removes its value. */
|
||||
export function reopenNexusNative(
|
||||
context: BattleInspectionContext,
|
||||
inspection: NexusInspection,
|
||||
): NexusInspection {
|
||||
validateNexusInspection(context, inspection);
|
||||
requireInspection(
|
||||
inspection.mode === 'cunning' &&
|
||||
context.native &&
|
||||
context.native.field !== 'leader' &&
|
||||
has(context.native, 'value') &&
|
||||
inspection.firstCurrent,
|
||||
'Only an answered non-leader native inspection can reopen.',
|
||||
);
|
||||
const next = structuredClone(inspection);
|
||||
delete next.firstCurrent;
|
||||
next.signature = signature(next);
|
||||
validateNexusInspection(
|
||||
{
|
||||
...context,
|
||||
native: { player: context.native.player, field: context.native.field },
|
||||
},
|
||||
next,
|
||||
);
|
||||
return next;
|
||||
}
|
||||
/** Record a legitimate replacement native answer before the engine stores it. */
|
||||
export function answerNexusNative(
|
||||
context: BattleInspectionContext,
|
||||
inspection: NexusInspection,
|
||||
value: InspectionValue,
|
||||
): NexusInspection {
|
||||
validateNexusInspection(context, inspection);
|
||||
requireInspection(
|
||||
inspection.mode === 'cunning' &&
|
||||
context.native &&
|
||||
!has(context.native, 'value') &&
|
||||
!has(inspection, 'firstCurrent'),
|
||||
'The original native inspection is not awaiting a replacement answer.',
|
||||
);
|
||||
validateValue(context.native.field, value);
|
||||
const next = { ...structuredClone(inspection), firstCurrent: { value } };
|
||||
next.signature = signature(next);
|
||||
validateNexusInspection(
|
||||
{ ...context, native: { ...context.native, value } },
|
||||
next,
|
||||
);
|
||||
return next;
|
||||
}
|
||||
/** The engine calls this only after the original Karama response finishes. */
|
||||
export function allowNexusInspection(
|
||||
context: BattleInspectionContext,
|
||||
inspection: NexusInspection,
|
||||
): NexusInspection {
|
||||
validateNexusInspection(context, inspection);
|
||||
requireInspection(
|
||||
inspection.mode === 'cunning' &&
|
||||
inspection.stage === 'response' &&
|
||||
inspection.answers.length === 0,
|
||||
'Only the original Cunning response can allow its answer.',
|
||||
);
|
||||
const next = { ...structuredClone(inspection), stage: 'answer' as const };
|
||||
next.signature = signature(next);
|
||||
validateNexusInspection(context, next);
|
||||
return next;
|
||||
}
|
||||
export function answerNexusInspection(
|
||||
context: BattleInspectionContext,
|
||||
inspection: NexusInspection,
|
||||
value: InspectionValue,
|
||||
): NexusInspection {
|
||||
validateNexusInspection(context, inspection);
|
||||
requireInspection(
|
||||
inspection.stage === 'answer',
|
||||
'This Nexus inspection is not awaiting an answer.',
|
||||
);
|
||||
validateValue(inspection.field, value);
|
||||
const next = {
|
||||
...structuredClone(inspection),
|
||||
stage: 'answered' as const,
|
||||
answers: [...inspection.answers, value],
|
||||
};
|
||||
next.signature = signature(next);
|
||||
validateNexusInspection(context, next);
|
||||
return next;
|
||||
}
|
||||
/** Ordinary native-power cancellation can stop only the original unanswered
|
||||
* extra Cunning attempt. The first disclosure and spent-card record survive. */
|
||||
export function cancelNexusInspection(
|
||||
context: BattleInspectionContext,
|
||||
inspection: NexusInspection,
|
||||
): NexusInspection {
|
||||
validateNexusInspection(context, inspection);
|
||||
requireInspection(
|
||||
inspection.mode === 'cunning' &&
|
||||
inspection.stage === 'response' &&
|
||||
inspection.answers.length === 0,
|
||||
'Only the original unanswered Cunning inspection can be canceled.',
|
||||
);
|
||||
const next = { ...structuredClone(inspection), stage: 'canceled' as const };
|
||||
next.signature = signature(next);
|
||||
validateNexusInspection(context, next);
|
||||
return next;
|
||||
}
|
||||
export function reopenNexusInspection(
|
||||
context: BattleInspectionContext,
|
||||
inspection: NexusInspection,
|
||||
): NexusInspection {
|
||||
validateNexusInspection(context, inspection);
|
||||
requireInspection(
|
||||
inspection.stage === 'answered' && inspection.field !== 'leader',
|
||||
'Only an answered non-leader inspection can reopen.',
|
||||
);
|
||||
const next = { ...structuredClone(inspection), stage: 'answer' as const };
|
||||
next.signature = signature(next);
|
||||
validateNexusInspection(context, next);
|
||||
return next;
|
||||
}
|
||||
/** Current binding only. Historical answers survive on the record but do not
|
||||
* constrain a reopened field. Callers validate the full public context first. */
|
||||
export function committedPlanElements(
|
||||
battle: InspectionBattle,
|
||||
target: string,
|
||||
): CommittedPlanElement[] {
|
||||
const result: CommittedPlanElement[] = [];
|
||||
if (battle.prescience) {
|
||||
const native = battle.prescience;
|
||||
validateNative(native);
|
||||
if (opponent(battle, native.player) === target && has(native, 'value'))
|
||||
result.push({
|
||||
source: 'native',
|
||||
beneficiary: native.player,
|
||||
target,
|
||||
field: native.field,
|
||||
value: native.value!,
|
||||
});
|
||||
}
|
||||
const inspection = battle.nexusInspection;
|
||||
if (inspection) {
|
||||
validateRecord(inspection);
|
||||
requireInspection(
|
||||
battle.event === inspection.event,
|
||||
'The Nexus inspection belongs to another battle.',
|
||||
);
|
||||
if (inspection.mode !== 'betrayal')
|
||||
requireInspection(
|
||||
opponent(battle, inspection.owner) === inspection.target,
|
||||
'An inspection must target its owner’s battle opponent.',
|
||||
);
|
||||
if (inspection.stage === 'answered' && inspection.target === target)
|
||||
result.push({
|
||||
source: 'nexus',
|
||||
beneficiary: inspection.owner,
|
||||
target,
|
||||
field: inspection.field,
|
||||
value: inspection.answers.at(-1)!,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
export const inspectedPlanElements = committedPlanElements;
|
||||
+62
-28
@@ -626,11 +626,17 @@ function plans(g: GameView): Action[] {
|
||||
? b.opponentForces.normal * (b.opponentForces.normalFixedHalf ? 0.5 : 1) +
|
||||
b.opponentForces.elite * b.opponentForces.eliteStrength
|
||||
: enemies;
|
||||
const insight = b.insight;
|
||||
const revealedCard =
|
||||
insight && ['weapon', 'defense'].includes(insight.field)
|
||||
? treacheryDeck(['ix']).find((c) => c.id === insight.value)
|
||||
: undefined;
|
||||
const insights = [
|
||||
...(b.insight && b.prescience?.player === me.id ? [b.insight] : []),
|
||||
...(b.nexusInspection?.owner === me.id
|
||||
? b.nexusInsights.filter((insight) => insight.active)
|
||||
: []),
|
||||
];
|
||||
const known = (field: string) =>
|
||||
insights.find((insight) => insight.field === field);
|
||||
const knownCards = treacheryDeck(['ix']);
|
||||
const revealedWeapon = knownCards.find((c) => c.id === known('weapon')?.value);
|
||||
const revealedDefense = knownCards.find((c) => c.id === known('defense')?.value);
|
||||
const inspected =
|
||||
b.fullPlanInsight?.target === other.id ? b.fullPlanInsight : null;
|
||||
const enemyWeapon = inspected?.cards.find(
|
||||
@@ -639,9 +645,13 @@ function plans(g: GameView): Action[] {
|
||||
const enemyDefense = inspected?.cards.find(
|
||||
(c) => c.id === inspected.plan.defense,
|
||||
);
|
||||
const committed = b.prescience?.player !== me.id ? insight : null;
|
||||
const fixed = (field: string, value: string | null) =>
|
||||
committed?.field !== field || committed.value === value;
|
||||
const commitments = b.ownCommitments.filter(
|
||||
(element) => element.target === me.id,
|
||||
);
|
||||
const fixed = (field: string, value: string | number | null) =>
|
||||
commitments.every(
|
||||
(element) => element.field !== field || element.value === value,
|
||||
);
|
||||
const weapons = [
|
||||
null,
|
||||
...(me.hand ?? []).filter(isWeaponCard).map((c) => c.id),
|
||||
@@ -670,10 +680,7 @@ function plans(g: GameView): Action[] {
|
||||
!!voice &&
|
||||
(availableLeaders.length > 0 || heroes.length > 0) &&
|
||||
(me.hand ?? []).some((c) => defaultVoiceMatch(c, voice.kind));
|
||||
const knownEnemyLeader =
|
||||
insight?.field === 'leader' && b.prescience?.player === me.id
|
||||
? g.allLeaders.find((l) => l.id === insight.value)
|
||||
: undefined;
|
||||
const knownEnemyLeader = g.allLeaders.find((l) => l.id === known('leader')?.value);
|
||||
const expectedLeader = inspected
|
||||
? (g.allLeaders.find((l) => l.id === inspected.plan.leader)?.strength ??
|
||||
0) + (inspected.plan.kwisatz ? 2 : 0)
|
||||
@@ -688,8 +695,8 @@ function plans(g: GameView): Action[] {
|
||||
: 3));
|
||||
const expectedDial = inspected
|
||||
? inspected.plan.dial
|
||||
: insight?.field === 'dial' && b.prescience?.player === me.id
|
||||
? Number(insight.value)
|
||||
: known('dial')
|
||||
? Number(known('dial')!.value)
|
||||
: Math.ceil(enemyCapacity * [0.25, 0.4, 0.6, 0.75][level]);
|
||||
const options: { action: Action; score: number }[] = [];
|
||||
for (const leader of leaders)
|
||||
@@ -813,7 +820,11 @@ function plans(g: GameView): Action[] {
|
||||
weapon,
|
||||
defense,
|
||||
};
|
||||
if (committed?.field === 'dial') action.dial = committed.value;
|
||||
const committedDial = commitments.find(
|
||||
(element) => element.field === 'dial',
|
||||
);
|
||||
if (committedDial) action.dial = committedDial.value;
|
||||
if (!fixed('dial', Number(action.dial))) continue;
|
||||
const commitment = typedForces
|
||||
? supporting(Number(action.dial))
|
||||
: null;
|
||||
@@ -845,18 +856,13 @@ function plans(g: GameView): Action[] {
|
||||
const selfExplosion = battleWeaponsExplode(w, d);
|
||||
const correctDefense = inspected
|
||||
? weaponKills(enemyWeapon) && !weaponKills(enemyWeapon, d)
|
||||
: insight?.field === 'weapon' &&
|
||||
!!revealedCard &&
|
||||
weaponKills(revealedCard) &&
|
||||
!weaponKills(revealedCard, d);
|
||||
: !!revealedWeapon &&
|
||||
weaponKills(revealedWeapon) &&
|
||||
!weaponKills(revealedWeapon, d);
|
||||
const explosionRisk = inspected
|
||||
? battleWeaponsExplode(w, d, enemyWeapon, enemyDefense)
|
||||
: (insight?.field === 'defense' &&
|
||||
isShield(revealedCard) &&
|
||||
w?.kind === 'lasgun') ||
|
||||
(insight?.field === 'weapon' &&
|
||||
revealedCard?.kind === 'lasgun' &&
|
||||
isShield(d));
|
||||
: (isShield(revealedDefense) && w?.kind === 'lasgun') ||
|
||||
(revealedWeapon?.kind === 'lasgun' && isShield(d));
|
||||
const undialed = stoneBattle
|
||||
? Math.min(
|
||||
...casualtyOptions(
|
||||
@@ -922,7 +928,9 @@ function plans(g: GameView): Action[] {
|
||||
me.hand ?? [],
|
||||
),
|
||||
);
|
||||
if (b.compliantPlan) ranked.push({ type: 'battlePlan', ...b.compliantPlan });
|
||||
if (b.compliantPlan && commitments.every(
|
||||
(element) => b.compliantPlan![element.field] === element.value,
|
||||
)) ranked.push({ type: 'battlePlan', ...b.compliantPlan });
|
||||
return ranked;
|
||||
}
|
||||
|
||||
@@ -1303,6 +1311,8 @@ function policyActions(g: GameView): Action[] {
|
||||
((g.response.kind === 'voice' && g.battle!.voice?.target === me.id) ||
|
||||
(g.response.kind === 'prescience' &&
|
||||
g.battle!.prescience?.player !== me.id) ||
|
||||
(g.response.kind === 'nexusPrescience' &&
|
||||
g.battle!.nexusInspection?.target === me.id) ||
|
||||
g.response.kind === 'harkonnenTraitor' ||
|
||||
g.response.kind === 'eliteStrength' ||
|
||||
g.response.kind === 'fremenSupport' ||
|
||||
@@ -3098,10 +3108,18 @@ function policyActions(g: GameView): Action[] {
|
||||
},
|
||||
];
|
||||
}
|
||||
const field = b.prescience!.field;
|
||||
const nexus = b.preparation.kind === 'nexusPrescienceAnswer'
|
||||
? b.nexusInspection
|
||||
: null;
|
||||
if (
|
||||
b.preparation.kind === 'nexusPrescienceAnswer' &&
|
||||
(!nexus || nexus.target !== me.id || nexus.event !== b.event || nexus.stage !== 'answer')
|
||||
) return [];
|
||||
const field = nexus ? nexus.field : b.prescience!.field;
|
||||
// Candidate completions are legal-checked by the server; no rival secrets enter this choice.
|
||||
return plans(g).map((p) => ({
|
||||
type: 'prescienceAnswer',
|
||||
type: nexus ? 'nexusPrescienceAnswer' : 'prescienceAnswer',
|
||||
...(nexus ? { event: nexus.event } : {}),
|
||||
value: p[field],
|
||||
}));
|
||||
}
|
||||
@@ -3298,6 +3316,22 @@ function standaloneGholaAction(g: GameView, ordinary: Action[]): Action | null {
|
||||
export function botActions(g: GameView): Action[] {
|
||||
if (g.automaticContinuationPending) return [];
|
||||
if (g.nexusCards?.waiting.length) return nexusCardBotActions(g);
|
||||
const nexus = g.nexusAtreides;
|
||||
if (
|
||||
g.status === 'playing' && g.phase === 6 && nexus && !nexus.blocked &&
|
||||
g.nexusCards?.card === 'atreides' && nexus.event === g.battle?.event &&
|
||||
!g.truthtrance && !g.phaseOpening && !g.decision &&
|
||||
(nexus.mode === 'betrayal' || !g.response)
|
||||
) {
|
||||
if (nexus.mode === 'betrayal')
|
||||
return [{ type: 'nexusAtreides', event: nexus.event, mode: nexus.mode }];
|
||||
const preferences = nexus.mode === 'cunning' || rank(g) >= 2
|
||||
? ['weapon', 'defense', 'leader', 'dial'] as const
|
||||
: ['dial', 'weapon', 'defense', 'leader'] as const;
|
||||
const field = preferences.find((field) => nexus.fields.includes(field));
|
||||
if (field)
|
||||
return [{ type: 'nexusAtreides', event: nexus.event, mode: nexus.mode, field }];
|
||||
}
|
||||
const intelligence = tupileIntelligenceActions(g);
|
||||
if (intelligence.length) return intelligence;
|
||||
const actions = [...junctionTransportActions(g, rank(g)), ...policyActions(g)].flatMap((action) => {
|
||||
|
||||
+10
-4
@@ -1,18 +1,24 @@
|
||||
import type { Game, Player } from './engine';
|
||||
import { retentionReservesCard } from './moritani-retention';
|
||||
import { committedPlanElements } from './battle-inspections';
|
||||
/** Only cards not already fixed into a battle may be converted into spice. */
|
||||
export function cashInCards(
|
||||
g: Pick<Game, 'battle' | 'moritaniRetention' | 'pendingWinnerDiscards'>,
|
||||
p: Pick<Player, 'id' | 'hand'>,
|
||||
) {
|
||||
const plan = g.battle?.plans[p.id];
|
||||
const insight = g.battle?.prescience;
|
||||
const commitments = g.battle ? committedPlanElements(g.battle, p.id) : [];
|
||||
return p.hand.filter(
|
||||
(c) =>
|
||||
!(g.pendingWinnerDiscards?.player === p.id &&
|
||||
[...g.pendingWinnerDiscards.cards, ...g.pendingWinnerDiscards.optional].includes(c.id)) &&
|
||||
!(
|
||||
g.pendingWinnerDiscards?.player === p.id &&
|
||||
[
|
||||
...g.pendingWinnerDiscards.cards,
|
||||
...g.pendingWinnerDiscards.optional,
|
||||
].includes(c.id)
|
||||
) &&
|
||||
!retentionReservesCard(g, p.id, c.id) &&
|
||||
(!plan || ![plan.weapon, plan.defense, plan.leader].includes(c.id)) &&
|
||||
(!insight || insight.player === p.id || insight.value !== c.id),
|
||||
!commitments.some((element) => element.value === c.id),
|
||||
);
|
||||
}
|
||||
+212
-18
@@ -1,4 +1,5 @@
|
||||
import { createNexusCards, validateNexusCards, drawNexusCard, replaceNexusCard, discardNexusCard, projectNexusCards, type NexusState } from './nexus-cards';
|
||||
import { createNexusInspection, allowNexusInspection, answerNexusInspection, reopenNexusInspection, cancelNexusInspection, reopenNexusNative, answerNexusNative, validateNexusInspection, committedPlanElements, type NexusInspection, type BattleInspectionContext } from './battle-inspections';
|
||||
import { createNexusCards, validateNexusCards, drawNexusCard, replaceNexusCard, discardNexusCard, projectNexusCards, nexusCardMode, type NexusState } from './nexus-cards';
|
||||
import { createNexusCardPhase, markNexusCardOccurred, validateNexusCardPhase, closeNexusCardPhase, nexusCardChoices, finishNexusCardChoice, type NexusCardPhase, type NexusCardChoice } from './nexus-card-phase';
|
||||
import { createHomeworldOccupationHistory, observeHomeworldOccupation, validateHomeworldOccupationHistory, tupileOccupationStatus, type HomeworldOccupationHistory } from './homeworld-occupation-history';
|
||||
import { tupileIntelligenceTargets, quoteTupileIntelligenceRequest, type TupileIntelligenceCategory } from './tupile-intelligence';
|
||||
@@ -532,6 +533,9 @@ export type PlanField = Exclude<
|
||||
'support' | 'kwisatz' | 'allyPayment'
|
||||
>;
|
||||
export type Battle = {
|
||||
nexusInspection?: NexusInspection;
|
||||
/** Independent presence marker: deleting a spent-card record is not a legacy save. */
|
||||
nexusInspectionUsed?: string;
|
||||
homeworldDefensePassed?: string[];
|
||||
event?: string;
|
||||
strongholdCopy?: StrongholdId;
|
||||
@@ -561,7 +565,7 @@ export type Battle = {
|
||||
revealed: boolean;
|
||||
traitorCalls: Record<string, boolean>;
|
||||
preparation?: {
|
||||
kind: 'voice' | 'prescience' | 'prescienceAnswer';
|
||||
kind: 'voice' | 'prescience' | 'prescienceAnswer' | 'nexusPrescienceAnswer';
|
||||
owner: string;
|
||||
beneficiary: string;
|
||||
};
|
||||
@@ -757,6 +761,7 @@ export type ResponseWindow = {
|
||||
| 'moritaniRetention'
|
||||
| 'voice'
|
||||
| 'prescience'
|
||||
| 'nexusPrescience'
|
||||
| 'advisor'
|
||||
| 'emperorIncome'
|
||||
| 'richesePurchaseIncome'
|
||||
@@ -1484,6 +1489,140 @@ function projectedNexusCards(g: Game, id: string) {
|
||||
choices: nexusCardChoices(nexus.phase, nexus.cards, id),
|
||||
waiting: nexus.phase?.stage === 'drawing' ? nexus.phase.eligible.filter(owner => !nexus.phase!.done.includes(owner)) : [] };
|
||||
}
|
||||
function battleInspectionContext(g: Game): BattleInspectionContext {
|
||||
const b = g.battle!;
|
||||
return { event: b.event!, attacker: b.attacker, defender: b.defender,
|
||||
players: g.players.map(({id,faction}) => ({id,faction})), native: b.prescience };
|
||||
}
|
||||
function nexusInspectionIntegrity(g: Game) {
|
||||
const b = g.battle;
|
||||
if (!b) return;
|
||||
const record = b.nexusInspection;
|
||||
requireRule(!!record === !!b.nexusInspectionUsed, 'The Nexus inspection presence record is missing.');
|
||||
if (!record) return;
|
||||
requireRule(g.phase === 6 && b.event && b.nexusInspectionUsed === b.event &&
|
||||
g.nexusCards?.cards?.discard.includes('atreides'), 'The played Nexus inspection has lost its battle or physical card.');
|
||||
nexusRule(() => validateNexusInspection(battleInspectionContext(g), record));
|
||||
const continuation = g.pendingTreacheryDiscard?.continuation;
|
||||
const contexts = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume,
|
||||
g.pendingRichesePurchaseIncome?.resume, g.summonedWorm?.resume,
|
||||
continuation && 'resume' in continuation ? continuation.resume : null];
|
||||
const responses = contexts.flatMap(context => {
|
||||
const pending = context && 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] : null;
|
||||
return [context?.response, pending?.use.kind === 'cancel' ? pending.use.response : null];
|
||||
}).filter(response => response?.kind === 'nexusPrescience');
|
||||
requireRule((record.stage === 'response') === (responses.length > 0),
|
||||
'The extra inspection has lost or reopened its original cancellation response.');
|
||||
for (const response of responses) validateNexusInspectionResponse(g, response!);
|
||||
for (const target of [b.attacker, b.defender]) {
|
||||
const plan = b.plans[target];
|
||||
if (plan) requireRule(committedPlanElements(b, target).every(element => plan[element.field] === element.value),
|
||||
'The sealed plan contradicts its inspected commitment.');
|
||||
}
|
||||
if (record.stage === 'answer' || record.stage === 'response')
|
||||
requireRule(!b.revealed && (b.preparation?.kind === 'nexusPrescienceAnswer' ||
|
||||
(b.preparation?.kind === 'prescienceAnswer' && b.prescience && !Object.hasOwn(b.prescience, 'value'))),
|
||||
'The unanswered Nexus inspection is missing its owned answer.');
|
||||
if (b.preparation?.kind === 'nexusPrescienceAnswer')
|
||||
requireRule((record.stage === 'answer' || record.stage === 'response') && b.preparation.owner === record.target &&
|
||||
b.preparation.beneficiary === record.owner, 'The pending Nexus answer belongs to the wrong player.');
|
||||
}
|
||||
function nexusAtreidesOffer(g: Game, id: string) {
|
||||
if (g.nexusCards?.cards?.hands[id] !== 'atreides') return null;
|
||||
const p = getPlayer(g, id), b = g.battle;
|
||||
const mode = nexusCardMode('atreides', p.faction, g.players.map(player => player.faction));
|
||||
let blocked: string | null = null;
|
||||
if (g.status !== 'playing' || g.phase !== 6 || !b?.event || b.revealed)
|
||||
blocked = 'Play this Nexus Card before the current battle plans are revealed.';
|
||||
else if (p.ally) blocked = 'Allied players cannot use a Nexus Card.';
|
||||
else if (b.nexusInspection || b.nexusInspectionUsed) blocked = 'This battle already used the Atreides Nexus Card.';
|
||||
else if (g.truthtrance || g.decision || g.phaseOpening || g.pendingKarama || g.pendingTreacheryDiscard || g.pendingNullentropy)
|
||||
blocked = 'Finish the current interaction before playing this Nexus Card.';
|
||||
else if (mode === 'betrayal') {
|
||||
blocked = 'Betrayal reactions await the private response timing decision.';
|
||||
} else if (![b.attacker, b.defender].includes(id) || b.plans[id])
|
||||
blocked = 'Use this inspection in your own battle before sealing your plan.';
|
||||
else if (g.response || b.preparation || (b.preLeader && !b.preLeader.closed))
|
||||
blocked = 'Finish the preceding battle preparation first.';
|
||||
else if (mode === 'cunning' && (!b.prescience || b.prescience.player !== id || !Object.hasOwn(b.prescience, 'value')))
|
||||
blocked = 'Cunning needs your first completed native inspection.';
|
||||
const target = b && (b.attacker === id ? b.defender : b.attacker);
|
||||
const fields = mode === 'betrayal' ? [] : (['leader', 'weapon', 'defense', 'dial'] as PlanField[])
|
||||
.filter(field => !(mode === 'cunning' && (field === b?.prescience?.field ||
|
||||
(field === 'dial' && target && b?.noFieldPlayers?.includes(target)))));
|
||||
return { event: b?.event ?? '', mode, fields, blocked };
|
||||
}
|
||||
function finishInspectionAnswers(g: Game) {
|
||||
const b = g.battle!;
|
||||
if (b.prescience && !Object.hasOwn(b.prescience, 'value')) {
|
||||
b.preparation = { kind: 'prescienceAnswer', owner: b.prescience.player === b.attacker ? b.defender : b.attacker,
|
||||
beneficiary: b.prescience.player };
|
||||
} else if (b.nexusInspection?.stage === 'answer') {
|
||||
b.preparation = { kind: 'nexusPrescienceAnswer', owner: b.nexusInspection.target, beneficiary: b.nexusInspection.owner };
|
||||
} else if (b.nexusInspection && b.nexusInspection.mode !== 'betrayal') delete b.preparation;
|
||||
else finishBattlePreparation(g);
|
||||
}
|
||||
function answerCurrentNexusInspection(g: Game, value: unknown) {
|
||||
const b = g.battle!, record = b.nexusInspection!;
|
||||
const target = getPlayer(g, record.target);
|
||||
requireRule(feasiblePrescience(g, target, record.field, value),
|
||||
'Choose an element that permits a legal battle plan and respects every prior commitment.');
|
||||
b.nexusInspection = nexusRule(() => answerNexusInspection(battleInspectionContext(g), record, value as string | number | null));
|
||||
log(g, `${target.name} committed the requested ${record.field}. Its value is private to the two combatants.`);
|
||||
finishInspectionAnswers(g);
|
||||
}
|
||||
function validateNexusInspectionResponse(g: Game, response: ResponseWindow) {
|
||||
const b = g.battle, record = b?.nexusInspection;
|
||||
requireRule(response.kind === 'nexusPrescience' && record?.mode === 'cunning' && record.stage === 'response' &&
|
||||
!record.answers.length && response.owner === record.owner && response.intent === b!.event,
|
||||
'The extra Prescience response no longer matches its original attempt.');
|
||||
return record;
|
||||
}
|
||||
function settleNexusInspectionResponse(g: Game, response: ResponseWindow, canceled: boolean) {
|
||||
const b = g.battle!, record = validateNexusInspectionResponse(g, response);
|
||||
if (canceled) {
|
||||
b!.nexusInspection = nexusRule(() => cancelNexusInspection(battleInspectionContext(g), record));
|
||||
log(g, 'Karama canceled the additional Atreides inspection. The first disclosed element remains binding.');
|
||||
finishInspectionAnswers(g);
|
||||
} else {
|
||||
b.nexusInspection = nexusRule(() => allowNexusInspection(battleInspectionContext(g), record));
|
||||
if (b.plans[record.target]) answerCurrentNexusInspection(g, b.plans[record.target][record.field]);
|
||||
}
|
||||
}
|
||||
function playNexusAtreides(g: Game, p: Player, action: Action) {
|
||||
const offer = nexusAtreidesOffer(g, p.id);
|
||||
requireRule(offer && !offer.blocked && action.event === offer.event && action.mode === offer.mode,
|
||||
offer?.blocked ?? 'This Atreides Nexus opportunity is stale.');
|
||||
requireRule(Object.keys(action).sort().join(',') === (offer.mode === 'betrayal' ? 'event,mode,type' : 'event,field,mode,type') &&
|
||||
(offer.mode === 'betrayal' || offer.fields.includes(action.field as PlanField)), 'Choose a current Nexus inspection field.');
|
||||
const b = g.battle!;
|
||||
const field = offer.mode === 'betrayal' ? b.prescience!.field : action.field as PlanField;
|
||||
const beneficiary = offer.mode === 'betrayal' ? b.prescience!.player : p.id;
|
||||
const target = beneficiary === b.attacker ? b.defender : b.attacker;
|
||||
const record = nexusRule(() => createNexusInspection(battleInspectionContext(g), { mode: offer.mode, owner: p.id, target, field }));
|
||||
g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players));
|
||||
b.nexusInspection = record;
|
||||
b.nexusInspectionUsed = b.event;
|
||||
log(g, `${p.name} played the Atreides Nexus Card: ${offer.mode === 'cunning' ? 'Cunning' : offer.mode === 'secretAlly' ? 'Secret Ally' : 'Betrayal'}.`,
|
||||
{ faction: p.faction, name: 'Atreides Nexus' });
|
||||
if (offer.mode === 'betrayal') {
|
||||
g.response = null;
|
||||
delete b.prescience;
|
||||
finishBattlePreparation(g);
|
||||
} else {
|
||||
b.preparation = { kind: 'nexusPrescienceAnswer', owner: target, beneficiary: p.id };
|
||||
if (offer.mode === 'cunning') g.response = { kind: 'nexusPrescience', owner: p.id, intent: b.event, passed: [] };
|
||||
else if (b.plans[target]) answerCurrentNexusInspection(g, b.plans[target][field]);
|
||||
}
|
||||
}
|
||||
function projectedNexusInsights(g: Game, id: string) {
|
||||
const record = g.battle?.nexusInspection;
|
||||
if (!record || ![record.owner, record.target].includes(id)) return [];
|
||||
return record.answers.map((value, index) => ({ field: record.field, value,
|
||||
label: g.players.flatMap(p => [...p.leaders, ...p.hand]).concat(g.dukeVidal ? [g.dukeVidal.leader] : [])
|
||||
.find(item => item.id === value)?.name ?? String(value ?? 'None'),
|
||||
active: record.stage === 'answered' && index === record.answers.length - 1 }));
|
||||
}
|
||||
function homeworldRule<T>(quote: () => T): T {
|
||||
try {
|
||||
return quote();
|
||||
@@ -5600,8 +5739,7 @@ function karamaSpendingBlock(g: Game, p: Player, card: Card): string | null {
|
||||
const plan = g.battle?.plans[p.id];
|
||||
if (plan && [plan.weapon, plan.defense, plan.leader].includes(card.id))
|
||||
return 'A sealed battle card cannot be spent as Karama.';
|
||||
const insight = g.battle?.prescience;
|
||||
if (insight && insight.player !== p.id && insight.value === card.id)
|
||||
if (g.battle && committedPlanElements(g.battle, p.id).some(element => element.value === card.id))
|
||||
return 'The card committed to prescience must remain available.';
|
||||
if (card.kind === 'worthless' && g.pendingKarama)
|
||||
return 'A Worthless conversion is already pending.';
|
||||
@@ -5973,6 +6111,7 @@ function validateKaramaUse(
|
||||
if (use.response.kind === 'ixAuction')
|
||||
currentIxAuctionDrawQuote(g, use.response, true, spendingCard);
|
||||
moritaniAllianceCancellationQuote(g, use.response);
|
||||
if (use.response.kind === 'nexusPrescience') validateNexusInspectionResponse(g, use.response);
|
||||
if (isCombatResponseKind(use.response.kind))
|
||||
currentCombatResponseQuote(g, {
|
||||
kind: 'response',
|
||||
@@ -6107,6 +6246,11 @@ function assertKaramaPromiseFeasibility(
|
||||
delete b.prescience;
|
||||
delete b.preparation;
|
||||
break;
|
||||
case 'nexusPrescience':
|
||||
if (!b?.nexusInspection) return false;
|
||||
b.nexusInspection = cancelNexusInspection(battleInspectionContext(projected), b.nexusInspection);
|
||||
delete b.preparation;
|
||||
break;
|
||||
case 'eliteStrength':
|
||||
if (!b) return false;
|
||||
(b.eliteBlocked ??= []).push(use.response.owner);
|
||||
@@ -7219,8 +7363,7 @@ function ambassadorDiscardBlock(g: Game, p: Player, card: Card, effect?: Ambassa
|
||||
const plan = g.battle?.plans[p.id];
|
||||
if (plan && [plan.weapon, plan.defense, plan.leader].includes(card.id))
|
||||
return 'This card is committed to a sealed battle plan.';
|
||||
const insight = g.battle?.prescience;
|
||||
if (insight && insight.player !== p.id && insight.value === card.id)
|
||||
if (g.battle && committedPlanElements(g.battle, p.id).some(element => element.value === card.id))
|
||||
return 'This card is committed to prescience.';
|
||||
if (effect === 'choam')
|
||||
return homeworldRule(() => homeworldWorthlessSaleBlock(g, p.id, card));
|
||||
@@ -10745,9 +10888,11 @@ function validateResidualPoison(
|
||||
);
|
||||
requireRule(
|
||||
!Object.keys(b.plans).length &&
|
||||
!(b.prescience?.field === 'leader' && 'value' in b.prescience),
|
||||
![b.attacker, b.defender].some(owner => committedPlanElements(b, owner).some(element => element.field === 'leader')),
|
||||
'Residual Poison must be played before either combatant commits a leader.',
|
||||
);
|
||||
requireRule(!(b.nexusInspection?.mode === 'cunning' && b.nexusInspection.stage === 'answered'),
|
||||
'Residual Poison after two inspected elements awaits the ruling for incompatible surviving commitments.');
|
||||
requireRule(
|
||||
!g.truthtrance && !g.response && !g.decision && !g.phaseOpening,
|
||||
'Resolve the current interaction before playing Residual Poison.',
|
||||
@@ -10826,15 +10971,20 @@ function playResidualPoison(
|
||||
`${p.name} played Residual Poison against ${target.name}. ${victim.name} was randomly selected from the available leaders and sent to the Tanks. No spice is awarded, no forces are lost, and battle preparation continues.`,
|
||||
{ faction: p.faction, name: 'Residual Poison' },
|
||||
);
|
||||
// Opposing involuntary death may release an impossible answer; it never rerolls the victim.
|
||||
reconcileBattlePromises(g);
|
||||
// First retire an inspection made impossible by the death itself. Testing on
|
||||
// a private copy without Truthtrance avoids releasing a still-feasible truth
|
||||
// answer solely because an obsolete inspection was left in the trial.
|
||||
const insight = b.prescience;
|
||||
if (insight && 'value' in insight && insight.field !== 'leader') {
|
||||
const trial = structuredClone(g);
|
||||
trial.battle!.truthPromises = [];
|
||||
const answering = getPlayer(
|
||||
g,
|
||||
trial,
|
||||
insight.player === b.attacker ? b.defender : b.attacker,
|
||||
);
|
||||
if (!feasiblePrescience(g, answering, insight.field, insight.value)) {
|
||||
if (!feasiblePrescience(trial, answering, insight.field, insight.value)) {
|
||||
if (b.nexusInspection?.mode === 'cunning')
|
||||
b.nexusInspection = nexusRule(() => reopenNexusNative(battleInspectionContext(g), b.nexusInspection!));
|
||||
delete insight.value;
|
||||
b.preparation = {
|
||||
kind: 'prescienceAnswer',
|
||||
@@ -10847,6 +10997,17 @@ function playResidualPoison(
|
||||
);
|
||||
}
|
||||
}
|
||||
const nexus = b.nexusInspection;
|
||||
if (nexus?.stage === 'answered' && nexus.field !== 'leader') {
|
||||
const trial = structuredClone(g);
|
||||
trial.battle!.truthPromises = [];
|
||||
if (!findReachableBattlePlan(trial, getPlayer(trial, nexus.target))) {
|
||||
b.nexusInspection = nexusRule(() => reopenNexusInspection(battleInspectionContext(g), nexus));
|
||||
finishInspectionAnswers(g);
|
||||
log(g, 'The leader death made the inspected element impossible. Its owner must answer the same element again; the earlier private observation is retained.');
|
||||
}
|
||||
}
|
||||
reconcileBattlePromises(g);
|
||||
if (b.preLeader && !b.preLeader.closed)
|
||||
b.preLeader.ready = b.preLeader.ready.filter((id) => id !== p.id);
|
||||
}
|
||||
@@ -11132,11 +11293,10 @@ function validatePlan(
|
||||
'Your battle plan must comply with the Voice.',
|
||||
);
|
||||
}
|
||||
const prescience = b.prescience;
|
||||
if (prescience && prescience.player !== p.id && 'value' in prescience)
|
||||
for (const element of committedPlanElements(b, p.id))
|
||||
requireRule(
|
||||
plan[prescience.field] === prescience.value,
|
||||
'The element revealed to Atreides must remain unchanged.',
|
||||
plan[element.field] === element.value,
|
||||
'Every element revealed by a battle inspection must remain unchanged.',
|
||||
);
|
||||
requireRule(
|
||||
respectsBattlePromises(
|
||||
@@ -11338,6 +11498,7 @@ function gholaOptions(g: Game, p: Player) {
|
||||
}
|
||||
function marketGholaIntegrity(g: Game) {
|
||||
nexusCardsIntegrity(g);
|
||||
nexusInspectionIntegrity(g);
|
||||
homeworldHistoryIntegrity(g);
|
||||
grummanCollectionIntegrity(g);
|
||||
homeworldVictoryReturnIntegrity(g);
|
||||
@@ -11462,7 +11623,10 @@ function findLegalBattlePlan(
|
||||
const b = g.battle!;
|
||||
const promises = options.promises ?? b.truthPromises ?? [];
|
||||
const fixed = options.prescience;
|
||||
const commitments = committedPlanElements(b, p.id);
|
||||
const accepts = (plan: Partial<Plan>) =>
|
||||
commitments.every(element =>
|
||||
plan[element.field] === undefined || plan[element.field] === element.value) &&
|
||||
respectsBattlePromises(promises, p.id, plan, p.hand) &&
|
||||
(!fixed ||
|
||||
plan[fixed.field] === undefined ||
|
||||
@@ -11603,6 +11767,8 @@ function findReachableBattlePlan(
|
||||
const pending = pendingBattleRevivalIncome(initial, player);
|
||||
if (pending) player.spice += pending;
|
||||
// The real table must finish its current window before it can execute a preparation action.
|
||||
if (initial.battle?.nexusInspection?.stage === 'response')
|
||||
initial.battle.nexusInspection = allowNexusInspection(battleInspectionContext(initial), initial.battle.nexusInspection);
|
||||
initial.response = null;
|
||||
initial.pendingKarama = null;
|
||||
// Search after Truthtrance resolves, retaining every existing or proposed
|
||||
@@ -13708,6 +13874,7 @@ function finishResponse(g: Game, canceled: boolean) {
|
||||
karamaConversionIntegrity(g);
|
||||
currentFactionPayment(g);
|
||||
const response = g.response!;
|
||||
if (response.kind === 'nexusPrescience') validateNexusInspectionResponse(g, response);
|
||||
const ecazCollectionQuote =
|
||||
response.kind === 'ecazCollection'
|
||||
? currentEcazCollectionQuote(g, response, canceled)
|
||||
@@ -14484,6 +14651,8 @@ function finishResponse(g: Game, canceled: boolean) {
|
||||
);
|
||||
} else log(g, 'CHOAM battle force payments went to the bank.');
|
||||
finishBattle(g);
|
||||
} else if (response.kind === 'nexusPrescience') {
|
||||
settleNexusInspectionResponse(g, response, canceled);
|
||||
} else if (isCombatResponseKind(response.kind)) {
|
||||
commitCombatResponseQuote(
|
||||
g,
|
||||
@@ -16524,6 +16693,7 @@ function applyActionInner(
|
||||
if (t === 'advanceBots') return g;
|
||||
if (t === 'nexusCardChoice') { decideNexusCard(g, p, action); return g; }
|
||||
requireRule(g.nexusCards?.phase?.stage !== 'drawing', 'Finish the closing Nexus card choices first.');
|
||||
if (t === 'nexusAtreides') { playNexusAtreides(g, p, action); return g; }
|
||||
requireRule(
|
||||
!(
|
||||
t === 'card' &&
|
||||
@@ -19346,7 +19516,7 @@ function applyActionInner(
|
||||
if (t === 'declineBattlePower') {
|
||||
const b = g.battle;
|
||||
requireRule(
|
||||
b?.preparation?.owner === id && b.preparation.kind !== 'prescienceAnswer',
|
||||
b?.preparation?.owner === id && !['prescienceAnswer', 'nexusPrescienceAnswer'].includes(b.preparation.kind),
|
||||
'You do not own this battle power decision.',
|
||||
);
|
||||
const kind = b.preparation.kind;
|
||||
@@ -19383,6 +19553,20 @@ function applyActionInner(
|
||||
g.response = { kind: 'prescience', owner: id, passed: [] };
|
||||
return g;
|
||||
}
|
||||
if (t === 'nexusPrescienceAnswer') {
|
||||
const b = g.battle, record = b?.nexusInspection;
|
||||
requireRule(b?.preparation?.kind === 'nexusPrescienceAnswer' &&
|
||||
b.preparation.owner === id && record?.stage === 'answer' && record.target === id &&
|
||||
action.event === record.event && Object.keys(action).sort().join(',') === 'event,type,value',
|
||||
'This Nexus answer does not match your current inspection.');
|
||||
requireRule(record.mode !== 'cunning' || record.field !== 'dial' || !b.noFieldPlayers?.includes(id),
|
||||
'Atreides may not inspect the number dialed in a No-Field battle.');
|
||||
const value = record.field === 'dial'
|
||||
? typeof action.value === 'number' ? action.value : NaN
|
||||
: action.value === null || action.value === '' ? null : stringField(action.value);
|
||||
answerCurrentNexusInspection(g, value);
|
||||
return g;
|
||||
}
|
||||
if (t === 'prescienceAnswer') {
|
||||
const b = g.battle;
|
||||
requireRule(
|
||||
@@ -19414,8 +19598,10 @@ function applyActionInner(
|
||||
feasiblePrescience(g, p, field, value),
|
||||
'Choose an element that permits a legal battle plan and obeys the Voice.',
|
||||
);
|
||||
if (b.nexusInspection?.mode === 'cunning')
|
||||
b.nexusInspection = nexusRule(() => answerNexusNative(battleInspectionContext(g), b.nexusInspection!, value));
|
||||
b.prescience.value = value;
|
||||
finishBattlePreparation(g);
|
||||
finishInspectionAnswers(g);
|
||||
return g;
|
||||
}
|
||||
if (t === 'voice') {
|
||||
@@ -19714,7 +19900,7 @@ export function viewGame(state: Game, id: string) {
|
||||
(b.truthPromises?.some(
|
||||
(promise) => promise.player === id && !promise.released,
|
||||
) ||
|
||||
(b.prescience && b.prescience.player !== id && 'value' in b.prescience))
|
||||
committedPlanElements(b, id).length > 0)
|
||||
? findReachableBattlePlan(g, me)
|
||||
: null;
|
||||
return {
|
||||
@@ -19965,6 +20151,7 @@ export function viewGame(state: Game, id: string) {
|
||||
techTokens: g.techTokens ?? null,
|
||||
strongholdCards: g.strongholdCards ?? null,
|
||||
nexusCards: projectedNexusCards(g, id),
|
||||
nexusAtreides: nexusAtreidesOffer(g, id),
|
||||
homeworldRevivalDeployment: projectedHomeworldRevivalReturn(g, id),
|
||||
caladanReinforcement: projectedHomeworldVictoryReturn(g, id),
|
||||
homeworldRevivalBlocks: homeworldRevivalChoiceBlocks(g, me),
|
||||
@@ -20594,6 +20781,13 @@ export function viewGame(state: Game, id: string) {
|
||||
traitorSubmitted: Object.keys(b.traitorCalls),
|
||||
traitorVoters: traitorVoters(g, b),
|
||||
preparation: b.preparation ?? null,
|
||||
nexusInspection: b.nexusInspection ? {
|
||||
event: b.nexusInspection.event, mode: b.nexusInspection.mode,
|
||||
owner: b.nexusInspection.owner, target: b.nexusInspection.target,
|
||||
field: b.nexusInspection.field, stage: b.nexusInspection.stage,
|
||||
} : null,
|
||||
nexusInsights: projectedNexusInsights(g, id),
|
||||
ownCommitments: committedPlanElements(b, id),
|
||||
prescience: b.prescience
|
||||
? {
|
||||
player: b.prescience.player,
|
||||
|
||||
@@ -75,6 +75,11 @@ const cancellationSources = {
|
||||
prescience: g.battle?.prescience ?? null,
|
||||
preparation: g.battle?.preparation ?? null,
|
||||
}),
|
||||
nexusPrescience: (g) => ({
|
||||
battle: battleSource(g),
|
||||
inspection: g.battle?.nexusInspection ?? null,
|
||||
preparation: g.battle?.preparation ?? null,
|
||||
}),
|
||||
advisor: (g, response) =>
|
||||
response.advisorResume === 'ambassador'
|
||||
? {
|
||||
|
||||
+11
-7
@@ -3340,7 +3340,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
searchText: NEXUS_CARD_REFERENCE.map(card => `${card.faction} ${card.betrayal} ${card.cunning} ${card.secretAlly}`).join(' '),
|
||||
category: 'Advanced & expansions',
|
||||
coverage: 'Partial',
|
||||
summary: 'A separate twelve-card deck rewards remaining unallied. Ownership and private draw choices are integrated; individual effects remain in development.',
|
||||
summary: 'A separate twelve-card deck rewards remaining unallied. Private draws and Atreides inspection controls are integrated. Other effects and complete module play remain in development.',
|
||||
steps: [
|
||||
'This optional module can be combined with Basic or Advanced rules independently of the factions and other expansion modules selected. Include one Nexus Card for each of the twelve factions.',
|
||||
'At the end of the entire Spice Blow and Nexus phase, if a Nexus occurred and at least one alliance exists, each unallied player may draw one card or discard their held card and draw a replacement. You may decline. The two Advanced spice piles do not grant two closing draws.',
|
||||
@@ -3349,15 +3349,19 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Before drawing, choose whether to keep your own faction card, redraw it once, or redraw whenever it appears. Its effect is available in the reference before making this choice. The server applies that preference immediately; the table never pauses in a way that identifies a card you chose to keep.',
|
||||
'Entering any alliance discards your held Nexus Card, including alliances made through an Ambassador or Terror token. An offer that has not been accepted does not discard it.',
|
||||
'Use Cunning for your own faction, Betrayal when another player controls the printed faction, and Secret Ally when that faction is absent. Used cards go to the separate Nexus discard pile. Each printed effect has its own timing.',
|
||||
'The development table supports custody, closing choices, replacement, immediate redraw, alliance discard and private card inspection. Effect execution and full module games are unfinished, so public module starts remain disabled.',
|
||||
'Atreides Cunning adds a second, different opposing plan element after the first native answer. Both remain binding. Use it in your own battle before sealing your plan; Karama may cancel the extra attempt while preserving the first disclosure.',
|
||||
'With Atreides absent, Secret Ally inspects one opposing element in your battle. A sealed plan supplies only that selected answer automatically. Otherwise the opponent chooses an answer that permits a legal plan. Other players receive no private answer.',
|
||||
'The No-Field restriction names Atreides, so Cunning cannot inspect that dial. A non-Atreides Secret Ally holder may request it without learning the token denomination or other plan elements.',
|
||||
'Residual Poison cannot follow a committed leader. If it makes a single nonleader inspection impossible, the same field is answered again and the earlier observation remains private history. Residual Poison after two completed Cunning inspections awaits a ruling on incompatible surviving commitments.',
|
||||
'Betrayal reactions await a private response timing decision. Other factions’ Nexus effects and complete module games are unfinished, so public module starts remain disabled.',
|
||||
],
|
||||
related: ['ecaz-modules', 'spice-blow', 'alliance-funding'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Physical ownership and whole-phase draw lifecycle; all individual card effects still require integration.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Private draw, keep, replace and immediate own-faction redraw; readable card inspection. Effect play controls remain unfinished.', evidence: ['components/nexus-cards.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'All profiles draw when empty and keep their held card. Effect strategy and module strength calibration are unfinished.', evidence: ['game/nexus-card-options.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Common rules and twelve printed faces are recorded. Effect interaction guidance remains in development.', evidence: ['docs/NEXUS_CARD_RULES.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused custody and phase regressions; complete module games remain a release requirement.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts'] },
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Physical lifecycle plus Atreides Cunning and Secret Ally inspections, distinct commitments and cancellation. Betrayal and remaining card families are unfinished.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Private card lifecycle, Atreides element selection, owned answers, private history and all four plan locks. Remaining effect controls are unfinished.', evidence: ['components/nexus-cards.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'All profiles handle lifecycle, offered Atreides inspections and both binding answers using only authorized views. Complete module strategy and calibration remain unfinished.', evidence: ['game/nexus-card-options.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Common rules, twelve printed faces and Atreides inspection interactions are recorded with explicit unresolved boundaries.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Custody, phase, inspection, controls, bot and concurrent recovery regressions; complete module games remain a release requirement.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts'] },
|
||||
],
|
||||
},
|
||||
...[
|
||||
|
||||
@@ -73,6 +73,7 @@ const RESPONSES = {
|
||||
moritaniRetention: true,
|
||||
voice: true,
|
||||
prescience: true,
|
||||
nexusPrescience: true,
|
||||
advisor: true,
|
||||
emperorIncome: true,
|
||||
richesePurchaseIncome: true,
|
||||
|
||||
Reference in new issue
Block a user