diff --git a/README.md b/README.md index 78cb766..61759cc 100644 --- a/README.md +++ b/README.md @@ -51,3 +51,6 @@ Browse SRD on Actions, Spells and Gear. Select 2014 (SRD 5.1) or 2024 (SRD 5.2.1 Each entry carries source URL, page and edition. The gzip seed is generated by scripts/import-srd.py from the official PDFs; source SHA-256 digests and attribution are inside it. Regenerate using PyMuPDF and 2014.pdf / 2024.pdf in a source directory. The Docker runtime only needs the committed seed, not Python or the PDFs. Startup seeds rules_catalogue transactionally, keyed by stable ids, once per seed checksum. Repeated starts do not duplicate entries or modify character sheets. Authenticated API: GET /api/catalogue?edition=2024&kind=spell&q=Fire%20Bolt (name search, optional spell level/item category, 40-entry pages via offset); GET /api/catalogue/:id reads the complete entry. MCP search_rules_catalogue and get_rules_entry offer the same source-backed read-only lookup. Browsing needs a connection; entries copied into sheets retain their normal offline support. Spells copy into the spellbook as unprepared. Weapon/action selection opens the editor for the user to set their attack modifier. Item selection appends inventory notes and preserves existing equipment. + +## Inventory +Gear uses structured inventory entries with quantities, equipment and attunement markers, item notes and SRD provenance. Repeated catalogue selections increment the same entry; editions remain distinct. Existing inventory text remains in the inventory notebook. The additive optional `inventoryItems` field is preserved when older clients omit it, under the existing account and revision guards. Explicitly sending an empty array clears the collection. Equipment markers do not automatically change character stats. diff --git a/package-lock.json b/package-lock.json index b9d5f23..eb1dd9d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dnd-character-pwa", - "version": "0.4.0", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dnd-character-pwa", - "version": "0.4.0", + "version": "0.5.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.32.1", "dexie": "^4.2.0", diff --git a/package.json b/package.json index bac58c7..2e54a4b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dnd-character-pwa", - "version": "0.4.0", + "version": "0.5.0", "private": true, "type": "module", "scripts": { diff --git a/server/index.mjs b/server/index.mjs index 61ab26e..de2a10b 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -19,7 +19,7 @@ let config;async function provider(){return config??=await oidc.discovery(new UR const cookie=(res,name,value,seconds)=>res.append('Set-Cookie',`${name}=${value}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${seconds}`); const readCookie=(req,name)=>{const raw=(req.headers.cookie||'').split(';').map(x=>x.trim()).find(x=>x.startsWith(`${name}=`));return raw?raw.slice(name.length+1):''}; app.use((req,res,next)=>{res.set({'X-Content-Type-Options':'nosniff','Referrer-Policy':'same-origin','Content-Security-Policy':"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",'Cache-Control':'no-store'});next()}); -app.get('/health',(_,res)=>res.json({status:'ok',version:'0.4.0',catalogue:catalogue.summary,revision:process.env.SOURCE_COMMIT||'unknown'})); +app.get('/health',(_,res)=>res.json({status:'ok',version:'0.5.0',catalogue:catalogue.summary,revision:process.env.SOURCE_COMMIT||'unknown'})); app.get('/auth/login',async(req,res,next)=>{try{const pkce=oidc.randomPKCECodeVerifier(),state=oidc.randomState(),nonce=oidc.randomNonce();cookie(res,'__Host-character-flow',await codec.seal({purpose:'character-flow',pkce,state,nonce},600),600);const url=oidc.buildAuthorizationUrl(await provider(),{redirect_uri:`${origin}/auth/callback`,scope:'openid profile',state,nonce,code_challenge:await oidc.calculatePKCECodeChallenge(pkce),code_challenge_method:'S256'});res.redirect(url.href)}catch(e){next(e)}}); app.get('/auth/callback',async(req,res,next)=>{try{const flow=await codec.open(readCookie(req,'__Host-character-flow'));cookie(res,'__Host-character-flow','',0);if(!flow||typeof flow.pkce!=='string'||typeof flow.state!=='string'||typeof flow.nonce!=='string')return res.status(400).send('Sign-in expired. Start again at /auth/login.');const url=new URL(req.originalUrl,origin);const tokens=await oidc.authorizationCodeGrant(await provider(),url,{pkceCodeVerifier:flow.pkce,expectedState:flow.state,expectedNonce:flow.nonce});const claims=tokens.claims();if(!claims?.sub)throw new Error('Missing identity');cookie(res,'__Host-character-session',await codec.seal({purpose:'character-session',sub:claims.sub,name:claims.name||claims.preferred_username||'Adventurer'}),43200);const back=await codec.open(readCookie(req,'__Host-character-mcp-return'));cookie(res,'__Host-character-mcp-return','',0);res.redirect(back?.purpose==='mcp-return'?`/mcp/consent?ticket=${encodeURIComponent(back.ticket)}`:'/')}catch(e){next(e)}}); app.use(async(req,res,next)=>{const session=await codec.open(readCookie(req,'__Host-character-session'));req.identity=session?.sub&&typeof session.name==='string'&&(!session.purpose||session.purpose==='character-session')?session:null;next()}); diff --git a/server/mcp.mjs b/server/mcp.mjs index 888939e..ddc5a20 100644 --- a/server/mcp.mjs +++ b/server/mcp.mjs @@ -7,7 +7,7 @@ import {randomInt,randomUUID} from 'node:crypto'; import {characterSchema,newCharacter,changeHP,applyRest} 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.4.0'}); + const server=new McpServer({name:'arrowstack-character',version:'0.5.0'}); const read={readOnlyHint:true,destructiveHint:false,idempotentHint:true,openWorldHint:false}; const write={readOnlyHint:false,destructiveHint:false,idempotentHint:false,openWorldHint:false}; const allowed=()=>{if(!scopes.includes('characters:write'))throw new Error('Reconnect with characters:write permission to edit sheets.')}; diff --git a/server/storage.mjs b/server/storage.mjs index 29d3907..f308551 100644 --- a/server/storage.mjs +++ b/server/storage.mjs @@ -12,6 +12,9 @@ export class Storage{ async list(owner){return (await this.pool.query('SELECT id,character,revision,deleted FROM character_sheets WHERE owner=$1 ORDER BY id',[owner])).rows} 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){ + // Older clients omit the additive item collection. Preserve it under the same revision guard. + if(character&&!deleted&&revision>0&&character.inventoryItems===undefined){const prior=await this.get(owner,id);if(prior?.character?.inventoryItems!==undefined)character={...character,inventoryItems:prior.character.inventoryItems}} + 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 e9a7b12..3eb61da 100644 --- a/src/App.vue +++ b/src/App.vue @@ -4,9 +4,10 @@ import {useCharacters} from './store'; import UiIcon from './UiIcon.vue'; import CampaignsView from './CampaignsView.vue'; import RulesPicker,{type RuleEntry} from './RulesPicker.vue'; +import InventoryFolio from './InventoryFolio.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,changeHP,newCharacter,characterSchema,restPreview,applyRest,type Character} from './model'; +import {abilities,modifier,proficiency,signed,skills,skillBonus,addInventoryItem,changeHP,newCharacter,characterSchema,restPreview,applyRest,type Character} from './model'; 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); const c=computed(()=>store.all.find(x=>x.id===activeId.value)); @@ -36,7 +37,7 @@ function exportCharacter(){if(!c.value)return;const blob=new Blob([JSON.stringif async function importCharacter(event:Event){const input=event.target as HTMLInputElement;try{const file=input.files?.[0];if(!file)return;if(file.size>2_000_000)throw new Error();const valid=characterSchema.parse(JSON.parse(await file.text()));valid.id=crypto.randomUUID();store.all.push(valid);activeId.value=valid.id;store.persist(valid);message.value='Character imported as a new copy.'}catch{message.value='Could not import. Choose a valid character JSON backup under 2 MB.'}input.value=''} async function logout(){await store.flush();localStorage.removeItem('character-identity');navigator.serviceWorker.controller?.postMessage('CLEAR_AUTH');for(const key of await caches.keys())if(key.startsWith('character-shell-'))await caches.delete(key);user.value=null;const form=document.createElement('form');form.method='POST';form.action='/auth/logout';document.body.appendChild(form);form.submit()} async function remove(){if(c.value&&confirm(`Delete ${c.value.name} from your account and synced devices? Export a backup first.`)){await store.remove(c.value.id);activeId.value=store.all[0]?.id||''}} -function chooseRule(entry:RuleEntry){if(!c.value)return;const source=`${entry.source} · ${entry.edition} · page ${entry.page}`;const notes=`${source}\n${entry.description}\nSource: ${entry.sourceUrl}`;if(entry.kind==='spell'){if(c.value.spells.length>=500){catalogueMode.value=null;message.value='Your spellbook is full.';return}c.value.spells.push({id:crypto.randomUUID(),name:entry.name,level:entry.level||0,prepared:false,notes});catalogueMode.value=null;message.value=`${entry.name} added to your spellbook.`}else if(catalogueMode.value==='action'){catalogueMode.value=null;if(c.value.actions.length>=200){message.value='Your action list is full.';return}actionEditor();actionDraft.value={name:entry.name,bonus:0,damage:entry.damage||'',notes}}else{const addition=`${entry.name} (${source})\n${entry.description}`;if((c.value.inventory+'\n\n'+addition).length>12000){catalogueMode.value=null;message.value='Your inventory notes are full. Export or shorten them before adding this item.';return}c.value.inventory=[c.value.inventory,addition].filter(Boolean).join('\n\n');catalogueMode.value=null;message.value=`${entry.name} added to inventory.`}} +function chooseRule(entry:RuleEntry){if(!c.value)return;const source=`${entry.source} · ${entry.edition} · page ${entry.page}`;const notes=`${source}\n${entry.description}\nSource: ${entry.sourceUrl}`;if(entry.kind==='spell'){if(c.value.spells.length>=500){catalogueMode.value=null;message.value='Your spellbook is full.';return}c.value.spells.push({id:crypto.randomUUID(),name:entry.name,level:entry.level||0,prepared:false,notes});catalogueMode.value=null;message.value=`${entry.name} added to your spellbook.`}else if(catalogueMode.value==='action'){catalogueMode.value=null;if(c.value.actions.length>=200){message.value='Your action list is full.';return}actionEditor();actionDraft.value={name:entry.name,bonus:0,damage:entry.damage||'',notes}}else{catalogueMode.value=null;try{addInventoryItem(c.value,{id:crypto.randomUUID(),name:entry.name,quantity:1,category:entry.category as 'Weapon'|'Armor'|'Adventuring gear'|'Magic item'||'Other',equipped:false,attuned:false,weight:entry.weight||'',cost:entry.cost||'',notes:entry.description,catalogueId:entry.id,source:`${source}\n${entry.sourceUrl}`});message.value=`${entry.name} added to inventory.`}catch(e){message.value=(e as Error).message}}} function openAction(action:Character['actions'][number]){selectedAction.value=action;rollResult.value='';drawer.value='Roll'} @@ -80,10 +81,10 @@ function openAction(action:Character['actions'][number]){selectedAction.value=ac -

Gear & inventory

Equipment, currency, attunement and anything you picked up along the way.

+ - + diff --git a/src/InventoryFolio.vue b/src/InventoryFolio.vue new file mode 100644 index 0000000..22576c8 --- /dev/null +++ b/src/InventoryFolio.vue @@ -0,0 +1,22 @@ + + diff --git a/src/RulesPicker.vue b/src/RulesPicker.vue index d1bf191..af63c68 100644 --- a/src/RulesPicker.vue +++ b/src/RulesPicker.vue @@ -1,6 +1,6 @@