diff --git a/src/CoinPurse.vue b/src/CoinPurse.vue index a86ad18..c0f55fd 100644 --- a/src/CoinPurse.vue +++ b/src/CoinPurse.vue @@ -1,8 +1,16 @@ - Coin purseSet amounts{{d.key.toUpperCase()}}{{d.name}}{{purse[d.key].toLocaleString()}}−+ + Coin purseSet amounts + + + YOUR COIN BAG{{bagLabel}}{{coinCount.toLocaleString()}} {{coinCount===1?'coin':'coins'}} insideFills as your coin collection grows. + + {{d.key.toUpperCase()}}{{d.name}}{{purse[d.key].toLocaleString()}}−+ Total value {{total}} GPAmount to add / spend1 GP = 10 SP = 100 CP · Coin counts stay in their denominations.{{error}} {{d.name}}Save amountsCancel diff --git a/src/model.ts b/src/model.ts index f26cc77..5f8c922 100644 --- a/src/model.ts +++ b/src/model.ts @@ -8,6 +8,19 @@ 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; + +// A bag's illustrated fullness represents physical coin count, not GP value. +// Keep this visual-only: denomination balances and conversions never change. +export const coinBagStates=['empty','few','partial','full','overflowing'] as const; +export type CoinBagState=typeof coinBagStates[number]; +export function coinBagState(purse:Currency):CoinBagState{ + const coins=purse.gp+purse.sp+purse.cp; + if(coins===0)return 'empty'; + if(coins<5)return 'few'; + if(coins<20)return 'partial'; + if(coins<100)return 'full'; + return 'overflowing'; +} 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'); diff --git a/src/style.css b/src/style.css index 00fef49..2ffd944 100644 --- a/src/style.css +++ b/src/style.css @@ -155,3 +155,13 @@ input[type=checkbox]{accent-color:#3b603d} .companion-actions{display:flex;gap:8px 12px;flex-wrap:wrap;margin-top:12px}.companion-actions button{min-height:44px}.companion-drawer .hint{font-size:12px}.companion-drawer .folio-section{margin:16px 0}.companion-drawer .form-grid{margin-bottom:14px}.companion-drawer textarea{min-height:78px} @media(max-width:650px){.gear-subnav{gap:7px}.gear-subnav button{font-size:14px;padding:8px 10px}.gear-subnav .ui-icon{width:22px;height:22px}.companion-stats{grid-template-columns:repeat(3,minmax(0,1fr))}.companion-hp{grid-column:1/-1;border-top:1px solid #b99c6850;padding-top:12px}.companion-hp>div{justify-content:flex-start}.companion-actions button{flex:1}.companions-folio .folio-heading-tools{width:100%}.companions-folio .folio-heading-tools button{flex:1}} @media(max-width:400px){.gear-subnav button{font-size:12px}.companion-heading{flex-wrap:wrap}.companion-status{margin-left:60px}.companion-location{grid-template-columns:1fr;gap:12px}.companion-intro h3{font-size:25px}} + +/* Five hand-painted bag sprites share a transparent canvas and sit directly on parchment. */ +.coin-bag-display{display:flex;align-items:center;justify-content:center;gap:10px;min-height:174px;margin:8px 0 13px;padding:0 8px 12px;border-bottom:1px solid #ad8d5140} +.coin-bag-art{display:block;flex:none;width:clamp(142px,39%,220px);height:auto;aspect-ratio:512/480;object-fit:contain;filter:drop-shadow(0 5px 5px #6e4b1929);transition:filter .2s ease} +.coin-bag-copy{display:flex;flex-direction:column;align-items:flex-start;gap:6px;min-width:0;max-width:240px;color:#58462d} +.coin-bag-eyebrow{font-size:10px;letter-spacing:1.5px;font-weight:700;color:#805e32} +.coin-bag-copy strong{font-family:Georgia,serif;font-weight:400;font-size:clamp(24px,4vw,34px);line-height:1.1} +.coin-bag-copy>span:not(.coin-bag-eyebrow){font-family:Georgia,serif;font-size:16px} +.coin-bag-copy small{color:#736045;font-size:11px;line-height:1.45} +@media(max-width:430px){.coin-bag-display{min-height:137px;gap:4px;padding-left:0;padding-right:0}.coin-bag-art{width:46%;max-width:174px}.coin-bag-copy strong{font-size:25px}.coin-bag-copy>span:not(.coin-bag-eyebrow){font-size:14px}.coin-bag-copy small{font-size:10px}} diff --git a/tests/currency.test.ts b/tests/currency.test.ts index 1fa829d..834afd3 100644 --- a/tests/currency.test.ts +++ b/tests/currency.test.ts @@ -1,7 +1,22 @@ 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 {newCharacter,characterSchema,currencySchema,copperValue,coinBagState,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()}}); + +test('coin bag progression reflects total coin pieces across denominations, without changing value',()=>{ + const actual=[ + [{gp:0,sp:0,cp:0},'empty'], + [{gp:1,sp:0,cp:0},'few'], + [{gp:0,sp:0,cp:4},'few'], + [{gp:0,sp:5,cp:0},'partial'], + [{gp:4,sp:5,cp:10},'partial'], + [{gp:0,sp:20,cp:0},'full'], + [{gp:70,sp:29,cp:0},'full'], + [{gp:20,sp:30,cp:50},'overflowing'], + [{gp:coinLimit,sp:coinLimit,cp:coinLimit},'overflowing'] + ] as const; + for(const [purse,expected] of actual){const before=structuredClone(purse);assert.equal(coinBagState(purse),expected);assert.deepEqual(purse,before)} +});
Total value {{total}} GP
1 GP = 10 SP = 100 CP · Coin counts stay in their denominations.
{{error}}