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
@@ -0,0 +1,103 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import type { GameView } from '../game/engine';
|
||||
|
||||
const aliases = registerHooks({
|
||||
resolve(specifier, context, next) {
|
||||
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier, context);
|
||||
},
|
||||
});
|
||||
const { MoritaniAssassinate, MoritaniAssassinateHistory } = await import('../components/moritani-assassinate');
|
||||
|
||||
function view(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
me: 'm',
|
||||
players: [
|
||||
{ id: 'm', name: 'Moritani', faction: 'moritani' },
|
||||
{ id: 'o', name: 'House Opponent', faction: 'atreides' },
|
||||
],
|
||||
response: null,
|
||||
truthtrance: null,
|
||||
allLeaders: [
|
||||
{ id: 'atreides-leader-1', name: 'Duke Leto', faction: 'atreides', strength: 5 },
|
||||
{ id: 'atreides-leader-2', name: 'Thufir Hawat', faction: 'atreides', strength: 3 },
|
||||
{ id: 'traitor-1', name: 'Duke Leto', faction: 'atreides', strength: 5 },
|
||||
{ id: 'traitor-2', name: 'Thufir Hawat', faction: 'atreides', strength: 4 },
|
||||
],
|
||||
decision: { kind: 'moritaniAssassinate', player: 'm', event: 'assassinate-1' },
|
||||
moritaniAssassinate: {
|
||||
owner: 'm',
|
||||
blocked: null,
|
||||
pending: {
|
||||
event: 'assassinate-1', opponent: 'o', territory: 'Arrakeen',
|
||||
cards: [
|
||||
{ card: 'traitor-1', name: 'Duke Leto', bounty: 5, dead: false },
|
||||
{ card: 'traitor-2', name: 'Thufir Hawat', bounty: 0, dead: true },
|
||||
], blocked: null,
|
||||
},
|
||||
history: [],
|
||||
},
|
||||
...overrides,
|
||||
} as unknown as GameView;
|
||||
}
|
||||
|
||||
function markup(game: GameView, busy = false) {
|
||||
return renderToStaticMarkup(createElement(MoritaniAssassinate, { game, act() {}, busy }));
|
||||
}
|
||||
|
||||
void test('owner sees public traitor choices, bounty, dead-target legality and rules', () => {
|
||||
const html = markup(view());
|
||||
assert.match(html, /Traitor Card to reveal/);
|
||||
assert.match(html, /Duke Leto/);
|
||||
assert.match(html, /5 spice bounty/);
|
||||
assert.match(html, /Thufir Hawat · dead target · 0 spice/);
|
||||
assert.match(html, /replaced automatically during Mentat Pause/);
|
||||
assert.match(html, /cannot be canceled with Karama/);
|
||||
const dead = markup(view({ moritaniAssassinate: {
|
||||
owner: 'm', blocked: null, pending: {
|
||||
event: 'assassinate-1', opponent: 'o', territory: 'Arrakeen',
|
||||
cards: [{ card: 'traitor-2', name: 'Thufir Hawat', bounty: 0, dead: true }], blocked: null,
|
||||
}, history: [],
|
||||
} }));
|
||||
assert.match(dead, /0 spice; target already dead/);
|
||||
assert.match(dead, /Printed leader strength: 4/);
|
||||
});
|
||||
|
||||
void test('nonowner, stale, blocked, response and Truthtrance contexts stay guarded', () => {
|
||||
assert.equal(markup(view({ me: 'o' })), '');
|
||||
assert.equal(markup(view({ decision: { kind: 'moritaniAssassinate', player: 'm', event: 'old' } })), '');
|
||||
const blocked = markup(view({ moritaniAssassinate: {
|
||||
owner: 'm', blocked: 'No eligible leader remains.', pending: {
|
||||
event: 'assassinate-1', opponent: 'o', territory: 'Arrakeen', cards: [], blocked: 'No eligible leader remains.',
|
||||
}, history: [],
|
||||
} }));
|
||||
assert.match(blocked, /No eligible leader remains/);
|
||||
assert.match(blocked, />Continue</);
|
||||
assert.match(markup(view({ response: { kind: 'test' } })), /disabled=""/);
|
||||
assert.match(markup(view({ truthtrance: { stage: 'answer' } })), /disabled=""/);
|
||||
});
|
||||
|
||||
void test('public history shows face-up traitor markers and read-only inspection for every seat', () => {
|
||||
const html = renderToStaticMarkup(createElement(MoritaniAssassinateHistory, {
|
||||
game: view({
|
||||
moritaniAssassinate: {
|
||||
owner: 'm', blocked: null, pending: null,
|
||||
history: [
|
||||
{ event: 'old-1', turn: 2, opponent: 'o', faction: 'atreides', territory: 'arrakeen', card: 'atreides-leader-1', bounty: 5, stage: 'revealed' },
|
||||
{ event: 'old-2', turn: 3, opponent: 'o', faction: 'atreides', territory: 'carthag', card: 'atreides-leader-2', bounty: 3, stage: 'replaced' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
}));
|
||||
assert.match(html, /Assassinate Leaders revealed cards/);
|
||||
assert.match(html, /Revealed · awaiting replacement/);
|
||||
assert.match(html, /Set aside face up · replaced/);
|
||||
assert.match(html, /Arrakeen/);
|
||||
assert.match(html, /Inspect traitor/);
|
||||
assert.doesNotMatch(html, /Decline Assassinate/);
|
||||
});
|
||||
|
||||
aliases.deregister();
|
||||
@@ -0,0 +1,169 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {applyAction,viewGame,initializeMoritaniAssassinateGameForAudit,createGame,newPlayer,joinGame,type Game} from '../game/engine';
|
||||
import {DIFFICULTIES} from '../game/bot-profiles';
|
||||
import {moritaniAssassinateSignature} from '../game/moritani-assassinate';
|
||||
import {assassinationGame,stageAssassinationBattle,prepareAssassinationBattle,resolveAssassinationBattle,assassinationChoice,assassinationToMentat,assassinationPhysical,assassinationRejects,assassinationActions} from './moritani-assassinate-fixture';
|
||||
|
||||
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]);
|
||||
assert.equal(viewGame(pending,'m').moritaniAssassinate!.pending!.cards[0].bounty,0);
|
||||
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 call records an explicit unresolved guard and cannot create an assassination 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);
|
||||
assert.match(viewGame(done,'m').moritaniAssassinate!.blocked!,/clarification/);
|
||||
const corrupt=structuredClone(done);corrupt.moritaniAssassinate!.normalTraitorCall=false;
|
||||
assert.throws(()=>viewGame(corrupt,'m'),/original event/);
|
||||
});
|
||||
|
||||
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'));
|
||||
const wrongDraw=structuredClone(settled),receipt=wrongDraw.moritaniAssassinate!.opportunities[0];
|
||||
receipt.replacement=wrongDraw.traitorReserve![0];
|
||||
receipt.signature=moritaniAssassinateSignature(receipt);
|
||||
wrongDraw.lastBattleContext!.moritaniAssassinate!.signature=receipt.signature;
|
||||
assert.throws(()=>viewGame(wrongDraw,'m'),/replacement must stay/);
|
||||
});
|
||||
|
||||
void test('the explicit preview refuses Basic and exceptional-faction lobbies without mutation',()=>{
|
||||
for(const [advanced,opponent] of [[false,'guild'],[true,'harkonnen'],[true,'ecaz']] as const){
|
||||
const g=createGame('ASSAINIT',newPlayer('m','Moritani','moritani'),advanced,['ecaz']);
|
||||
joinGame(g,newPlayer('x','Opponent',opponent));for(const p of g.players)p.ready=true;
|
||||
const before=structuredClone(g);assert.throws(()=>initializeMoritaniAssassinateGameForAudit(g));assert.deepEqual(g,before);
|
||||
}
|
||||
});
|
||||
|
||||
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 sameTurn=structuredClone(revealed),earlier=sameTurn.moritaniAssassinate!.opportunities[0];
|
||||
earlier.turn=sameTurn.turn;earlier.signature=moritaniAssassinateSignature(earlier);
|
||||
assert.throws(()=>viewGame(sameTurn,'m'),/replacement must stay/);
|
||||
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));
|
||||
const bad=structuredClone(final),later=bad.moritaniAssassinate!.opportunities[1];
|
||||
later.turn=bad.moritaniAssassinate!.opportunities[0].turn;later.signature=moritaniAssassinateSignature(later);
|
||||
bad.lastBattleContext!.moritaniAssassinate!.signature=later.signature;
|
||||
assert.throws(()=>viewGame(bad,'m'),/replacement must stay/);
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, initializeMoritaniAssassinateGameForAudit, joinGame, newPlayer, viewGame, type Game, type Action } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import type { Difficulty } from '../game/bot-profiles';
|
||||
|
||||
export function assassinationActions(g:Game,id:string,difficulty:Difficulty='Medium') {
|
||||
const view=viewGame(g,id);view.players.find(p=>p.id===id)!.bot=difficulty;
|
||||
return botActions(view);
|
||||
}
|
||||
/** Genuine Advanced Moritani/Guild setup. Subsequent helpers explicitly stage
|
||||
* a conserved battle and physical traitor swap; they do not certify natural use. */
|
||||
export function assassinationGame(ids=['m','g']) {
|
||||
let g=createGame('ASSASSIN',newPlayer(ids[0],'Moritani','moritani'),true,['ecaz']);
|
||||
joinGame(g,newPlayer(ids[1],'Guild','guild'));
|
||||
if(ids[2])joinGame(g,newPlayer(ids[2],'Emperor','emperor'));
|
||||
for(const p of g.players)p.ready=true;
|
||||
g=initializeMoritaniAssassinateGameForAudit(g);
|
||||
for(let n=0;g.status==='setup'&&n<50;n++) {
|
||||
const next=g.players.flatMap(p=>assassinationActions(g,p.id).map(action=>({id:p.id,action})))[0];
|
||||
assert.ok(next,'The real Moritani setup must offer a legal choice');
|
||||
g=applyAction(g,next.id,next.action);
|
||||
}
|
||||
assert.equal(g.status,'playing');assert.equal(g.phase,0);
|
||||
return g;
|
||||
}
|
||||
export function giveAssassinationTraitor(g:Game,identity:string) {
|
||||
const owner=g.players[0],old=owner.traitors[0];
|
||||
assert.ok(old);
|
||||
if(old===identity)return;
|
||||
const other=g.players.find(p=>p.id!==owner.id&&p.traitors.includes(identity));
|
||||
if(other)other.traitors[other.traitors.indexOf(identity)]=old;
|
||||
else {
|
||||
const i=g.traitorReserve!.indexOf(identity);assert.ok(i>=0,'Fixture swap must find the physical traitor');
|
||||
g.traitorReserve![i]=old;
|
||||
}
|
||||
owner.traitors[0]=identity;
|
||||
}
|
||||
export function stageAssassinationBattle(state:Game,identity='guild-1',dead=false) {
|
||||
const g=structuredClone(state);
|
||||
giveAssassinationTraitor(g,identity);
|
||||
g.phase=6;g.ready=[];g.order=[g.players[1].id,g.players[0].id,...g.players.slice(2).map(p=>p.id)];g.active=g.players[1].id;g.storm=18;g.battle=null;g.decision=null;g.response=null;
|
||||
for(const [index,p] of g.players.entries()){
|
||||
p.reserves+=Object.values(p.forces).reduce((a,b)=>a+b,0);p.forces={};
|
||||
if(p.elites){p.elites.reserves+=Object.values(p.elites.forces).reduce((a,b)=>a+b,0);p.elites.forces={};}
|
||||
if(index<2){p.reserves-=3;p.forces['arrakeen:10']=3;}
|
||||
}
|
||||
const target=g.players[1].leaders.find(l=>l.id===identity);
|
||||
if(target&&dead){target.dead=true;target.deaths=1;delete target.usedAt;}
|
||||
return g;
|
||||
}
|
||||
export function prepareAssassinationBattle(state:Game,options:{winnerWeapon?:string}={}) {
|
||||
let g=applyAction(state,state.players[1].id,{type:'chooseBattle',territory:'arrakeen',target:state.players[0].id});
|
||||
for(let n=0;g.response&&n<15;n++) {
|
||||
const candidate=g.players.flatMap(p=>assassinationActions(g,p.id).map(action=>({id:p.id,action}))).find(c=>c.action.type==='passResponse');
|
||||
assert.ok(candidate);g=applyAction(g,candidate.id,candidate.action);
|
||||
}
|
||||
assert.ok(g.battle);assert.equal(g.battle.preparation,undefined);
|
||||
const [m,w]=g.players;
|
||||
g=applyAction(g,w.id,{type:'battlePlan',leader:w.leaders[0].id,dial:0,support:0,...(options.winnerWeapon?{weapon:options.winnerWeapon}:{})});
|
||||
g=applyAction(g,m.id,{type:'battlePlan',leader:m.leaders[4].id,dial:0,support:0});
|
||||
return g;
|
||||
}
|
||||
export function resolveAssassinationBattle(state:Game,options:{normalCall?:boolean;winnerWeapon?:string}={}) {
|
||||
let g=prepareAssassinationBattle(state,options);
|
||||
const [m,w]=g.players;
|
||||
for(const id of [w.id,m.id])g=applyAction(g,id,{type:'traitorCall',call:id===m.id&&!!options.normalCall});
|
||||
return g;
|
||||
}
|
||||
export function assassinationChoice(g:Game,card:string|null) {
|
||||
assert.equal(g.decision?.kind,'moritaniAssassinate');
|
||||
const d=g.decision as Extract<NonNullable<Game['decision']>,{kind:'moritaniAssassinate'}>;
|
||||
return applyAction(g,d.player,{type:'decision',event:d.event,...(card===null?{decline:true}:{card})});
|
||||
}
|
||||
export function assassinationToMentat(state:Game) {
|
||||
let g=state;
|
||||
for(let n=0;g.phase!==8&&n<80;n++) {
|
||||
const choices=g.players.flatMap(p=>assassinationActions(g,p.id).map(action=>({id:p.id,action})));
|
||||
const next=choices.find(c=>c.action.type==='ready')??choices[0];
|
||||
assert.ok(next,`Continuation stalled in ${g.phase}/${g.decision?.kind}`);
|
||||
g=applyAction(g,next.id,next.action);
|
||||
}
|
||||
assert.equal(g.phase,8);return g;
|
||||
}
|
||||
export function assassinationPhysical(g:Game) {
|
||||
return {
|
||||
cards:[...g.deck,...g.discard,...g.players.flatMap(p=>p.hand)].map(c=>c.id).sort(),
|
||||
traitors:[...(g.traitorReserve??[]),...g.players.flatMap(p=>p.traitors),...(g.moritaniAssassinate?.opportunities.filter(r=>r.stage==='replaced').map(r=>r.card!)??[])].sort(),
|
||||
forces:g.players.map(p=>({id:p.id,total:p.reserves+p.tanks+Object.values(p.forces).reduce((a,b)=>a+b,0),elite:(p.elites?.reserves??0)+(p.elites?.tanks??0)+Object.values(p.elites?.forces??{}).reduce((a,b)=>a+b,0)})),
|
||||
};
|
||||
}
|
||||
export function assassinationRejects(g:Game,id:string,action:Action) {
|
||||
const before=structuredClone(g);assert.throws(()=>applyAction(g,id,action));assert.deepEqual(g,before);
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { leaders } from '../game/cards';
|
||||
import {
|
||||
createMoritaniAssassinateOpportunity,
|
||||
moritaniAssassinateChoices,
|
||||
moritaniAssassinateSignature,
|
||||
moritaniAssassinateTrigger,
|
||||
quoteMoritaniAssassinate,
|
||||
validateMoritaniAssassinate,
|
||||
type MoritaniAssassinateContext,
|
||||
type MoritaniAssassinateReceipt,
|
||||
type MoritaniAssassinateTrigger,
|
||||
} from '../game/moritani-assassinate';
|
||||
|
||||
function fixture(
|
||||
faction: MoritaniAssassinateTrigger['faction'] = 'atreides',
|
||||
event = 'battle:2:arrakeen',
|
||||
): MoritaniAssassinateContext {
|
||||
const input: MoritaniAssassinateTrigger = {
|
||||
advanced: true,
|
||||
owner: 'm',
|
||||
ownerFaction: 'moritani',
|
||||
opponent: 'a',
|
||||
faction,
|
||||
event,
|
||||
turn: 2,
|
||||
territory: 'arrakeen',
|
||||
opposingLeader: `${faction}-0`,
|
||||
opposingLeaderSurvived: true,
|
||||
normalBattle: true,
|
||||
lostBattle: true,
|
||||
traitorCalled: false,
|
||||
};
|
||||
const receipt = createMoritaniAssassinateOpportunity(input);
|
||||
return {
|
||||
state: {
|
||||
version: 1,
|
||||
owner: 'm',
|
||||
normalTraitorCall: false,
|
||||
opportunities: [receipt],
|
||||
},
|
||||
receipt,
|
||||
held: [
|
||||
`${faction}-0`,
|
||||
`${faction}-1`,
|
||||
`${faction}-2`,
|
||||
'moritani-0',
|
||||
'cheap-hero-traitor',
|
||||
],
|
||||
normallyRevealed: [],
|
||||
leaders: leaders(faction),
|
||||
};
|
||||
}
|
||||
function revised(
|
||||
receipt: MoritaniAssassinateReceipt,
|
||||
patch: Partial<MoritaniAssassinateReceipt>,
|
||||
) {
|
||||
const r = { ...receipt, ...patch };
|
||||
r.signature = moritaniAssassinateSignature(r);
|
||||
return r;
|
||||
}
|
||||
|
||||
void test('public trigger requires a native Advanced Moritani normal loss, a surviving native opposing disc and no actual traitor call', () => {
|
||||
const input: MoritaniAssassinateTrigger = {
|
||||
advanced: true,
|
||||
owner: 'm',
|
||||
ownerFaction: 'moritani',
|
||||
opponent: 'a',
|
||||
faction: 'atreides',
|
||||
event: 'battle',
|
||||
turn: 2,
|
||||
territory: 'arrakeen',
|
||||
opposingLeader: 'atreides-0',
|
||||
opposingLeaderSurvived: true,
|
||||
normalBattle: true,
|
||||
lostBattle: true,
|
||||
traitorCalled: false,
|
||||
};
|
||||
assert.equal(moritaniAssassinateTrigger(input), true);
|
||||
for (const patch of [
|
||||
{ advanced: false },
|
||||
{ ownerFaction: 'atreides' },
|
||||
{ opponent: 'm' },
|
||||
{ lostBattle: false },
|
||||
{ normalBattle: false },
|
||||
{ opposingLeaderSurvived: false },
|
||||
{ traitorCalled: true },
|
||||
{ opposingLeader: null },
|
||||
{ opposingLeader: 'cheap-hero-traitor' },
|
||||
{ opposingLeader: 'emperor-0' },
|
||||
{ faction: 'harkonnen', opposingLeader: 'harkonnen-0' },
|
||||
{ faction: 'ecaz', opposingLeader: 'ecaz-0' },
|
||||
{ turn: 0 },
|
||||
{ turn: 1.5 },
|
||||
{ event: '' },
|
||||
]) {
|
||||
const invalid = { ...input, ...patch } as MoritaniAssassinateTrigger,
|
||||
before = structuredClone(invalid);
|
||||
assert.equal(moritaniAssassinateTrigger(invalid), false);
|
||||
assert.throws(
|
||||
() => createMoritaniAssassinateOpportunity(invalid),
|
||||
/Assassinate/,
|
||||
);
|
||||
assert.deepEqual(invalid, before);
|
||||
}
|
||||
for (const faction of [
|
||||
'atreides',
|
||||
'beneGesserit',
|
||||
'guild',
|
||||
'emperor',
|
||||
'fremen',
|
||||
] as const)
|
||||
assert.equal(
|
||||
moritaniAssassinateTrigger({
|
||||
...input,
|
||||
faction,
|
||||
opposingLeader: `${faction}-0`,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
void test('only a different held native Traitor Card is selectable, and live targets pay exactly their printed strength without mutating inputs', () => {
|
||||
for (const faction of [
|
||||
'atreides',
|
||||
'beneGesserit',
|
||||
'guild',
|
||||
'emperor',
|
||||
'fremen',
|
||||
] as const) {
|
||||
const context = fixture(faction),
|
||||
before = structuredClone(context),
|
||||
native = context.leaders[1];
|
||||
const choices = moritaniAssassinateChoices(context);
|
||||
assert.equal(choices.blocked, null);
|
||||
assert.deepEqual(choices.unavailable, []);
|
||||
assert.deepEqual(
|
||||
choices.cards.map((c) => c.card),
|
||||
[`${faction}-1`, `${faction}-2`],
|
||||
);
|
||||
const quote = quoteMoritaniAssassinate(context, native.id);
|
||||
assert.equal(quote.leader, native.id);
|
||||
assert.equal(quote.bounty, native.strength);
|
||||
assert.equal(quote.kill, true);
|
||||
assert.equal(quote.receipt.stage, 'revealed');
|
||||
assert.equal(quote.receipt.card, native.id);
|
||||
assert.equal(quote.receipt.replacement, null);
|
||||
validateMoritaniAssassinate(
|
||||
{ ...context.state, opportunities: [quote.receipt] },
|
||||
{ turn: 2, held: context.held, removed: [] },
|
||||
);
|
||||
assert.deepEqual(context, before);
|
||||
for (const card of [
|
||||
`${faction}-0`,
|
||||
'moritani-0',
|
||||
'cheap-hero-traitor',
|
||||
`${faction}-4`,
|
||||
]) {
|
||||
assert.throws(() => quoteMoritaniAssassinate(context, card), /eligible/);
|
||||
assert.deepEqual(context, before);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('already-dead targets remain revealable for no death or spice, while prior use locations do not remove a live target', () => {
|
||||
const context = fixture();
|
||||
context.leaders[1].dead = true;
|
||||
context.leaders[1].deaths = 2;
|
||||
context.leaders[2].usedAt = 'carthag';
|
||||
const before = structuredClone(context);
|
||||
assert.deepEqual(moritaniAssassinateChoices(context).cards[0], {
|
||||
card: 'atreides-1',
|
||||
name: context.leaders[1].name,
|
||||
bounty: 0,
|
||||
dead: true,
|
||||
});
|
||||
const dead = quoteMoritaniAssassinate(context, 'atreides-1');
|
||||
assert.equal(dead.kill, false);
|
||||
assert.equal(dead.bounty, 0);
|
||||
assert.equal(dead.receipt.stage, 'revealed');
|
||||
assert.equal(quoteMoritaniAssassinate(context, 'atreides-2').kill, true);
|
||||
validateMoritaniAssassinate(
|
||||
{ ...context.state, opportunities: [dead.receipt] },
|
||||
{ turn: 2, held: context.held, removed: [] },
|
||||
);
|
||||
assert.deepEqual(context, before);
|
||||
});
|
||||
|
||||
void test('ordinary traitor-call ambiguity and already normally revealed cards stay explicit private guards', () => {
|
||||
const context = fixture();
|
||||
context.state.normalTraitorCall = true;
|
||||
assert.match(
|
||||
moritaniAssassinateChoices(context).blocked!,
|
||||
/ordinary.*duration ruling/,
|
||||
);
|
||||
assert.throws(
|
||||
() => quoteMoritaniAssassinate(context, 'atreides-1'),
|
||||
/duration ruling/,
|
||||
);
|
||||
context.state.normalTraitorCall = false;
|
||||
context.normallyRevealed = ['atreides-1'];
|
||||
const choices = moritaniAssassinateChoices(context);
|
||||
assert.deepEqual(
|
||||
choices.cards.map((c) => c.card),
|
||||
['atreides-2'],
|
||||
);
|
||||
assert.match(choices.unavailable[0].reason, /normally revealed/);
|
||||
assert.throws(
|
||||
() => quoteMoritaniAssassinate(context, 'atreides-1'),
|
||||
/eligible/,
|
||||
);
|
||||
context.normallyRevealed = ['atreides-1', 'atreides-2'];
|
||||
assert.match(
|
||||
moritaniAssassinateChoices(context).blocked!,
|
||||
/normally revealed/,
|
||||
);
|
||||
});
|
||||
|
||||
void test('missing, duplicated, altered or exceptional target custody cannot select another physical leader or invent bounty', () => {
|
||||
for (const damage of [
|
||||
(c: MoritaniAssassinateContext) => {
|
||||
c.leaders = c.leaders.filter((l) => l.id !== 'atreides-1');
|
||||
},
|
||||
(c: MoritaniAssassinateContext) => {
|
||||
c.leaders = [...c.leaders, c.leaders[1]];
|
||||
},
|
||||
(c: MoritaniAssassinateContext) => {
|
||||
c.leaders[1].strength = 99;
|
||||
},
|
||||
(c: MoritaniAssassinateContext) => {
|
||||
c.leaders[1].name = 'Wrong disc';
|
||||
},
|
||||
(c: MoritaniAssassinateContext) => {
|
||||
c.leaders[1].faction = 'guild';
|
||||
},
|
||||
(c: MoritaniAssassinateContext) => {
|
||||
c.leaders[1].capturedBy = 'other';
|
||||
},
|
||||
(c: MoritaniAssassinateContext) => {
|
||||
c.leaders[1].gholaBy = 'other';
|
||||
},
|
||||
(c: MoritaniAssassinateContext) => {
|
||||
c.leaders[1].controller = null;
|
||||
},
|
||||
(c: MoritaniAssassinateContext) => {
|
||||
c.leaders[1].concealed = { captor: 'other', dead: false, deaths: 0 };
|
||||
},
|
||||
]) {
|
||||
const context = fixture();
|
||||
damage(context);
|
||||
const before = structuredClone(context);
|
||||
const choices = moritaniAssassinateChoices(context);
|
||||
assert.ok(!choices.cards.some((c) => c.card === 'atreides-1'));
|
||||
assert.match(
|
||||
choices.unavailable.find((c) => c.card === 'atreides-1')!.reason,
|
||||
/custody/,
|
||||
);
|
||||
assert.throws(
|
||||
() => quoteMoritaniAssassinate(context, 'atreides-1'),
|
||||
/eligible/,
|
||||
);
|
||||
assert.deepEqual(context, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a revealed card remains held until one Mentat replacement, and the used opposing faction cannot be assassinated again', () => {
|
||||
const context = fixture(),
|
||||
revealed = quoteMoritaniAssassinate(context, 'atreides-1').receipt;
|
||||
const state = { ...context.state, opportunities: [revealed] };
|
||||
validateMoritaniAssassinate(state, {
|
||||
turn: 2,
|
||||
held: context.held,
|
||||
removed: [],
|
||||
});
|
||||
assert.throws(
|
||||
() =>
|
||||
validateMoritaniAssassinate(state, {
|
||||
turn: 2,
|
||||
held: context.held.filter((c) => c !== 'atreides-1'),
|
||||
removed: ['atreides-1'],
|
||||
}),
|
||||
/stay held/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
validateMoritaniAssassinate(state, {
|
||||
turn: 3,
|
||||
held: context.held,
|
||||
removed: [],
|
||||
}),
|
||||
/stay held/,
|
||||
);
|
||||
const replaced = revised(revealed, {
|
||||
stage: 'replaced',
|
||||
replacement: 'moritani-4',
|
||||
});
|
||||
state.opportunities = [replaced];
|
||||
validateMoritaniAssassinate(state, {
|
||||
turn: 3,
|
||||
held: [...context.held.filter((c) => c !== 'atreides-1'), 'moritani-4'],
|
||||
removed: ['atreides-1'],
|
||||
});
|
||||
const next = fixture('atreides', 'battle:3:carthag');
|
||||
next.receipt.turn = 3;
|
||||
next.receipt.signature = moritaniAssassinateSignature(next.receipt);
|
||||
next.state.opportunities = [replaced, next.receipt];
|
||||
assert.match(moritaniAssassinateChoices(next).blocked!, /already been used/);
|
||||
assert.throws(
|
||||
() => quoteMoritaniAssassinate(next, 'atreides-2'),
|
||||
/already been used/,
|
||||
);
|
||||
});
|
||||
|
||||
void test('declining keeps all cards and allows a later loss; distinct opposing factions may each use one card', () => {
|
||||
const context = fixture(),
|
||||
declined = revised(context.receipt, { stage: 'declined' });
|
||||
const retry = fixture('atreides', 'retry');
|
||||
retry.state.opportunities = [declined, retry.receipt];
|
||||
assert.equal(moritaniAssassinateChoices(retry).blocked, null);
|
||||
const first = quoteMoritaniAssassinate(retry, 'atreides-1').receipt;
|
||||
const other = fixture('guild', 'guild-battle');
|
||||
other.state.opportunities = [declined, first, other.receipt];
|
||||
const second = quoteMoritaniAssassinate(other, 'guild-1').receipt;
|
||||
validateMoritaniAssassinate(
|
||||
{ ...other.state, opportunities: [declined, first, second] },
|
||||
{ turn: 2, held: ['atreides-1', 'guild-1'], removed: [] },
|
||||
);
|
||||
assert.deepEqual(context.state.opportunities, [context.receipt]);
|
||||
});
|
||||
|
||||
void test('signed JSON state rejects changed events, invalid stage fields, duplicate faction use, invented bounty and reopened physical custody', () => {
|
||||
const context = fixture(),
|
||||
revealed = quoteMoritaniAssassinate(context, 'atreides-1').receipt;
|
||||
const state = { ...context.state, opportunities: [revealed] },
|
||||
before = structuredClone(state);
|
||||
validateMoritaniAssassinate(JSON.parse(JSON.stringify(state)), {
|
||||
turn: 2,
|
||||
held: context.held,
|
||||
removed: [],
|
||||
});
|
||||
for (const patch of [
|
||||
{ bounty: 999 },
|
||||
{ card: 'atreides-0' },
|
||||
{ card: 'guild-1' },
|
||||
{ replacement: 'guild-0' },
|
||||
{ stage: 'choice' },
|
||||
{ stage: 'replaced', replacement: 'unknown' },
|
||||
{ stage: 'replaced', replacement: 'atreides-1' },
|
||||
{ owner: 'x' },
|
||||
{ faction: 'harkonnen' },
|
||||
{ opposingLeader: 'cheap-hero-traitor' },
|
||||
] as Partial<MoritaniAssassinateReceipt>[]) {
|
||||
const damaged = { ...state, opportunities: [revised(revealed, patch)] };
|
||||
const unchanged = structuredClone(damaged);
|
||||
assert.throws(() => validateMoritaniAssassinate(damaged), /Assassinate/);
|
||||
assert.deepEqual(damaged, unchanged);
|
||||
}
|
||||
assert.throws(
|
||||
() =>
|
||||
validateMoritaniAssassinate({
|
||||
...state,
|
||||
opportunities: [revealed, structuredClone(revealed)],
|
||||
}),
|
||||
/duplicated/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
validateMoritaniAssassinate({
|
||||
...state,
|
||||
opportunities: [
|
||||
revealed,
|
||||
revised(revealed, { event: 'other', card: 'atreides-2', bounty: 4 }),
|
||||
],
|
||||
}),
|
||||
/once per opposing faction/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
validateMoritaniAssassinate({
|
||||
...state,
|
||||
opportunities: [{ ...revealed, event: 'changed' }],
|
||||
}),
|
||||
/changed/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
validateMoritaniAssassinate(state, {
|
||||
turn: 2,
|
||||
held: context.held,
|
||||
removed: ['atreides-1'],
|
||||
}),
|
||||
/overlapping/,
|
||||
);
|
||||
assert.deepEqual(state, before);
|
||||
const stale = fixture();
|
||||
stale.receipt = { ...stale.receipt, event: 'stale' };
|
||||
assert.throws(() => moritaniAssassinateChoices(stale), /original saved/);
|
||||
});
|
||||
@@ -0,0 +1,410 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import { moritaniAssassinateSignature } from '../game/moritani-assassinate';
|
||||
import type { RoomsClock } from '../db/rooms';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
import {
|
||||
assassinationGame,
|
||||
stageAssassinationBattle,
|
||||
resolveAssassinationBattle,
|
||||
assassinationActions,
|
||||
assassinationPhysical,
|
||||
} from './moritani-assassinate-fixture';
|
||||
|
||||
const clock: RoomsClock = { now: () => 42000, sleep: async () => {} };
|
||||
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value));
|
||||
const rows = (db: DatabaseSync) =>
|
||||
db.prepare('SELECT state,version FROM rooms ORDER BY code').all();
|
||||
|
||||
async function fixture(t: test.TestContext, dead = false, truth = false) {
|
||||
const store = unitStore();
|
||||
t.after(() => store.sqlite.close());
|
||||
const made = await store.rooms.createRoom(
|
||||
'Assassinate recovery',
|
||||
'moritani',
|
||||
true,
|
||||
['ecaz'],
|
||||
);
|
||||
const code = made.view.code,
|
||||
tokens = [
|
||||
made.token,
|
||||
(await store.rooms.joinRoom(code, 'Guild', 'guild')).token!,
|
||||
];
|
||||
const auths = await Promise.all(
|
||||
tokens.map((token) => store.rooms.authenticate(code, token)),
|
||||
),
|
||||
ids = auths.map((a) => a.playerId);
|
||||
let g = stageAssassinationBattle(assassinationGame(ids), 'guild-1', dead);
|
||||
let truthCard: string | undefined;
|
||||
if (truth) {
|
||||
const card = [...g.deck, ...g.players.flatMap((p) => p.hand)].find(
|
||||
(c) => c.effect === 'truthtrance',
|
||||
)!;
|
||||
g.deck = g.deck.filter((c) => c.id !== card.id);
|
||||
for (const p of g.players) p.hand = p.hand.filter((c) => c.id !== card.id);
|
||||
g.players[0].hand.push(card);
|
||||
truthCard = card.id;
|
||||
}
|
||||
g = resolveAssassinationBattle(g);
|
||||
assert.equal(g.phase, 6);
|
||||
assert.equal(g.decision?.kind, 'moritaniAssassinate');
|
||||
g.code = code;
|
||||
g.version = (await store.rooms.readRoom(code)).version;
|
||||
store.sqlite
|
||||
.prepare('UPDATE rooms SET state=?,version=? WHERE code=?')
|
||||
.run(JSON.stringify(g), g.version, code);
|
||||
store.writes.length = 0;
|
||||
const receipt = g.moritaniAssassinate!.opportunities.at(-1)!;
|
||||
return {
|
||||
...store,
|
||||
code,
|
||||
tokens,
|
||||
auths,
|
||||
ids,
|
||||
g,
|
||||
truthCard,
|
||||
action: { type: 'decision', event: receipt.event, card: 'guild-1' },
|
||||
};
|
||||
}
|
||||
type Fixture = Awaited<ReturnType<typeof fixture>>;
|
||||
|
||||
async function restored(f: Fixture, expected: Game) {
|
||||
const before = rows(f.sqlite);
|
||||
for (const [index, token] of f.tokens.entries()) {
|
||||
const rooms = f.restart(),
|
||||
auth = await rooms.authenticate(f.code, token),
|
||||
view = await rooms.readSeatView(f.code, auth);
|
||||
assert.deepEqual(view, viewGame(expected, f.ids[index]));
|
||||
assert.equal('moritaniAssassinatePreview' in view, false);
|
||||
assert.equal('moritaniAssassinateResume' in view, false);
|
||||
for (const rival of view.players.filter((p) => p.id !== f.ids[index])) {
|
||||
assert.equal('traitors' in rival, false);
|
||||
assert.equal('hand' in rival, false);
|
||||
assert.equal('spice' in rival, false);
|
||||
}
|
||||
if (index === 1)
|
||||
assert.deepEqual(view.moritaniAssassinate?.pending?.cards ?? [], []);
|
||||
for (const record of view.moritaniAssassinate?.history ?? []) {
|
||||
assert.equal('signature' in record, false);
|
||||
assert.equal('replacement' in record, false);
|
||||
}
|
||||
}
|
||||
await f.restart().continueRoomAutomatic(f.code, clock);
|
||||
assert.deepEqual(rows(f.sqlite), before);
|
||||
}
|
||||
async function act(
|
||||
f: Fixture,
|
||||
index: number,
|
||||
action: Action,
|
||||
version?: number,
|
||||
) {
|
||||
const rooms = f.restart(),
|
||||
auth = await rooms.authenticate(f.code, f.tokens[index]),
|
||||
g = await rooms.readRoom(f.code);
|
||||
await rooms.act(f.code, auth, version ?? g.version, action, clock);
|
||||
return f.restart().readRoom(f.code);
|
||||
}
|
||||
async function actualMentat(f: Fixture, state: Game) {
|
||||
let g = state;
|
||||
for (let n = 0; g.phase !== 8 && n < 30; n++) {
|
||||
const candidates = g.players.flatMap((p, index) =>
|
||||
assassinationActions(g, p.id).map((action) => ({ index, action })),
|
||||
);
|
||||
const next =
|
||||
candidates.find((c) => c.action.type === 'ready') ?? candidates[0];
|
||||
assert.ok(
|
||||
next,
|
||||
`Actual saved continuation must progress from phase ${g.phase}`,
|
||||
);
|
||||
g = await act(f, next.index, next.action);
|
||||
}
|
||||
assert.equal(g.phase, 8);
|
||||
return g;
|
||||
}
|
||||
|
||||
void test('saved live and already-dead Assassinate reveals settle once, keep the physical traitor until actual Mentat and draw one private replacement', async (t) => {
|
||||
for (const dead of [false, true]) {
|
||||
const f = await fixture(t, dead),
|
||||
initial = clone(f.g),
|
||||
physical = assassinationPhysical(initial);
|
||||
const target = initial.players[1].leaders.find((l) => l.id === 'guild-1')!,
|
||||
bounty = dead ? 0 : target.strength;
|
||||
const seats = f.sqlite
|
||||
.prepare('SELECT * FROM seats ORDER BY player_id')
|
||||
.all();
|
||||
await restored(f, initial);
|
||||
const before = rows(f.sqlite);
|
||||
await assert.rejects(act(f, 1, f.action));
|
||||
await assert.rejects(act(f, 0, { ...f.action, event: 'old' }));
|
||||
await assert.rejects(act(f, 0, { ...f.action, card: 'guild-0' }));
|
||||
await assert.rejects(
|
||||
act(f, 0, f.action, initial.version - 1),
|
||||
/table changed/,
|
||||
);
|
||||
assert.deepEqual(rows(f.sqlite), before);
|
||||
assert.equal(f.writes.length, 0);
|
||||
const revealed = await act(f, 0, f.action, initial.version),
|
||||
receipt = revealed.moritaniAssassinate!.opportunities.at(-1)!;
|
||||
assert.equal(revealed.version, initial.version + 1);
|
||||
assert.equal(revealed.phase, 7);
|
||||
assert.equal(revealed.decision, null);
|
||||
assert.equal(revealed.moritaniAssassinateResume, undefined);
|
||||
assert.equal(receipt.stage, 'revealed');
|
||||
assert.equal(receipt.bounty, bounty);
|
||||
assert.equal(receipt.replacement, null);
|
||||
assert.equal(revealed.players[0].spice, initial.players[0].spice + bounty);
|
||||
const afterTarget = revealed.players[1].leaders.find(
|
||||
(l) => l.id === target.id,
|
||||
)!;
|
||||
assert.equal(afterTarget.dead, true);
|
||||
assert.equal(afterTarget.deaths, target.deaths + Number(!dead));
|
||||
assert.deepEqual(
|
||||
revealed.players[1].leaders.filter((l) => l.id !== target.id),
|
||||
initial.players[1].leaders.filter((l) => l.id !== target.id),
|
||||
);
|
||||
assert.deepEqual(revealed.players[0].traitors, initial.players[0].traitors);
|
||||
assert.deepEqual(revealed.traitorReserve, initial.traitorReserve);
|
||||
assert.deepEqual(assassinationPhysical(revealed), physical);
|
||||
await restored(f, revealed);
|
||||
const settled = rows(f.sqlite),
|
||||
writes = f.writes.length;
|
||||
await assert.rejects(act(f, 0, f.action, initial.version), /table changed/);
|
||||
await assert.rejects(act(f, 0, f.action, revealed.version));
|
||||
assert.deepEqual(rows(f.sqlite), settled);
|
||||
assert.equal(f.writes.length, writes);
|
||||
const replacement = revealed.traitorReserve![0],
|
||||
mentat = await actualMentat(f, revealed),
|
||||
done = mentat.moritaniAssassinate!.opportunities.at(-1)!;
|
||||
assert.equal(done.stage, 'replaced');
|
||||
assert.equal(done.replacement, replacement);
|
||||
assert.deepEqual(mentat.traitorReserve, revealed.traitorReserve!.slice(1));
|
||||
assert.deepEqual(mentat.players[0].traitors, [
|
||||
...revealed.players[0].traitors.filter((c) => c !== 'guild-1'),
|
||||
replacement,
|
||||
]);
|
||||
assert.equal(
|
||||
mentat.players.flatMap((p) => p.traitors).includes('guild-1'),
|
||||
false,
|
||||
);
|
||||
assert.equal(mentat.traitorReserve!.includes('guild-1'), false);
|
||||
assert.equal(
|
||||
mentat.moritaniAssassinate!.opportunities.filter(
|
||||
(r) => r.stage === 'replaced' && r.card === 'guild-1',
|
||||
).length,
|
||||
1,
|
||||
);
|
||||
assert.deepEqual(assassinationPhysical(mentat), physical);
|
||||
await restored(f, mentat);
|
||||
const saved = rows(f.sqlite);
|
||||
await assert.rejects(act(f, 0, f.action));
|
||||
assert.deepEqual(rows(f.sqlite), saved);
|
||||
assert.deepEqual(
|
||||
f.sqlite.prepare('SELECT * FROM seats ORDER BY player_id').all(),
|
||||
seats,
|
||||
);
|
||||
assert.ok(f.writes.every((w) => w.changes === 1));
|
||||
}
|
||||
});
|
||||
|
||||
void test('concurrent identical Assassinate reveals commit only one death, bounty and delayed replacement', async (t) => {
|
||||
const f = await fixture(t),
|
||||
target = f.g.players[1].leaders.find((l) => l.id === 'guild-1')!;
|
||||
let arrivals = 0,
|
||||
release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
f.hooks.beforeWrite = async () => {
|
||||
if (++arrivals === 2) release();
|
||||
await gate;
|
||||
};
|
||||
const timer = setTimeout(release, 2000);
|
||||
let results: PromiseSettledResult<unknown>[];
|
||||
try {
|
||||
results = await Promise.allSettled(
|
||||
[0, 1].map(() => act(f, 0, f.action, f.g.version)),
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
delete f.hooks.beforeWrite;
|
||||
}
|
||||
assert.equal(arrivals, 2);
|
||||
assert.equal(results.filter((r) => r.status === 'fulfilled').length, 1);
|
||||
assert.deepEqual(
|
||||
f.writes.map((w) => w.changes).sort((a, b) => a - b),
|
||||
[0, 1],
|
||||
);
|
||||
const g = await f.restart().readRoom(f.code);
|
||||
assert.equal(g.version, f.g.version + 1);
|
||||
assert.equal(g.players[0].spice, f.g.players[0].spice + target.strength);
|
||||
assert.equal(
|
||||
g.players[1].leaders.find((l) => l.id === target.id)!.deaths,
|
||||
target.deaths + 1,
|
||||
);
|
||||
assert.equal(g.moritaniAssassinate!.opportunities.length, 1);
|
||||
const mentat = await actualMentat(f, g);
|
||||
assert.equal(mentat.traitorReserve!.length, f.g.traitorReserve!.length - 1);
|
||||
assert.equal(mentat.moritaniAssassinate!.opportunities[0].stage, 'replaced');
|
||||
assert.deepEqual(assassinationPhysical(mentat), assassinationPhysical(f.g));
|
||||
await restored(f, mentat);
|
||||
});
|
||||
|
||||
void test('a real Truthtrance interruption restores the same private Assassinate choice and original battle cleanup', async (t) => {
|
||||
const f = await fixture(t, false, true),
|
||||
resume = clone(f.g.moritaniAssassinateResume),
|
||||
physical = assassinationPhysical(f.g);
|
||||
let g = await act(f, 0, { type: 'card', card: f.truthCard });
|
||||
assert.equal(g.truthtrance?.stage, 'priority');
|
||||
await restored(f, g);
|
||||
while (g.truthtrance?.stage === 'priority') {
|
||||
const p = g.players.find((p) => !g.truthtrance!.passed.includes(p.id))!;
|
||||
g = await act(f, f.ids.indexOf(p.id), { type: 'truthPass' });
|
||||
}
|
||||
g = await act(f, 0, {
|
||||
type: 'truthAsk',
|
||||
question: {
|
||||
kind: 'fact',
|
||||
target: f.ids[1],
|
||||
fact: { kind: 'spice', compare: 'gte', value: 0 },
|
||||
},
|
||||
});
|
||||
await restored(f, g);
|
||||
assert.deepEqual(g.moritaniAssassinateResume, resume);
|
||||
const pending = rows(f.sqlite);
|
||||
await assert.rejects(act(f, 0, f.action));
|
||||
assert.deepEqual(rows(f.sqlite), pending);
|
||||
g = await act(f, 1, { type: 'truthAnswer', answer: 'yes' });
|
||||
assert.equal(g.truthtrance, null);
|
||||
assert.equal(g.decision?.kind, 'moritaniAssassinate');
|
||||
assert.deepEqual(g.moritaniAssassinateResume, resume);
|
||||
assert.equal(g.discard.filter((c) => c.id === f.truthCard).length, 1);
|
||||
assert.deepEqual(assassinationPhysical(g), physical);
|
||||
await restored(f, g);
|
||||
g = await act(f, 0, {
|
||||
type: 'decision',
|
||||
event: f.action.event,
|
||||
decline: true,
|
||||
});
|
||||
assert.equal(g.phase, 7);
|
||||
assert.equal(g.moritaniAssassinate!.opportunities[0].stage, 'declined');
|
||||
assert.deepEqual(g.players[0].traitors, f.g.players[0].traitors);
|
||||
assert.deepEqual(g.traitorReserve, f.g.traitorReserve);
|
||||
const mentat = await actualMentat(f, g);
|
||||
assert.deepEqual(mentat.traitorReserve, f.g.traitorReserve);
|
||||
assert.deepEqual(assassinationPhysical(mentat), physical);
|
||||
await restored(f, mentat);
|
||||
});
|
||||
|
||||
void test('damaged saved Assassinate cleanup, receipt, obligation or decision rejects reads and actions without SQLite repair', async (t) => {
|
||||
const f = await fixture(t);
|
||||
for (const damage of [
|
||||
(g: Game) => {
|
||||
delete g.moritaniAssassinateResume;
|
||||
},
|
||||
(g: Game) => {
|
||||
g.moritaniAssassinateResume!.signature = 'changed';
|
||||
},
|
||||
(g: Game) => {
|
||||
const r = g.moritaniAssassinateResume!;
|
||||
r.continuation.winner = f.ids[0];
|
||||
r.signature = JSON.stringify(r.continuation);
|
||||
},
|
||||
(g: Game) => {
|
||||
const r = g.moritaniAssassinateResume!;
|
||||
r.continuation.territory = 'carthag';
|
||||
r.signature = JSON.stringify(r.continuation);
|
||||
},
|
||||
(g: Game) => {
|
||||
const r = g.moritaniAssassinateResume!;
|
||||
r.continuation.combatants = [f.ids[0]];
|
||||
r.signature = JSON.stringify(r.continuation);
|
||||
},
|
||||
(g: Game) => {
|
||||
const r = g.moritaniAssassinateResume!;
|
||||
r.continuation.cards = [g.deck[0].id];
|
||||
r.signature = JSON.stringify(r.continuation);
|
||||
},
|
||||
(g: Game) => {
|
||||
const r = g.moritaniAssassinateResume!;
|
||||
r.continuation.casualties!.options[0].normal = 1;
|
||||
r.signature = JSON.stringify(r.continuation);
|
||||
},
|
||||
(g: Game) => {
|
||||
g.moritaniAssassinate!.opportunities[0].card = 'guild-1';
|
||||
},
|
||||
(g: Game) => {
|
||||
const r = g.moritaniAssassinate!.opportunities[0];
|
||||
r.opposingLeader = 'guild-2';
|
||||
r.signature = moritaniAssassinateSignature(r);
|
||||
},
|
||||
(g: Game) => {
|
||||
delete g.lastBattleContext!.moritaniAssassinate;
|
||||
},
|
||||
(g: Game) => {
|
||||
delete g.lastBattleContext!.moritaniAssassinate!.continuation;
|
||||
},
|
||||
(g: Game) => {
|
||||
delete g.moritaniAssassinateCallEvents;
|
||||
},
|
||||
(g: Game) => {
|
||||
g.decision = null;
|
||||
},
|
||||
]) {
|
||||
const g = clone(f.g);
|
||||
damage(g);
|
||||
f.sqlite
|
||||
.prepare('UPDATE rooms SET state=? WHERE code=?')
|
||||
.run(JSON.stringify(g), f.code);
|
||||
const before = rows(f.sqlite),
|
||||
writes = f.writes.length;
|
||||
for (const token of f.tokens) {
|
||||
const rooms = f.restart(),
|
||||
auth = await rooms.authenticate(f.code, token);
|
||||
await assert.rejects(rooms.readSeatView(f.code, auth), /assassinat/i);
|
||||
}
|
||||
await assert.rejects(act(f, 0, f.action), /assassinat/i);
|
||||
assert.deepEqual(rows(f.sqlite), before);
|
||||
assert.equal(f.writes.length, writes);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a replacement receipt cannot point to a card still in reserve or conceal a transfer to the opponent even when the global traitor census is unchanged', async (t) => {
|
||||
const f = await fixture(t),
|
||||
revealed = await act(f, 0, f.action),
|
||||
mentat = await actualMentat(f, revealed);
|
||||
const physical = assassinationPhysical(mentat);
|
||||
for (const damage of [
|
||||
(g: Game) => {
|
||||
const r = g.moritaniAssassinate!.opportunities[0];
|
||||
r.replacement = g.traitorReserve![0];
|
||||
r.signature = moritaniAssassinateSignature(r);
|
||||
g.lastBattleContext!.moritaniAssassinate!.signature = r.signature;
|
||||
},
|
||||
(g: Game) => {
|
||||
const card = g.moritaniAssassinate!.opportunities[0].replacement!,
|
||||
index = g.players[0].traitors.indexOf(card);
|
||||
assert.ok(index >= 0);
|
||||
g.players[0].traitors[index] = g.players[1].traitors[0];
|
||||
g.players[1].traitors[0] = card;
|
||||
},
|
||||
]) {
|
||||
const g = clone(mentat);
|
||||
damage(g);
|
||||
assert.deepEqual(assassinationPhysical(g), physical);
|
||||
f.sqlite
|
||||
.prepare('UPDATE rooms SET state=? WHERE code=?')
|
||||
.run(JSON.stringify(g), f.code);
|
||||
const before = rows(f.sqlite),
|
||||
writes = f.writes.length;
|
||||
for (const token of f.tokens) {
|
||||
const rooms = f.restart(),
|
||||
auth = await rooms.authenticate(f.code, token);
|
||||
await assert.rejects(rooms.readSeatView(f.code, auth), /replacement/i);
|
||||
}
|
||||
await assert.rejects(act(f, 0, { type: 'ready' }), /replacement/i);
|
||||
assert.deepEqual(rows(f.sqlite), before);
|
||||
assert.equal(f.writes.length, writes);
|
||||
}
|
||||
});
|
||||
@@ -197,3 +197,22 @@ void test('Nexus prototype initializes all twelve hidden cards once and preserve
|
||||
other,
|
||||
);
|
||||
});
|
||||
|
||||
void test('Moritani assassination preview starts only the fresh opted-in Advanced profile and preserves seats', (t) => {
|
||||
const {db}=fixture(t);
|
||||
const game=createGame('PROTOTYP',newPlayer('i','Moritani','moritani'),true,['ecaz']);
|
||||
joinGame(game,newPlayer('g','Guild','guild'));for(const p of game.players)p.ready=true;
|
||||
db.prepare('UPDATE rooms SET state=? WHERE code=?').run(JSON.stringify(game),game.code);
|
||||
const seats=db.prepare('SELECT * FROM seats').all(),other=db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00');
|
||||
startPrototypeRoom(db,game.code,7,'moritani-assassinate');
|
||||
const saved=JSON.parse(db.prepare('SELECT state FROM rooms WHERE code=?').get(game.code)!.state as string) as Game;
|
||||
assert.equal(saved.status,'setup');assert.equal(saved.version,8);assert.equal(saved.advanced,true);
|
||||
assert.equal(saved.moritaniAssassinatePreview,true);assert.equal(saved.moritaniAssassinate!.owner,'i');
|
||||
assert.deepEqual(saved.moritaniAssassinate!.opportunities,[]);assert.deepEqual(saved.moritaniAssassinateCallEvents,[]);
|
||||
for(const p of saved.players)assert.equal('moritaniAssassinatePreview' in viewGame(saved,p.id),false);
|
||||
const rows=db.prepare('SELECT * FROM rooms ORDER BY code').all();
|
||||
assert.throws(()=>startPrototypeRoom(db,game.code,7,'moritani-assassinate'));
|
||||
assert.throws(()=>startPrototypeRoom(db,game.code,8,'moritani-assassinate'));
|
||||
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(),rows);
|
||||
assert.deepEqual(db.prepare('SELECT * FROM seats').all(),seats);assert.deepEqual(db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00'),other);
|
||||
});
|
||||
Reference in new issue
Block a user