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,44); 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()])} });