Files
Dune/tests/moritani-assassinate-engine.test.ts
Quintine eff668b202 Support Moritani assassination of captured and foreign-ghola targets
Retain printed native identity and valid original custody while projecting only
uniform potential bounty before commitment. Resolve living/dead targets once,
clear selected capture/concealment and retain ordinary Ghola return custody.
Selected concealed-outcome disclosure is explicitly provisional.

Verified: 14 affected offline files100/100, types/lint and production build;
six actual choice/gameplay/GameTable SSR journeys and twelve profile paths.
Native capture source47 actions; actual paid Ghola prefix140 actions followed
by explicit conserved seven-action battle. No saved-room/D1/reset/restart or
deployment operation. Runtime-only driver fixes reused unchanged passed checks.
2026-10-24 19:03:03 +11:00

163 lines
11 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {applyAction,viewGame,type Game} from '../game/engine';
import {DIFFICULTIES} from '../game/bot-profiles';
import {assassinationGame,stageAssassinationBattle,prepareAssassinationBattle,resolveAssassinationBattle,assassinationChoice,assassinationToMentat,assassinationPhysical,assassinationRejects,assassinationActions} from './moritani-assassinate-fixture';
import {advanceToNextStorm,finishToMovement} from './fixture-advanced-source';
void test('a real lost battle reveals one different opposing traitor, kills its native disc once and replaces the physical card at Mentat',()=>{
const staged=stageAssassinationBattle(assassinationGame()),inventory=assassinationPhysical(staged);
const pending=resolveAssassinationBattle(staged),r=pending.moritaniAssassinate!.opportunities[0];
assert.equal(r.stage,'choice');assert.equal(r.card,null);assert.equal(pending.phase,6);
assert.deepEqual(assassinationPhysical(pending),inventory);
assert.equal(viewGame(pending,'g').moritaniAssassinate!.pending!.cards.length,0);
assert.equal(viewGame(pending,'m').moritaniAssassinate!.pending!.cards[0].card,'guild-1');
const before=pending.players[0].spice,dead=pending.players[1].leaders.find(l=>l.id==='guild-1')!;
const done=assassinationChoice(pending,'guild-1');
assert.equal(done.players[0].spice,before+dead.strength);
assert.equal(done.players[1].leaders.find(l=>l.id===dead.id)!.deaths,dead.deaths+1);
assert.equal(done.players[1].leaders.find(l=>l.id==='guild-0')!.dead,false);
assert.equal(done.moritaniAssassinate!.opportunities[0].stage,'revealed');
assert.ok(done.players[0].traitors.includes('guild-1'));assert.equal(done.response,null);
for(const p of done.players)assert.deepEqual(viewGame(JSON.parse(JSON.stringify(done)),p.id),viewGame(done,p.id));
const next=assassinationToMentat(done),settled=next.moritaniAssassinate!.opportunities[0];
assert.equal(settled.stage,'replaced');assert.ok(settled.replacement);
assert.equal(next.players[0].traitors.includes('guild-1'),false);
assert.ok(next.players[0].traitors.includes(settled.replacement));
assert.deepEqual(assassinationPhysical(next),inventory);
const rival=viewGame(next,'g');assert.equal('replacement' in rival.moritaniAssassinate!.history[0],false);
assert.equal('moritaniAssassinatePreview' in rival,false);assert.equal('moritaniAssassinateCallEvents' in rival,false);
});
void test('an already dead target remains a legal assassination reveal with no bounty or repeated death',()=>{
const pending=resolveAssassinationBattle(stageAssassinationBattle(assassinationGame(),'guild-1',true));
const before=pending.players[0].spice,dead=structuredClone(pending.players[1].leaders[1]);
const choice=viewGame(pending,'m').moritaniAssassinate!.pending!.cards[0];
assert.equal(choice.potentialBounty,dead.strength);
assert.equal('dead' in choice,false);assert.equal('bounty' in choice,false);
const done=assassinationChoice(pending,'guild-1');
assert.equal(done.players[0].spice,before);assert.deepEqual(done.players[1].leaders[1],dead);
const next=assassinationToMentat(done);assert.equal(next.moritaniAssassinate!.opportunities[0].stage,'replaced');
assert.equal(next.moritaniAssassinate!.opportunities[0].bounty,0);
});
void test('eligible and empty private traitor hands create indistinguishable public loss decisions and explicit decline preserves the card',()=>{
const a=stageAssassinationBattle(assassinationGame());
const b=stageAssassinationBattle(a,a.traitorReserve!.find(id=>id.startsWith('moritani-'))!);
const l=resolveAssassinationBattle(a),r=resolveAssassinationBattle(b);
// Runtime UUIDs are independent; erase only public random event identifiers.
const normalize=(g:Game)=>{
const view=viewGame(g,'g');
const event=g.moritaniAssassinate!.opportunities[0].event;
return JSON.parse(JSON.stringify(view).replaceAll(event,'battle-event'));
};
assert.deepEqual(normalize(l),normalize(r));
assert.equal(viewGame(r,'m').moritaniAssassinate!.pending!.cards.length,0);
const before=assassinationPhysical(l),held=[...l.players[0].traitors];
const done=assassinationChoice(l,null);assert.deepEqual(done.players[0].traitors,held);
assert.equal(done.moritaniAssassinate!.opportunities[0].stage,'declined');
assert.deepEqual(assassinationPhysical(done),before);
assert.equal(viewGame(done,'g').moritaniAssassinate!.history.length,0);
});
void test('all four profiles choose from the owner-only quote and decline when no eligible card exists',()=>{
for(const identity of ['guild-1','moritani-0']) {
const pending=resolveAssassinationBattle(stageAssassinationBattle(assassinationGame(),identity));
for(const profile of DIFFICULTIES) {
const actions=assassinationActions(pending,'m',profile);assert.equal(actions.length,1);
const next=applyAction(pending,'m',actions[0]);
assert.equal(next.moritaniAssassinate!.opportunities[0].stage,identity==='guild-1'?'revealed':'declined');
}
}
});
void test('stale, other-seat, same-leader, dishonest mixed and duplicate assassination actions reject without changing their input',()=>{
const pending=resolveAssassinationBattle(stageAssassinationBattle(assassinationGame()));
const event=pending.decision!.kind==='moritaniAssassinate'?pending.decision!.event:'';
for(const [id,action] of [
['g',{type:'decision',event,card:'guild-1'}],
['m',{type:'decision',event:'stale',card:'guild-1'}],
['m',{type:'decision',event,card:'guild-0'}],
['m',{type:'decision',event,card:'guild-1',decline:true}],
['m',{type:'decision',event,card:'moritani-0'}],
] as const)assassinationRejects(pending,id,action);
const done=assassinationChoice(pending,'guild-1');
assassinationRejects(done,'m',{type:'decision',event,card:'guild-1'});
const mentat=assassinationToMentat(done);
assassinationRejects(mentat,'m',{type:'decision',event,card:'guild-1'});
});
void test('a normal Moritani traitor reveal forfeits assassination and cannot create an opportunity',()=>{
const staged=stageAssassinationBattle(assassinationGame(),'guild-0');
const done=resolveAssassinationBattle(staged,{normalCall:true});
assert.equal(done.moritaniAssassinate!.normalTraitorCall,true);
assert.equal(done.moritaniAssassinateCallEvents!.length,1);
assert.equal(done.moritaniAssassinate!.opportunities.length,0);
const corrupt=structuredClone(done);corrupt.moritaniAssassinate!.normalTraitorCall=false;
assert.throws(()=>viewGame(corrupt,'m'),/original event/);
});
void test('a sourced normal-reveal forfeiture persists across turns and denies a later genuine loss',()=>{
const first=finishToMovement(advanceToNextStorm(assassinationToMentat(resolveAssassinationBattle(stageAssassinationBattle(assassinationGame(['m','g','e']),'guild-0'),{normalCall:true}))));
assert.equal(first.moritaniAssassinate!.normalTraitorCall,true);assert.equal(first.moritaniAssassinate!.opportunities.length,0);
assert.equal(first.turn,2);
first.players=[first.players[0],first.players[2],first.players[1]];
const second=resolveAssassinationBattle(stageAssassinationBattle(first));
assert.equal(second.lastBattleContext!.result,'normal');assert.notEqual(second.lastBattleContext!.winner,'m');
assert.equal(second.moritaniAssassinate!.normalTraitorCall,true);
assert.equal(second.moritaniAssassinate!.opportunities.length,0);
assert.notEqual(second.decision?.kind,'moritaniAssassinate');
});
void test('saved assassination receipts and cleanup obligations fail closed after corruption',()=>{
const pending=resolveAssassinationBattle(stageAssassinationBattle(assassinationGame()));
for(const breakSave of [
(g:Game)=>{g.moritaniAssassinate!.opportunities=[];},
(g:Game)=>{g.moritaniAssassinate!.opportunities[0].opponent='m';},
(g:Game)=>{delete g.moritaniAssassinateResume;},
(g:Game)=>{g.moritaniAssassinateResume!.continuation.cards=['treachery-1'];},
(g:Game)=>{g.moritaniAssassinateResume!.continuation.territory='carthag';g.moritaniAssassinateResume!.signature=JSON.stringify(g.moritaniAssassinateResume!.continuation);},
(g:Game)=>{g.decision=null;},
]) {const bad=structuredClone(pending);breakSave(bad);assert.throws(()=>viewGame(bad,'m'));}
const settled=assassinationToMentat(assassinationChoice(pending,'guild-1'));
const missing=structuredClone(settled);missing.moritaniAssassinate!.opportunities=[];
assert.throws(()=>viewGame(missing,'m'));
});
void test('a sealed normal traitor call does not publish the assassination guard before the other voter replies',()=>{
const prepared=prepareAssassinationBattle(stageAssassinationBattle(assassinationGame(),'guild-0'));
const called=applyAction(prepared,'m',{type:'traitorCall',call:true});
const declined=applyAction(prepared,'m',{type:'traitorCall',call:false});
assert.deepEqual(viewGame(called,'g'),viewGame(declined,'g'));
assert.equal(called.moritaniAssassinate!.normalTraitorCall,true);
});
function nextAssassinationDraw(g:Game,identity:string) {
const i=g.traitorReserve!.indexOf(identity);
if(i>=0){const [card]=g.traitorReserve!.splice(i,1);g.traitorReserve!.unshift(card);return;}
const holder=g.players.find(p=>p.traitors.includes(identity))!;assert.ok(holder&&holder.id!==g.players[0].id);
const old=g.traitorReserve!.shift()!;holder.traitors[holder.traitors.indexOf(identity)]=old;g.traitorReserve!.unshift(identity);
}
void test('the per-faction marker prevents a later use even when the replacement is another matching faction card',()=>{
const staged=stageAssassinationBattle(assassinationGame());nextAssassinationDraw(staged,'guild-2');
const first=assassinationToMentat(assassinationChoice(resolveAssassinationBattle(staged),'guild-1'));
assert.deepEqual(first.players[0].traitors,['guild-2']);first.turn++;
const second=resolveAssassinationBattle(stageAssassinationBattle(first,'guild-2'));
assert.notEqual(second.decision?.kind,'moritaniAssassinate');
assert.equal(second.moritaniAssassinate!.opportunities.length,1);
});
void test('a private replacement may fund a later assassination against another faction without breaking historical physical custody',()=>{
const staged=stageAssassinationBattle(assassinationGame(['m','g','e']));nextAssassinationDraw(staged,'emperor-1');
const inventory=assassinationPhysical(staged);
const first=assassinationToMentat(assassinationChoice(resolveAssassinationBattle(staged),'guild-1'));
assert.deepEqual(first.players[0].traitors,['emperor-1']);first.turn++;
first.players=[first.players[0],first.players[2],first.players[1]];
const second=resolveAssassinationBattle(stageAssassinationBattle(first,'emperor-1'));
const revealed=assassinationChoice(second,'emperor-1');
const final=assassinationToMentat(revealed);
assert.deepEqual(final.moritaniAssassinate!.opportunities.map(r=>r.stage),['replaced','replaced']);
const physical=assassinationPhysical(final);physical.forces.sort((a,b)=>a.id.localeCompare(b.id));inventory.forces.sort((a,b)=>a.id.localeCompare(b.id));
assert.deepEqual(physical,inventory);
for(const p of final.players)assert.deepEqual(viewGame(JSON.parse(JSON.stringify(final)),p.id),viewGame(final,p.id));
});