106 lines
8.2 KiB
TypeScript
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,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()])}
|
|
});
|