Prototype Moritani Assassinate Leaders with saved Mentat replacement
Connect a qualifying native Advanced Moritani battle loss to private reveal/continue controls, live or already-dead opposing-faction targets, printed-strength bank bounty, and automatic Mentat replacement. Keep the revealed physical card held until Mentat, then set it aside as a public per-faction use marker and draw one private card. All four AI profiles use the owner-only legal quote. New local preview profile supports genuine setup without opening public starts. Keep unresolved normal traitor-call duration and uniform private no-choice UX explicit. Record normal calls and guard later use without claiming a settled permanent loss. Exclude exceptional captured/foreign leader custody, other expansion factions and combined optional modules from this bounded preview. No mode or publication gate opens. Independent source/privacy/persistence review and 32 focused cases pass. Battle obligations independently bind cleanup, sealed normal-call logs wait for public revelation, physical replacement custody rejects missing cards and forged same-turn chains, and invalid actions remain immutable. Five authenticated SQLite cases cover pending/resolved restoration, Truthtrance interruption, competing submissions, stale/duplicate actions and corrupt saves. The local writer preserves seats and unrelated rooms. Validation: types, lint, 4690 offline tests, production build and 43 HTTP tests passed with unchanged source. Two genuine Advanced four-profile games finish in 296 and 101 actions with zero rejected candidates, ten periodic JSON rounds and eight restored final seat views. All physical cards, forces and elites remain conserved. Two observed assassination opportunities are declined; these complete games do not establish natural reveal/replacement use. The separate browser exercise explicitly stages conserved battle forces and one physical traitor swap after genuine setup. Actual battle actions open the choice. Master Bewt dies once and pays three spice; the card remains held through cleanup and is automatically replaced at Mentat with a public set-aside marker. Pending and resolved refresh, readable context-correct inspectors, physical custody and private projection pass. Guild independently collects two spice from Arrakeen. The saved QA game remains at human Terror placement. After the reported outage, all 801 opening games and original seat/recovery records are preserved (835 after HTTP checks). All 802 rooms and original records immediately before verification also remain unchanged. Database integrity and foreign keys pass. The healthy server was reused without restart, reset or recurring automation. Verified source tree: ad64f6775fb4c481002cc5eccf6d31e490085d11e10b7b19fb7df4947bcf0ccd
This commit is contained in:
1 parent
8906b41bda
commit
58ae84f8e2
20 files changed
+2152
-13
No files matched your search
@@ -1586,6 +1586,14 @@ function policyActions(g: GameView): Action[] {
|
||||
}
|
||||
return [{ type: 'decision', decline: true }];
|
||||
}
|
||||
if (d.kind === 'moritaniAssassinate') {
|
||||
const pending = g.moritaniAssassinate?.pending;
|
||||
if (!pending || pending.event !== d.event) return [];
|
||||
const cards = [...pending.cards].sort((a,b) => b.bounty-a.bounty || a.card.localeCompare(b.card));
|
||||
return [cards.length && !pending.blocked
|
||||
? {type:'decision',event:d.event,card:cards[0].card}
|
||||
: {type:'decision',event:d.event,decline:true}];
|
||||
}
|
||||
if (d.kind === 'moritaniRetention') {
|
||||
const candidates = (me.hand ?? [])
|
||||
.filter((card) => d.cards.includes(card.id))
|
||||
|
||||
+199
@@ -28,6 +28,7 @@ import { greatMakerSignature, greatMakerMajority, validateGreatMaker, type Great
|
||||
import { quoteNexusChoamTrade, validateNexusChoamTrade, nexusChoamTradeSignature, type NexusChoamTrade } from './nexus-choam-trade';
|
||||
import { EMPEROR_NEXUS_REVIVALS, emperorNexusEvent, emperorNexusSignature, emperorNexusModeSupported, emperorNexusPools, emperorNexusRevivalElites, validateEmperorNexusRevival, type NexusEmperorRevival } from './nexus-emperor-secret-ally';
|
||||
import { truthKnowledgeOf } from './truthtrance-knowledge';
|
||||
import { createMoritaniAssassinateOpportunity, moritaniAssassinateTrigger, moritaniAssassinateChoices, quoteMoritaniAssassinate, moritaniAssassinateSignature, validateMoritaniAssassinate, type MoritaniAssassinateState, type MoritaniAssassinateReceipt } from './moritani-assassinate';
|
||||
import { createNexusGuildSecretAlly, validateNexusGuildSecretAlly, quoteNexusGuildSecretShipment, type NexusGuildSecretAllyReceipt } from './nexus-guild-secret-ally';
|
||||
import { shipmentAvailable } from './shipment-opportunity';
|
||||
import { createNexusGuildCunning, validateNexusGuildCunning, nexusGuildCunningMoves, type NexusGuildCunningReceipt } from './nexus-guild-cunning';
|
||||
@@ -750,6 +751,7 @@ export type Decision =
|
||||
| { kind: 'rihani'; player: string; event: string; stage: 'offer' | 'return' }
|
||||
| { kind: 'fullPlanOffer'; player: string }
|
||||
| {kind:'bureaucratPayment';player:string;event:string}
|
||||
| { kind: 'moritaniAssassinate'; player: string; event: string }
|
||||
| { kind: 'mentatQuestion'; player: string; event: string; owner: string; target: string; stage: 'name' | 'reveal'; weapon: string | null }
|
||||
| { kind: 'fullPlanRead'; player: string; target: string }
|
||||
| { kind: 'homeworldShipmentGuild'; player: string; shipper: string; destination: string; amount: number; event: string }
|
||||
@@ -1487,6 +1489,15 @@ export type Game = {
|
||||
bureaucratUseEvents?: string[];
|
||||
/** Private development opt-in pending the uniform Mentat response UX decision. */
|
||||
mentatQuestionPreview?: true;
|
||||
/** Private development opt-in while uniform hidden-eligibility UX is pending. */
|
||||
moritaniAssassinatePreview?: true;
|
||||
moritaniAssassinate?: MoritaniAssassinateState;
|
||||
moritaniAssassinateCallEvents?: string[];
|
||||
moritaniAssassinateResume?: {
|
||||
event: string;
|
||||
continuation: Extract<NonNullable<Game['pendingTreacheryDiscard']>['continuation'], {kind:'battleResolved'}>;
|
||||
signature: string;
|
||||
};
|
||||
mentatHistory?: MentatObservation[];
|
||||
mentatHistoryEvents?: string[];
|
||||
advanced: boolean;
|
||||
@@ -1556,6 +1567,7 @@ export type Game = {
|
||||
sandmaster?: { leader: string; key: string; before: number; after: number };
|
||||
caladanReinforcement?: HomeworldVictoryObligation;
|
||||
nexusSardaukarCasualties?: string;
|
||||
moritaniAssassinate?: {event:string;signature:string;continuation?:string};
|
||||
} | null;
|
||||
auction: Auction | null;
|
||||
battle: Battle | null;
|
||||
@@ -5048,7 +5060,162 @@ function leaderSkillAssignmentUnavailable(
|
||||
? ADVANCED_ATREIDES_SUK_ASSIGNMENT_BLOCK
|
||||
: null;
|
||||
}
|
||||
function moritaniAssassinateModeSupported(g: Game) {
|
||||
return g.advanced && g.expansions.length === 1 && g.expansions[0] === 'ecaz' &&
|
||||
g.players.some(p => p.faction === 'moritani') &&
|
||||
g.players.every(p => ['moritani','atreides','beneGesserit','guild','emperor','fremen'].includes(p.faction)) &&
|
||||
!g.nexusCards && !g.leaderSkills && !g.strongholdCards && !g.homeworlds &&
|
||||
!g.discoveryEnabled && !g.discoveries && !g.techTokens;
|
||||
}
|
||||
function moritaniAssassinateContext(g: Game, receipt: MoritaniAssassinateReceipt) {
|
||||
const p = getPlayer(g,receipt.owner), opponent = getPlayer(g,receipt.opponent);
|
||||
return {state:g.moritaniAssassinate!,receipt,held:p.traitors,
|
||||
normallyRevealed:p.revealedTraitors ?? [],leaders:opponent.leaders};
|
||||
}
|
||||
function moritaniAssassinateIntegrity(g: Game) {
|
||||
const state = g.moritaniAssassinate, resume = g.moritaniAssassinateResume;
|
||||
requireRule(g.moritaniAssassinatePreview === undefined || g.moritaniAssassinatePreview === true,
|
||||
'Invalid assassination preview setting.');
|
||||
if (!state) {
|
||||
requireRule(!g.moritaniAssassinatePreview && !resume && !g.moritaniAssassinateCallEvents && !g.lastBattleContext?.moritaniAssassinate &&
|
||||
g.decision?.kind !== 'moritaniAssassinate', 'The assassination preview lost its original state.');
|
||||
return;
|
||||
}
|
||||
requireRule(g.moritaniAssassinatePreview === true && moritaniAssassinateModeSupported(g) &&
|
||||
state.owner === byFaction(g,'moritani')?.id, 'This assassination preview no longer matches its original faction profile.');
|
||||
nexusRule(() => validateMoritaniAssassinate(state,{turn:g.turn,held:getPlayer(g,state.owner).traitors,
|
||||
removed:Array.isArray(state.opportunities) ? state.opportunities.filter(r => r?.stage === 'replaced').map(r => r.card!) : []}));
|
||||
const calls = g.moritaniAssassinateCallEvents;
|
||||
requireRule(Array.isArray(calls) && calls.every(event => typeof event === 'string' && event.length > 0) &&
|
||||
new Set(calls).size === calls.length && state.normalTraitorCall === (calls.length > 0),
|
||||
'The assassination normal-traitor-call guard lost its original event record.');
|
||||
for (const receipt of state.opportunities) {
|
||||
const opponent = getPlayer(g,receipt.opponent);
|
||||
requireRule(receipt.turn <= g.turn && opponent.faction === receipt.faction &&
|
||||
receipt.owner === state.owner && TERRITORIES.some(t => t.id === receipt.territory),
|
||||
'The assassination history no longer matches its original battle participants.');
|
||||
}
|
||||
const pending = state.opportunities.filter(r => r.stage === 'choice');
|
||||
requireRule(pending.length <= 1, 'Finish the current assassination opportunity first.');
|
||||
const obligation = g.lastBattleContext?.moritaniAssassinate;
|
||||
if (obligation) {
|
||||
const receipt = state.opportunities.find(r => r.event === obligation.event);
|
||||
requireRule(receipt && receipt.signature === obligation.signature && receipt.event === g.lastBattleContext!.event,
|
||||
'The completed battle lost or changed its assassination obligation.');
|
||||
}
|
||||
if (pending.length) {
|
||||
const r = pending[0];
|
||||
requireRule(g.status === 'playing' && g.phase === 6 && g.turn === r.turn && !g.battle &&
|
||||
g.lastBattleContext?.event === r.event && g.lastBattleContext.winner === r.opponent &&
|
||||
g.lastBattleContext.result === 'normal' && g.lastBattleContext.territory === r.territory &&
|
||||
obligation?.event === r.event, 'The assassination choice lost its resolved battle.');
|
||||
requireRule(resume && resume.event === r.event && resume.continuation.event === r.event &&
|
||||
resume.continuation.winner === r.opponent && resume.continuation.territory === r.territory &&
|
||||
resume.continuation.result === 'normal' && JSON.stringify(resume.continuation.combatants) === JSON.stringify(g.lastBattleContext.combatants) &&
|
||||
resume.signature === JSON.stringify(resume.continuation) && resume.signature === obligation.continuation,
|
||||
'The assassination choice lost or changed its saved cleanup.');
|
||||
const decisions = homeworldSavedDecisions(g).filter(d => d.kind === 'moritaniAssassinate');
|
||||
requireRule(decisions.length === 1 && decisions[0].player === r.owner &&
|
||||
(decisions[0] as Extract<Decision,{kind:'moritaniAssassinate'}>).event === r.event,
|
||||
'The assassination choice lost its owning decision.');
|
||||
} else requireRule(!resume && g.decision?.kind !== 'moritaniAssassinate', 'A settled assassination cannot retain its decision.');
|
||||
if (g.status === 'playing' || g.status === 'finished') {
|
||||
const retired = state.opportunities.filter(r => r.stage === 'replaced').map(r => r.card!);
|
||||
const physical = [...(g.traitorReserve ?? []),...g.players.flatMap(p => p.traitors),...retired].sort();
|
||||
requireRule(JSON.stringify(physical) === JSON.stringify([...nexusTraitorUniverse(g)].sort()),
|
||||
'Assassination must preserve every physical Traitor Card in hand, deck or face-up set-aside custody.');
|
||||
for (const r of state.opportunities.filter(r => r.stage === 'revealed'))
|
||||
requireRule(getPlayer(g,r.owner).traitors.includes(r.card!), 'The revealed assassination card must remain held until Mentat replacement.');
|
||||
const replacements = new Set<string>();
|
||||
for (const [index,r] of state.opportunities.entries()) if (r.stage === 'replaced') {
|
||||
const laterUses = state.opportunities.slice(index+1).filter(next => next.card === r.replacement &&
|
||||
(next.stage === 'revealed' || next.stage === 'replaced'));
|
||||
const ownedReplacement = laterUses.length ? laterUses.every(next => next.turn > r.turn)
|
||||
: getPlayer(g,r.owner).traitors.includes(r.replacement!);
|
||||
requireRule(!replacements.has(r.replacement!) && ownedReplacement,
|
||||
'Each assassination replacement must stay with Moritani or be consumed by its later assassination.');
|
||||
replacements.add(r.replacement!);
|
||||
}
|
||||
}
|
||||
}
|
||||
function syncMoritaniAssassinateReceipt(g: Game, receipt: MoritaniAssassinateReceipt) {
|
||||
receipt.signature = moritaniAssassinateSignature(receipt);
|
||||
if (g.lastBattleContext?.event === receipt.event)
|
||||
g.lastBattleContext.moritaniAssassinate = {...g.lastBattleContext.moritaniAssassinate,event:receipt.event,signature:receipt.signature};
|
||||
}
|
||||
function recordMoritaniAssassinateOpportunity(g: Game, b: Battle, winner: Player | undefined, loser: Player | null) {
|
||||
const state = g.moritaniAssassinate;
|
||||
if (!state || state.normalTraitorCall || !winner || loser?.id !== state.owner ||
|
||||
state.opportunities.some(r => r.faction === winner.faction && ['revealed','replaced'].includes(r.stage))) return;
|
||||
const opposingLeader = winner.leaders.find(l => l.id === b.plans[winner.id].leader);
|
||||
const trigger = {advanced:g.advanced,owner:state.owner,ownerFaction:loser.faction,opponent:winner.id,
|
||||
faction:winner.faction,event:g.lastBattleContext!.event,turn:g.turn,territory:b.territory,
|
||||
opposingLeader:opposingLeader?.id ?? null,opposingLeaderSurvived:!!opposingLeader && !opposingLeader.dead,
|
||||
normalBattle:g.lastBattleContext!.result === 'normal',lostBattle:true,
|
||||
traitorCalled:Object.values(b.traitorCalls).some(Boolean)};
|
||||
if (!moritaniAssassinateTrigger(trigger)) return;
|
||||
const receipt = createMoritaniAssassinateOpportunity(trigger);
|
||||
state.opportunities.push(receipt);
|
||||
syncMoritaniAssassinateReceipt(g,receipt);
|
||||
}
|
||||
function projectedMoritaniAssassinate(g: Game, viewer: string) {
|
||||
const state = g.moritaniAssassinate;
|
||||
if (!state) return null;
|
||||
const pending = state.opportunities.find(r => r.stage === 'choice');
|
||||
const own = viewer === state.owner;
|
||||
const choices = pending && own ? nexusRule(() => moritaniAssassinateChoices(moritaniAssassinateContext(g,pending))) : null;
|
||||
return {owner:state.owner,blocked:own && state.normalTraitorCall
|
||||
? 'Assassinate Leaders after a normal traitor call awaits clarification of the printed loss-of-advantage rule.' : null,
|
||||
pending:pending ? {event:pending.event,opponent:pending.opponent,territory:pending.territory,
|
||||
cards:choices?.cards ?? [],blocked:choices?.blocked ?? null} : null,
|
||||
history:state.opportunities.filter(r => r.stage === 'revealed' || r.stage === 'replaced').map(r => ({
|
||||
event:r.event,turn:r.turn,opponent:r.opponent,faction:r.faction,territory:r.territory,
|
||||
card:r.card!,bounty:r.bounty,stage:r.stage as 'revealed'|'replaced',
|
||||
}))};
|
||||
}
|
||||
function actMoritaniAssassinate(g: Game, decision: Extract<Decision,{kind:'moritaniAssassinate'}>, action: Action) {
|
||||
const state = g.moritaniAssassinate!, r = state.opportunities.find(r => r.stage === 'choice')!;
|
||||
requireRule(r && action.event === r.event && decision.event === r.event && decision.player === r.owner,
|
||||
'This assassination opportunity has already changed.');
|
||||
const resume = g.moritaniAssassinateResume!;
|
||||
requireRule(resume?.event === r.event, 'The original assassination cleanup is missing.');
|
||||
if (action.decline === true) {
|
||||
requireRule(Object.keys(action).every(k => ['type','event','decline'].includes(k)), 'Continue without selecting an assassination card.');
|
||||
r.stage = 'declined';
|
||||
log(g, `${getPlayer(g,r.owner).name} finished the Assassinate Leaders opportunity without revealing a card.`, {faction:'moritani',name:'Assassinate Leaders'});
|
||||
} else {
|
||||
requireRule(Object.keys(action).every(k => ['type','event','card'].includes(k)), 'Choose one assassination Traitor Card or continue.');
|
||||
const quote = nexusRule(() => quoteMoritaniAssassinate(moritaniAssassinateContext(g,r),stringField(action.card)));
|
||||
const target = getPlayer(g,r.opponent).leaders.find(l => l.id === quote.leader)!;
|
||||
if (quote.kill) {target.dead = true;target.deaths++;delete target.usedAt;}
|
||||
const owner = getPlayer(g,r.owner);
|
||||
requireRule(Number.isSafeInteger(owner.spice + quote.bounty), 'Assassination income exceeds the supported spice balance.');
|
||||
owner.spice += quote.bounty;
|
||||
owner.revealedTraitors = [...new Set([...(owner.revealedTraitors ?? []),quote.leader])];
|
||||
Object.assign(r,quote.receipt);
|
||||
log(g, `${owner.name} revealed ${target.name} as a traitor of ${faction(r.faction).name}. ${quote.kill ? `${target.name} was sent to the Tanks and ${owner.name} collected ${quote.bounty} spice from the bank.` : 'That leader was already in the Tanks; no new death or spice was awarded.'} This uses Assassinate Leaders against that faction for the game. The revealed card will be set aside and replaced during Mentat Pause.`, {faction:'moritani',name:'Assassinate Leaders'});
|
||||
}
|
||||
syncMoritaniAssassinateReceipt(g,r);
|
||||
delete g.moritaniAssassinateResume;
|
||||
continueResolvedBattle(g,resume.continuation);
|
||||
}
|
||||
function replaceMoritaniAssassinationTraitors(g: Game) {
|
||||
const state = g.moritaniAssassinate;
|
||||
if (!state) return;
|
||||
for (const r of state.opportunities.filter(r => r.stage === 'revealed')) {
|
||||
const owner = getPlayer(g,r.owner);
|
||||
requireRule(r.turn === g.turn && owner.traitors.includes(r.card!) && (g.traitorReserve?.length ?? 0) > 0,
|
||||
'Mentat replacement needs the original revealed card and one remaining physical Traitor Card.');
|
||||
owner.traitors = owner.traitors.filter(id => id !== r.card);
|
||||
r.replacement = g.traitorReserve!.shift()!;
|
||||
owner.traitors.push(r.replacement);
|
||||
r.stage = 'replaced';
|
||||
syncMoritaniAssassinateReceipt(g,r);
|
||||
log(g, `${owner.name} set the revealed ${faction(r.faction).name} Traitor Card aside face up and drew one private replacement during Mentat Pause. The set-aside card records this faction's used assassination.`, {faction:'moritani',name:'Assassination replacement'});
|
||||
}
|
||||
}
|
||||
function leaderSkillsIntegrity(g: Game) {
|
||||
moritaniAssassinateIntegrity(g);
|
||||
bureaucratPaymentIntegrity(g);
|
||||
mentatQuestionIntegrity(g);
|
||||
diplomatDefenseIntegrity(g);
|
||||
@@ -5638,6 +5805,17 @@ export function initializeFactionExpansionsGameForAudit(state: Game): Game {
|
||||
}), 'The faction initializer cannot overwrite existing inventories, leader custody or revival history.');
|
||||
return initializeSetupGameForAudit(state, false, false, false, false, false, false, true);
|
||||
}
|
||||
/** Genuine, explicitly opted-in preview; no player action enables this profile. */
|
||||
export function initializeMoritaniAssassinateGameForAudit(state: Game): Game {
|
||||
requireRule(!state.moritaniAssassinatePreview && !state.moritaniAssassinate && !state.moritaniAssassinateResume && !state.moritaniAssassinateCallEvents,
|
||||
'The assassination preview starts only from a fresh lobby.');
|
||||
requireRule(moritaniAssassinateModeSupported(state), 'The assassination preview needs Advanced Moritani with ordinary base opponents except Harkonnen, the Ecaz expansion and no optional modules.');
|
||||
const g = initializeFactionExpansionsGameForAudit(state);
|
||||
g.moritaniAssassinatePreview = true;
|
||||
g.moritaniAssassinate = {version:1,owner:byFaction(g,'moritani')!.id,normalTraitorCall:false,opportunities:[]};
|
||||
g.moritaniAssassinateCallEvents = [];
|
||||
return g;
|
||||
}
|
||||
/** Prototype-only Discovery setup. Public starts stay gated while remaining effects are connected. */
|
||||
export function initializeDiscoveryGameForAudit(state: Game): Game {
|
||||
requireRule(state.discoveryEnabled === true && !state.discoveries && !state.discoveryStash && !state.greatMaker,
|
||||
@@ -12347,6 +12525,7 @@ function beginPhase(g: Game) {
|
||||
collect(g);
|
||||
}
|
||||
if (g.phase === 8) {
|
||||
replaceMoritaniAssassinationTraitors(g);
|
||||
if (g.inflation && g.inflation.updatedTurn < g.turn) {
|
||||
if (g.inflation.flipped) {
|
||||
g.inflation = null;
|
||||
@@ -14898,6 +15077,8 @@ function resolveBattle(g: Game) {
|
||||
: (g.players.flatMap((p) => p.leaders).find((l) => l.id === identity)
|
||||
?.name ?? identity);
|
||||
log(g, `${holder.name} revealed ${name} as a traitor.`);
|
||||
if (holder.id === g.moritaniAssassinate?.owner)
|
||||
log(g, `${holder.name} called a traitor normally. Further Assassinate Leaders use awaits clarification of the printed loss-of-advantage rule.`, {faction:'moritani',name:'Assassinate Leaders boundary'});
|
||||
}
|
||||
const dead = (l: Leader | undefined) => {
|
||||
if (l) {
|
||||
@@ -15113,6 +15294,7 @@ function resolveBattle(g: Game) {
|
||||
...(playedCardRoles ? { cardRoles: playedCardRoles } : {}),
|
||||
...(sardaukar?.casualties ? {nexusSardaukarCasualties:sardaukar.receipt.event} : {}),
|
||||
};
|
||||
recordMoritaniAssassinateOpportunity(g, b, winner, losingPlayer);
|
||||
if (winner && quote.sandmaster) {
|
||||
const placement = nexusRule(() => sandmasterVictorySpice(b.territory, g.spice));
|
||||
if (placement) {
|
||||
@@ -15185,6 +15367,8 @@ function resolveBattle(g: Game) {
|
||||
cards,
|
||||
...(casualtyCommitment ? { casualties: casualtyCommitment } : {}),
|
||||
};
|
||||
if (g.lastBattleContext.moritaniAssassinate)
|
||||
g.lastBattleContext.moritaniAssassinate.continuation = JSON.stringify(continuation);
|
||||
if (discarded.length)
|
||||
stageTreacheryDiscard(g, 'battle:mandatory', discarded, continuation);
|
||||
else continueResolvedBattle(g, continuation);
|
||||
@@ -15196,6 +15380,14 @@ function continueResolvedBattle(
|
||||
{ kind: 'battleResolved' }
|
||||
>,
|
||||
) {
|
||||
const assassination = g.moritaniAssassinate?.opportunities.find(r => r.stage === 'choice');
|
||||
if (assassination) {
|
||||
requireRule(assassination.event === continuation.event && !g.moritaniAssassinateResume,
|
||||
'The assassination opportunity must preserve its original battle cleanup.');
|
||||
g.moritaniAssassinateResume = {event:assassination.event,continuation:structuredClone(continuation),signature:JSON.stringify(continuation)};
|
||||
g.decision = {kind:'moritaniAssassinate',player:assassination.owner,event:assassination.event};
|
||||
return;
|
||||
}
|
||||
const { cards, territory: to } = continuation;
|
||||
const winner = continuation.winner ? getPlayer(g, continuation.winner) : null;
|
||||
const losses = continuation.casualties?.options;
|
||||
@@ -20334,6 +20526,7 @@ function applyActionInner(
|
||||
);
|
||||
g.decision = null;
|
||||
requireRule(decision.kind !== 'leaderSkillVisibility' && decision.kind !== 'leaderSkillRevival', 'Use the Leader Skills controls for this decision.');
|
||||
if (decision.kind === 'moritaniAssassinate') {actMoritaniAssassinate(g,decision,action);return g;}
|
||||
if (decision.kind === 'mentatQuestion') {
|
||||
actMentatQuestion(g, decision, action);
|
||||
return g;
|
||||
@@ -23238,6 +23431,11 @@ function applyActionInner(
|
||||
if (action.call && b.traitorDeclarationVersion)
|
||||
b.traitorDeclarations![id] = nexusRule(() => createTraitorDeclaration(traitorDeclarationContext(g), id));
|
||||
b.traitorCalls[id] = action.call;
|
||||
if (action.call && g.moritaniAssassinate?.owner === id) {
|
||||
requireRule(b.event && !g.moritaniAssassinateCallEvents!.includes(b.event), 'This normal traitor call was already recorded.');
|
||||
g.moritaniAssassinate.normalTraitorCall = true;
|
||||
g.moritaniAssassinateCallEvents!.push(b.event);
|
||||
}
|
||||
if (
|
||||
action.call &&
|
||||
p.faction === 'harkonnen' &&
|
||||
@@ -23929,6 +24127,7 @@ export function viewGame(state: Game, id: string) {
|
||||
incomeCanceled:
|
||||
g.choamCharity?.turn === g.turn && g.choamCharity.canceled,
|
||||
},
|
||||
moritaniAssassinate: projectedMoritaniAssassinate(g,id),
|
||||
moritaniRetention: g.moritaniRetention ?? null,
|
||||
moritaniRetentionCard: g.moritaniRetention?.keep
|
||||
? (cardOf(
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
import { leaders as printedLeaders, type Leader } from './cards';
|
||||
import { FACTIONS, type FactionId } from './catalog';
|
||||
|
||||
export type MoritaniAssassinateReceipt = {
|
||||
event: string;
|
||||
turn: number;
|
||||
territory: string;
|
||||
owner: string;
|
||||
opponent: string;
|
||||
faction: FactionId;
|
||||
opposingLeader: string;
|
||||
stage: 'choice' | 'declined' | 'revealed' | 'replaced';
|
||||
card: string | null;
|
||||
bounty: number;
|
||||
replacement: string | null;
|
||||
signature: string;
|
||||
};
|
||||
export type MoritaniAssassinateState = {
|
||||
version: 1;
|
||||
owner: string;
|
||||
normalTraitorCall: boolean;
|
||||
opportunities: MoritaniAssassinateReceipt[];
|
||||
};
|
||||
export type MoritaniAssassinateTrigger = {
|
||||
advanced: boolean;
|
||||
owner: string;
|
||||
ownerFaction: FactionId;
|
||||
opponent: string;
|
||||
faction: FactionId;
|
||||
event: string;
|
||||
turn: number;
|
||||
territory: string;
|
||||
opposingLeader: string | null;
|
||||
opposingLeaderSurvived: boolean;
|
||||
normalBattle: boolean;
|
||||
lostBattle: boolean;
|
||||
traitorCalled: boolean;
|
||||
};
|
||||
export type MoritaniAssassinateContext = {
|
||||
state: MoritaniAssassinateState;
|
||||
receipt: MoritaniAssassinateReceipt;
|
||||
held: readonly string[];
|
||||
normallyRevealed: readonly string[];
|
||||
leaders: readonly Leader[];
|
||||
};
|
||||
/** The engine owns the full traitor universe and the actual Mentat draw.
|
||||
* These lists verify the still-reserved and permanently removed card zones. */
|
||||
export type MoritaniAssassinateCustody = {
|
||||
turn: number;
|
||||
held: readonly string[];
|
||||
removed: readonly string[];
|
||||
};
|
||||
export type MoritaniAssassinateChoice = {
|
||||
card: string;
|
||||
name: string;
|
||||
bounty: number;
|
||||
dead: boolean;
|
||||
};
|
||||
|
||||
const opponents: readonly FactionId[] = [
|
||||
'atreides',
|
||||
'beneGesserit',
|
||||
'guild',
|
||||
'emperor',
|
||||
'fremen',
|
||||
];
|
||||
const identifier = (value: unknown): value is string =>
|
||||
typeof value === 'string' &&
|
||||
value.trim().length > 0 &&
|
||||
!['__proto__', 'constructor', 'prototype'].includes(value);
|
||||
const exact = (value: unknown, keys: readonly string[]): boolean =>
|
||||
!!value &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
[Object.prototype, null].includes(Object.getPrototypeOf(value)) &&
|
||||
Object.keys(value).sort().join(',') === [...keys].sort().join(',');
|
||||
const identities = (value: unknown): value is string[] =>
|
||||
Array.isArray(value) &&
|
||||
value.every(identifier) &&
|
||||
new Set(value).size === value.length;
|
||||
function requireAssassinate(value: unknown, message: string): asserts value {
|
||||
if (!value) throw new Error(message);
|
||||
}
|
||||
function printed(faction: FactionId, id: string) {
|
||||
return FACTIONS.some((f) => f.id === faction)
|
||||
? printedLeaders(faction).find((l) => l.id === id)
|
||||
: undefined;
|
||||
}
|
||||
function ordinaryIdentity(id: string) {
|
||||
return FACTIONS.some((f) => printed(f.id, id));
|
||||
}
|
||||
/** Consistency proof only; the engine separately binds the battle event and
|
||||
* phase continuation. No secret card identity enters this public trigger. */
|
||||
export function moritaniAssassinateTrigger(
|
||||
input: MoritaniAssassinateTrigger,
|
||||
): boolean {
|
||||
return (
|
||||
!!input &&
|
||||
input.advanced === true &&
|
||||
input.ownerFaction === 'moritani' &&
|
||||
identifier(input.owner) &&
|
||||
identifier(input.opponent) &&
|
||||
input.owner !== input.opponent &&
|
||||
identifier(input.event) &&
|
||||
identifier(input.territory) &&
|
||||
Number.isSafeInteger(input.turn) &&
|
||||
input.turn > 0 &&
|
||||
opponents.includes(input.faction) &&
|
||||
identifier(input.opposingLeader) &&
|
||||
!!printed(input.faction, input.opposingLeader) &&
|
||||
input.opposingLeaderSurvived === true &&
|
||||
input.normalBattle === true &&
|
||||
input.lostBattle === true &&
|
||||
input.traitorCalled === false
|
||||
);
|
||||
}
|
||||
export function moritaniAssassinateSignature(
|
||||
receipt: MoritaniAssassinateReceipt,
|
||||
): string {
|
||||
return JSON.stringify([
|
||||
'moritaniAssassinate',
|
||||
receipt.event,
|
||||
receipt.turn,
|
||||
receipt.territory,
|
||||
receipt.owner,
|
||||
receipt.opponent,
|
||||
receipt.faction,
|
||||
receipt.opposingLeader,
|
||||
receipt.stage,
|
||||
receipt.card,
|
||||
receipt.bounty,
|
||||
receipt.replacement,
|
||||
]);
|
||||
}
|
||||
export function createMoritaniAssassinateOpportunity(
|
||||
input: MoritaniAssassinateTrigger,
|
||||
): MoritaniAssassinateReceipt {
|
||||
requireAssassinate(
|
||||
moritaniAssassinateTrigger(input),
|
||||
'Assassinate needs an Advanced native Moritani normal battle loss against a surviving opposing leader, without a traitor call.',
|
||||
);
|
||||
const receipt: MoritaniAssassinateReceipt = {
|
||||
event: input.event,
|
||||
turn: input.turn,
|
||||
territory: input.territory,
|
||||
owner: input.owner,
|
||||
opponent: input.opponent,
|
||||
faction: input.faction,
|
||||
opposingLeader: input.opposingLeader!,
|
||||
stage: 'choice',
|
||||
card: null,
|
||||
bounty: 0,
|
||||
replacement: null,
|
||||
signature: '',
|
||||
};
|
||||
receipt.signature = moritaniAssassinateSignature(receipt);
|
||||
return receipt;
|
||||
}
|
||||
|
||||
export function validateMoritaniAssassinate(
|
||||
state: MoritaniAssassinateState,
|
||||
custody?: MoritaniAssassinateCustody,
|
||||
): void {
|
||||
requireAssassinate(
|
||||
exact(state, ['version', 'owner', 'normalTraitorCall', 'opportunities']) &&
|
||||
state.version === 1 &&
|
||||
identifier(state.owner) &&
|
||||
typeof state.normalTraitorCall === 'boolean' &&
|
||||
Array.isArray(state.opportunities),
|
||||
'Invalid saved Moritani Assassinate state.',
|
||||
);
|
||||
if (custody)
|
||||
requireAssassinate(
|
||||
Number.isSafeInteger(custody.turn) &&
|
||||
custody.turn > 0 &&
|
||||
identities(custody.held) &&
|
||||
identities(custody.removed) &&
|
||||
!custody.held.some((id) => custody.removed.includes(id)),
|
||||
'Assassinate has invalid or overlapping physical traitor custody.',
|
||||
);
|
||||
const events = new Set<string>(),
|
||||
used = new Set<FactionId>(),
|
||||
cards = new Set<string>();
|
||||
for (const [index, r] of state.opportunities.entries()) {
|
||||
requireAssassinate(
|
||||
exact(r, [
|
||||
'event',
|
||||
'turn',
|
||||
'territory',
|
||||
'owner',
|
||||
'opponent',
|
||||
'faction',
|
||||
'opposingLeader',
|
||||
'stage',
|
||||
'card',
|
||||
'bounty',
|
||||
'replacement',
|
||||
'signature',
|
||||
]) &&
|
||||
identifier(r.event) &&
|
||||
!events.has(r.event) &&
|
||||
identifier(r.territory) &&
|
||||
r.owner === state.owner &&
|
||||
identifier(r.opponent) &&
|
||||
r.opponent !== r.owner &&
|
||||
opponents.includes(r.faction) &&
|
||||
Number.isSafeInteger(r.turn) &&
|
||||
r.turn > 0 &&
|
||||
(!custody || r.turn <= custody.turn) &&
|
||||
(index === 0 || r.turn >= state.opportunities[index - 1].turn) &&
|
||||
identifier(r.opposingLeader) &&
|
||||
!!printed(r.faction, r.opposingLeader) &&
|
||||
['choice', 'declined', 'revealed', 'replaced'].includes(r.stage) &&
|
||||
r.signature === moritaniAssassinateSignature(r),
|
||||
'Assassinate has a changed, duplicated or malformed battle opportunity.',
|
||||
);
|
||||
events.add(r.event);
|
||||
if (r.stage === 'choice' || r.stage === 'declined') {
|
||||
requireAssassinate(
|
||||
r.card === null &&
|
||||
r.bounty === 0 &&
|
||||
r.replacement === null &&
|
||||
(r.stage !== 'choice' ||
|
||||
(index === state.opportunities.length - 1 &&
|
||||
(!custody || r.turn === custody.turn))),
|
||||
'An unspent Assassinate choice cannot contain a reveal, bounty or replacement.',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const target =
|
||||
typeof r.card === 'string' ? printed(r.faction, r.card) : undefined;
|
||||
requireAssassinate(
|
||||
target &&
|
||||
r.card !== r.opposingLeader &&
|
||||
!used.has(r.faction) &&
|
||||
!cards.has(r.card!) &&
|
||||
Number.isSafeInteger(r.bounty) &&
|
||||
(r.bounty === 0 || r.bounty === target.strength),
|
||||
'Assassinate must use a different native traitor once per opposing faction for zero or printed bounty.',
|
||||
);
|
||||
used.add(r.faction);
|
||||
cards.add(r.card!);
|
||||
if (r.stage === 'revealed')
|
||||
requireAssassinate(
|
||||
r.replacement === null &&
|
||||
(!custody ||
|
||||
(r.turn === custody.turn &&
|
||||
custody.held.includes(r.card!) &&
|
||||
!custody.removed.includes(r.card!))),
|
||||
'The revealed Assassinate traitor must stay held until its Mentat replacement.',
|
||||
);
|
||||
else
|
||||
requireAssassinate(
|
||||
identifier(r.replacement) &&
|
||||
ordinaryIdentity(r.replacement) &&
|
||||
r.replacement !== r.card &&
|
||||
(!custody ||
|
||||
(custody.removed.includes(r.card!) &&
|
||||
!custody.held.includes(r.card!))),
|
||||
'The replaced Assassinate traitor needs one distinct replacement and its face-up removed card.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Private diagnostics; callers must never gate a public pause on this result. */
|
||||
export function moritaniAssassinateChoices(
|
||||
context: MoritaniAssassinateContext,
|
||||
): {
|
||||
blocked: string | null;
|
||||
cards: MoritaniAssassinateChoice[];
|
||||
unavailable: { card: string; reason: string }[];
|
||||
} {
|
||||
const { state, receipt, held, normallyRevealed, leaders } = context;
|
||||
validateMoritaniAssassinate(state);
|
||||
requireAssassinate(
|
||||
identities(held) && identities(normallyRevealed) && Array.isArray(leaders),
|
||||
'Assassinate needs distinct held/revealed traitors and the native leader pool.',
|
||||
);
|
||||
const current = state.opportunities.find((r) => r.event === receipt?.event);
|
||||
requireAssassinate(
|
||||
current &&
|
||||
exact(receipt, Object.keys(current)) &&
|
||||
moritaniAssassinateSignature(current) ===
|
||||
moritaniAssassinateSignature(receipt) &&
|
||||
receipt.signature === current.signature,
|
||||
'Choose the original saved Assassinate opportunity.',
|
||||
);
|
||||
const unavailable: { card: string; reason: string }[] = [];
|
||||
const stopped = (blocked: string) => ({ blocked, cards: [], unavailable });
|
||||
if (receipt.stage !== 'choice')
|
||||
return stopped('This Assassinate opportunity has already ended.');
|
||||
if (state.normalTraitorCall)
|
||||
return stopped(
|
||||
'Assassinate after an ordinary Moritani traitor call awaits its duration ruling.',
|
||||
);
|
||||
if (
|
||||
state.opportunities.some(
|
||||
(r) =>
|
||||
r.faction === receipt.faction &&
|
||||
(r.stage === 'revealed' || r.stage === 'replaced'),
|
||||
)
|
||||
)
|
||||
return stopped('Assassinate has already been used against this faction.');
|
||||
const reserved = new Set(
|
||||
state.opportunities
|
||||
.filter((r) => r.stage === 'revealed' || r.stage === 'replaced')
|
||||
.map((r) => r.card!),
|
||||
);
|
||||
const cards: MoritaniAssassinateChoice[] = [];
|
||||
for (const card of held) {
|
||||
const native = printed(receipt.faction, card);
|
||||
if (!native || card === receipt.opposingLeader) continue;
|
||||
let reason: string | null = null;
|
||||
if (normallyRevealed.includes(card))
|
||||
reason =
|
||||
'Using an already normally revealed Traitor Card for Assassinate remains pending.';
|
||||
else if (reserved.has(card))
|
||||
reason =
|
||||
'That Traitor Card is already reserved or removed by Assassinate.';
|
||||
const matches = leaders.filter((l) => l.id === card),
|
||||
leader = matches[0];
|
||||
if (
|
||||
!reason &&
|
||||
(matches.length !== 1 ||
|
||||
!leader ||
|
||||
leader.name !== native.name ||
|
||||
leader.strength !== native.strength ||
|
||||
leader.faction !== receipt.faction ||
|
||||
typeof leader.dead !== 'boolean' ||
|
||||
leader.capturedBy ||
|
||||
leader.gholaBy ||
|
||||
leader.concealed ||
|
||||
(leader.controller !== undefined &&
|
||||
leader.controller !== receipt.opponent))
|
||||
)
|
||||
reason =
|
||||
'Assassinate with missing, altered, captured or foreign-ghola target custody remains pending.';
|
||||
if (reason) {
|
||||
unavailable.push({ card, reason });
|
||||
continue;
|
||||
}
|
||||
cards.push({
|
||||
card,
|
||||
name: native.name,
|
||||
bounty: leader.dead ? 0 : native.strength,
|
||||
dead: leader.dead,
|
||||
});
|
||||
}
|
||||
return {
|
||||
blocked: cards.length
|
||||
? null
|
||||
: (unavailable[0]?.reason ??
|
||||
'No different unrevealed native Traitor Card of this opposing faction is held.'),
|
||||
cards,
|
||||
unavailable,
|
||||
};
|
||||
}
|
||||
export function quoteMoritaniAssassinate(
|
||||
context: MoritaniAssassinateContext,
|
||||
card: string,
|
||||
): {
|
||||
receipt: MoritaniAssassinateReceipt;
|
||||
leader: string;
|
||||
bounty: number;
|
||||
kill: boolean;
|
||||
} {
|
||||
const choices = moritaniAssassinateChoices(context),
|
||||
choice = choices.cards.find((c) => c.card === card);
|
||||
requireAssassinate(
|
||||
!choices.blocked && choice,
|
||||
choices.blocked ?? 'Choose one eligible held Traitor Card for Assassinate.',
|
||||
);
|
||||
const receipt: MoritaniAssassinateReceipt = {
|
||||
...context.receipt,
|
||||
stage: 'revealed',
|
||||
card: choice.card,
|
||||
bounty: choice.bounty,
|
||||
replacement: null,
|
||||
signature: '',
|
||||
};
|
||||
receipt.signature = moritaniAssassinateSignature(receipt);
|
||||
return {
|
||||
receipt,
|
||||
leader: choice.card,
|
||||
bounty: choice.bounty,
|
||||
kill: !choice.dead,
|
||||
};
|
||||
}
|
||||
+12
-4
@@ -2558,17 +2558,25 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
id: 'moritani-assassinate-leaders',
|
||||
title: 'Moritani: advanced Assassinate Leaders',
|
||||
category: 'Advanced & expansions',
|
||||
coverage: 'Planned',
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Prototyped',
|
||||
summary:
|
||||
'A battle-loss advantage using a different Traitor Card, separate from Terror Assassination.',
|
||||
'An opted-in Advanced Moritani preview reveals a different opposing-faction Traitor Card after a loss and replaces it during Mentat Pause.',
|
||||
steps: [
|
||||
'After you lose a battle, this advanced advantage is available only if the opposing leader disc survived and no Traitor was called.',
|
||||
'You may reveal a Traitor Card for that opposing faction, but it must name a different leader from the one you just fought. If that named leader is not in the Tanks, kill it and collect spice for its value.',
|
||||
'During Mentat Pause, set the revealed card aside face up as a marker and draw a new Traitor Card. You can use this advantage only once against each faction in the game.',
|
||||
'You may instead reveal a traitor normally in battle, but doing so loses this advanced advantage. The cancellation table gives Karama no effect against Assassinate Leaders.',
|
||||
'An already-dead named leader remains a legal reveal, awarding no death or spice. During Mentat Pause, set the revealed card aside face up as a marker and draw a new Traitor Card. You can use this advantage only once against each faction in the game.',
|
||||
'The printed rule says a normal traitor reveal loses this advantage, but its duration remains unresolved. The preview guards further assassination after a normal call. Karama has no effect against Assassinate Leaders.',
|
||||
'If Harkonnen has captured your own leader and you hold its Traitor Card, you may call that traitor normally; you may not use Assassinate Leaders on that basis.',
|
||||
'Terror Assassination chooses a random leader when its token is triggered. It does not use this battle-loss condition, different-leader Traitor Card or once-per-faction allowance. Advanced starts and Moritani starts remain unavailable.',
|
||||
],
|
||||
checklist: [
|
||||
{area:'Implementation',status:'Partial',detail:'Explicit Advanced Moritani/base-opponent preview before any normal traitor call; native disc deaths, printed bounty, dead-target zero bounty and exact Mentat replacement.',evidence:['game/moritani-assassinate.ts','game/engine.ts']},
|
||||
{area:'Player controls',status:'Partial',detail:'Uniform private choice/decline, named target and bounty, public face-up markers and readable inspection; normal activation awaits private-step UX decision.',evidence:['components/moritani-assassinate.tsx','components/game-table.tsx']},
|
||||
{area:'AI',status:'Partial',detail:'All four profiles choose from the owner-only quote or decline; no hidden rival data or strength-calibration claim.',evidence:['game/bots.ts','tests/moritani-assassinate-engine.test.ts']},
|
||||
{area:'Documentation',status:'Partial',detail:'Official authority, dead-target conditional, normal-call ambiguity, exceptional custody and optional-module boundaries remain explicit.',evidence:['docs/MORITANI_ASSASSINATE_LEADERS.md']},
|
||||
{area:'Verification',status:'Partial',detail:'Rules, controls, genuine-setup staged battles, private views, authenticated recovery, concurrent writes and physical replacement chains; full faction acceptance remains open.',evidence:['tests/moritani-assassinate-quote.test.ts','tests/moritani-assassinate-engine.test.ts','tests/moritani-assassinate-controls.test.tsx','tests/moritani-assassinate-recovery.test.ts']},
|
||||
],
|
||||
related: [
|
||||
'faction-moritani',
|
||||
'moritani-terror-effects',
|
||||
|
||||
@@ -6,6 +6,7 @@ import { RICHESE_CARD_DEFINITIONS } from './richese-cards';
|
||||
const DECISIONS = {
|
||||
leaderSkillVisibility: true,
|
||||
leaderSkillRevival: true,
|
||||
moritaniAssassinate: true,
|
||||
mentatQuestion: true,
|
||||
bureaucratPayment: true,
|
||||
sukRescue: true,
|
||||
|
||||
Reference in new issue
Block a user