From 4ec9e977161e948645285a0f0e9d096aa745928f Mon Sep 17 00:00:00 2001 From: Admin Date: Thu, 8 Oct 2026 01:54:05 +0000 Subject: [PATCH] Release coin purse v0.8.0 --- README.md | 3 +++ package-lock.json | 4 ++-- package.json | 2 +- server/index.mjs | 2 +- server/mcp.mjs | 2 +- server/storage.mjs | 4 ++-- src/CoinPurse.vue | 18 ++++++++++++++++++ src/InventoryFolio.vue | 3 ++- src/model.ts | 10 ++++++++-- src/style.css | 2 ++ tests/currency.test.ts | 7 +++++++ 11 files changed, 47 insertions(+), 10 deletions(-) create mode 100644 src/CoinPurse.vue create mode 100644 tests/currency.test.ts diff --git a/README.md b/README.md index 6e72c8a..9702247 100644 --- a/README.md +++ b/README.md @@ -60,3 +60,6 @@ Campaigns open to Session for DMs and Party for players. Session combines active ## 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. + +## 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. diff --git a/package-lock.json b/package-lock.json index a7f9adf..0a01d86 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dnd-character-pwa", - "version": "0.7.0", + "version": "0.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dnd-character-pwa", - "version": "0.7.0", + "version": "0.8.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.32.1", "dexie": "^4.2.0", diff --git a/package.json b/package.json index deaa452..ba6ed35 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dnd-character-pwa", - "version": "0.7.0", + "version": "0.8.0", "private": true, "type": "module", "scripts": { diff --git a/server/index.mjs b/server/index.mjs index 8f59442..51157cb 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.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/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 2e62b48..4c269ac 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.7.0'}); + const server=new McpServer({name:'arrowstack-character',version:'0.8.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 f308551..b6438c8 100644 --- a/server/storage.mjs +++ b/server/storage.mjs @@ -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 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}} + // Preserve additive collections omitted by older clients, under the same account/revision guard. + 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]); if(!result.rows[0])throw new Conflict();return result.rows[0]; diff --git a/src/CoinPurse.vue b/src/CoinPurse.vue new file mode 100644 index 0000000..a86ad18 --- /dev/null +++ b/src/CoinPurse.vue @@ -0,0 +1,18 @@ + + diff --git a/src/InventoryFolio.vue b/src/InventoryFolio.vue index 22576c8..63ebc71 100644 --- a/src/InventoryFolio.vue +++ b/src/InventoryFolio.vue @@ -1,5 +1,5 @@