Release coin purse v0.8.0
This commit is contained in:
parent
933259d9ca
commit
4ec9e97716
@ -60,3 +60,6 @@ Campaigns open to Session for DMs and Party for players. Session combines active
|
|||||||
|
|
||||||
## Session preparation
|
## Session preparation
|
||||||
Select a session fieldbook, prepare scenes and link campaign NPCs, accessible journal notes and that session’s prepared encounters. Scenes are ordered by creation and may be marked current or complete. Making a scene current does not reveal it. Explicit reveal/hide controls share only title and description; scene secrets/links and all encounter preparation are filtered server-side for players. Starting a preparation creates an independent fresh combat snapshot with a session association; repeated or stale starts cannot create another active instance of the same preparation. Templates retain their original HP and can be reused after combat finishes. NPC and note links are pruned when those entries are deleted. Existing campaigns gain empty additive collections without a destructive migration. Four additional MCP tools use the same campaign-role, write-scope and revision checks: save_campaign_scene, advance_campaign_scene, save_prepared_encounter and start_prepared_encounter (31 total with catalogue enabled). Loot acceptance remains the next stage.
|
Select a session fieldbook, prepare scenes and link campaign NPCs, accessible journal notes and that session’s prepared encounters. Scenes are ordered by creation and may be marked current or complete. Making a scene current does not reveal it. Explicit reveal/hide controls share only title and description; scene secrets/links and all encounter preparation are filtered server-side for players. Starting a preparation creates an independent fresh combat snapshot with a session association; repeated or stale starts cannot create another active instance of the same preparation. Templates retain their original HP and can be reused after combat finishes. NPC and note links are pruned when those entries are deleted. Existing campaigns gain empty additive collections without a destructive migration. Four additional MCP tools use the same campaign-role, write-scope and revision checks: save_campaign_scene, advance_campaign_scene, save_prepared_encounter and start_prepared_encounter (31 total with catalogue enabled). Loot acceptance remains the next stage.
|
||||||
|
|
||||||
|
## Coin purse
|
||||||
|
Gear tracks gold (gp), silver (sp) and copper (cp) separately, with integer add/spend controls and direct amount editing. Total value uses copper arithmetic (100 CP = 10 SP = 1 GP) without exchanging denominations. Currency is an optional additive sheet field, retained when older clients omit it. Existing inventory notes are untouched; no free-text coin amounts are imported automatically. Currency remains private in party projections and is included in account sync, backups and character MCP reads/updates.
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "dnd-character-pwa",
|
"name": "dnd-character-pwa",
|
||||||
"version": "0.7.0",
|
"version": "0.8.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "dnd-character-pwa",
|
"name": "dnd-character-pwa",
|
||||||
"version": "0.7.0",
|
"version": "0.8.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.7.0",
|
"version": "0.8.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.7.0',catalogue:catalogue.summary,revision:process.env.SOURCE_COMMIT||'unknown'}));
|
app.get('/health',(_,res)=>res.json({status:'ok',version:'0.8.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.7.0'});
|
const server=new McpServer({name:'arrowstack-character',version:'0.8.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,8 +12,8 @@ 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.
|
// Preserve additive collections omitted by older clients, under the same account/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}}
|
if(character&&!deleted&&revision>0&&(character.inventoryItems===undefined||character.currency===undefined)){const prior=await this.get(owner,id);for(const key of ['inventoryItems','currency'])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]);
|
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];
|
||||||
|
|||||||
18
src/CoinPurse.vue
Normal file
18
src/CoinPurse.vue
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {computed,ref,watch} from 'vue';import {currencySchema,changeCurrency,copperValue,coinLimit,type Character,type Currency} from './model';
|
||||||
|
const props=defineProps<{character:Character}>();
|
||||||
|
const denominations:{key:keyof Currency,name:string}[]=[{key:'gp',name:'Gold'},{key:'sp',name:'Silver'},{key:'cp',name:'Copper'}];
|
||||||
|
const purse=computed(()=>props.character.currency||{gp:0,sp:0,cp:0}),total=computed(()=>{const cp=copperValue(purse.value);return `${Math.floor(cp/100).toLocaleString()}.${String(cp%100).padStart(2,'0')}`});
|
||||||
|
const amount=ref(1),editing=ref(false),error=ref(''),draft=ref<Currency>({gp:0,sp:0,cp:0});
|
||||||
|
watch(()=>props.character.id,()=>{editing.value=false;error.value='';amount.value=1;draft.value={gp:0,sp:0,cp:0}});
|
||||||
|
const validAmount=computed(()=>Number.isInteger(amount.value)&&amount.value>=1&&amount.value<=coinLimit);
|
||||||
|
function adjust(key:keyof Currency,direction:number){error.value='';try{if(!validAmount.value)throw new Error('Enter a whole amount from 1 to 9,999,999.');changeCurrency(props.character,key,direction*amount.value)}catch(e){error.value=(e as Error).message}}
|
||||||
|
function edit(){draft.value={...purse.value};error.value='';editing.value=true}
|
||||||
|
function save(){try{props.character.currency=currencySchema.parse(draft.value);error.value='';editing.value=false}catch{error.value='Coin counts must be whole numbers from 0 to 9,999,999.'}}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<section class="coin-purse" aria-labelledby="purse-heading"><div class="section-title"><h3 id="purse-heading">Coin purse</h3><button class="quiet" @click="edit">Set amounts</button></div><div class="coin-row"><div class="coin-denomination" v-for="d in denominations" :key="d.key"><span class="coin-token" :class="d.key" aria-hidden="true">{{d.key.toUpperCase()}}</span><span class="coin-name">{{d.name}}</span><strong>{{purse[d.key].toLocaleString()}}</strong><div class="coin-controls"><button class="quiet" @click="adjust(d.key,-1)" :disabled="!validAmount||purse[d.key]<amount" :aria-label="`Spend ${amount} ${d.name.toLowerCase()} coins`">−</button><button class="quiet" @click="adjust(d.key,1)" :disabled="!validAmount||purse[d.key]+amount>coinLimit" :aria-label="`Add ${amount} ${d.name.toLowerCase()} coins`">+</button></div></div></div>
|
||||||
|
<div class="purse-total"><p>Total value <strong>{{total}} GP</strong></p><label>Amount to add / spend<input type="number" v-model.number="amount" min="1" :max="coinLimit" step="1" inputmode="numeric"></label></div><p class="hint coin-exchange">1 GP = 10 SP = 100 CP · Coin counts stay in their denominations.</p><p v-if="error" class="notice danger" role="alert">{{error}}</p>
|
||||||
|
<form v-if="editing" class="purse-editor" @submit.prevent="save"><div class="coin-edit-fields"><label v-for="d in denominations" :key="d.key">{{d.name}}<input type="number" v-model.number="draft[d.key]" min="0" :max="coinLimit" step="1" inputmode="numeric" required></label></div><div class="campaign-actions"><button class="primary">Save amounts</button><button type="button" class="quiet" @click="editing=false;error=''">Cancel</button></div></form>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {computed,ref} from 'vue';import UiIcon from './UiIcon.vue';import {inventoryItemSchema,addInventoryItem,changeItemQuantity,type Character,type InventoryItem} from './model';
|
import {computed,ref} from 'vue';import UiIcon from './UiIcon.vue';import CoinPurse from './CoinPurse.vue';import {inventoryItemSchema,addInventoryItem,changeItemQuantity,type Character,type InventoryItem} from './model';
|
||||||
const props=defineProps<{character:Character}>(),emit=defineEmits<{browse:[]}>();
|
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('');
|
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:''}}
|
function blank():InventoryItem{return {id:crypto.randomUUID(),name:'',quantity:1,category:'Other',equipped:false,attuned:false,weight:'',cost:'',notes:''}}
|
||||||
@ -10,6 +10,7 @@ function remove(item:InventoryItem){props.character.inventoryItems=items.value.f
|
|||||||
</script>
|
</script>
|
||||||
<template>
|
<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>
|
<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>
|
||||||
|
<CoinPurse :character="character" />
|
||||||
<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>
|
<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>
|
<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>
|
<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>
|
||||||
|
|||||||
10
src/model.ts
10
src/model.ts
@ -4,15 +4,21 @@ export type Ability=typeof abilities[number];
|
|||||||
const score=z.number().int().min(1).max(30);
|
const score=z.number().int().min(1).max(30);
|
||||||
const counter=z.number().int().min(0).max(9999);
|
const counter=z.number().int().min(0).max(9999);
|
||||||
const text=z.string().max(12000);
|
const text=z.string().max(12000);
|
||||||
|
export const coinLimit=9999999;
|
||||||
|
const coin=z.number().int().min(0).max(coinLimit);
|
||||||
|
export const currencySchema=z.object({gp:coin,sp:coin,cp:coin});
|
||||||
|
export type Currency=z.infer<typeof currencySchema>;
|
||||||
|
export function copperValue(purse:Currency){return purse.gp*100+purse.sp*10+purse.cp}
|
||||||
|
export function changeCurrency(c:Character,denomination:keyof Currency,amount:number){if(!['gp','sp','cp'].includes(denomination)||!Number.isInteger(amount))throw new Error('Use a whole number of coins.');const purse=currencySchema.parse(c.currency||{gp:0,sp:0,cp:0});const next=purse[denomination]+amount;if(next<0)throw new Error('Not enough coins of this denomination.');if(next>coinLimit)throw new Error('Coin count exceeds the limit.');purse[denomination]=next;c.currency=purse}
|
||||||
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 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 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,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 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(),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:'',inventoryItems:[],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:'',currency:{gp:0,sp:0,cp:0},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}`;
|
||||||
|
|||||||
@ -135,3 +135,5 @@ input[type=checkbox]{accent-color:#3b603d}
|
|||||||
|
|
||||||
/* Catalogue paper follows its inner scroll, independently of the page behind it. */
|
/* Catalogue paper follows its inner scroll, independently of the page behind it. */
|
||||||
.rules-picker{background-attachment:local;overscroll-behavior-y:contain}
|
.rules-picker{background-attachment:local;overscroll-behavior-y:contain}
|
||||||
|
|
||||||
|
.coin-purse{border-bottom:1px solid #ad8d5180;padding:8px 0 22px;margin-bottom:26px}.coin-purse h3{font-family:Georgia,serif;font-size:25px;font-weight:400;margin:0}.coin-row{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}.coin-denomination{display:flex;align-items:center;flex-direction:column;gap:7px;padding:12px 0}.coin-token{width:42px;height:42px;display:grid;place-items:center;border:2px solid #987039;border-radius:50%;box-shadow:inset 0 0 0 3px #fff5d46b,0 2px 0 #70532a30;font-family:Georgia,serif;font-size:12px;font-weight:700;color:#614b25;background:#e8c66b}.coin-token.sp{background:#d2d5cd;border-color:#87918a;color:#47544b}.coin-token.cp{background:#d49c71;border-color:#98603c;color:#684225}.coin-name{font-size:12px;color:#6d6246}.coin-denomination strong{font-family:Georgia,serif;font-size:28px;font-weight:400;overflow-wrap:anywhere;text-align:center}.coin-controls{display:flex;gap:8px}.coin-controls button{min-width:44px;min-height:44px;font-size:22px;padding:6px}.purse-total{display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;border-top:1px solid #ad8d5140;padding-top:14px}.purse-total p{font-size:13px;margin:0}.purse-total strong{display:block;font-family:Georgia,serif;font-size:25px;font-weight:400;margin-top:5px}.purse-total label{font-size:11px;max-width:170px;margin:0}.purse-total input{margin-top:6px}.coin-exchange{font-size:11px;margin-bottom:0}.coin-edit-fields{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}.purse-editor{border-top:1px solid #ad8d5140;margin-top:18px;padding-top:16px}@media(max-width:420px){.coin-row{gap:6px}.coin-denomination strong{font-size:24px}.coin-edit-fields{grid-template-columns:1fr}.purse-total label{max-width:140px}}
|
||||||
|
|||||||
7
tests/currency.test.ts
Normal file
7
tests/currency.test.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import 'fake-indexeddb/auto';import {test} from 'node:test';import assert from 'node:assert/strict';import {newDb} from 'pg-mem';
|
||||||
|
import {newCharacter,characterSchema,currencySchema,copperValue,changeCurrency,coinLimit} from '../src/model.ts';import {Storage,Conflict} from '../server/storage.mjs';import {CharacterDB,saveCharacter,loadCharacters} from '../src/db.ts';import {Campaigns} from '../server/campaigns.mjs';
|
||||||
|
import {characterMcp} from '../server/mcp.mjs';import {Client} from '@modelcontextprotocol/sdk/client/index.js';import {InMemoryTransport} from '@modelcontextprotocol/sdk/inMemory.js';
|
||||||
|
test('coin tally uses integer copper and rejects fractional counts, overdrafts and overflow',()=>{const c=newCharacter();changeCurrency(c,'gp',12);changeCurrency(c,'sp',3);changeCurrency(c,'cp',7);assert.equal(copperValue(c.currency!),1237);changeCurrency(c,'sp',-2);assert.deepEqual(c.currency,{gp:12,sp:1,cp:7});const before=structuredClone(c.currency);assert.throws(()=>changeCurrency(c,'cp',-8));assert.throws(()=>changeCurrency(c,'gp',0.5));assert.throws(()=>changeCurrency(c,'gp',coinLimit));assert.deepEqual(c.currency,before);assert.throws(()=>currencySchema.parse({gp:-1,sp:0,cp:0}));assert.throws(()=>currencySchema.parse({gp:0,sp:1.1,cp:0}));assert.equal(copperValue({gp:coinLimit,sp:coinLimit,cp:coinLimit}),coinLimit*111);delete c.currency;assert.equal(characterSchema.parse(c).currency,undefined);changeCurrency(c,'cp',1);assert.deepEqual(c.currency,{gp:0,sp:0,cp:1})});
|
||||||
|
test('currency survives backup and device persistence without mixing accounts or rewriting notes',async()=>{const database=new CharacterDB('currency-'+crypto.randomUUID()),c=newCharacter();c.currency={gp:14,sp:2,cp:9};c.inventory='Old note: 15 gold';try{await saveCharacter('alice',characterSchema.parse(JSON.parse(JSON.stringify(c))),database);await saveCharacter('bob',{...c,currency:{gp:1,sp:0,cp:0}},database);assert.deepEqual((await loadCharacters('alice',database))[0].currency,c.currency);assert.equal((await loadCharacters('alice',database))[0].inventory,c.inventory);assert.equal((await loadCharacters('bob',database))[0].currency?.gp,1)}finally{await database.delete()}});
|
||||||
|
test('old-client omission preserves purse; explicit zero clears under account and revision guards',async()=>{const pg=newDb().adapters.createPg(),s=new Storage(new pg.Pool());await s.init();const c=newCharacter();c.currency={gp:15,sp:3,cp:1};await s.put('alice',c.id,c,0);const old={...c,name:'Old client'};delete old.currency;delete old.inventoryItems;await s.put('alice',c.id,old,1);assert.deepEqual((await s.get('alice',c.id)).character.currency,c.currency);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,currency:{gp:0,sp:0,cp:0}},2);assert.deepEqual((await s.get('alice',c.id)).character.currency,{gp:0,sp:0,cp:0})});
|
||||||
|
test('MCP currency roundtrip preserves purse on HP edits and legacy replacement; party view omits it',async()=>{const pg=newDb().adapters.createPg(),s=new Storage(new pg.Pool());await s.init();const c=newCharacter();c.currency={gp:4,sp:5,cp:6};await s.put('alice',c.id,c,0);const campaigns=new Campaigns(s);await campaigns.init();const campaign=await campaigns.create('alice','Alice',{name:'Coins'});await campaigns.mutate('alice',campaign.id,1,'assign',{character_id:c.id});assert.equal((await campaigns.view('alice',campaign.id)).party[0].character.currency,undefined);const server=characterMcp({storage:s,sub:'alice',scopes:['characters:read','characters:write']}),client=new Client({name:'coin-test',version:'1'}),[a,b]=InMemoryTransport.createLinkedPair();await server.connect(a);await client.connect(b);try{const got:any=await client.callTool({name:'get_character',arguments:{id:c.id}});assert.deepEqual(got.structuredContent.sheet.character.currency,c.currency);assert.equal((await client.callTool({name:'change_hit_points',arguments:{id:c.id,expected_revision:1,amount:-1}})).isError,undefined);assert.deepEqual((await s.get('alice',c.id)).character.currency,c.currency);const old=(await s.get('alice',c.id)).character;delete old.currency;assert.equal((await client.callTool({name:'update_character',arguments:{character:old,expected_revision:2}})).isError,undefined);assert.deepEqual((await s.get('alice',c.id)).character.currency,c.currency)}finally{await client.close()}});
|
||||||
Loading…
x
Reference in New Issue
Block a user