71 lines
20 KiB
TypeScript
71 lines
20 KiB
TypeScript
import {test} from 'node:test';import assert from 'node:assert/strict';import {newDb} from 'pg-mem';import express from 'express';import {once} from 'node:events';
|
|
import {Storage} from '../server/storage.mjs';import {Campaigns,installCampaigns} from '../server/campaigns.mjs';import {newCharacter} from '../src/model.ts';import {characterMcp} from '../server/mcp.mjs';import {Client} from '@modelcontextprotocol/sdk/client/index.js';import {InMemoryTransport} from '@modelcontextprotocol/sdk/inMemory.js';
|
|
async function fixture(){const pg=newDb().adapters.createPg(),storage=new Storage(new pg.Pool());await storage.init();const campaigns=new Campaigns(storage);await campaigns.init();const c=await campaigns.create('dm','Dungeon Master',{name:'Winter road'});const act=async(sub:string,action:string,input:any)=>campaigns.mutate(sub,c.id,(await campaigns.row(c.id)).revision,action,input);const invite=await act('dm','invite',{role:'player'});await campaigns.join('player','Player',{code:invite.invite_code});return {storage,campaigns,c,act}}
|
|
test('campaign assignment exposes only shared stats and requires ownership; roles are campaign scoped',async()=>{const {storage,campaigns,c,act}=await fixture(),pc=newCharacter();pc.notes='Private history';pc.inventory=[{id:'item',name:'Secret',quantity:1,notes:'Hidden'}];await storage.put('player',pc.id,pc,0);assert.deepEqual(await campaigns.list('stranger'),[]);await assert.rejects(campaigns.view('stranger',c.id));assert.equal((await campaigns.view('dm',c.id)).party.length,0);await assert.rejects(act('dm','assign',{character_id:pc.id}));await act('player','assign',{character_id:pc.id});const party=(await campaigns.view('dm',c.id)).party[0];assert.equal(party.character.name,pc.name);assert.equal(party.character.notes,undefined);assert.equal(party.character.inventory,undefined);assert.equal(party.allowDmEdit,false);const other=await campaigns.create('player','Player',{name:'Other game'});assert.equal((await campaigns.view('player',other.id)).role,'dm');assert.equal((await campaigns.view('player',c.id)).role,'player');await act('player','unassign',{character_id:pc.id});assert.equal((await campaigns.view('dm',c.id)).party.length,0)});
|
|
test('invitations are single-use, expire and stop working after inviter loses DM role',async()=>{const {campaigns,c,act}=await fixture();const invite=await act('dm','invite',{role:'dm'});await campaigns.join('co','Co-DM',{code:invite.invite_code});await assert.rejects(campaigns.join('stranger','Stranger',{code:invite.invite_code}));const pending=await act('dm','invite',{});await act('co','member',{sub:'dm',role:'player'});await assert.rejects(campaigns.join('stranger','Stranger',{code:pending.invite_code}));const expired=await act('co','invite',{});const row=await campaigns.row(c.id);row.data.invites.forEach((i:any)=>i.expires=0);await campaigns.pool.query('UPDATE campaigns SET data=$2 WHERE id=$1',[c.id,row.data]);await assert.rejects(campaigns.join('stranger','Stranger',{code:expired.invite_code}));await assert.rejects(act('co','member',{sub:'co',role:'remove'}));});
|
|
test('private notes, DM secrets and hidden NPCs remain filtered after role changes',async()=>{const {campaigns,c,act}=await fixture();await act('player','note',{title:'Private',body:'secret',visibility:'private'});await act('player','note',{title:'Party',body:'shared',visibility:'party'});await assert.rejects(act('player','note',{title:'DM',body:'secret',visibility:'dm'}));await act('dm','note',{title:'DM',body:'secret',visibility:'dm'});const secret=(await campaigns.view('dm',c.id)).notes.find((n:any)=>n.title==='DM');await act('dm','npc',{name:'Hidden',hp:10,maxHp:10,ac:10,notes:'plot'});await act('dm','npc',{name:'Visible',hp:10,maxHp:10,ac:10,notes:'plot',visible:true});const player=await campaigns.view('player',c.id);assert.deepEqual(player.notes.map((n:any)=>n.title),['Private','Party']);assert.equal(player.npcs.length,1);assert.equal(player.npcs[0].notes,undefined);assert.deepEqual((await campaigns.view('dm',c.id)).notes.map((n:any)=>n.title),['Party','DM']);await act('dm','member',{sub:'player',role:'dm'});await act('player','member',{sub:'dm',role:'player'});assert.equal((await campaigns.view('dm',c.id)).notes.some((n:any)=>n.id===secret.id),false);await assert.rejects(act('dm','note',{id:secret.id,title:'Leak',body:'secret',visibility:'party'}));await assert.rejects(act('dm','removeNote',{id:secret.id}));});
|
|
test('delegated saved HP requires current DM role and owner permission, and preserves private data',async()=>{const {storage,campaigns,c,act}=await fixture(),pc=newCharacter();pc.notes='Private';pc.hp.temp=3;await storage.put('player',pc.id,pc,0);await act('player','assign',{character_id:pc.id});const input={owner:'player',character_id:pc.id,expected_revision:1,amount:-5};await assert.rejects(campaigns.partyHp('dm',c.id,input));await act('player','assign',{character_id:pc.id,allow_dm_edit:true});await assert.rejects(campaigns.partyHp('player',c.id,input));const changed=await campaigns.partyHp('dm',c.id,input);assert.equal(changed.revision,2);assert.deepEqual(changed.character.hp,{current:8,max:10,temp:0});assert.equal(changed.character.notes,undefined);assert.equal((await storage.get('player',pc.id)).character.notes,'Private');await assert.rejects(campaigns.partyHp('dm',c.id,input));await act('player','assign',{character_id:pc.id,allow_dm_edit:false});await assert.rejects(campaigns.partyHp('dm',c.id,{...input,expected_revision:2}));await act('dm','member',{sub:'player',role:'remove'});await assert.rejects(campaigns.view('player',c.id));assert.equal((await campaigns.view('dm',c.id)).party.length,0)});
|
|
test('DM encounter snapshots sort initiative, wrap rounds and clamp HP without touching saved PCs',async()=>{const {storage,campaigns,c,act}=await fixture(),pc=newCharacter();await storage.put('player',pc.id,pc,0);await assert.rejects(act('player','session',{title:'No'}));await assert.rejects(act('player','npc',{name:'No',hp:1,maxHp:1,ac:1}));await assert.rejects(act('player','invite',{}));await act('dm','session',{title:'Session 1',summary:'Meet at the inn'});await act('dm','encounter',{name:'Ambush',participants:[{name:'PC',initiative:8,hp:10,maxHp:10,ac:10},{name:'Goblin',initiative:15,hp:5,maxHp:5,ac:12}]});let encounter=(await campaigns.view('player',c.id)).encounters[0];assert.equal(encounter.participants[0].name,'Goblin');await assert.rejects(act('player','encounterStep',{id:encounter.id,action:'next'}));await act('dm','encounterStep',{id:encounter.id,action:'next'});await act('dm','encounterStep',{id:encounter.id,action:'next'});await act('dm','encounterStep',{id:encounter.id,action:'damage',participant_id:encounter.participants[0].id,amount:50});encounter=(await campaigns.view('player',c.id)).encounters[0];assert.equal(encounter.round,2);assert.equal(encounter.turn,0);assert.equal(encounter.participants[0].hp,0);assert.equal((await storage.get('player',pc.id)).character.hp.current,10);const old=(await campaigns.row(c.id)).revision;await act('dm','encounterStep',{id:encounter.id,action:'finish'});await assert.rejects(campaigns.mutate('dm',c.id,old,'note',{title:'Stale',body:'',visibility:'party'}));assert.equal((await campaigns.view('dm',c.id)).notes.length,0)});
|
|
test('HTTP campaign routes deny unsigned users and cross-origin writes',async()=>{const {campaigns}=await fixture(),app=express();app.use(express.json());app.use((req:any,res,next)=>{if(req.get('x-test-user'))req.identity={sub:req.get('x-test-user'),name:'Test'};next()});installCampaigns(app,{campaigns,origin:'https://dnd.example.test'});const server=app.listen(0,'127.0.0.1');await once(server,'listening');const base=`http://127.0.0.1:${(server.address() as any).port}`;try{assert.equal((await fetch(base+'/api/campaigns')).status,401);assert.equal((await fetch(base+'/api/campaigns',{method:'POST',headers:{'x-test-user':'dm','content-type':'application/json',origin:'https://evil.test'},body:JSON.stringify({name:'No'})})).status,403);assert.equal((await fetch(base+'/api/campaigns',{headers:{'x-test-user':'dm'}})).status,200)}finally{await new Promise<void>(r=>server.close(()=>r()))}});
|
|
test('MCP exposes campaign tools and enforces read scopes and campaign membership',async()=>{const {storage,campaigns,c}=await fixture();async function connect(sub:string,scopes:string[]){const server=characterMcp({storage,campaigns,sub,scopes}),client=new Client({name:'campaign-test',version:'1'});const [a,b]=InMemoryTransport.createLinkedPair();await server.connect(a);await client.connect(b);return client}const read=await connect('dm',['characters:read']),player=await connect('player',['characters:read','characters:write']),stranger=await connect('stranger',['characters:read']);try{assert.equal((await read.listTools()).tools.length,37);assert.equal((await read.callTool({name:'create_campaign',arguments:{name:'Denied'}})).isError,true);assert.equal((await stranger.callTool({name:'get_campaign',arguments:{campaign_id:c.id}})).isError,true);assert.equal((await player.callTool({name:'invite_campaign_member',arguments:{campaign_id:c.id,expected_revision:(await campaigns.row(c.id)).revision,input:{role:'dm'}}})).isError,true);assert.equal((await read.callTool({name:'get_campaign',arguments:{campaign_id:c.id}})).isError,undefined)}finally{await Promise.all([read.close(),player.close(),stranger.close()])}});
|
|
|
|
test('DM conditions are manual snapshots, bounded, revision guarded and removed explicitly',async()=>{
|
|
const {storage,campaigns,c,act}=await fixture(),pc=newCharacter();await storage.put('player',pc.id,pc,0);
|
|
await act('dm','encounter',{name:'Mountain pass',participants:[{name:pc.name,initiative:12,hp:10,maxHp:10,ac:12}]});
|
|
const e=(await campaigns.view('dm',c.id)).encounters[0],p=e.participants[0];
|
|
assert.deepEqual(p.conditions,[]);
|
|
const change={id:e.id,action:'conditions',participant_id:p.id,conditions:['Prone','Concentrating','Prone']};
|
|
await assert.rejects(act('player','encounterStep',change));await assert.rejects(campaigns.mutate('outsider',c.id,(await campaigns.row(c.id)).revision,'encounterStep',change));
|
|
await act('dm','encounterStep',change);let updated=(await campaigns.view('player',c.id)).encounters[0].participants[0];
|
|
assert.deepEqual(updated.conditions,['Prone','Concentrating']);assert.equal(updated.hp,10);assert.equal((await storage.get('player',pc.id)).character.hp.current,10);
|
|
const revision=(await campaigns.row(c.id)).revision;await act('dm','encounterStep',{...change,conditions:[]});
|
|
await assert.rejects(campaigns.mutate('dm',c.id,revision,'encounterStep',change));
|
|
await assert.rejects(act('dm','encounterStep',{...change,conditions:Array(13).fill('Prone')}));await assert.rejects(act('dm','encounterStep',{...change,conditions:[' ']}));await assert.rejects(act('dm','encounterStep',{...change,conditions:undefined}));
|
|
assert.deepEqual((await campaigns.view('dm',c.id)).encounters[0].participants[0].conditions,[]);
|
|
await act('dm','encounterStep',{id:e.id,action:'finish'});await assert.rejects(act('dm','encounterStep',change));
|
|
});
|
|
|
|
test('MCP changes encounter conditions for DMs and denies player and read-only callers',async()=>{
|
|
const {storage,campaigns,c,act}=await fixture();await act('dm','encounter',{name:'Ridge',participants:[{name:'Guard',initiative:1,hp:10,maxHp:10,ac:12}]});
|
|
const e=(await campaigns.view('dm',c.id)).encounters[0];
|
|
async function connect(sub:string,scopes:string[]){const server=characterMcp({storage,campaigns,sub,scopes}),client=new Client({name:'conditions-test',version:'1'});const [a,b]=InMemoryTransport.createLinkedPair();await server.connect(a);await client.connect(b);return client}
|
|
const dm=await connect('dm',['characters:read','characters:write']),player=await connect('player',['characters:read','characters:write']),read=await connect('dm',['characters:read']);
|
|
const args={campaign_id:c.id,expected_revision:(await campaigns.row(c.id)).revision,input:{id:e.id,action:'conditions',participant_id:e.participants[0].id,conditions:['Prone']}};
|
|
try{assert.equal((await player.callTool({name:'advance_campaign_encounter',arguments:args})).isError,true);assert.equal((await read.callTool({name:'advance_campaign_encounter',arguments:args})).isError,true);assert.equal((await dm.callTool({name:'advance_campaign_encounter',arguments:args})).isError,undefined);assert.deepEqual((await campaigns.view('player',c.id)).encounters[0].participants[0].conditions,['Prone']);assert.equal((await dm.callTool({name:'advance_campaign_encounter',arguments:args})).isError,true)}finally{await Promise.all([dm.close(),player.close(),read.close()])}
|
|
});
|
|
|
|
async function prepFixture(){const f=await fixture();await f.act('dm','session',{title:'Mountain night'});const session=(await f.campaigns.view('dm',f.c.id)).sessions[0];await f.act('dm','preparedEncounter',{session_id:session.id,name:'Secret mountain troll',participants:[{name:'Troll',initiative:15,hp:20,maxHp:20,ac:13}]});const plan=(await f.campaigns.view('dm',f.c.id)).preparedEncounters[0];return {...f,session,plan}}
|
|
test('scenes reveal only descriptions; links and preparation stay private through role changes',async()=>{
|
|
const {campaigns,c,act,session,plan}=await prepFixture();await act('dm','npc',{name:'Hidden spy',hp:5,maxHp:5,ac:10,notes:'Secret allegiance'});await act('dm','note',{title:'Secret map',body:'Trap door',visibility:'private'});let v=await campaigns.view('dm',c.id);
|
|
await act('dm','scene',{session_id:session.id,title:'The mountain inn',description:'A warm fire',dm_notes:'The innkeeper is the spy',npc_ids:[v.npcs[0].id],note_ids:[v.notes[0].id],encounter_ids:[plan.id]});const scene=(await campaigns.view('dm',c.id)).scenes[0];
|
|
let player=await campaigns.view('player',c.id);assert.deepEqual(player.scenes,[]);assert.deepEqual(player.preparedEncounters,[]);assert.equal(player.encounters.length,0);
|
|
await assert.rejects(act('player','sceneStep',{id:scene.id,action:'reveal'}));await act('dm','sceneStep',{id:scene.id,action:'current'});assert.equal((await campaigns.view('player',c.id)).scenes.length,0);
|
|
await act('dm','sceneStep',{id:scene.id,action:'reveal'});player=await campaigns.view('player',c.id);assert.equal(player.scenes[0].description,'A warm fire');for(const key of ['dm_notes','npc_ids','note_ids','encounter_ids'])assert.equal(player.scenes[0][key],undefined);assert.equal(player.npcs.length,0);assert.equal(player.notes.length,0);
|
|
const inv=await act('dm','invite',{role:'dm'});await campaigns.join('co','Co-DM',{code:inv.invite_code});await act('co','scene',{...scene,title:'Edited by co-DM'});assert.equal((await campaigns.view('co',c.id)).notes.length,0);assert.equal((await campaigns.view('co',c.id)).scenes[0].note_ids.length,1);await act('co','member',{sub:'dm',role:'player'});const demoted=await campaigns.view('dm',c.id);assert.equal(demoted.scenes[0].dm_notes,undefined);assert.deepEqual(demoted.preparedEncounters,[]);await act('co','sceneStep',{id:scene.id,action:'hide'});assert.deepEqual((await campaigns.view('player',c.id)).scenes,[]);
|
|
});
|
|
test('prepared combat starts fresh once, preserves preparation, and rejects stale or cross-session starts',async()=>{
|
|
const {storage,campaigns,c,act,session,plan}=await prepFixture();const pc=newCharacter();await storage.put('player',pc.id,pc,0);
|
|
await act('dm','scene',{session_id:session.id,title:'Pass',encounter_ids:[plan.id]});const scene=(await campaigns.view('dm',c.id)).scenes[0];
|
|
await assert.rejects(act('player','startPrepared',{id:plan.id}));await assert.rejects(act('stranger','startPrepared',{id:plan.id}));await assert.rejects(act('dm','startPrepared',{id:plan.id,scene_id:'elsewhere'}));
|
|
const revision=(await campaigns.row(c.id)).revision;await act('dm','startPrepared',{id:plan.id,scene_id:scene.id});const e=(await campaigns.view('dm',c.id)).encounters[0];assert.equal(e.session_id,session.id);assert.equal(e.scene_id,scene.id);assert.equal(e.prepared_id,plan.id);assert.equal(e.round,1);assert.deepEqual(e.participants[0].conditions,[]);
|
|
await assert.rejects(campaigns.mutate('dm',c.id,revision,'startPrepared',{id:plan.id}));await assert.rejects(act('dm','startPrepared',{id:plan.id}));assert.equal((await campaigns.view('dm',c.id)).encounters.length,1);
|
|
const player=(await campaigns.view('player',c.id));assert.equal(player.encounters[0].name,plan.name);assert.equal(player.encounters[0].prepared_id,undefined);assert.equal(player.encounters[0].scene_id,undefined);assert.deepEqual(player.scenes,[]);
|
|
await act('dm','encounterStep',{id:e.id,action:'damage',participant_id:e.participants[0].id,amount:7});assert.equal((await campaigns.view('dm',c.id)).preparedEncounters[0].participants[0].hp,20);assert.equal((await storage.get('player',pc.id)).character.hp.current,10);
|
|
await act('dm','encounterStep',{id:e.id,action:'finish'});await act('dm','startPrepared',{id:plan.id});const fresh=(await campaigns.view('dm',c.id)).encounters[1];assert.notEqual(fresh.participants[0].id,e.participants[0].id);assert.equal(fresh.participants[0].hp,20);assert.equal(fresh.round,1);
|
|
});
|
|
test('scene links require campaign/session access and current scenes are exclusive per session',async()=>{
|
|
const {campaigns,c,act,session,plan}=await prepFixture();await act('dm','session',{title:'Later night'});const later=(await campaigns.view('dm',c.id)).sessions[1];
|
|
await assert.rejects(act('dm','scene',{session_id:later.id,title:'Wrong',encounter_ids:[plan.id]}));await assert.rejects(act('dm','preparedEncounter',{session_id:'foreign-session',name:'No',participants:plan.participants}));await act('player','note',{title:'Private player secret',body:'Never share',visibility:'private'});const row=await campaigns.row(c.id);await assert.rejects(act('dm','scene',{session_id:session.id,title:'No',note_ids:[row.data.notes[0].id]}));
|
|
for(const title of ['First','Second'])await act('dm','scene',{session_id:session.id,title});let scenes=(await campaigns.view('dm',c.id)).scenes;
|
|
await act('dm','sceneStep',{id:scenes[0].id,action:'current'});await act('dm','sceneStep',{id:scenes[1].id,action:'current'});scenes=(await campaigns.view('dm',c.id)).scenes;assert.equal(scenes.filter((s:any)=>s.status==='current').length,1);assert.equal(scenes[0].status,'planned');await act('dm','sceneStep',{id:scenes[1].id,action:'reveal'});
|
|
await act('dm','scene',{id:scenes[1].id,session_id:session.id,title:'Edited'});const edited=(await campaigns.view('dm',c.id)).scenes[1];assert.equal(edited.revealed,true);assert.equal(edited.status,'current');await assert.rejects(act('dm','scene',{id:edited.id,session_id:later.id,title:'Moved'}));
|
|
// Legacy campaigns get empty additive collections without overwriting existing content.
|
|
const legacy=await campaigns.row(c.id);delete legacy.data.scenes;delete legacy.data.preparedEncounters;await campaigns.pool.query('UPDATE campaigns SET data=$2 WHERE id=$1',[c.id,legacy.data]);assert.deepEqual((await campaigns.view('dm',c.id)).scenes,[]);await act('dm','scene',{session_id:session.id,title:'New scene'});assert.equal((await campaigns.view('dm',c.id)).preparedEncounters.length,0);
|
|
});
|
|
test('MCP prepares privately and explicitly reveals/starts with role and write-scope checks',async()=>{
|
|
const {storage,campaigns,c,session}=await prepFixture();async function connect(sub:string,scopes:string[]){const server=characterMcp({storage,campaigns,sub,scopes}),client=new Client({name:'prep-test',version:'1'});const [a,b]=InMemoryTransport.createLinkedPair();await server.connect(a);await client.connect(b);return client}
|
|
const dm=await connect('dm',['characters:read','characters:write']),read=await connect('dm',['characters:read']),player=await connect('player',['characters:read','characters:write']);
|
|
const args=async(input:any)=>({campaign_id:c.id,expected_revision:(await campaigns.row(c.id)).revision,input});
|
|
try{const input={session_id:session.id,title:'Secret scene',dm_notes:'Secret password'};assert.equal((await read.callTool({name:'save_campaign_scene',arguments:await args(input)})).isError,true);assert.equal((await player.callTool({name:'save_campaign_scene',arguments:await args(input)})).isError,true);assert.equal((await dm.callTool({name:'save_campaign_scene',arguments:await args(input)})).isError,undefined);const scene=(await campaigns.view('dm',c.id)).scenes[0];assert.equal((await dm.callTool({name:'advance_campaign_scene',arguments:await args({id:scene.id,action:'reveal'})})).isError,undefined);const result:any=await player.callTool({name:'get_campaign',arguments:{campaign_id:c.id}});assert.equal(result.structuredContent.campaign.scenes[0].dm_notes,undefined);const prepared=(await campaigns.view('dm',c.id)).preparedEncounters[0];assert.equal((await dm.callTool({name:'start_prepared_encounter',arguments:await args({id:prepared.id})})).isError,undefined)}finally{await Promise.all([dm.close(),read.close(),player.close()])}
|
|
});
|