Release inventory folio v0.5.0
This commit is contained in:
parent
731a396da1
commit
32efa65bc9
@ -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.
|
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.
|
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.
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "dnd-character-pwa",
|
"name": "dnd-character-pwa",
|
||||||
"version": "0.4.0",
|
"version": "0.5.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "dnd-character-pwa",
|
"name": "dnd-character-pwa",
|
||||||
"version": "0.4.0",
|
"version": "0.5.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.32.1",
|
"@modelcontextprotocol/sdk": "^1.32.1",
|
||||||
"dexie": "^4.2.0",
|
"dexie": "^4.2.0",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dnd-character-pwa",
|
"name": "dnd-character-pwa",
|
||||||
"version": "0.4.0",
|
"version": "0.5.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@ -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 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):''};
|
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.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/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.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()});
|
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()});
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import {randomInt,randomUUID} from 'node:crypto';
|
|||||||
import {characterSchema,newCharacter,changeHP,applyRest} from './model.mjs';
|
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});
|
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}){
|
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 read={readOnlyHint:true,destructiveHint:false,idempotentHint:true,openWorldHint:false};
|
||||||
const write={readOnlyHint:false,destructiveHint:false,idempotentHint:false,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.')};
|
const allowed=()=>{if(!scopes.includes('characters:write'))throw new Error('Reconnect with characters:write permission to edit sheets.')};
|
||||||
|
|||||||
@ -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 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 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){
|
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]);
|
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];
|
if(!result.rows[0])throw new Conflict();return result.rows[0];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,9 +4,10 @@ import {useCharacters} from './store';
|
|||||||
import UiIcon from './UiIcon.vue';
|
import UiIcon from './UiIcon.vue';
|
||||||
import CampaignsView from './CampaignsView.vue';
|
import CampaignsView from './CampaignsView.vue';
|
||||||
import RulesPicker,{type RuleEntry} from './RulesPicker.vue';
|
import RulesPicker,{type RuleEntry} from './RulesPicker.vue';
|
||||||
|
import InventoryFolio from './InventoryFolio.vue';
|
||||||
import type {IconName} from './icons';
|
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'}];
|
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 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 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));
|
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 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 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||''}}
|
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'}
|
function openAction(action:Character['actions'][number]){selectedAction.value=action;rollResult.value='';drawer.value='Roll'}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -80,10 +81,10 @@ function openAction(action:Character['actions'][number]){selectedAction.value=ac
|
|||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
<section class="panel" v-if="tab==='Gear'"><div class="section-title"><h2><UiIcon name="gear" />Gear & inventory</h2><button class="quiet" @click="catalogueMode='item'">Browse SRD items</button></div><p class="hint">Equipment, currency, attunement and anything you picked up along the way.</p><textarea v-model="c.inventory" rows="14" placeholder="Longbow, 20 arrows…" maxlength="12000"></textarea></section>
|
<InventoryFolio v-if="tab==='Gear'" :character="c" @browse="catalogueMode='item'" />
|
||||||
<template v-if="tab==='More'"><section class="panel"><div class="section-title"><h2><UiIcon name="notes" />Your story</h2><span class="chip">Saved automatically</span></div><textarea v-model="c.notes" rows="8" placeholder="Backstory, allies, session notes…" maxlength="12000"></textarea></section><section class="panel"><h2>Character & account</h2><p class="hint">Signed in as {{user.name}}. Characters sync to your Arrowstack account and stay available on this device for offline play. Export backups to keep an extra copy.</p><div class="settings-actions"><button @click="edit">Edit character</button><button @click="exportCharacter">Export JSON backup</button><label class="button">Import JSON<input type="file" accept="application/json,.json" hidden @change="importCharacter"></label><button @click="create">New character</button><button @click="drawer='Manage resources'">Manage resources</button><button class="danger-text" @click="remove">Delete character</button><button @click="logout" :disabled="offline">Sign out</button></div><p class="hint">Offline play lasts until your 12-hour sign-in session expires. Reconnect to sign in again. Attack modifiers, spell details and resource reset rules are entered manually; automated class rules are a future feature.</p></section></template>
|
<template v-if="tab==='More'"><section class="panel"><div class="section-title"><h2><UiIcon name="notes" />Your story</h2><span class="chip">Saved automatically</span></div><textarea v-model="c.notes" rows="8" placeholder="Backstory, allies, session notes…" maxlength="12000"></textarea></section><section class="panel"><h2>Character & account</h2><p class="hint">Signed in as {{user.name}}. Characters sync to your Arrowstack account and stay available on this device for offline play. Export backups to keep an extra copy.</p><div class="settings-actions"><button @click="edit">Edit character</button><button @click="exportCharacter">Export JSON backup</button><label class="button">Import JSON<input type="file" accept="application/json,.json" hidden @change="importCharacter"></label><button @click="create">New character</button><button @click="drawer='Manage resources'">Manage resources</button><button class="danger-text" @click="remove">Delete character</button><button @click="logout" :disabled="offline">Sign out</button></div><p class="hint">Offline play lasts until your 12-hour sign-in session expires. Reconnect to sign in again. Attack modifiers, spell details and resource reset rules are entered manually; automated class rules are a future feature.</p></section></template>
|
||||||
</template>
|
</template>
|
||||||
<footer v-if="campaignMode||!['Actions','Spells'].includes(tab)">✦ ARROWSTACK CHARACTER <span>v0.4 · Made for game night</span></footer>
|
<footer v-if="campaignMode||!['Actions','Spells','Gear'].includes(tab)">✦ ARROWSTACK CHARACTER <span>v0.4 · Made for game night</span></footer>
|
||||||
</main>
|
</main>
|
||||||
<nav class="bottom-nav" v-if="c&&!campaignMode"><button v-for="item in navigation" :key="item.label" :class="{active:tab===item.label}" :aria-current="tab===item.label?'page':undefined" @click="tab=item.label"><UiIcon :name="item.icon" />{{item.label}}</button></nav>
|
<nav class="bottom-nav" v-if="c&&!campaignMode"><button v-for="item in navigation" :key="item.label" :class="{active:tab===item.label}" :aria-current="tab===item.label?'page':undefined" @click="tab=item.label"><UiIcon :name="item.icon" />{{item.label}}</button></nav>
|
||||||
<RulesPicker v-if="catalogueMode&&user&&c" :mode="catalogueMode" @close="catalogueMode=null" @choose="chooseRule" />
|
<RulesPicker v-if="catalogueMode&&user&&c" :mode="catalogueMode" @close="catalogueMode=null" @choose="chooseRule" />
|
||||||
|
|||||||
22
src/InventoryFolio.vue
Normal file
22
src/InventoryFolio.vue
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {computed,ref} from 'vue';import UiIcon from './UiIcon.vue';import {inventoryItemSchema,addInventoryItem,changeItemQuantity,type Character,type InventoryItem} from './model';
|
||||||
|
const props=defineProps<{character:Character}>(),emit=defineEmits<{browse:[]}>();
|
||||||
|
const items=computed(()=>props.character.inventoryItems||[]),categories=computed(()=>['Weapon','Armor','Adventuring gear','Magic item','Other'].filter(category=>items.value.some(i=>i.category===category))),editing=ref(false),editingId=ref<string|null>(null),error=ref('');
|
||||||
|
function blank():InventoryItem{return {id:crypto.randomUUID(),name:'',quantity:1,category:'Other',equipped:false,attuned:false,weight:'',cost:'',notes:''}}
|
||||||
|
const draft=ref<InventoryItem>(blank());
|
||||||
|
function editor(item?:InventoryItem){draft.value=item?structuredClone(JSON.parse(JSON.stringify(item))):blank();editingId.value=item?.id||null;error.value='';editing.value=true}
|
||||||
|
function save(){try{const item=inventoryItemSchema.parse(draft.value);if(editingId.value){const index=items.value.findIndex(i=>i.id===editingId.value);if(index<0)throw new Error('This item changed. Close the editor and reload it.');props.character.inventoryItems=items.value.map((old,i)=>i===index?item:old)}else addInventoryItem(props.character,item);editing.value=false}catch(e){error.value=(e as Error).name==='ZodError'?'Check the item name and quantity (0–9999).':(e as Error).message}}
|
||||||
|
function remove(item:InventoryItem){props.character.inventoryItems=items.value.filter(i=>i.id!==item.id)}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<section class="actions-folio inventory-folio" aria-labelledby="inventory-heading"><div class="folio-heading"><h2 id="inventory-heading"><UiIcon name="gear" />Inventory</h2><div class="folio-heading-tools"><button class="quiet" @click="emit('browse')">Browse SRD</button><button class="quiet folio-add" @click="editor()">+ Custom</button></div></div>
|
||||||
|
<div v-if="!items.length" class="folio-empty"><UiIcon name="gear" /><h3>Pack for the adventure.</h3><p>Weapons, useful supplies and treasures from the road.</p><button class="primary" @click="emit('browse')">Browse items</button><button class="quiet" @click="editor()">Add a custom item</button></div>
|
||||||
|
<template v-else><p class="folio-spell-hint">{{items.length}} {{items.length===1?'entry':'entries'}} · {{items.reduce((n,i)=>n+i.quantity,0)}} items carried</p>
|
||||||
|
<section v-for="category in categories" :key="category" class="spell-chapter"><h3 class="spell-chapter-title">{{category==='Armor'?'Armour':category==='Weapon'?'Weapons':category==='Magic item'?'Magic items':category==='Other'?'Other belongings':'Adventuring gear'}}</h3>
|
||||||
|
<article class="folio-action inventory-entry" v-for="item in items.filter(i=>i.category===category)" :key="item.id"><div class="folio-action-main"><UiIcon :name="item.category==='Weapon'?'actions':item.category==='Armor'?'ac':'gear'" class="folio-action-icon" /><div class="folio-action-copy"><h3>{{item.name}}</h3><p class="folio-modifier">{{[item.weight&&item.weight+' each',item.cost&&item.cost+' each',item.equipped&&'Equipped',item.attuned&&'Attuned'].filter(Boolean).join(' · ')||'In your pack'}}</p></div><div class="inventory-quantity"><button class="quiet" @click="changeItemQuantity(item,-1)" :disabled="item.quantity===0" :aria-label="`Use one ${item.name}`">−</button><span :aria-label="`Quantity ${item.quantity}`">{{item.quantity}}</span><button class="quiet" @click="changeItemQuantity(item,1)" :disabled="item.quantity===9999" :aria-label="`Add one ${item.name}`">+</button></div></div>
|
||||||
|
<details class="folio-options"><summary>Item notes & options</summary><p v-if="item.notes" class="folio-action-notes">{{item.notes}}</p><p v-if="item.source" class="inventory-source">{{item.source}}</p><div class="inventory-flags"><label><input type="checkbox" v-model="item.equipped">Equipped</label><label><input type="checkbox" v-model="item.attuned">Attuned</label></div><div class="folio-tools"><button class="quiet" @click="editor(item)">Edit item</button><button class="quiet danger-text" @click="remove(item)" :aria-label="`Remove ${item.name}`">Remove</button></div></details></article></section></template>
|
||||||
|
<details class="inventory-notebook" :open="!!character.inventory&&!items.length"><summary><UiIcon name="notes" />Inventory notes</summary><p class="hint">Existing notes stay here. Keep currency, packing lists and anything else you want to record.</p><label>Notes<textarea v-model="character.inventory" rows="5" maxlength="12000" placeholder="Coins, packing lists, treasures…"></textarea></label></details>
|
||||||
|
<p v-if="items.length" class="folio-spell-hint">Equipped and attuned are manual markers. Armour class and other sheet stats are edited on your character.</p>
|
||||||
|
</section>
|
||||||
|
<div v-if="editing" class="overlay" @click.self="editing=false"><section class="drawer" role="dialog" aria-modal="true" :aria-label="editingId?'Edit item':'Add item'"><div class="section-title drawer-heading"><h2><UiIcon name="gear" />{{editingId?'Edit item':'Add item'}}</h2><button class="close" @click="editing=false" aria-label="Close item editor">×</button></div><p v-if="error" class="notice danger" role="alert">{{error}}</p><form @submit.prevent="save"><label>Name<input v-model="draft.name" maxlength="120" required></label><div class="form-grid"><label>Quantity<input type="number" v-model.number="draft.quantity" min="0" max="9999" step="1" required></label><label>Category<select v-model="draft.category"><option>Weapon</option><option>Armor</option><option>Adventuring gear</option><option>Magic item</option><option>Other</option></select></label><label>Weight per item<input v-model="draft.weight" maxlength="120" placeholder="1 lb."></label><label>Value per item<input v-model="draft.cost" maxlength="120" placeholder="5 GP"></label></div><div class="inventory-flags"><label><input type="checkbox" v-model="draft.equipped">Equipped</label><label><input type="checkbox" v-model="draft.attuned">Attuned</label></div><label>Item notes<textarea v-model="draft.notes" rows="5" maxlength="12000"></textarea></label><button class="primary wide">{{editingId?'Save item':'Add item'}}</button></form></section></div>
|
||||||
|
</template>
|
||||||
@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {ref,onMounted,onUnmounted,watch} from 'vue';import UiIcon from './UiIcon.vue';
|
import {ref,onMounted,onUnmounted,watch} from 'vue';import UiIcon from './UiIcon.vue';
|
||||||
export type RuleEntry={id:string,edition:'2014'|'2024',kind:'action'|'spell'|'item',name:string,level?:number,category?:string,damage?:string,description:string,source:string,sourceUrl:string,page:number};
|
export type RuleEntry={id:string,edition:'2014'|'2024',kind:'action'|'spell'|'item',name:string,level?:number,category?:string,weight?:string,cost?:string,damage?:string,description:string,source:string,sourceUrl:string,page:number};
|
||||||
const props=defineProps<{mode:'action'|'spell'|'item'}>(),emit=defineEmits<{close:[],choose:[entry:RuleEntry]}>();
|
const props=defineProps<{mode:'action'|'spell'|'item'}>(),emit=defineEmits<{close:[],choose:[entry:RuleEntry]}>();
|
||||||
const edition=ref(localStorage.getItem('rules-edition')==='2014'?'2014':'2024'),q=ref(''),level=ref(''),category=ref(''),weaponMode=ref(false),entries=ref<RuleEntry[]>([]),selected=ref<RuleEntry|null>(null),sources=ref<{edition:string,attribution:string,licenseUrl:string}[]>([]),offset=ref(0),total=ref(0),busy=ref(false),error=ref('');let generation=0,timer:ReturnType<typeof setTimeout>;
|
const edition=ref(localStorage.getItem('rules-edition')==='2014'?'2014':'2024'),q=ref(''),level=ref(''),category=ref(''),weaponMode=ref(false),entries=ref<RuleEntry[]>([]),selected=ref<RuleEntry|null>(null),sources=ref<{edition:string,attribution:string,licenseUrl:string}[]>([]),offset=ref(0),total=ref(0),busy=ref(false),error=ref('');let generation=0,timer:ReturnType<typeof setTimeout>;
|
||||||
async function load(){const token=++generation;busy.value=true;error.value='';selected.value=null;try{const params=new URLSearchParams({edition:edition.value,kind:weaponMode.value?'item':props.mode,q:q.value,offset:String(offset.value)});if(props.mode==='spell'&&level.value!=='')params.set('level',level.value);if(weaponMode.value)params.set('category','Weapon');else if(category.value)params.set('category',category.value);const r=await fetch('/api/catalogue?'+params,{cache:'no-store'});if(!r.ok)throw new Error(r.status===401?'Sign in again to browse the catalogue.':'Could not load the catalogue.');const d=await r.json();if(token===generation){entries.value=d.entries;total.value=d.total;sources.value=d.sources}}catch(e){if(token===generation)error.value=navigator.onLine?(e as Error).message:'Connect to browse the rules catalogue. Entries added to your sheet remain available offline.'}finally{if(token===generation)busy.value=false}}
|
async function load(){const token=++generation;busy.value=true;error.value='';selected.value=null;try{const params=new URLSearchParams({edition:edition.value,kind:weaponMode.value?'item':props.mode,q:q.value,offset:String(offset.value)});if(props.mode==='spell'&&level.value!=='')params.set('level',level.value);if(weaponMode.value)params.set('category','Weapon');else if(category.value)params.set('category',category.value);const r=await fetch('/api/catalogue?'+params,{cache:'no-store'});if(!r.ok)throw new Error(r.status===401?'Sign in again to browse the catalogue.':'Could not load the catalogue.');const d=await r.json();if(token===generation){entries.value=d.entries;total.value=d.total;sources.value=d.sources}}catch(e){if(token===generation)error.value=navigator.onLine?(e as Error).message:'Connect to browse the rules catalogue. Entries added to your sheet remain available offline.'}finally{if(token===generation)busy.value=false}}
|
||||||
|
|||||||
@ -6,11 +6,13 @@ const counter=z.number().int().min(0).max(9999);
|
|||||||
const text=z.string().max(12000);
|
const text=z.string().max(12000);
|
||||||
export const resourceSchema=z.object({id:z.string(),name:text,current:counter,max:counter,reset:z.enum(['short-rest','long-rest','manual'])}).refine(r=>r.current<=r.max,'Current cannot exceed maximum');
|
export const resourceSchema=z.object({id:z.string(),name:text,current:counter,max:counter,reset:z.enum(['short-rest','long-rest','manual'])}).refine(r=>r.current<=r.max,'Current cannot exceed maximum');
|
||||||
export const actionSchema=z.object({id:z.string(),name:text,bonus:z.number().int().min(-50).max(100),damage:text,notes:text});
|
export const actionSchema=z.object({id:z.string(),name:text,bonus:z.number().int().min(-50).max(100),damage:text,notes:text});
|
||||||
|
export const inventoryItemSchema=z.object({id:z.string().min(1).max(200),name:z.string().trim().min(1).max(120),quantity:z.number().int().min(0).max(9999),category:z.enum(['Weapon','Armor','Adventuring gear','Magic item','Other']),equipped:z.boolean(),attuned:z.boolean(),weight:z.string().max(120),cost:z.string().max(120),notes:text,catalogueId:z.string().max(300).optional(),source:text.optional()});
|
||||||
|
export type InventoryItem=z.infer<typeof inventoryItemSchema>;
|
||||||
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 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,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,inventoryItems:z.array(inventoryItemSchema).max(300).refine(items=>new Set(items.map(i=>i.id)).size===items.length,'Item ids must be unique').optional(),notes:text});
|
||||||
export type Character=z.infer<typeof characterSchema>;
|
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 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:'',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:[],inventory:'',inventoryItems:[],notes:''}}
|
||||||
export const modifier=(n:number)=>Math.floor((n-10)/2);
|
export const modifier=(n:number)=>Math.floor((n-10)/2);
|
||||||
export const proficiency=(level:number)=>2+Math.floor((level-1)/4);
|
export const proficiency=(level:number)=>2+Math.floor((level-1)/4);
|
||||||
export const signed=(n:number)=>n>=0?`+${n}`:`${n}`;
|
export const signed=(n:number)=>n>=0?`+${n}`:`${n}`;
|
||||||
@ -18,3 +20,6 @@ export function skillBonus(c:Character,name:string){return modifier(c.abilities[
|
|||||||
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 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 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(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))}
|
||||||
|
|||||||
@ -105,3 +105,5 @@ input[type=checkbox]{accent-color:#3b603d}
|
|||||||
@media(max-width:700px){.spell-chapter{margin:20px 0}.spell-prepare{min-width:60px;font-size:10px;padding:4px}.folio-spell .folio-action-main{gap:10px}}
|
@media(max-width:700px){.spell-chapter{margin:20px 0}.spell-prepare{min-width:60px;font-size:10px;padding:4px}.folio-spell .folio-action-main{gap:10px}}
|
||||||
|
|
||||||
.folio-heading-tools{display:flex;align-items:center;gap:4px}.folio-heading-tools .quiet{min-height:44px;white-space:nowrap}.folio-empty>.quiet{display:block;margin:10px auto 0}.rules-picker{max-width:680px}.rules-result{display:flex;align-items:center;justify-content:space-between;gap:16px;width:100%;text-align:left;border:0;border-bottom:1px solid #ad8d51;border-radius:0;background:transparent;box-shadow:none;padding:15px 0}.rules-result:hover{background:#ad8d5118}.rules-result b{font-family:Georgia,serif;font-size:21px;font-weight:400;overflow-wrap:anywhere}.rules-result small{display:block;color:#6e6549;font-size:12px;margin-top:5px}.catalogue-pages{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:12px;margin:22px 0}.catalogue-pages button{padding:9px 12px}.rules-entry-name{margin-top:20px;font-size:29px}.rules-description{white-space:pre-wrap;overflow-wrap:anywhere;font-size:14px;line-height:1.7}.rules-source{display:block;margin:22px 0;color:#284e3d;font-size:13px}.rules-attribution{margin-top:26px;border-top:1px solid #ad8d51;padding-top:14px;font-size:11px;line-height:1.6;overflow-wrap:anywhere}.rules-attribution summary{cursor:pointer;min-height:44px;padding:12px 0}.rules-attribution summary:focus-visible{outline:3px solid #b78a3e}.rules-attribution p{margin:10px 0}@media(max-width:420px){.folio-heading-tools{gap:0}.folio-heading-tools .quiet{font-size:11px;padding:8px 4px}.folio-heading h2{font-size:25px}.folio-heading h2 .ui-icon{width:33px;height:33px}.rules-picker{padding:20px}.catalogue-pages button{font-size:12px}}
|
.folio-heading-tools{display:flex;align-items:center;gap:4px}.folio-heading-tools .quiet{min-height:44px;white-space:nowrap}.folio-empty>.quiet{display:block;margin:10px auto 0}.rules-picker{max-width:680px}.rules-result{display:flex;align-items:center;justify-content:space-between;gap:16px;width:100%;text-align:left;border:0;border-bottom:1px solid #ad8d51;border-radius:0;background:transparent;box-shadow:none;padding:15px 0}.rules-result:hover{background:#ad8d5118}.rules-result b{font-family:Georgia,serif;font-size:21px;font-weight:400;overflow-wrap:anywhere}.rules-result small{display:block;color:#6e6549;font-size:12px;margin-top:5px}.catalogue-pages{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:12px;margin:22px 0}.catalogue-pages button{padding:9px 12px}.rules-entry-name{margin-top:20px;font-size:29px}.rules-description{white-space:pre-wrap;overflow-wrap:anywhere;font-size:14px;line-height:1.7}.rules-source{display:block;margin:22px 0;color:#284e3d;font-size:13px}.rules-attribution{margin-top:26px;border-top:1px solid #ad8d51;padding-top:14px;font-size:11px;line-height:1.6;overflow-wrap:anywhere}.rules-attribution summary{cursor:pointer;min-height:44px;padding:12px 0}.rules-attribution summary:focus-visible{outline:3px solid #b78a3e}.rules-attribution p{margin:10px 0}@media(max-width:420px){.folio-heading-tools{gap:0}.folio-heading-tools .quiet{font-size:11px;padding:8px 4px}.folio-heading h2{font-size:25px}.folio-heading h2 .ui-icon{width:33px;height:33px}.rules-picker{padding:20px}.catalogue-pages button{font-size:12px}}
|
||||||
|
|
||||||
|
.inventory-quantity{display:flex;align-items:center;gap:3px;flex-shrink:0}.inventory-quantity button{min-width:36px;min-height:44px;font-size:20px;padding:6px}.inventory-quantity span{min-width:24px;font-size:15px;text-align:center;font-weight:600}.inventory-flags{display:flex;flex-wrap:wrap;gap:14px;margin:14px 0}.inventory-flags label{display:flex;align-items:center;gap:8px;min-height:44px;margin:0}.inventory-source{font-size:11px;color:#6e6549;white-space:pre-wrap;overflow-wrap:anywhere;line-height:1.6}.inventory-notebook{border-top:1px solid #ad8d51;padding:16px 0;margin-top:26px}.inventory-notebook summary{display:flex;align-items:center;gap:10px;cursor:pointer;min-height:44px;font-family:Georgia,serif;font-size:21px}.inventory-notebook summary:after{content:'+';margin-left:auto;font-family:system-ui;font-size:19px}.inventory-notebook[open] summary:after{content:'−'}.inventory-notebook summary:focus-visible{outline:3px solid #b78a3e}.inventory-notebook summary .ui-icon{width:30px;height:30px}.inventory-notebook textarea{background:#fff3d366;border-radius:3px}.inventory-entry .folio-action-copy{align-self:center}@media(max-width:420px){.inventory-entry .folio-action-icon{display:none}.inventory-entry .folio-options{margin-left:0}.inventory-quantity{gap:0}.inventory-quantity button{min-width:32px;padding:5px}.inventory-entry .folio-action-main{gap:8px}.inventory-entry h3{font-size:21px}}
|
||||||
|
|||||||
9
tests/inventory.test.ts
Normal file
9
tests/inventory.test.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import {test} from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {newDb} from 'pg-mem';
|
||||||
|
import {Storage,Conflict} from '../server/storage.mjs';
|
||||||
|
import {newCharacter,characterSchema,addInventoryItem,changeItemQuantity} from '../src/model.ts';
|
||||||
|
const item=(id='one',catalogueId='2014:rope')=>({id,catalogueId,name:'Rope',quantity:1,category:'Adventuring gear' as const,equipped:false,attuned:false,weight:'5 lb.',cost:'1 GP',notes:'Keep dry'});
|
||||||
|
test('inventory adds catalogue quantities without replacing notes or mixing editions',()=>{const c=newCharacter();c.inventory='15 gold';const first=addInventoryItem(c,item());first.equipped=true;first.notes='My rope';addInventoryItem(c,item('two'));assert.equal(first.quantity,2);assert.equal(first.notes,'My rope');assert.equal(first.equipped,true);addInventoryItem(c,item('three','2024:rope'));assert.equal(c.inventoryItems?.length,2);assert.equal(c.inventory,'15 gold');assert.throws(()=>addInventoryItem(c,{...item('three'),catalogueId:undefined}));});
|
||||||
|
test('inventory validates bounds, unique ids and retains legacy sheets',()=>{const c=newCharacter();delete c.inventoryItems;c.inventory='Longbow, 20 arrows';assert.equal(characterSchema.parse(c).inventoryItems,undefined);assert.equal(characterSchema.parse(c).inventory,c.inventory);const i=addInventoryItem(c,item());changeItemQuantity(i,-9);assert.equal(i.quantity,0);changeItemQuantity(i,10000);assert.equal(i.quantity,9999);assert.throws(()=>addInventoryItem(c,item('two')));assert.throws(()=>changeItemQuantity(i,0.5));assert.throws(()=>characterSchema.parse({...c,inventoryItems:[i,i]}));assert.throws(()=>characterSchema.parse({...c,inventoryItems:[{...i,quantity:-1}]}));});
|
||||||
|
test('older clients preserve inventory under revision and account guards; explicit empty clears',async()=>{const pg=newDb().adapters.createPg(),s=new Storage(new pg.Pool());await s.init();const c=newCharacter();c.inventory='Legacy coins';addInventoryItem(c,item());await s.put('alice',c.id,c,0);const old={...c,name:'Older client'};delete old.inventoryItems;await s.put('alice',c.id,old,1);assert.equal((await s.get('alice',c.id)).character.inventoryItems[0].name,'Rope');assert.equal((await s.get('alice',c.id)).character.inventory,'Legacy coins');await assert.rejects(s.put('alice',c.id,old,1),Conflict);await assert.rejects(s.put('bob',c.id,old,2),Conflict);await s.put('alice',c.id,{...old,inventoryItems:[]},2);assert.deepEqual((await s.get('alice',c.id)).character.inventoryItems,[]);});
|
||||||
Loading…
x
Reference in New Issue
Block a user