dnd-character-deploy/tests/dm-tools.test.ts

106 lines
8.2 KiB
TypeScript

import 'fake-indexeddb/auto';
import {test} from 'node:test';import assert from 'node:assert/strict';import {newDb} from 'pg-mem';
import {Storage} from '../server/storage.mjs';import {Campaigns} from '../server/campaigns.mjs';
import {newCharacter,coinLimit} 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 campaign=await campaigns.create('dm','DM',{name:'Fresh campaign'});
const act=async(sub:string,action:string,input:unknown)=>campaigns.mutate(sub,campaign.id,(await campaigns.row(campaign.id)).revision,action,input);
const invitation=await act('dm','invite',{role:'player'});await campaigns.join('player','Player',{code:invitation.invite_code});
return {storage,campaigns,campaign,act};
}
async function partyFixture(){
const f=await fixture(),pc=newCharacter();pc.name='Mira';await f.storage.put('player',pc.id,pc,0);
await f.act('player','assign',{character_id:pc.id});
return {...f,pc};
}
const coins=(gp:number)=>({gp,sp:0,cp:0});
const reward=(pc:{id:string},items:any[]=[],gp=0)=>({title:'Spoils of the road',description:'Recovered after the battle',recipient:{owner:'player',character_id:pc.id},currency:coins(gp),items});
const item={name:'Ornate dagger',quantity:2,category:'Weapon',notes:'A faded maker mark'};
test('hidden quests, DM notes and draft loot never leak to players',async()=>{
const {campaigns,campaign,act,pc}=await partyFixture();
await assert.rejects(act('player','quest',{title:'Forbidden',description:'No',dm_notes:''}));
await act('dm','quest',{title:'Find the spy',description:'A missing messenger',dm_notes:'The mayor is the spy'});
await act('dm','quest',{title:'Road patrol',description:'Protect the road',dm_notes:'Hidden ambush',visible:true});
await act('dm','loot',reward(pc,[item],5));
const dmView=await campaigns.view('dm',campaign.id),playerView=await campaigns.view('player',campaign.id);
assert.equal(dmView.quests.length,2);assert.equal(dmView.loot.length,1);
assert.deepEqual(playerView.quests.map((q:any)=>q.title),['Road patrol']);
assert.equal(playerView.quests[0].dm_notes,undefined);
assert.equal(playerView.loot.length,0);
const hidden=dmView.quests.find((q:any)=>q.title==='Find the spy');
await act('dm','quest',{...hidden,visible:true});
const revealed=(await campaigns.view('player',campaign.id)).quests.find((q:any)=>q.title==='Find the spy');
assert.equal(revealed.description,'A missing messenger');assert.equal(revealed.dm_notes,undefined);
await act('dm','removeQuest',{id:hidden.id});
assert.equal((await campaigns.view('player',campaign.id)).quests.length,1);
});
test('offered loot claims atomically once and changes only the assigned owner sheet',async()=>{
const {storage,campaigns,campaign,act,pc}=await partyFixture();
const stranger=newCharacter();await storage.put('stranger',stranger.id,stranger,0);
await act('dm','loot',reward(pc,[item],12));
const offer=(await campaigns.view('dm',campaign.id)).loot[0];
await assert.rejects(act('player','lootStep',{id:offer.id,action:'offer'}));
await act('dm','lootStep',{id:offer.id,action:'offer'});
assert.equal((await campaigns.view('player',campaign.id)).loot[0].status,'offered');
await assert.rejects(act('dm','loot',{...reward(pc,[item],99),id:offer.id}));
await assert.rejects(campaigns.claimLoot('stranger',campaign.id,{id:offer.id,expected_campaign_revision:(await campaigns.row(campaign.id)).revision,expected_character_revision:1}));
const cRev=(await campaigns.row(campaign.id)).revision,sRev=(await storage.get('player',pc.id)).revision;
const result=await campaigns.claimLoot('player',campaign.id,{id:offer.id,expected_campaign_revision:cRev,expected_character_revision:sRev});
assert.equal(result.status,'claimed');
const sheet=(await storage.get('player',pc.id)).character;
assert.equal(sheet.currency.gp,12);
assert.equal(sheet.inventoryItems[0].quantity,2);
assert.equal(sheet.inventoryItems[0].name,'Ornate dagger');
assert.equal(sheet.notes,pc.notes);
assert.equal((await storage.get('stranger',stranger.id)).character.currency.gp,0);
await assert.rejects(campaigns.claimLoot('player',campaign.id,{id:offer.id,expected_campaign_revision:(await campaigns.row(campaign.id)).revision,expected_character_revision:(await storage.get('player',pc.id)).revision}));
assert.equal((await storage.get('player',pc.id)).character.currency.gp,12);
await assert.rejects(act('dm','lootStep',{id:offer.id,action:'withdraw'}));
});
test('stale revisions and failed rewards leave the parcel unclaimed and inventory untouched',async()=>{
const {storage,campaigns,campaign,act,pc}=await partyFixture();
const updated={...pc,currency:coins(coinLimit)};await storage.put('player',pc.id,updated,1);
await act('dm','loot',reward(pc,[item],1));const offer=(await campaigns.view('dm',campaign.id)).loot[0];
await act('dm','lootStep',{id:offer.id,action:'offer'});
const currentRev=(await campaigns.row(campaign.id)).revision;
await assert.rejects(campaigns.claimLoot('player',campaign.id,{id:offer.id,expected_campaign_revision:currentRev-1,expected_character_revision:2}));
await assert.rejects(campaigns.claimLoot('player',campaign.id,{id:offer.id,expected_campaign_revision:currentRev,expected_character_revision:1}));
await assert.rejects(campaigns.claimLoot('player',campaign.id,{id:offer.id,expected_campaign_revision:currentRev,expected_character_revision:2}));
assert.equal((await campaigns.view('dm',campaign.id)).loot[0].status,'offered');
const sheet=(await storage.get('player',pc.id)).character;
assert.equal(sheet.currency.gp,coinLimit);assert.equal(sheet.inventoryItems.length,0);
await act('dm','lootStep',{id:offer.id,action:'withdraw'});
assert.equal((await campaigns.view('player',campaign.id)).loot.length,0);
});
test('MCP tools use the same campaign permissions and claim authorization',async()=>{
const {storage,campaigns,campaign,pc}=await partyFixture();
async function connect(sub:string,scopes:string[]){
const srv=characterMcp({storage,campaigns,sub,scopes}),client=new Client({name:'dm-tools-test',version:'1'});
const [a,b]=InMemoryTransport.createLinkedPair();await srv.connect(a);await client.connect(b);return client;
}
const writer=await connect('dm',['characters:read','characters:write']),player=await connect('player',['characters:read','characters:write']),read=await connect('dm',['characters:read']);
try{
const revision=(await campaigns.row(campaign.id)).revision;
assert.equal((await writer.listTools()).tools.length,42);
assert.equal((await player.callTool({name:'save_campaign_quest',arguments:{campaign_id:campaign.id,expected_revision:revision,input:{title:'No',description:'',dm_notes:''}}})).isError,true);
assert.equal((await read.callTool({name:'save_campaign_quest',arguments:{campaign_id:campaign.id,expected_revision:revision,input:{title:'No',description:'',dm_notes:''}}})).isError,true);
const created:any=await writer.callTool({name:'save_campaign_loot',arguments:{campaign_id:campaign.id,expected_revision:revision,input:reward(pc,[],3)}});
assert.equal(created.isError,undefined);
const lootId=created.structuredContent.campaign.loot[0].id;
const opened:any=await writer.callTool({name:'change_campaign_loot_offer',arguments:{campaign_id:campaign.id,expected_revision:created.structuredContent.campaign.revision,input:{id:lootId,action:'offer'}}});
assert.equal(opened.isError,undefined);
const claimed:any=await player.callTool({name:'claim_campaign_loot',arguments:{campaign_id:campaign.id,id:lootId,expected_campaign_revision:opened.structuredContent.campaign.revision,expected_character_revision:1}});
assert.equal(claimed.isError,undefined);assert.equal((await storage.get('player',pc.id)).character.currency.gp,3);
const second:any=await player.callTool({name:'claim_campaign_loot',arguments:{campaign_id:campaign.id,id:lootId,expected_campaign_revision:claimed.structuredContent.campaign_revision,expected_character_revision:2}});
assert.equal(second.isError,true);
}finally{await Promise.all([writer.close(),player.close(),read.close()])}
});