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.
163 lines
11 KiB
TypeScript
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));
|
|
});
|