diff --git a/server/mcp.mjs b/server/mcp.mjs index 4ea284b..ab7a2d2 100644 --- a/server/mcp.mjs +++ b/server/mcp.mjs @@ -4,7 +4,7 @@ import {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js'; import {StreamableHTTPServerTransport} from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import {z} from 'zod'; import {randomInt,randomUUID} from 'node:crypto'; -import {characterSchema,newCharacter,changeHP,applyRest,companionSchema,stableLocationSchema,addCompanion,stableCompanion,retrieveCompanion,changeCompanionHP} from './model.mjs'; +import {characterSchema,newCharacter,changeHP,applyRest,companionSchema,stableLocationSchema,addCompanion,stableCompanion,retrieveCompanion,changeCompanionHP,configureSpellSlotPool,changeSpellSlotPool} from './model.mjs'; const id=z.string().min(1).max(200),revision=z.number().int().min(1),result=data=>({content:[{type:'text',text:JSON.stringify(data)}],structuredContent:data}); export function characterMcp({storage,campaigns,catalogue,sub,scopes}){ const server=new McpServer({name:'arrowstack-character',version:'0.8.0'}); @@ -19,7 +19,7 @@ export function characterMcp({storage,campaigns,catalogue,sub,scopes}){ tool('create_character','Create a new account-backed character. Returns its generated id and revision.',{name:z.string().min(1).max(120)},write,async({name})=>{allowed();const c=newCharacter();c.id=randomUUID();c.name=name;return {sheet:await storage.put(sub,c.id,c,0)}}); tool('update_character','Replace a complete character using the revision returned by get_character. Preserves unrelated changes by rejecting stale revisions.',{character:characterSchema,expected_revision:revision},write,async({character,expected_revision})=>{allowed();await get(character.id);return {sheet:await storage.put(sub,character.id,character,expected_revision)}}); tool('change_hit_points','Apply signed HP change: negative damages temporary HP first; positive heals up to maximum.',{id,expected_revision:revision,amount:z.number().int().min(-9999).max(9999)},write,async a=>mutate(a,c=>changeHP(c,a.amount))); - tool('apply_rest','Apply declared short/long-rest resource resets. Long rest also restores HP and clears temporary HP. Hit dice and edition/class-specific rules remain manual.',{id,expected_revision:revision,type:z.enum(['short-rest','long-rest'])},write,async a=>mutate(a,c=>applyRest(c,a.type))); + tool('apply_rest','Apply declared resource resets. Long rest restores configured regular spell slots; either rest restores Pact Magic slots. Hit dice and edition/class-specific exceptions remain manual.',{id,expected_revision:revision,type:z.enum(['short-rest','long-rest'])},write,async a=>mutate(a,c=>applyRest(c,a.type))); tool('set_resource','Set remaining uses for one resource within its maximum.',{id,expected_revision:revision,resource_id:id,remaining:z.number().int().min(0).max(9999)},write,async a=>mutate(a,c=>{const r=c.resources.find(r=>r.id===a.resource_id);if(!r||a.remaining>r.max)throw new Error('Resource missing or remaining uses exceed maximum');r.current=a.remaining})); tool('list_companions','Read horses, mounts and pets, including stats, current whereabouts and last stable.',{id},read,async({id})=>{const row=await get(id);return {revision:row.revision,companions:row.character.companions||[]}}); @@ -27,6 +27,10 @@ export function characterMcp({storage,campaigns,catalogue,sub,scopes}){ tool('stable_companion','Record where and when a mount or pet was stabled, preserving the last stable after collection.',{id,expected_revision:revision,companion_id:id,stable:stableLocationSchema},write,async a=>mutate(a,c=>{const animal=(c.companions||[]).find(x=>x.id===a.companion_id);if(!animal)throw new Error('Companion not found');stableCompanion(animal,a.stable)})); tool('retrieve_companion','Mark a mount or pet as travelling with the party without forgetting its last stable.',{id,expected_revision:revision,companion_id:id},write,async a=>mutate(a,c=>{const animal=(c.companions||[]).find(x=>x.id===a.companion_id);if(!animal)throw new Error('Companion not found');retrieveCompanion(animal)})); tool('change_companion_hp','Apply damage or healing to a named companion up to its HP maximum.',{id,expected_revision:revision,companion_id:id,amount:z.number().int().min(-9999).max(9999)},write,async a=>mutate(a,c=>{const animal=(c.companions||[]).find(x=>x.id===a.companion_id);if(!animal)throw new Error('Companion not found');changeCompanionHP(animal,a.amount)})); + + tool('list_spell_slots','Read configured regular and Pact Magic slot pools and their remaining uses.',{id},read,async({id})=>{const row=await get(id);return {revision:row.revision,spellSlots:row.character.spellSlots??{levels:[],pact:null}}}); + tool('configure_spell_slots','Configure the maximum regular slots at a spell level (1–9), or Pact Magic slots (1–5). A maximum of zero removes the pool; changing a total preserves remaining slots when possible.',{id,expected_revision:revision,kind:z.enum(['standard','pact']),level:z.number().int().min(1).max(9),max:z.number().int().min(0).max(20)},write,async a=>mutate(a,c=>configureSpellSlotPool(c,a.kind,a.level,a.max))); + tool('change_spell_slots','Spend slots with a negative change or restore with a positive change. Refuses to overspend or exceed maximum.',{id,expected_revision:revision,kind:z.enum(['standard','pact']),level:z.number().int().min(1).max(9),amount:z.number().int().min(-20).max(20)},write,async a=>mutate(a,c=>changeSpellSlotPool(c,a.kind,a.level,a.amount))); tool('delete_character','Delete a saved character across synced devices. Export a backup before deletion.',{id,expected_revision:revision},{...write,destructiveHint:true},async({id,expected_revision})=>{allowed();await get(id);return {sheet:await storage.put(sub,id,null,expected_revision,true)}}); tool('roll_dice','Roll cryptographically random dice. Does not change the character.',{count:z.number().int().min(1).max(100).default(1),sides:z.number().int().min(2).max(1000).default(20),bonus:z.number().int().min(-10000).max(10000).default(0)},{...read,idempotentHint:false},async({count,sides,bonus})=>{const rolls=Array.from({length:count},()=>randomInt(1,sides+1));return {rolls,bonus,total:rolls.reduce((a,b)=>a+b,bonus)}}); server.registerResource('characters','character://collection',{description:'Your saved character collection',mimeType:'application/json'},async uri=>({contents:[{uri:uri.href,mimeType:'application/json',text:JSON.stringify((await storage.list(sub)).filter(r=>!r.deleted))}]})); diff --git a/server/storage.mjs b/server/storage.mjs index 3b5faa4..214a0fc 100644 --- a/server/storage.mjs +++ b/server/storage.mjs @@ -13,7 +13,7 @@ export class Storage{ async get(owner,id){return (await this.pool.query('SELECT id,character,revision,deleted FROM character_sheets WHERE owner=$1 AND id=$2',[owner,id])).rows[0]} async put(owner,id,character,revision,deleted=false){ // Preserve additive collections omitted by older clients, under the same account/revision guard. - if(character&&!deleted&&revision>0&&(character.inventoryItems===undefined||character.currency===undefined||character.companions===undefined)){const prior=await this.get(owner,id);for(const key of ['inventoryItems','currency','companions'])if(character[key]===undefined&&prior?.character?.[key]!==undefined)character={...character,[key]:prior.character[key]}} + if(character&&!deleted&&revision>0&&(character.inventoryItems===undefined||character.currency===undefined||character.companions===undefined||character.spellSlots===undefined)){const prior=await this.get(owner,id);for(const key of ['inventoryItems','currency','companions','spellSlots'])if(character[key]===undefined&&prior?.character?.[key]!==undefined)character={...character,[key]:prior.character[key]}} const result=revision===0?await this.pool.query('INSERT INTO character_sheets(owner,id,character,deleted) VALUES($1,$2,$3,$4) ON CONFLICT(owner,id) DO NOTHING RETURNING id,character,revision,deleted',[owner,id,character,deleted]):await this.pool.query('UPDATE character_sheets SET character=$3,deleted=$4,revision=revision+1 WHERE owner=$1 AND id=$2 AND revision=$5 RETURNING id,character,revision,deleted',[owner,id,character,deleted,revision]); if(!result.rows[0])throw new Conflict();return result.rows[0]; diff --git a/src/App.vue b/src/App.vue index c547257..9741238 100644 --- a/src/App.vue +++ b/src/App.vue @@ -6,9 +6,10 @@ import CampaignsView from './CampaignsView.vue'; import RulesPicker,{type RuleEntry} from './RulesPicker.vue'; import InventoryFolio from './InventoryFolio.vue'; import CompanionsFolio from './CompanionsFolio.vue'; +import SpellSlotsFolio from './SpellSlotsFolio.vue'; import type {IconName} from './icons'; const navigation: {label:string,icon:IconName}[]=[{label:'Sheet',icon:'sheet'},{label:'Actions',icon:'actions'},{label:'Spells',icon:'spells'},{label:'Gear',icon:'gear'},{label:'More',icon:'notes'}]; -import {abilities,modifier,proficiency,signed,skills,skillBonus,addInventoryItem,changeHP,newCharacter,characterSchema,restPreview,applyRest,type Character} from './model'; +import {abilities,modifier,proficiency,signed,skills,skillBonus,addInventoryItem,changeHP,newCharacter,characterSchema,restPreview,spellSlotRestPreview,applyRest,type Character} from './model'; const gearSection=ref<'equipment'|'companions'>('equipment'); const campaignMode=ref(false),catalogueMode=ref<'action'|'spell'|'item'|null>(null); const store=useCharacters(),activeId=ref(''),tab=ref('Sheet'),drawer=ref(''),error=ref(''),message=ref(''),user=ref<{sub:string,name:string,expiresAt:number}|null>(null),loading=ref(true),offline=ref(!navigator.onLine); @@ -16,6 +17,7 @@ const c=computed(()=>store.all.find(x=>x.id===activeId.value)); const draft=ref(null),hpAmount=ref(1),restType=ref<'short-rest'|'long-rest'>('short-rest'),rollResult=ref(''),selectedAction=ref(null); const drawerIcon=computed(()=>({Character:'sheet',Account:'notes','Hit points':'hp','Add action':'actions','Add resource':'charges','Manage resources':'slots','Add spell':'spells',Roll:'actions',Dice:'initiative',Rest:restType.value==='long-rest'?'long-rest':'short-rest'} as Record)[drawer.value]||'sheet'); const restChanges=computed(()=>c.value?restPreview(c.value,restType.value):[]); +const restSlotChanges=computed(()=>c.value?spellSlotRestPreview(c.value,restType.value):[]); const editingActionId=ref(null),editingSpellId=ref(null); const spellLevels=computed(()=>c.value?[...new Set(c.value.spells.map(s=>s.level))].sort((a,b)=>a-b):[]); const actionDraft=ref({name:'',bonus:0,damage:'',notes:''}),resourceDraft=ref({name:'',current:1,max:1,reset:'long-rest' as 'short-rest'|'long-rest'|'manual'}),spellDraft=ref({name:'',level:1,prepared:true,notes:''}); @@ -73,8 +75,9 @@ function openAction(action:Character['actions'][number]){selectedAction.value=ac

Spellbook

+

A little magic starts here.

Record your spells and cantrips, prepare your favourites and keep their notes close.

-