Mirror coin bag visuals and coin-count progression from private source 382e7df
This commit is contained in:
parent
0ef1e4d562
commit
e7ab4a2d49
@ -1,8 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import {computed,ref,watch} from 'vue';import {currencySchema,changeCurrency,copperValue,coinLimit,type Character,type Currency} from './model';
|
||||
import {computed,ref,watch} from 'vue';import {currencySchema,changeCurrency,copperValue,coinBagState,coinLimit,type Character,type Currency} from './model';
|
||||
import emptyBag from './assets/coin-bag-empty.webp';
|
||||
import fewBag from './assets/coin-bag-few.webp';
|
||||
import partialBag from './assets/coin-bag-partial.webp';
|
||||
import fullBag from './assets/coin-bag-full.webp';
|
||||
import overflowingBag from './assets/coin-bag-overflowing.webp';
|
||||
const bagArt={empty:emptyBag,few:fewBag,partial:partialBag,full:fullBag,overflowing:overflowingBag};
|
||||
const bagNames={empty:'Empty',few:'A few coins',partial:'Part-filled',full:'Full',overflowing:'Overflowing'};
|
||||
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 bagState=computed(()=>coinBagState(purse.value)),bagImage=computed(()=>bagArt[bagState.value]),bagLabel=computed(()=>bagNames[bagState.value]),coinCount=computed(()=>purse.value.gp+purse.value.sp+purse.value.cp);
|
||||
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);
|
||||
@ -11,7 +19,12 @@ 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>
|
||||
<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-bag-display">
|
||||
<img class="coin-bag-art" :src="bagImage" :alt="`Coin pouch: ${bagLabel.toLowerCase()}`" width="512" height="480" />
|
||||
<div class="coin-bag-copy" aria-live="polite"><span class="coin-bag-eyebrow">YOUR COIN BAG</span><strong>{{bagLabel}}</strong><span>{{coinCount.toLocaleString()}} {{coinCount===1?'coin':'coins'}} inside</span><small>Fills as your coin collection grows.</small></div>
|
||||
</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>
|
||||
|
||||
13
src/model.ts
13
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<typeof currencySchema>;
|
||||
|
||||
// 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');
|
||||
|
||||
@ -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}}
|
||||
|
||||
@ -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)}
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user