Mirror canonical dnd-character-pwa spell slot tracker from 942488e
This commit is contained in:
parent
34d6c8042e
commit
c39f777478
@ -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))}]}));
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -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<Character|null>(null),hpAmount=ref(1),restType=ref<'short-rest'|'long-rest'>('short-rest'),rollResult=ref(''),selectedAction=ref<Character['actions'][number]|null>(null);
|
||||
const drawerIcon=computed<IconName>(()=>({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<string,IconName>)[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<string|null>(null),editingSpellId=ref<string|null>(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
|
||||
</section>
|
||||
<section class="actions-folio spells-folio" v-if="tab==='Spells'" aria-labelledby="spells-heading">
|
||||
<div class="folio-heading"><h2 id="spells-heading"><UiIcon name="spells" />Spellbook</h2><div class="folio-heading-tools"><button class="quiet" @click="catalogueMode='spell'">Browse SRD</button><button class="quiet folio-add" @click="spellEditor()">+ Custom</button></div></div>
|
||||
<SpellSlotsFolio :character="c" />
|
||||
<div class="folio-empty" v-if="!c.spells.length"><UiIcon name="spells" /><h3>A little magic starts here.</h3><p>Record your spells and cantrips, prepare your favourites and keep their notes close.</p><button class="primary" @click="catalogueMode='spell'">Browse spells</button><button class="quiet" @click="spellEditor()">Write a custom spell</button></div>
|
||||
<template v-else><p class="folio-spell-hint">Spell slots are tracked with resources on the Sheet tab.</p>
|
||||
<template v-else><p class="folio-spell-hint">Prepare spells below. Use the slot tracker above when you cast a levelled spell; cantrips cost no slots.</p>
|
||||
<section v-for="level in spellLevels" :key="level" class="spell-chapter" :aria-labelledby="`spell-level-${level}`"><h3 :id="`spell-level-${level}`" class="spell-chapter-title">{{level===0?'Cantrips':'Level '+level}}</h3>
|
||||
<article class="folio-action folio-spell" v-for="spell in c.spells.filter(s=>s.level===level)" :key="spell.id">
|
||||
<div class="folio-action-main"><UiIcon name="spells" class="folio-action-icon" /><div class="folio-action-copy"><h3>{{spell.name}}</h3><p class="folio-modifier">{{spell.level?'Level '+spell.level:'Cantrip'}} · {{spell.prepared?'Prepared':'Not prepared'}}</p></div><label class="spell-prepare"><input type="checkbox" v-model="spell.prepared" :aria-label="`Prepare ${spell.name}`"><span>Prepared</span></label></div>
|
||||
@ -110,7 +113,7 @@ function openAction(action:Character['actions'][number]){selectedAction.value=ac
|
||||
<form v-if="drawer==='Add resource'" @submit.prevent="addResource"><label>Name<input v-model="resourceDraft.name" placeholder="Level 1 spell slots" required maxlength="120"></label><div class="form-grid"><label>Remaining<input type="number" v-model.number="resourceDraft.current" min="0" :max="resourceDraft.max" required></label><label>Maximum<input type="number" v-model.number="resourceDraft.max" min="0" max="9999" required></label></div><label>Recovery<select v-model="resourceDraft.reset"><option value="short-rest">Short or long rest</option><option value="long-rest">Long rest</option><option value="manual">Manual only</option></select></label><button class="primary wide">Add resource</button></form>
|
||||
<template v-if="drawer==='Manage resources'&&c"><div class="resource" v-for="r in c.resources"><b>{{r.name}}</b><button class="quiet danger-text" @click="c.resources=c.resources.filter(x=>x.id!==r.id)">Remove</button></div><button @click="drawer='Add resource'">+ Add resource</button></template>
|
||||
<form v-if="drawer==='Add spell'" @submit.prevent="addSpell"><label>Name<input v-model="spellDraft.name" required maxlength="120"></label><label>Level (0 = cantrip)<input type="number" v-model.number="spellDraft.level" min="0" max="9" required></label><label>Spell notes<textarea v-model="spellDraft.notes" rows="6" maxlength="12000" placeholder="Casting time, range, components, duration, effects…"></textarea></label><button class="primary wide">{{editingSpellId?'Save spell':'Add spell'}}</button></form>
|
||||
<template v-if="drawer==='Rest'&&c"><p class="hint">Review before applying your rest.</p><div class="preview-row" v-if="restType==='long-rest'"><span>Hit points</span><b>{{c.hp.current}} → {{c.hp.max}}</b></div><div class="preview-row" v-if="restType==='long-rest'"><span>Temporary HP</span><b>{{c.hp.temp}} → 0</b></div><div class="preview-row" v-for="r in restChanges"><span>{{r.name}}</span><b>{{r.current}} → {{r.max}}</b></div><p class="hint">Manual resources, notes and gear remain as entered. Hit dice and class-specific rest rules are not automated yet.</p><button class="primary wide" @click="confirmRest">Confirm rest</button></template>
|
||||
<template v-if="drawer==='Rest'&&c"><p class="hint">Review before applying your rest.</p><div class="preview-row" v-if="restType==='long-rest'"><span>Hit points</span><b>{{c.hp.current}} → {{c.hp.max}}</b></div><div class="preview-row" v-if="restType==='long-rest'"><span>Temporary HP</span><b>{{c.hp.temp}} → 0</b></div><div class="preview-row" v-for="r in restChanges"><span>{{r.name}}</span><b>{{r.current}} → {{r.max}}</b></div><div class="preview-row" v-for="r in restSlotChanges"><span>{{r.name}}</span><b>{{r.current}} → {{r.max}}</b></div><p class="hint">Regular spell slots recover on a long rest; Pact Magic slots recover on both rest types. Manual resources, notes and gear stay as entered. Hit dice and other class-specific rules remain manual.</p><button class="primary wide" @click="confirmRest">Confirm rest</button></template>
|
||||
<template v-if="drawer==='Roll'&&selectedAction"><h3>{{selectedAction.name}}</h3><p class="hint">Attack {{signed(selectedAction.bonus)}} · {{selectedAction.damage}}</p><p>{{selectedAction.notes}}</p><div class="roll-buttons"><button @click="roll(selectedAction.bonus,-1)">Disadvantage</button><button class="primary" @click="roll(selectedAction.bonus)">Roll d20</button><button @click="roll(selectedAction.bonus,1)">Advantage</button></div><p class="roll-result" role="status">{{rollResult}}</p></template>
|
||||
<template v-if="drawer==='Dice'"><p class="roll-result" role="status">{{rollResult}}</p><p class="hint">Includes the modifier shown on your sheet.</p></template>
|
||||
</section></div>
|
||||
|
||||
92
src/SpellSlotsFolio.vue
Normal file
92
src/SpellSlotsFolio.vue
Normal file
@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import {computed,ref,watch} from 'vue';
|
||||
import UiIcon from './UiIcon.vue';
|
||||
import {configureSpellSlotPool,changeSpellSlotPool,spellSlotsSchema,type Character,type SpellSlotKind} from './model';
|
||||
|
||||
const props=defineProps<{character:Character}>();
|
||||
const slots=computed(()=>props.character.spellSlots??{levels:[],pact:null});
|
||||
const regular=computed(()=>[...slots.value.levels].sort((a,b)=>a.level-b.level));
|
||||
const pact=computed(()=>slots.value.pact);
|
||||
const configured=computed(()=>regular.value.length>0||!!pact.value);
|
||||
const remaining=computed(()=>regular.value.reduce((n,s)=>n+s.remaining,0)+(pact.value?.remaining??0));
|
||||
const maximum=computed(()=>regular.value.reduce((n,s)=>n+s.max,0)+(pact.value?.max??0));
|
||||
|
||||
const editing=ref(false),error=ref(''),totals=ref<number[]>(Array(9).fill(0));
|
||||
const pactEnabled=ref(false),pactLevel=ref(1),pactMax=ref(1);
|
||||
watch(()=>props.character.id,()=>{editing.value=false;error.value=''});
|
||||
function openEditor(){
|
||||
const current=slots.value;
|
||||
totals.value=Array.from({length:9},(_,i)=>current.levels.find(s=>s.level===i+1)?.max??0);
|
||||
pactEnabled.value=!!current.pact;
|
||||
pactLevel.value=current.pact?.level??1;
|
||||
pactMax.value=current.pact?.max??1;
|
||||
error.value='';editing.value=true;
|
||||
}
|
||||
function save(){
|
||||
try{
|
||||
const next:Character={...props.character,spellSlots:structuredClone(props.character.spellSlots??{levels:[],pact:null})};
|
||||
totals.value.forEach((count,index)=>configureSpellSlotPool(next,'standard',index+1,count));
|
||||
if(pactEnabled.value)configureSpellSlotPool(next,'pact',pactLevel.value,pactMax.value);
|
||||
else next.spellSlots!.pact=null;
|
||||
props.character.spellSlots=spellSlotsSchema.parse(next.spellSlots);
|
||||
editing.value=false;error.value='';
|
||||
}catch(e){error.value=e instanceof Error&&e.name==='ZodError'?'Slots must be whole numbers between 0 and 20. Pact Magic uses levels 1–5.':e instanceof Error?e.message:'Please check the slot totals.'}
|
||||
}
|
||||
function change(kind:SpellSlotKind,level:number,amount:number){error.value='';try{changeSpellSlotPool(props.character,kind,level,amount)}catch(e){error.value=e instanceof Error?e.message:'Unable to change slots.'}}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="spell-slots-folio" aria-labelledby="spell-slots-heading">
|
||||
<div class="spell-slots-heading">
|
||||
<div><span class="eyebrow">THE MAGIC YOU HAVE LEFT</span><h3 id="spell-slots-heading"><UiIcon name="slots" />Spell slots</h3></div>
|
||||
<button class="quiet" @click="openEditor">{{configured?'Edit totals':'Configure slots'}}</button>
|
||||
</div>
|
||||
<div v-if="!configured" class="spell-slots-empty">
|
||||
<p>Set your slots for each spell level, including Pact Magic if you use it.</p>
|
||||
<button class="primary" @click="openEditor">Set up spell slots</button>
|
||||
</div>
|
||||
<template v-else>
|
||||
<p class="spell-slots-count">{{remaining}} of {{maximum}} slots remaining <span>· Tap − to expend and + to recover</span></p>
|
||||
<div class="spell-slot-list">
|
||||
<div v-for="pool in regular" :key="pool.level" class="spell-slot-row">
|
||||
<div class="slot-level"><span class="slot-sigil">✦</span><div><small>SPELL LEVEL</small><strong>{{pool.level}}</strong></div></div>
|
||||
<div class="spell-slot-track" :aria-label="\`Level \${pool.level}: \${pool.remaining} of \${pool.max} slots remaining\`" role="img">
|
||||
<span v-for="index in pool.max" :key="index" class="spell-slot-pip" :class="{spent:index>pool.remaining}" aria-hidden="true">✦</span>
|
||||
</div>
|
||||
<div class="spell-slot-controls"><span class="slot-fraction">{{pool.remaining}} / {{pool.max}}</span>
|
||||
<button class="quiet" @click="change('standard',pool.level,-1)" :disabled="pool.remaining===0" :aria-label="\`Expend level \${pool.level} spell slot\`">−</button>
|
||||
<button class="quiet" @click="change('standard',pool.level,1)" :disabled="pool.remaining===pool.max" :aria-label="\`Restore level \${pool.level} spell slot\`">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="pact" class="spell-slot-row pact-slot-row">
|
||||
<div class="slot-level"><span class="slot-sigil">☾</span><div><small>PACT MAGIC</small><strong>Lvl {{pact.level}}</strong></div></div>
|
||||
<div class="spell-slot-track" :aria-label="\`Pact Magic level \${pact.level}: \${pact.remaining} of \${pact.max} slots remaining\`" role="img">
|
||||
<span v-for="index in pact.max" :key="index" class="spell-slot-pip pact-pip" :class="{spent:index>pact.remaining}" aria-hidden="true">✦</span>
|
||||
</div>
|
||||
<div class="spell-slot-controls"><span class="slot-fraction">{{pact.remaining}} / {{pact.max}}</span>
|
||||
<button class="quiet" @click="change('pact',pact.level,-1)" :disabled="pact.remaining===0" aria-label="Expend Pact Magic slot">−</button>
|
||||
<button class="quiet" @click="change('pact',pact.level,1)" :disabled="pact.remaining===pact.max" aria-label="Restore Pact Magic slot">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="folio-spell-hint spell-slot-footnote">Regular slots recover on a long rest; Pact Magic slots recover on short or long rests. Other class features stay manual.</p>
|
||||
</template>
|
||||
<p v-if="error&&!editing" class="notice danger" role="alert">{{error}}</p>
|
||||
</section>
|
||||
<div v-if="editing" class="overlay" @click.self="editing=false"><section class="drawer spell-slots-drawer" role="dialog" aria-modal="true" aria-label="Configure spell slots">
|
||||
<div class="section-title drawer-heading"><h2><UiIcon name="slots" />Configure spell slots</h2><button class="close" @click="editing=false" aria-label="Close spell slot editor">×</button></div>
|
||||
<p class="hint">Enter the total slots for your class or multiclass combination. Leave unused spell levels at zero. Changes to a configured total keep your remaining uses, capped at the new maximum.</p>
|
||||
<p v-if="error" class="notice danger" role="alert">{{error}}</p>
|
||||
<form @submit.prevent="save">
|
||||
<fieldset class="slot-editor-fieldset"><legend>Regular spell slots</legend>
|
||||
<div class="slot-editor-grid"><label v-for="(_,index) in totals" :key="index">Level {{index+1}}<input type="number" v-model.number="totals[index]" min="0" max="20" step="1" inputmode="numeric" required></label></div>
|
||||
</fieldset>
|
||||
<fieldset class="slot-editor-fieldset"><legend>Pact Magic (warlock)</legend>
|
||||
<label class="slot-pact-toggle"><input type="checkbox" v-model="pactEnabled"> Track Pact Magic separately</label>
|
||||
<div v-if="pactEnabled" class="form-grid"><label>Slot level<select v-model.number="pactLevel"><option v-for="level in 5" :key="level" :value="level">Level {{level}}</option></select></label><label>Total pact slots<input type="number" v-model.number="pactMax" min="1" max="20" required step="1" inputmode="numeric"></label></div>
|
||||
<p class="hint">Pact Magic uses a separate shared pool whose spell level increases as you advance.</p>
|
||||
</fieldset>
|
||||
<button class="primary wide">Save spell slot totals</button>
|
||||
</form>
|
||||
</section></div>
|
||||
</template>
|
||||
54
src/model.ts
54
src/model.ts
@ -85,17 +85,65 @@ export function changeCompanionHP(animal:Companion,amount:number){
|
||||
}
|
||||
|
||||
export const spellSchema=z.object({id:z.string(),name:text,level:z.number().int().min(0).max(9),prepared:z.boolean(),notes:text});
|
||||
export const characterSchema=z.object({schemaVersion:z.literal(1),id:z.string(),name:z.string().min(1).max(120),className:text,level:z.number().int().min(1).max(20),species:text,background:text,abilities:z.object({str:score,dex:score,con:score,int:score,wis:score,cha:score}),skills:z.record(z.string(),z.enum(['none','proficient','expert'])),saves:z.array(z.enum(abilities)),hp:z.object({current:counter,max:z.number().int().min(1).max(9999),temp:counter}).refine(h=>h.current<=h.max),ac:counter,speed:counter,actions:z.array(actionSchema).max(200),resources:z.array(resourceSchema).max(200),spells:z.array(spellSchema).max(500),inventory:text,currency:currencySchema.optional(),inventoryItems:z.array(inventoryItemSchema).max(300).refine(items=>new Set(items.map(i=>i.id)).size===items.length,'Item ids must be unique').optional(),companions:z.array(companionSchema).max(100).refine(items=>new Set(items.map(i=>i.id)).size===items.length,'Companion ids must be unique').optional(),notes:text});
|
||||
|
||||
/* Spell slots are deliberately independent from the spell catalogue and class level:
|
||||
the DM/player supplies totals, so multiclass and homebrew sheets remain valid. */
|
||||
const slotCount=z.number().int().min(1).max(20);
|
||||
const slotRemaining=z.number().int().min(0).max(20);
|
||||
export const spellSlotPoolSchema=z.object({level:z.number().int().min(1).max(9),max:slotCount,remaining:slotRemaining})
|
||||
.refine(s=>s.remaining<=s.max,'Remaining slots cannot exceed maximum');
|
||||
export const pactSlotPoolSchema=z.object({level:z.number().int().min(1).max(5),max:slotCount,remaining:slotRemaining})
|
||||
.refine(s=>s.remaining<=s.max,'Remaining pact slots cannot exceed maximum');
|
||||
export const spellSlotsSchema=z.object({
|
||||
levels:z.array(spellSlotPoolSchema).max(9).refine(pools=>new Set(pools.map(p=>p.level)).size===pools.length,'Spell slot levels must be unique'),
|
||||
pact:pactSlotPoolSchema.nullable()
|
||||
});
|
||||
export type SpellSlots=z.infer<typeof spellSlotsSchema>;
|
||||
export type SpellSlotKind='standard'|'pact';
|
||||
export function configureSpellSlotPool(c:Character,kind:SpellSlotKind,level:number,max:number){
|
||||
if(!Number.isInteger(level)||level<1||level>9||(kind==='pact'&&level>5))throw new Error('Invalid spell slot level.');
|
||||
if(!Number.isInteger(max)||max<0||max>20)throw new Error('Slots must be a whole number from 0 to 20.');
|
||||
const slots:SpellSlots=structuredClone(c.spellSlots??{levels:[],pact:null});
|
||||
if(kind==='pact'){
|
||||
const old=slots.pact;
|
||||
slots.pact=max===0?null:{level,max,remaining:old?Math.min(old.remaining,max):max};
|
||||
}else{
|
||||
const old=slots.levels.find(pool=>pool.level===level);
|
||||
slots.levels=slots.levels.filter(pool=>pool.level!==level);
|
||||
if(max)slots.levels.push({level,max,remaining:old?Math.min(old.remaining,max):max});
|
||||
slots.levels.sort((a,b)=>a.level-b.level);
|
||||
}
|
||||
c.spellSlots=spellSlotsSchema.parse(slots);
|
||||
}
|
||||
export function changeSpellSlotPool(c:Character,kind:SpellSlotKind,level:number,delta:number){
|
||||
if(!Number.isInteger(delta)||Math.abs(delta)>20)throw new Error('Slots can only change in whole numbers up to 20.');
|
||||
const slots=c.spellSlots;
|
||||
const pool=kind==='pact'?slots?.pact:slots?.levels.find(s=>s.level===level);
|
||||
if(!pool||pool.level!==level)throw new Error('Configure this spell slot pool first.');
|
||||
const next=pool.remaining+delta;
|
||||
if(next<0)throw new Error('No spell slots remaining at this level.');
|
||||
if(next>pool.max)throw new Error('Spell slots are already full.');
|
||||
pool.remaining=next;
|
||||
}
|
||||
export function spellSlotRestPreview(c:Character,type:'short-rest'|'long-rest'){
|
||||
const slots=c.spellSlots;
|
||||
const pools=type==='long-rest'?(slots?.levels??[]):[];
|
||||
return [...pools.map(s=>({name:'Level '+s.level+' spell slots',current:s.remaining,max:s.max})),
|
||||
...(slots?.pact?[{name:'Pact Magic (level '+slots.pact.level+')',current:slots.pact.remaining,max:slots.pact.max}]:[])]
|
||||
.filter(s=>s.current!==s.max);
|
||||
}
|
||||
|
||||
export const characterSchema=z.object({schemaVersion:z.literal(1),id:z.string(),name:z.string().min(1).max(120),className:text,level:z.number().int().min(1).max(20),species:text,background:text,abilities:z.object({str:score,dex:score,con:score,int:score,wis:score,cha:score}),skills:z.record(z.string(),z.enum(['none','proficient','expert'])),saves:z.array(z.enum(abilities)),hp:z.object({current:counter,max:z.number().int().min(1).max(9999),temp:counter}).refine(h=>h.current<=h.max),ac:counter,speed:counter,actions:z.array(actionSchema).max(200),resources:z.array(resourceSchema).max(200),spells:z.array(spellSchema).max(500),spellSlots:spellSlotsSchema.optional(),inventory:text,currency:currencySchema.optional(),inventoryItems:z.array(inventoryItemSchema).max(300).refine(items=>new Set(items.map(i=>i.id)).size===items.length,'Item ids must be unique').optional(),companions:z.array(companionSchema).max(100).refine(items=>new Set(items.map(i=>i.id)).size===items.length,'Companion ids must be unique').optional(),notes:text});
|
||||
export type Character=z.infer<typeof characterSchema>;
|
||||
export const skills:Record<string,Ability>={'Acrobatics':'dex','Animal Handling':'wis','Arcana':'int','Athletics':'str','Deception':'cha','History':'int','Insight':'wis','Intimidation':'cha','Investigation':'int','Medicine':'wis','Nature':'int','Perception':'wis','Performance':'cha','Persuasion':'cha','Religion':'int','Sleight of Hand':'dex','Stealth':'dex','Survival':'wis'};
|
||||
export function newCharacter():Character{return {schemaVersion:1,id:crypto.randomUUID(),name:'New adventurer',className:'',level:1,species:'',background:'',abilities:{str:10,dex:10,con:10,int:10,wis:10,cha:10},skills:{},saves:[],hp:{current:10,max:10,temp:0},ac:10,speed:30,actions:[],resources:[],spells:[],inventory:'',currency:{gp:0,sp:0,cp:0},inventoryItems:[],companions:[],notes:''}}
|
||||
export function newCharacter():Character{return {schemaVersion:1,id:crypto.randomUUID(),name:'New adventurer',className:'',level:1,species:'',background:'',abilities:{str:10,dex:10,con:10,int:10,wis:10,cha:10},skills:{},saves:[],hp:{current:10,max:10,temp:0},ac:10,speed:30,actions:[],resources:[],spells:[],spellSlots:{levels:[],pact:null},inventory:'',currency:{gp:0,sp:0,cp:0},inventoryItems:[],companions:[],notes:''}}
|
||||
export const modifier=(n:number)=>Math.floor((n-10)/2);
|
||||
export const proficiency=(level:number)=>2+Math.floor((level-1)/4);
|
||||
export const signed=(n:number)=>n>=0?`+${n}`:`${n}`;
|
||||
export function skillBonus(c:Character,name:string){return modifier(c.abilities[skills[name]])+proficiency(c.level)*(c.skills[name]==='expert'?2:c.skills[name]==='proficient'?1:0)}
|
||||
export function changeHP(c:Character,amount:number){if(amount<0){const absorbed=Math.min(c.hp.temp,-amount);c.hp.temp-=absorbed;c.hp.current=Math.max(0,c.hp.current+amount+absorbed)}else c.hp.current=Math.min(c.hp.max,c.hp.current+amount)}
|
||||
export function restPreview(c:Character,type:'short-rest'|'long-rest'){return c.resources.filter(r=>r.reset===type||(type==='long-rest'&&r.reset==='short-rest')).filter(r=>r.current!==r.max)}
|
||||
export function applyRest(c:Character,type:'short-rest'|'long-rest'){for(const r of restPreview(c,type))r.current=r.max;if(type==='long-rest'){c.hp.current=c.hp.max;c.hp.temp=0}}
|
||||
export function applyRest(c:Character,type:'short-rest'|'long-rest'){for(const r of restPreview(c,type))r.current=r.max;if(c.spellSlots){if(type==='long-rest')for(const s of c.spellSlots.levels)s.remaining=s.max;if(c.spellSlots.pact)c.spellSlots.pact.remaining=c.spellSlots.pact.max}if(type==='long-rest'){c.hp.current=c.hp.max;c.hp.temp=0}}
|
||||
|
||||
export function addInventoryItem(c:Character,input:InventoryItem){const item=inventoryItemSchema.parse(input);const items=c.inventoryItems||[];const existing=item.catalogueId?items.find(i=>i.catalogueId===item.catalogueId):undefined;if(existing){if(existing.quantity+item.quantity>9999)throw new Error('Quantity cannot exceed 9999.');existing.quantity+=item.quantity;return existing}if(items.some(i=>i.id===item.id))throw new Error('Item id already exists.');if(items.length>=300)throw new Error('Your inventory is full (300 entries).');c.inventoryItems=[...items,item];return item}
|
||||
export function changeItemQuantity(item:InventoryItem,amount:number){if(!Number.isInteger(amount))throw new Error('Quantity changes must be whole numbers.');item.quantity=Math.max(0,Math.min(9999,item.quantity+amount))}
|
||||
|
||||
@ -165,3 +165,57 @@ input[type=checkbox]{accent-color:#3b603d}
|
||||
.coin-bag-copy>span:not(.coin-bag-eyebrow){font-family:Georgia,serif;font-size:16px}
|
||||
.coin-bag-copy small{color:#736045;font-size:11px;line-height:1.45}
|
||||
@media(max-width:430px){.coin-bag-display{min-height:137px;gap:4px;padding-left:0;padding-right:0}.coin-bag-art{width:46%;max-width:174px}.coin-bag-copy strong{font-size:25px}.coin-bag-copy>span:not(.coin-bag-eyebrow){font-size:14px}.coin-bag-copy small{font-size:10px}}
|
||||
|
||||
|
||||
/* Spell slots: carved runes arranged directly on the parchment, not floating cards. */
|
||||
.spell-slots-folio{padding:14px 0 16px;border-bottom:1px solid #a88a5575;margin:4px 0 18px}
|
||||
.spell-slots-heading{display:flex;justify-content:space-between;align-items:center;gap:12px}
|
||||
.spell-slots-heading h3{font-family:Georgia,serif;font-weight:400;font-size:28px;display:flex;align-items:center;gap:9px;margin:4px 0 0;color:#4c3e2e}
|
||||
.spell-slots-heading h3 .ui-icon{width:38px;height:38px}
|
||||
.spell-slots-heading .eyebrow{font-size:10px;letter-spacing:1.3px;color:#81653b}
|
||||
.spell-slots-heading .quiet{flex-shrink:0;min-height:44px}
|
||||
.spell-slots-empty{padding:18px 0 14px;display:flex;align-items:center;justify-content:space-between;gap:14px}
|
||||
.spell-slots-empty p{max-width:410px;margin:0;font-size:13px;line-height:1.6;color:#6d6246}
|
||||
.spell-slots-empty button{min-height:44px}
|
||||
.spell-slots-count{margin:16px 0 10px;font-family:Georgia,serif;font-size:16px;color:#604d35}
|
||||
.spell-slots-count span{font-family:system-ui;font-size:11px;color:#806b4b}
|
||||
.spell-slot-list{border-top:1px solid #b99c6850}
|
||||
.spell-slot-row{display:grid;grid-template-columns:130px minmax(0,1fr) auto;align-items:center;gap:12px;padding:12px 2px;border-bottom:1px solid #b99c6850}
|
||||
.slot-level{display:flex;gap:10px;align-items:center;min-width:0}
|
||||
.slot-sigil{display:grid;place-items:center;width:37px;height:37px;flex-shrink:0;border:1px solid #a17d46;border-radius:50%;background:#d7b77636;color:#906633;font-size:23px}
|
||||
.slot-level small{font-size:9px;letter-spacing:.9px;color:#846b43;display:block;white-space:nowrap}
|
||||
.slot-level strong{font-family:Georgia,serif;font-size:23px;line-height:1.2;font-weight:400;color:#574329;display:block}
|
||||
.spell-slot-track{display:flex;flex-wrap:wrap;align-items:center;gap:5px;min-width:0}
|
||||
.spell-slot-pip{width:22px;height:22px;display:grid;place-items:center;border:1px solid #93703d;border-radius:50%;background:linear-gradient(#f4d992,#d2ab5b);color:#6c4f20;font-size:14px;box-shadow:inset 0 0 0 2px #f9eac088}
|
||||
.spell-slot-pip.spent{color:#957f5e;background:#e1cfaa66;border-style:dashed;opacity:.58;box-shadow:none}
|
||||
.pact-slot-row .slot-sigil{color:#335c45;background:#cad5b488}
|
||||
.spell-slot-pip.pact-pip{border-color:#5b784f;background:linear-gradient(#d6deb8,#a9be87);color:#356046}
|
||||
.spell-slot-pip.pact-pip.spent{background:#b7c1a34d;color:#8d9a85}
|
||||
.spell-slot-controls{display:flex;gap:5px;align-items:center}
|
||||
.spell-slot-controls .quiet{font-size:20px;padding:4px 8px;min-width:44px;min-height:44px}
|
||||
.slot-fraction{font-family:Georgia,serif;font-size:19px;color:#564930;min-width:55px;text-align:center;font-variant-numeric:tabular-nums}
|
||||
.spell-slot-footnote{font-size:11px;margin:14px 0 2px}
|
||||
.slot-editor-fieldset{border:1px solid #a98a5270;border-radius:6px;padding:16px 12px;margin:20px 0}
|
||||
.slot-editor-fieldset legend{font-family:Georgia,serif;font-size:21px;padding:0 8px;color:#5e4b32}
|
||||
.slot-editor-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}
|
||||
.slot-editor-grid label{font-size:12px;color:#67553e}
|
||||
.slot-editor-grid input{width:100%;margin-top:5px}
|
||||
.slot-pact-toggle{display:flex;align-items:center;gap:10px;min-height:44px;font-size:14px;margin:0 0 14px}
|
||||
.slot-pact-toggle input{flex-shrink:0}
|
||||
.spell-slots-drawer .hint{font-size:12px;line-height:1.6}
|
||||
@media(max-width:700px){
|
||||
.spell-slot-row{grid-template-columns:108px minmax(0,1fr);gap:8px;padding:11px 0}
|
||||
.spell-slot-track{gap:4px}
|
||||
.spell-slot-pip{width:20px;height:20px;font-size:12px}
|
||||
.spell-slot-controls{grid-column:2;justify-content:flex-end;margin-top:-3px}
|
||||
.spell-slots-empty{flex-wrap:wrap}
|
||||
.spell-slots-heading h3{font-size:25px}
|
||||
}
|
||||
@media(max-width:390px){
|
||||
.spell-slot-row{grid-template-columns:97px minmax(0,1fr)}
|
||||
.slot-sigil{width:30px;height:30px;font-size:18px}
|
||||
.slot-level{gap:5px}
|
||||
.slot-level strong{font-size:20px}
|
||||
.slot-level small{font-size:8px}
|
||||
.spell-slots-count span{display:block;margin-top:4px}
|
||||
}
|
||||
|
||||
@ -7,7 +7,7 @@ test('private notes, DM secrets and hidden NPCs remain filtered after role chang
|
||||
test('delegated saved HP requires current DM role and owner permission, and preserves private data',async()=>{const {storage,campaigns,c,act}=await fixture(),pc=newCharacter();pc.notes='Private';pc.hp.temp=3;await storage.put('player',pc.id,pc,0);await act('player','assign',{character_id:pc.id});const input={owner:'player',character_id:pc.id,expected_revision:1,amount:-5};await assert.rejects(campaigns.partyHp('dm',c.id,input));await act('player','assign',{character_id:pc.id,allow_dm_edit:true});await assert.rejects(campaigns.partyHp('player',c.id,input));const changed=await campaigns.partyHp('dm',c.id,input);assert.equal(changed.revision,2);assert.deepEqual(changed.character.hp,{current:8,max:10,temp:0});assert.equal(changed.character.notes,undefined);assert.equal((await storage.get('player',pc.id)).character.notes,'Private');await assert.rejects(campaigns.partyHp('dm',c.id,input));await act('player','assign',{character_id:pc.id,allow_dm_edit:false});await assert.rejects(campaigns.partyHp('dm',c.id,{...input,expected_revision:2}));await act('dm','member',{sub:'player',role:'remove'});await assert.rejects(campaigns.view('player',c.id));assert.equal((await campaigns.view('dm',c.id)).party.length,0)});
|
||||
test('DM encounter snapshots sort initiative, wrap rounds and clamp HP without touching saved PCs',async()=>{const {storage,campaigns,c,act}=await fixture(),pc=newCharacter();await storage.put('player',pc.id,pc,0);await assert.rejects(act('player','session',{title:'No'}));await assert.rejects(act('player','npc',{name:'No',hp:1,maxHp:1,ac:1}));await assert.rejects(act('player','invite',{}));await act('dm','session',{title:'Session 1',summary:'Meet at the inn'});await act('dm','encounter',{name:'Ambush',participants:[{name:'PC',initiative:8,hp:10,maxHp:10,ac:10},{name:'Goblin',initiative:15,hp:5,maxHp:5,ac:12}]});let encounter=(await campaigns.view('player',c.id)).encounters[0];assert.equal(encounter.participants[0].name,'Goblin');await assert.rejects(act('player','encounterStep',{id:encounter.id,action:'next'}));await act('dm','encounterStep',{id:encounter.id,action:'next'});await act('dm','encounterStep',{id:encounter.id,action:'next'});await act('dm','encounterStep',{id:encounter.id,action:'damage',participant_id:encounter.participants[0].id,amount:50});encounter=(await campaigns.view('player',c.id)).encounters[0];assert.equal(encounter.round,2);assert.equal(encounter.turn,0);assert.equal(encounter.participants[0].hp,0);assert.equal((await storage.get('player',pc.id)).character.hp.current,10);const old=(await campaigns.row(c.id)).revision;await act('dm','encounterStep',{id:encounter.id,action:'finish'});await assert.rejects(campaigns.mutate('dm',c.id,old,'note',{title:'Stale',body:'',visibility:'party'}));assert.equal((await campaigns.view('dm',c.id)).notes.length,0)});
|
||||
test('HTTP campaign routes deny unsigned users and cross-origin writes',async()=>{const {campaigns}=await fixture(),app=express();app.use(express.json());app.use((req:any,res,next)=>{if(req.get('x-test-user'))req.identity={sub:req.get('x-test-user'),name:'Test'};next()});installCampaigns(app,{campaigns,origin:'https://dnd.example.test'});const server=app.listen(0,'127.0.0.1');await once(server,'listening');const base=`http://127.0.0.1:${(server.address() as any).port}`;try{assert.equal((await fetch(base+'/api/campaigns')).status,401);assert.equal((await fetch(base+'/api/campaigns',{method:'POST',headers:{'x-test-user':'dm','content-type':'application/json',origin:'https://evil.test'},body:JSON.stringify({name:'No'})})).status,403);assert.equal((await fetch(base+'/api/campaigns',{headers:{'x-test-user':'dm'}})).status,200)}finally{await new Promise<void>(r=>server.close(()=>r()))}});
|
||||
test('MCP exposes campaign tools and enforces read scopes and campaign membership',async()=>{const {storage,campaigns,c}=await fixture();async function connect(sub:string,scopes:string[]){const server=characterMcp({storage,campaigns,sub,scopes}),client=new Client({name:'campaign-test',version:'1'});const [a,b]=InMemoryTransport.createLinkedPair();await server.connect(a);await client.connect(b);return client}const read=await connect('dm',['characters:read']),player=await connect('player',['characters:read','characters:write']),stranger=await connect('stranger',['characters:read']);try{assert.equal((await read.listTools()).tools.length,34);assert.equal((await read.callTool({name:'create_campaign',arguments:{name:'Denied'}})).isError,true);assert.equal((await stranger.callTool({name:'get_campaign',arguments:{campaign_id:c.id}})).isError,true);assert.equal((await player.callTool({name:'invite_campaign_member',arguments:{campaign_id:c.id,expected_revision:(await campaigns.row(c.id)).revision,input:{role:'dm'}}})).isError,true);assert.equal((await read.callTool({name:'get_campaign',arguments:{campaign_id:c.id}})).isError,undefined)}finally{await Promise.all([read.close(),player.close(),stranger.close()])}});
|
||||
test('MCP exposes campaign tools and enforces read scopes and campaign membership',async()=>{const {storage,campaigns,c}=await fixture();async function connect(sub:string,scopes:string[]){const server=characterMcp({storage,campaigns,sub,scopes}),client=new Client({name:'campaign-test',version:'1'});const [a,b]=InMemoryTransport.createLinkedPair();await server.connect(a);await client.connect(b);return client}const read=await connect('dm',['characters:read']),player=await connect('player',['characters:read','characters:write']),stranger=await connect('stranger',['characters:read']);try{assert.equal((await read.listTools()).tools.length,37);assert.equal((await read.callTool({name:'create_campaign',arguments:{name:'Denied'}})).isError,true);assert.equal((await stranger.callTool({name:'get_campaign',arguments:{campaign_id:c.id}})).isError,true);assert.equal((await player.callTool({name:'invite_campaign_member',arguments:{campaign_id:c.id,expected_revision:(await campaigns.row(c.id)).revision,input:{role:'dm'}}})).isError,true);assert.equal((await read.callTool({name:'get_campaign',arguments:{campaign_id:c.id}})).isError,undefined)}finally{await Promise.all([read.close(),player.close(),stranger.close()])}});
|
||||
|
||||
test('DM conditions are manual snapshots, bounded, revision guarded and removed explicitly',async()=>{
|
||||
const {storage,campaigns,c,act}=await fixture(),pc=newCharacter();await storage.put('player',pc.id,pc,0);
|
||||
|
||||
@ -5,4 +5,4 @@ test('official seeds preserve both editions, provenance and all SRD spells',asyn
|
||||
test('seeding persists counts, runs idempotently and preserves personal sheets',async()=>{const pc=newCharacter();pc.notes='Private';await storage.put('owner',pc.id,pc,0);const before=(await catalogue.pool.query('SELECT id FROM rules_catalogue')).rows.length;assert.equal(before,seed.entries.length);await catalogue.init();assert.equal((await catalogue.pool.query('SELECT id FROM rules_catalogue')).rows.length,before);assert.equal((await storage.get('owner',pc.id)).character.notes,'Private');assert.equal((await storage.get('owner',pc.id)).revision,1)});
|
||||
test('catalogue filters and paging never mix editions or fabricate mechanics',async()=>{const old=await catalogue.list({edition:'2014',kind:'spell',q:'Fire Bolt'}),revised=await catalogue.list({edition:'2024',kind:'spell',q:'Fire Bolt'});assert.equal(old.total,1);assert.equal(revised.total,1);assert.notEqual(old.entries[0].id,revised.entries[0].id);assert.equal(old.entries[0].description,undefined);const entry=await catalogue.get(revised.entries[0].id);assert.equal(entry.level,0);assert.match(entry.description,/Casting Time: Action/);assert.match(entry.description,/1d10 Fire damage/);assert.match((await catalogue.get(old.entries[0].id)).description,/Casting Time: 1 action/);const cantrips=await catalogue.list({edition:'2024',kind:'spell',level:0});assert.ok(cantrips.entries.every((e:any)=>e.level===0));const first=await catalogue.list({edition:'2014',kind:'spell'}),second=await catalogue.list({edition:'2014',kind:'spell',offset:40});assert.equal(first.entries.length,40);assert.ok(!second.entries.some((e:any)=>first.entries.some((f:any)=>f.id===e.id)));assert.equal((await catalogue.list({q:"' OR 1=1 --"})).total,0);await assert.rejects(catalogue.list({edition:'unknown'}));assert.equal(await catalogue.get('missing'),undefined);const staff=await catalogue.list({edition:'2024',kind:'item',category:'Weapon',q:'Quarterstaff'});assert.equal(staff.total,1);assert.equal((await catalogue.get(staff.entries[0].id)).mastery,'Topple')});
|
||||
test('catalogue HTTP requires a session and validates edition and missing entry',async()=>{const app=express();app.use((req:any,res,next)=>{if(req.get('x-test-user'))req.identity={sub:'reader'};next()});installCatalogue(app,{catalogue});const server=app.listen(0,'127.0.0.1');await once(server,'listening');const base=`http://127.0.0.1:${(server.address() as any).port}`;try{assert.equal((await fetch(base+'/api/catalogue')).status,401);assert.equal((await fetch(base+'/api/catalogue?edition=bad',{headers:{'x-test-user':'1'}})).status,400);const r=await fetch(base+'/api/catalogue?edition=2024&kind=spell&q=Fire%20Bolt',{headers:{'x-test-user':'1'}});assert.equal(r.status,200);assert.equal((await r.json()).total,1);assert.equal((await fetch(base+'/api/catalogue/missing',{headers:{'x-test-user':'1'}})).status,404)}finally{await new Promise<void>(r=>server.close(()=>r()))}});
|
||||
test('read-only MCP searches and reads official catalogue without modifying characters',async()=>{const server=characterMcp({storage,catalogue,sub:'reader',scopes:['characters:read']}),client=new Client({name:'catalogue-test',version:'1'}),[a,b]=InMemoryTransport.createLinkedPair();await server.connect(a);await client.connect(b);try{assert.equal((await client.listTools()).tools.length,16);const result:any=await client.callTool({name:'search_rules_catalogue',arguments:{edition:'2024',kind:'spell',q:'Fire Bolt'}});assert.equal(result.isError,undefined);const id=result.structuredContent.entries[0].id;const entry:any=await client.callTool({name:'get_rules_entry',arguments:{id}});assert.equal(entry.structuredContent.entry.name,'Fire Bolt');assert.equal((await storage.list('reader')).length,0)}finally{await client.close()}});
|
||||
test('read-only MCP searches and reads official catalogue without modifying characters',async()=>{const server=characterMcp({storage,catalogue,sub:'reader',scopes:['characters:read']}),client=new Client({name:'catalogue-test',version:'1'}),[a,b]=InMemoryTransport.createLinkedPair();await server.connect(a);await client.connect(b);try{assert.equal((await client.listTools()).tools.length,19);const result:any=await client.callTool({name:'search_rules_catalogue',arguments:{edition:'2024',kind:'spell',q:'Fire Bolt'}});assert.equal(result.isError,undefined);const id=result.structuredContent.entries[0].id;const entry:any=await client.callTool({name:'get_rules_entry',arguments:{id}});assert.equal(entry.structuredContent.entry.name,'Fire Bolt');assert.equal((await storage.list('reader')).length,0)}finally{await client.close()}});
|
||||
|
||||
@ -8,7 +8,7 @@ async function storage(){const pg=newDb().adapters.createPg();const s=new Storag
|
||||
const hash=async(s:string)=>(await import('node:crypto')).createHash('sha256').update(s).digest('hex');
|
||||
test('persistent sheets isolate accounts and reject stale updates/deletions',async()=>{const s=await storage(),c=newCharacter();await s.put('alice',c.id,c,0);assert.equal((await s.list('bob')).length,0);await assert.rejects(s.put('bob',c.id,c,1),Conflict);const updated=await s.put('alice',c.id,{...c,name:'Updated'},1);assert.equal(updated.revision,2);await assert.rejects(s.put('alice',c.id,null,1,true),Conflict);assert.equal((await s.get('alice',c.id)).character.name,'Updated');const deleted=await s.put('alice',c.id,null,2,true);assert.equal(deleted.deleted,true);assert.equal((await s.list('alice')).length,1)});
|
||||
test('OAuth codes bind client, redirect, resource, scope and one-time use; refresh rotates; revocation rejects access',async()=>{const s=await storage(),p=new CharacterOAuth({storage:s,codec,origin});const client={client_id:'client',redirect_uris:['https://chatgpt.com/callback']};const payload={purpose:'mcp-code',clientId:'client',sub:'alice',challenge:'abc',redirectUri:client.redirect_uris[0],scopes:['characters:read'],sessionExpiry:Math.floor(Date.now()/1000)+3600,nonce:'unique'};const code=await codec.seal(payload,300);await s.code(await hash(code),{});assert.equal(await p.challengeForAuthorizationCode(client,code),'abc');await assert.rejects(p.challengeForAuthorizationCode({client_id:'other'},code));await assert.rejects(p.exchangeAuthorizationCode(client,code,undefined,'https://evil.test',p.resource));await assert.rejects(p.exchangeAuthorizationCode(client,code,undefined,payload.redirectUri,new URL('https://other.test/mcp')));const tokens=await p.exchangeAuthorizationCode(client,code,undefined,payload.redirectUri,p.resource);assert.equal((await p.verifyAccessToken(tokens.access_token)).extra.sub,'alice');await assert.rejects(p.exchangeAuthorizationCode(client,code,undefined,payload.redirectUri,p.resource));await assert.rejects(p.verifyAccessToken(await codec.seal({sub:'alice'},300)));await assert.rejects(p.exchangeRefreshToken(client,tokens.refresh_token,['characters:write'],p.resource));const renewed=await p.exchangeRefreshToken(client,tokens.refresh_token,undefined,p.resource);await assert.rejects(p.exchangeRefreshToken(client,tokens.refresh_token,undefined,p.resource));await p.revokeToken(client,{token:renewed.access_token});await assert.rejects(p.verifyAccessToken(tokens.access_token));});
|
||||
test('MCP SDK lists tools, performs account-scoped HP changes, rejects stale revisions and denies read-only writes',async()=>{const s=await storage(),c=newCharacter();c.hp.temp=5;await s.put('alice',c.id,c,0);async function connect(sub:string,scopes:string[]){const server=characterMcp({storage:s,sub,scopes}),client=new Client({name:'test',version:'1'});const [a,b]=InMemoryTransport.createLinkedPair();await server.connect(a);await client.connect(b);return client}const client=await connect('alice',['characters:read','characters:write']);assert.equal((await client.listTools()).tools.length,14);const hp=await client.callTool({name:'change_hit_points',arguments:{id:c.id,expected_revision:1,amount:-7}});assert.equal(hp.isError,undefined);assert.deepEqual((await s.get('alice',c.id)).character.hp,{current:8,max:10,temp:0});const stale=await client.callTool({name:'change_hit_points',arguments:{id:c.id,expected_revision:1,amount:-1}});assert.equal(stale.isError,true);const bob=await connect('bob',['characters:read']);const missing=await bob.callTool({name:'get_character',arguments:{id:c.id}});assert.equal(missing.isError,true);const readonly=await connect('alice',['characters:read']);assert.equal((await readonly.callTool({name:'create_character',arguments:{name:'Denied'}})).isError,true);await Promise.all([client.close(),bob.close(),readonly.close()])});
|
||||
test('MCP SDK lists tools, performs account-scoped HP changes, rejects stale revisions and denies read-only writes',async()=>{const s=await storage(),c=newCharacter();c.hp.temp=5;await s.put('alice',c.id,c,0);async function connect(sub:string,scopes:string[]){const server=characterMcp({storage:s,sub,scopes}),client=new Client({name:'test',version:'1'});const [a,b]=InMemoryTransport.createLinkedPair();await server.connect(a);await client.connect(b);return client}const client=await connect('alice',['characters:read','characters:write']);assert.equal((await client.listTools()).tools.length,17);const hp=await client.callTool({name:'change_hit_points',arguments:{id:c.id,expected_revision:1,amount:-7}});assert.equal(hp.isError,undefined);assert.deepEqual((await s.get('alice',c.id)).character.hp,{current:8,max:10,temp:0});const stale=await client.callTool({name:'change_hit_points',arguments:{id:c.id,expected_revision:1,amount:-1}});assert.equal(stale.isError,true);const bob=await connect('bob',['characters:read']);const missing=await bob.callTool({name:'get_character',arguments:{id:c.id}});assert.equal(missing.isError,true);const readonly=await connect('alice',['characters:read']);assert.equal((await readonly.callTool({name:'create_character',arguments:{name:'Denied'}})).isError,true);await Promise.all([client.close(),bob.close(),readonly.close()])});
|
||||
test('HTTP discovery, bearer denial, PKCE enforcement and API origin protection',async()=>{const s=await storage(),p=new CharacterOAuth({storage:s,codec,origin}),app=express();app.use(express.json());app.use(express.urlencoded({extended:false}));app.use((req,res,next)=>{req.identity={sub:'alice'};next()});const auth=p.install(app,{cookie:()=>{},readCookie:()=>''});installMcp(app,{storage:s,auth,origin});installCharacters(app,{storage:s,origin});const server=app.listen(0,'127.0.0.1');await once(server,'listening');const base=`http://127.0.0.1:${server.address().port}`;try{const denied=await fetch(base+'/mcp',{method:'POST',headers:{'content-type':'application/json'},body:'{}'});assert.equal(denied.status,401);assert.match(denied.headers.get('www-authenticate')||'',/oauth-protected-resource\/mcp/);const metadata=await(await fetch(base+'/.well-known/oauth-protected-resource/mcp')).json();assert.equal(metadata.resource,origin+'/mcp');const registered=await fetch(base+'/register',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({client_name:'Tests',redirect_uris:['https://chatgpt.com/callback'],token_endpoint_auth_method:'none'})});assert.equal(registered.status,201);const client=await registered.json();const authorize=await fetch(base+'/authorize?'+new URLSearchParams({client_id:client.client_id,redirect_uri:client.redirect_uris[0],response_type:'code',resource:origin+'/mcp',code_challenge:'a'.repeat(43),code_challenge_method:'S256'}),{redirect:'manual'});assert.equal(authorize.status,302);const badPkce=await fetch(base+'/token',{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded'},body:new URLSearchParams({client_id:client.client_id,grant_type:'authorization_code',code:'invalid',code_verifier:'wrong',redirect_uri:client.redirect_uris[0],resource:origin+'/mcp'})});assert.equal(badPkce.status,400);const c=newCharacter();const blocked=await fetch(base+'/api/characters/'+c.id,{method:'PUT',headers:{'content-type':'application/json','origin':'https://evil.test'},body:JSON.stringify({character:c,revision:0})});assert.equal(blocked.status,403)}finally{await new Promise<void>(resolve=>server.close(()=>resolve()))}});
|
||||
test('full consent → PKCE exchange → HTTP MCP call works and rejects wrong verifier',async()=>{
|
||||
const s=await storage(),p=new CharacterOAuth({storage:s,codec,origin}),app=express();app.use(express.json());app.use(express.urlencoded({extended:false}));app.use((req,res,next)=>{req.identity={sub:'alice',name:'Alice',exp:Math.floor(Date.now()/1000)+3600};next()});
|
||||
@ -24,6 +24,6 @@ test('full consent → PKCE exchange → HTTP MCP call works and rejects wrong v
|
||||
const tokenBody=(v:string)=>new URLSearchParams({client_id:c.client_id,grant_type:'authorization_code',code,code_verifier:v,redirect_uri:c.redirect_uris[0],resource:origin+'/mcp'});
|
||||
const wrong=await fetch(base+'/token',{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded'},body:tokenBody('x'.repeat(64))});assert.equal(wrong.status,400);
|
||||
const exchanged=await fetch(base+'/token',{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded'},body:tokenBody(verifier)});assert.equal(exchanged.status,200);const tokens=await exchanged.json();
|
||||
const {StreamableHTTPClientTransport}=await import('@modelcontextprotocol/sdk/client/streamableHttp.js');const client=new Client({name:'http-test',version:'1'});await client.connect(new StreamableHTTPClientTransport(new URL(base+'/mcp'),{requestInit:{headers:{Authorization:'Bearer '+tokens.access_token}}}));assert.equal((await client.listTools()).tools.length,14);const created=await client.callTool({name:'create_character',arguments:{name:'HTTP adventurer'}});assert.equal(created.isError,undefined);assert.equal((await s.list('alice'))[0].character.name,'HTTP adventurer');await client.close();
|
||||
const {StreamableHTTPClientTransport}=await import('@modelcontextprotocol/sdk/client/streamableHttp.js');const client=new Client({name:'http-test',version:'1'});await client.connect(new StreamableHTTPClientTransport(new URL(base+'/mcp'),{requestInit:{headers:{Authorization:'Bearer '+tokens.access_token}}}));assert.equal((await client.listTools()).tools.length,17);const created=await client.callTool({name:'create_character',arguments:{name:'HTTP adventurer'}});assert.equal(created.isError,undefined);assert.equal((await s.list('alice'))[0].character.name,'HTTP adventurer');await client.close();
|
||||
}finally{await new Promise<void>(resolve=>server.close(()=>resolve()))}
|
||||
});
|
||||
|
||||
75
tests/spell-slots.test.ts
Normal file
75
tests/spell-slots.test.ts
Normal file
@ -0,0 +1,75 @@
|
||||
|
||||
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<string,unknown>)=>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()}
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user