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 {newCharacter,characterSchema,configureSpellSlotPool,changeSpellSlotPool,spellSlotRestPreview,applyRest} from '../src/model.ts'; import {CharacterDB,saveCharacter,loadCharacters} from '../src/db.ts'; import {characterMcp} from '../server/mcp.mjs'; import {Client} from '@modelcontextprotocol/sdk/client/index.js'; import {InMemoryTransport} from '@modelcontextprotocol/sdk/inMemory.js'; test('slot usage is bounded, rest behavior distinguishes warlock pact from normal slots',()=>{ const c=newCharacter();configureSpellSlotPool(c,'standard',1,4);configureSpellSlotPool(c,'standard',3,2);configureSpellSlotPool(c,'pact',2,2); changeSpellSlotPool(c,'standard',1,-3);changeSpellSlotPool(c,'standard',3,-1);changeSpellSlotPool(c,'pact',2,-2); assert.throws(()=>changeSpellSlotPool(c,'pact',2,-1),/No spell slots/); assert.throws(()=>changeSpellSlotPool(c,'standard',8,-1),/Configure/); assert.deepEqual(spellSlotRestPreview(c,'short-rest').map(s=>s.name),['Pact Magic (level 2)']); applyRest(c,'short-rest'); assert.equal(c.spellSlots?.pact?.remaining,2); assert.equal(c.spellSlots?.levels[0].remaining,1); applyRest(c,'long-rest'); assert.deepEqual(c.spellSlots?.levels.map(s=>s.remaining),[4,2]); assert.throws(()=>changeSpellSlotPool(c,'standard',1,1),/already full/); assert.equal(spellSlotRestPreview(c,'long-rest').length,0); }); test('reconfigure and old saves preserve uses; bad totals and duplicate levels reject',()=>{ const c=newCharacter();configureSpellSlotPool(c,'standard',1,4);changeSpellSlotPool(c,'standard',1,-2); configureSpellSlotPool(c,'standard',1,3); assert.equal(c.spellSlots?.levels[0].remaining,2); configureSpellSlotPool(c,'standard',1,1); assert.equal(c.spellSlots?.levels[0].remaining,1); configureSpellSlotPool(c,'standard',1,0); assert.deepEqual(c.spellSlots?.levels,[]); configureSpellSlotPool(c,'pact',5,4);assert.throws(()=>configureSpellSlotPool(c,'pact',6,1)); assert.throws(()=>configureSpellSlotPool(c,'standard',2,21)); assert.throws(()=>configureSpellSlotPool(c,'standard',2,-1)); assert.throws(()=>changeSpellSlotPool(c,'pact',1,-1)); const old=newCharacter();delete old.spellSlots;assert.equal(characterSchema.parse(old).spellSlots,undefined); const broken={...c,spellSlots:{levels:[{level:1,max:2,remaining:2},{level:1,max:3,remaining:3}],pact:null}}; assert.equal(characterSchema.safeParse(broken).success,false); }); test('slot pools round trip in Dexie and server preserves omitted slots from older clients',async()=>{ const database=new CharacterDB('slots-'+crypto.randomUUID()),c=newCharacter(); configureSpellSlotPool(c,'standard',2,3);changeSpellSlotPool(c,'standard',2,-1); try{await saveCharacter('alice',c,database);assert.deepEqual((await loadCharacters('alice',database))[0].spellSlots,c.spellSlots)}finally{await database.delete()} const pg=newDb().adapters.createPg(),storage=new Storage(new pg.Pool());await storage.init(); await storage.put('alice',c.id,c,0); const legacy={...c};delete legacy.spellSlots; await storage.put('alice',c.id,legacy,1); assert.deepEqual((await storage.get('alice',c.id)).character.spellSlots,c.spellSlots); await storage.put('alice',c.id,{...legacy,spellSlots:{levels:[],pact:null}},2); assert.deepEqual((await storage.get('alice',c.id)).character.spellSlots,{levels:[],pact:null}); }); test('MCP spell slot tools enforce revision and read/write scope',async()=>{ const pg=newDb().adapters.createPg(),storage=new Storage(new pg.Pool());await storage.init(); const c=newCharacter();await storage.put('alice',c.id,c,0); async function connected(scopes:string[]){ const server=characterMcp({storage,sub:'alice',scopes}),client=new Client({name:'slot-test',version:'1'}); const [a,b]=InMemoryTransport.createLinkedPair();await server.connect(a);await client.connect(b); return {client,server}; } const writer=await connected(['characters:read','characters:write']),reader=await connected(['characters:read']); try{ const request=(client:Client,name:string,args:Record)=>client.callTool({name,arguments:{id:c.id,...args}}); const read:any=await request(reader.client,'list_spell_slots',{}); assert.deepEqual(read.structuredContent.spellSlots,{levels:[],pact:null}); assert.equal((await request(reader.client,'configure_spell_slots',{expected_revision:1,kind:'standard',level:1,max:2})).isError,true); assert.equal((await request(writer.client,'configure_spell_slots',{expected_revision:1,kind:'standard',level:1,max:2})).isError,undefined); assert.equal((await request(writer.client,'change_spell_slots',{expected_revision:2,kind:'standard',level:1,amount:-1})).isError,undefined); assert.equal((await request(writer.client,'change_spell_slots',{expected_revision:2,kind:'standard',level:1,amount:-1})).isError,true); const result:any=await request(reader.client,'list_spell_slots',{}); assert.equal(result.structuredContent.spellSlots.levels[0].remaining,1); }finally{await writer.client.close();await reader.client.close()} });