Mirror character portrait upload from 9ddc680
This commit is contained in:
parent
0fd9ed920f
commit
c4a096dc25
@ -68,3 +68,6 @@ Gear tracks gold (gp), silver (sp) and copper (cp) separately, with integer add/
|
|||||||
|
|
||||||
## Keycloak MCP user authorization
|
## Keycloak MCP user authorization
|
||||||
The MCP uses Keycloak for OAuth issuance via confidential authorization-code flow with S256 PKCE. ChatGPT client: `dnd-character-mcp` in dev realm, exact redirect `https://chatgpt.com/connector_platform_oauth_redirect`. The app verifies signed Keycloak JWTs via JWKS and accepts only tokens with `azp=dnd-character-mcp`, validates issuer and expiration. The authenticated user's Keycloak `sub` is used directly as sheet/campaign owner, matching the PWA. Machine/service account tokens are not supported. OAuth registration, authorization, consent and token endpoints are on Keycloak, not D&D; legacy in-app OAuth source remains inert. The website retains its separate confidential client and browser session. The MCP application does not mint tokens or store OAuth secrets.
|
The MCP uses Keycloak for OAuth issuance via confidential authorization-code flow with S256 PKCE. ChatGPT client: `dnd-character-mcp` in dev realm, exact redirect `https://chatgpt.com/connector_platform_oauth_redirect`. The app verifies signed Keycloak JWTs via JWKS and accepts only tokens with `azp=dnd-character-mcp`, validates issuer and expiration. The authenticated user's Keycloak `sub` is used directly as sheet/campaign owner, matching the PWA. Machine/service account tokens are not supported. OAuth registration, authorization, consent and token endpoints are on Keycloak, not D&D; legacy in-app OAuth source remains inert. The website retains its separate confidential client and browser session. The MCP application does not mint tokens or store OAuth secrets.
|
||||||
|
|
||||||
|
## Character portraits
|
||||||
|
The character editor and hero portrait frame accept device photo and illustration uploads (JPEG, PNG, WebP, supported HEIC/HEIF). Browser canvas centre-crops at 4:5 and compresses to JPEG/WebP, max 320,000 data URL characters. Unsupported, oversized (>12 MB) or undecodable images are rejected. No raw photo uploads or external image service: compressed portrait is an optional, nullable part of the validated character, so offline IndexedDB, JSON backups, MCP sheet reads and authenticated cross-device sync all retain it. Explicit null removes it; older clients omitting the optional field preserve existing images through the storage merge. Photos remain private to the character owner and are never shared in campaign party projections.
|
||||||
|
|||||||
@ -13,7 +13,7 @@ export class Storage{
|
|||||||
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){
|
||||||
// Preserve additive collections omitted by older clients, under the same account/revision guard.
|
// Preserve additive collections omitted by older clients, under the same account/revision guard.
|
||||||
if(character&&!deleted&&revision>0&&(character.inventoryItems===undefined||character.currency===undefined||character.companions===undefined||character.spellSlots===undefined)){const prior=await this.get(owner,id);for(const key of ['inventoryItems','currency','companions','spellSlots'])if(character[key]===undefined&&prior?.character?.[key]!==undefined)character={...character,[key]:prior.character[key]}}
|
if(character&&!deleted&&revision>0&&(character.inventoryItems===undefined||character.currency===undefined||character.companions===undefined||character.spellSlots===undefined||character.portrait===undefined)){const prior=await this.get(owner,id);for(const key of ['inventoryItems','currency','companions','spellSlots','portrait'])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];
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import RulesPicker,{type RuleEntry} from './RulesPicker.vue';
|
|||||||
import InventoryFolio from './InventoryFolio.vue';
|
import InventoryFolio from './InventoryFolio.vue';
|
||||||
import CompanionsFolio from './CompanionsFolio.vue';
|
import CompanionsFolio from './CompanionsFolio.vue';
|
||||||
import SpellSlotsFolio from './SpellSlotsFolio.vue';
|
import SpellSlotsFolio from './SpellSlotsFolio.vue';
|
||||||
|
import PortraitPicker from './PortraitPicker.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,addInventoryItem,changeHP,newCharacter,characterSchema,restPreview,spellSlotRestPreview,applyRest,type Character} from './model';
|
import {abilities,modifier,proficiency,signed,skills,skillBonus,addInventoryItem,changeHP,newCharacter,characterSchema,restPreview,spellSlotRestPreview,applyRest,type Character} from './model';
|
||||||
@ -24,6 +25,7 @@ const actionDraft=ref({name:'',bonus:0,damage:'',notes:''}),resourceDraft=ref({n
|
|||||||
watch(c,()=>{if(c.value&&user.value)store.persist(c.value)},{deep:true});
|
watch(c,()=>{if(c.value&&user.value)store.persist(c.value)},{deep:true});
|
||||||
async function authenticate(){try{const response=await fetch('/api/session',{cache:'no-store'});if(!response.ok){localStorage.removeItem('character-identity');navigator.serviceWorker.controller?.postMessage('CLEAR_AUTH');throw new Error('Please sign in with Arrowstack to open your characters.')}const identity=await response.json();if(store.owner&&store.owner!==identity.sub){await store.flush();location.reload();return}user.value=identity;error.value='';localStorage.setItem('character-identity',JSON.stringify(user.value))}catch(e){if(e instanceof TypeError){try{const cached=JSON.parse(localStorage.getItem('character-identity')||'null');if(cached?.sub&&cached.expiresAt>Date.now()){user.value=cached;offline.value=true}else throw new Error()}catch{error.value='Connect to the internet to sign in with Arrowstack.'}}else error.value=(e as Error).message}}
|
async function authenticate(){try{const response=await fetch('/api/session',{cache:'no-store'});if(!response.ok){localStorage.removeItem('character-identity');navigator.serviceWorker.controller?.postMessage('CLEAR_AUTH');throw new Error('Please sign in with Arrowstack to open your characters.')}const identity=await response.json();if(store.owner&&store.owner!==identity.sub){await store.flush();location.reload();return}user.value=identity;error.value='';localStorage.setItem('character-identity',JSON.stringify(user.value))}catch(e){if(e instanceof TypeError){try{const cached=JSON.parse(localStorage.getItem('character-identity')||'null');if(cached?.sub&&cached.expiresAt>Date.now()){user.value=cached;offline.value=true}else throw new Error()}catch{error.value='Connect to the internet to sign in with Arrowstack.'}}else error.value=(e as Error).message}}
|
||||||
onMounted(async()=>{await authenticate();if(user.value){try{await store.open(user.value.sub);activeId.value=store.all[0]?.id||'';if('serviceWorker'in navigator){await navigator.serviceWorker.register('/sw.js');const registration=await navigator.serviceWorker.ready;registration.active?.postMessage('CACHE_SHELL')}}catch{error.value='Could not open local character saves. Please retry.'}}loading.value=false;window.addEventListener('offline',()=>offline.value=true);window.addEventListener('online',async()=>{offline.value=false;await authenticate();if(user.value)await store.sync()});setInterval(()=>{if(user.value&&!offline.value)void store.sync()},30000);setInterval(()=>{if(user.value&&user.value.expiresAt<=Date.now()){user.value=null;drawer.value='';error.value='Your session expired. Connect and sign in again.'}},15000)});
|
onMounted(async()=>{await authenticate();if(user.value){try{await store.open(user.value.sub);activeId.value=store.all[0]?.id||'';if('serviceWorker'in navigator){await navigator.serviceWorker.register('/sw.js');const registration=await navigator.serviceWorker.ready;registration.active?.postMessage('CACHE_SHELL')}}catch{error.value='Could not open local character saves. Please retry.'}}loading.value=false;window.addEventListener('offline',()=>offline.value=true);window.addEventListener('online',async()=>{offline.value=false;await authenticate();if(user.value)await store.sync()});setInterval(()=>{if(user.value&&!offline.value)void store.sync()},30000);setInterval(()=>{if(user.value&&user.value.expiresAt<=Date.now()){user.value=null;drawer.value='';error.value='Your session expired. Connect and sign in again.'}},15000)});
|
||||||
|
function setPortrait(id:string,value:string|null){const character=store.all.find(x=>x.id===id);if(character)character.portrait=value}
|
||||||
function create(){draft.value=newCharacter();drawer.value='Character'}
|
function create(){draft.value=newCharacter();drawer.value='Character'}
|
||||||
function edit(){if(c.value){draft.value=structuredClone(JSON.parse(JSON.stringify(c.value)));drawer.value='Character'}}
|
function edit(){if(c.value){draft.value=structuredClone(JSON.parse(JSON.stringify(c.value)));drawer.value='Character'}}
|
||||||
function saveDraft(){try{const valid=characterSchema.parse(draft.value);const index=store.all.findIndex(x=>x.id===valid.id);if(index>=0)store.all[index]=valid;else store.all.push(valid);activeId.value=valid.id;store.persist(valid);drawer.value='';error.value=''}catch{message.value='Check your values: level 1–20, abilities 1–30, HP within maximum.'}}
|
function saveDraft(){try{const valid=characterSchema.parse(draft.value);const index=store.all.findIndex(x=>x.id===valid.id);if(index>=0)store.all[index]=valid;else store.all.push(valid);activeId.value=valid.id;store.persist(valid);drawer.value='';error.value=''}catch{message.value='Check your values: level 1–20, abilities 1–30, HP within maximum.'}}
|
||||||
@ -57,7 +59,7 @@ function openAction(action:Character['actions'][number]){selectedAction.value=ac
|
|||||||
<p v-if="store.saveError" class="notice danger" role="alert">{{store.saveError}}</p><p v-if="message" class="notice" role="status">{{message}} <button class="quiet" @click="message=''">Dismiss</button></p>
|
<p v-if="store.saveError" class="notice danger" role="alert">{{store.saveError}}</p><p v-if="message" class="notice" role="status">{{message}} <button class="quiet" @click="message=''">Dismiss</button></p>
|
||||||
<section v-if="!campaignMode&&!c" class="empty"><UiIcon name="sheet" class="empty-icon" /><p class="eyebrow">A BLANK PAGE. A NEW STORY.</p><h1>Who will you become?</h1><p>Create a character and keep everything you need<br>for game night in your pocket.</p><button class="primary" @click="create">Create your first character →</button><label class="button quiet">Import a character<input type="file" accept="application/json,.json" hidden @change="importCharacter"></label></section>
|
<section v-if="!campaignMode&&!c" class="empty"><UiIcon name="sheet" class="empty-icon" /><p class="eyebrow">A BLANK PAGE. A NEW STORY.</p><h1>Who will you become?</h1><p>Create a character and keep everything you need<br>for game night in your pocket.</p><button class="primary" @click="create">Create your first character →</button><label class="button quiet">Import a character<input type="file" accept="application/json,.json" hidden @change="importCharacter"></label></section>
|
||||||
<template v-if="!campaignMode&&c">
|
<template v-if="!campaignMode&&c">
|
||||||
<section class="hero"><div class="monogram">{{c.name.slice(0,1).toUpperCase()}}<span>✦</span></div><div><p class="eyebrow">LEVEL {{c.level}} · {{c.className||'ADVENTURER'}}</p><h1>{{c.name}}</h1><p>{{[c.species,c.background].filter(Boolean).join(' · ')||'Your story is waiting to be written.'}}</p></div><button class="edit" @click="edit">Edit character ↗</button></section>
|
<section class="hero"><PortraitPicker :key="c.id" :model-value="c.portrait" :name="c.name" compact @update:model-value="setPortrait(c.id,$event)" /><div><p class="eyebrow">LEVEL {{c.level}} · {{c.className||'ADVENTURER'}}</p><h1>{{c.name}}</h1><p>{{[c.species,c.background].filter(Boolean).join(' · ')||'Your story is waiting to be written.'}}</p></div><button class="edit" @click="edit">Edit character ↗</button></section>
|
||||||
<template v-if="tab==='Sheet'">
|
<template v-if="tab==='Sheet'">
|
||||||
<div class="combat-grid"><button class="hp-card" @click="drawer='Hit points'"><div class="card-label"><span class="label-with-icon"><UiIcon name="hp" /> HIT POINTS</span><span>+ / −</span></div><div class="hp-value">{{c.hp.current}}<small>/ {{c.hp.max}}</small></div><div class="hp-track"><i :style="{width:`${100*c.hp.current/c.hp.max}%`}"></i></div><div class="hp-footer"><span>{{c.hp.temp}} temporary</span><span>Tap to damage or heal</span></div></button><div class="stat-card"><UiIcon name="ac" /><span>ARMOUR CLASS</span><strong>{{c.ac}}</strong><small>AC</small></div><div class="stat-card"><UiIcon name="initiative" /><span>INITIATIVE</span><strong>{{signed(modifier(c.abilities.dex))}}</strong><button class="quiet" @click="roll(modifier(c.abilities.dex));drawer='Dice'">Roll d20 ↗</button></div><div class="stat-card"><UiIcon name="speed" /><span>SPEED</span><strong>{{c.speed}}</strong><small>feet</small></div></div>
|
<div class="combat-grid"><button class="hp-card" @click="drawer='Hit points'"><div class="card-label"><span class="label-with-icon"><UiIcon name="hp" /> HIT POINTS</span><span>+ / −</span></div><div class="hp-value">{{c.hp.current}}<small>/ {{c.hp.max}}</small></div><div class="hp-track"><i :style="{width:`${100*c.hp.current/c.hp.max}%`}"></i></div><div class="hp-footer"><span>{{c.hp.temp}} temporary</span><span>Tap to damage or heal</span></div></button><div class="stat-card"><UiIcon name="ac" /><span>ARMOUR CLASS</span><strong>{{c.ac}}</strong><small>AC</small></div><div class="stat-card"><UiIcon name="initiative" /><span>INITIATIVE</span><strong>{{signed(modifier(c.abilities.dex))}}</strong><button class="quiet" @click="roll(modifier(c.abilities.dex));drawer='Dice'">Roll d20 ↗</button></div><div class="stat-card"><UiIcon name="speed" /><span>SPEED</span><strong>{{c.speed}}</strong><small>feet</small></div></div>
|
||||||
<div class="ability-row"><button v-for="a in abilities" @click="roll(modifier(c.abilities[a]));drawer='Dice'"><UiIcon :name="a" /><span>{{a.toUpperCase()}}</span><strong>{{signed(modifier(c.abilities[a]))}}</strong><small>{{c.abilities[a]}}</small></button></div>
|
<div class="ability-row"><button v-for="a in abilities" @click="roll(modifier(c.abilities[a]));drawer='Dice'"><UiIcon :name="a" /><span>{{a.toUpperCase()}}</span><strong>{{signed(modifier(c.abilities[a]))}}</strong><small>{{c.abilities[a]}}</small></button></div>
|
||||||
@ -101,6 +103,7 @@ function openAction(action:Character['actions'][number]){selectedAction.value=ac
|
|||||||
<template v-if="drawer==='Account'"><p class="hint">Signed in as {{user.name}}. Characters sync to your account and remain available on this device for offline play.</p><button class="primary wide" @click="logout" :disabled="offline">Sign out of Character</button><p class="hint" v-if="offline">Reconnect to sign out.</p></template>
|
<template v-if="drawer==='Account'"><p class="hint">Signed in as {{user.name}}. Characters sync to your account and remain available on this device for offline play.</p><button class="primary wide" @click="logout" :disabled="offline">Sign out of Character</button><p class="hint" v-if="offline">Reconnect to sign out.</p></template>
|
||||||
<form v-if="drawer==='Character'&&draft" class="character-form" @submit.prevent="saveDraft">
|
<form v-if="drawer==='Character'&&draft" class="character-form" @submit.prevent="saveDraft">
|
||||||
<div class="editor-intro"><UiIcon name="sheet" /><div><p class="eyebrow">THE NEXT CHAPTER IS YOURS</p><h3>Bring your hero to life.</h3><p>A name, a calling, a story worth telling.</p></div></div>
|
<div class="editor-intro"><UiIcon name="sheet" /><div><p class="eyebrow">THE NEXT CHAPTER IS YOURS</p><h3>Bring your hero to life.</h3><p>A name, a calling, a story worth telling.</p></div></div>
|
||||||
|
<fieldset class="folio-section portrait-fieldset"><legend>Character portrait</legend><PortraitPicker :model-value="draft.portrait" :name="draft.name" @update:model-value="draft.portrait=$event" /><p class="hint">Choose a photo or illustration. It is cropped, compressed, saved for offline use and synced with this character.</p></fieldset>
|
||||||
<fieldset class="folio-section"><legend><UiIcon name="notes" />Identity</legend><label>Name<input v-model="draft.name" required maxlength="120" autocomplete="off"></label><div class="form-grid"><label>Class<input v-model="draft.className" maxlength="120" placeholder="Your calling"></label><label>Level<input type="number" v-model.number="draft.level" min="1" max="20" required></label><label>Species<input v-model="draft.species" maxlength="120" placeholder="Your ancestry"></label><label>Background<input v-model="draft.background" maxlength="120" placeholder="Your beginnings"></label></div></fieldset>
|
<fieldset class="folio-section"><legend><UiIcon name="notes" />Identity</legend><label>Name<input v-model="draft.name" required maxlength="120" autocomplete="off"></label><div class="form-grid"><label>Class<input v-model="draft.className" maxlength="120" placeholder="Your calling"></label><label>Level<input type="number" v-model.number="draft.level" min="1" max="20" required></label><label>Species<input v-model="draft.species" maxlength="120" placeholder="Your ancestry"></label><label>Background<input v-model="draft.background" maxlength="120" placeholder="Your beginnings"></label></div></fieldset>
|
||||||
<fieldset class="folio-section"><legend><UiIcon name="str" />Ability scores</legend><div class="form-grid three ability-fields"><label v-for="a in abilities" :key="a"><span class="field-caption"><UiIcon :name="a" />{{a.toUpperCase()}}</span><input type="number" v-model.number="draft.abilities[a]" min="1" max="30" required></label></div></fieldset>
|
<fieldset class="folio-section"><legend><UiIcon name="str" />Ability scores</legend><div class="form-grid three ability-fields"><label v-for="a in abilities" :key="a"><span class="field-caption"><UiIcon :name="a" />{{a.toUpperCase()}}</span><input type="number" v-model.number="draft.abilities[a]" min="1" max="30" required></label></div></fieldset>
|
||||||
<fieldset class="folio-section"><legend><UiIcon name="hp" />Vital statistics</legend><div class="form-grid three"><label>Max HP<input type="number" v-model.number="draft.hp.max" min="1" max="9999" required></label><label>Current HP<input type="number" v-model.number="draft.hp.current" min="0" :max="draft.hp.max" required></label><label>Temp HP<input type="number" v-model.number="draft.hp.temp" min="0" max="9999" required></label></div><div class="form-grid"><label><span class="field-caption"><UiIcon name="ac" />Armour class</span><input type="number" v-model.number="draft.ac" min="0" max="9999" required></label><label><span class="field-caption"><UiIcon name="speed" />Speed (ft)</span><input type="number" v-model.number="draft.speed" min="0" max="9999" required></label></div></fieldset>
|
<fieldset class="folio-section"><legend><UiIcon name="hp" />Vital statistics</legend><div class="form-grid three"><label>Max HP<input type="number" v-model.number="draft.hp.max" min="1" max="9999" required></label><label>Current HP<input type="number" v-model.number="draft.hp.current" min="0" :max="draft.hp.max" required></label><label>Temp HP<input type="number" v-model.number="draft.hp.temp" min="0" max="9999" required></label></div><div class="form-grid"><label><span class="field-caption"><UiIcon name="ac" />Armour class</span><input type="number" v-model.number="draft.ac" min="0" max="9999" required></label><label><span class="field-caption"><UiIcon name="speed" />Speed (ft)</span><input type="number" v-model.number="draft.speed" min="0" max="9999" required></label></div></fieldset>
|
||||||
|
|||||||
66
src/PortraitPicker.vue
Normal file
66
src/PortraitPicker.vue
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {ref} from 'vue';
|
||||||
|
const props=withDefaults(defineProps<{modelValue?:string|null;name:string;compact?:boolean}>(),{compact:false});
|
||||||
|
const emit=defineEmits<{(event:'update:modelValue',value:string|null):void}>();
|
||||||
|
const picker=ref<HTMLInputElement|null>(null),busy=ref(false),error=ref('');
|
||||||
|
const MAX_FILE_BYTES=12_000_000,MAX_DATA_LENGTH=320_000;
|
||||||
|
async function preparePortrait(file:File):Promise<string>{
|
||||||
|
if(!/^image\/(?:jpeg|png|webp|heic|heif)$/.test(file.type))throw new Error('Use a JPEG, PNG, WebP or iPhone photo.');
|
||||||
|
if(file.size>MAX_FILE_BYTES)throw new Error('This image is too large. Choose one under 12 MB.');
|
||||||
|
const objectUrl=URL.createObjectURL(file);
|
||||||
|
let image:HTMLImageElement;
|
||||||
|
try{
|
||||||
|
image=await new Promise<HTMLImageElement>((resolve,reject)=>{
|
||||||
|
const img=new Image();img.onload=()=>resolve(img);img.onerror=()=>reject(new Error('This image could not be opened. Try a JPG or PNG.'));img.src=objectUrl;
|
||||||
|
});
|
||||||
|
const sourceWidth=image.naturalWidth,sourceHeight=image.naturalHeight;
|
||||||
|
if(!sourceWidth||!sourceHeight)throw new Error('Image dimensions could not be read.');
|
||||||
|
// Portrait crop: 4:5 aspect ratio; never upload or store full resolution.
|
||||||
|
const ratio=4/5;
|
||||||
|
const sourceRatio=sourceWidth/sourceHeight;
|
||||||
|
const cropWidth=sourceRatio>ratio?sourceHeight*ratio:sourceWidth;
|
||||||
|
const cropHeight=sourceRatio>ratio?sourceHeight:sourceWidth/ratio;
|
||||||
|
const x=(sourceWidth-cropWidth)/2,y=(sourceHeight-cropHeight)/2;
|
||||||
|
for(const width of [480,400,320]){
|
||||||
|
const canvas=document.createElement('canvas');canvas.width=width;canvas.height=Math.round(width/ratio);
|
||||||
|
const ctx=canvas.getContext('2d');if(!ctx)throw new Error('Image editor is not available on this device.');
|
||||||
|
ctx.fillStyle='#e9dfc7';ctx.fillRect(0,0,canvas.width,canvas.height);
|
||||||
|
ctx.drawImage(image,x,y,cropWidth,cropHeight,0,0,canvas.width,canvas.height);
|
||||||
|
for(const quality of [0.82,0.68,0.53,0.4]){
|
||||||
|
let value=canvas.toDataURL('image/webp',quality);
|
||||||
|
if(!value.startsWith('data:image/webp;'))value=canvas.toDataURL('image/jpeg',quality);
|
||||||
|
if(value.length<=MAX_DATA_LENGTH)return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('Could not shrink the image enough. Try a simpler or smaller image.');
|
||||||
|
}finally{URL.revokeObjectURL(objectUrl)}
|
||||||
|
}
|
||||||
|
async function upload(event:Event){
|
||||||
|
const input=event.target as HTMLInputElement,file=input.files?.[0];
|
||||||
|
input.value='';if(!file||busy.value)return;
|
||||||
|
busy.value=true;error.value='';
|
||||||
|
try{const portrait=await preparePortrait(file);emit('update:modelValue',portrait)}
|
||||||
|
catch(e){error.value=(e as Error).message}
|
||||||
|
finally{busy.value=false}
|
||||||
|
}
|
||||||
|
function open(){if(!busy.value)picker.value?.click()}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="portrait-picker" :class="{'portrait-compact':compact}">
|
||||||
|
<button type="button" class="portrait-frame" :aria-label="(modelValue?'Change':'Upload')+' portrait for '+name" :disabled="busy" @click="open">
|
||||||
|
<img v-if="modelValue" :src="modelValue" :alt="'Portrait of '+name" />
|
||||||
|
<span v-else class="portrait-initial">{{name?.slice(0,1).toUpperCase()||'✦'}}</span>
|
||||||
|
<span class="portrait-camera" aria-hidden="true">✎</span>
|
||||||
|
</button>
|
||||||
|
<div v-if="!compact" class="portrait-picker-copy">
|
||||||
|
<strong>{{modelValue?'Your character portrait':'Give your hero a face'}}</strong>
|
||||||
|
<p>{{modelValue?'Tap to replace the portrait.':'Select an illustration or photo from this device.'}}</p>
|
||||||
|
<div class="portrait-picker-actions">
|
||||||
|
<button type="button" class="quiet" :disabled="busy" @click="open">{{busy?'Preparing image…':modelValue?'Change portrait':'Upload portrait'}}</button>
|
||||||
|
<button v-if="modelValue" type="button" class="quiet danger-text" :disabled="busy" @click="emit('update:modelValue',null);error=''">Remove</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input ref="picker" class="visually-hidden-portrait" type="file" accept="image/jpeg,image/png,image/webp,image/heic,image/heif" :disabled="busy" :aria-label="'Select portrait image for '+name" @change="upload" />
|
||||||
|
<p v-if="error" class="portrait-error" role="alert">{{error}}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@ -133,10 +133,11 @@ export function spellSlotRestPreview(c:Character,type:'short-rest'|'long-rest'){
|
|||||||
.filter(s=>s.current!==s.max);
|
.filter(s=>s.current!==s.max);
|
||||||
}
|
}
|
||||||
|
|
||||||
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),spellSlots:spellSlotsSchema.optional(),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(),companions:z.array(companionSchema).max(100).refine(items=>new Set(items.map(i=>i.id)).size===items.length,'Companion ids must be unique').optional(),notes:text});
|
export const portraitSchema=z.string().max(320000).regex(/^data:image\/(?:webp|jpeg);base64,[A-Za-z0-9+/]+={0,2}$/,'Use a compressed JPEG or WebP portrait.');
|
||||||
|
export const characterSchema=z.object({schemaVersion:z.literal(1),id:z.string(),name:z.string().min(1).max(120),portrait:portraitSchema.nullable().optional(),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),spellSlots:spellSlotsSchema.optional(),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(),companions:z.array(companionSchema).max(100).refine(items=>new Set(items.map(i=>i.id)).size===items.length,'Companion 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:[],spellSlots:{levels:[],pact:null},inventory:'',currency:{gp:0,sp:0,cp:0},inventoryItems:[],companions:[],notes:''}}
|
export function newCharacter():Character{return {schemaVersion:1,id:crypto.randomUUID(),name:'New adventurer',portrait:null,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:[],spellSlots:{levels:[],pact:null},inventory:'',currency:{gp:0,sp:0,cp:0},inventoryItems:[],companions:[],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}`;
|
||||||
|
|||||||
@ -237,3 +237,24 @@ input[type=checkbox]{accent-color:#3b603d}
|
|||||||
.dm-coins{display:flex;gap:16px;flex-wrap:wrap;margin:13px 0;font-family:Georgia,serif;font-size:19px;color:#70512a}
|
.dm-coins{display:flex;gap:16px;flex-wrap:wrap;margin:13px 0;font-family:Georgia,serif;font-size:19px;color:#70512a}
|
||||||
.dm-toolkit .campaign-actions{flex-wrap:wrap}.campaign-subnav{flex-wrap:wrap}
|
.dm-toolkit .campaign-actions{flex-wrap:wrap}.campaign-subnav{flex-wrap:wrap}
|
||||||
@media(max-width:700px){.dm-toolkit .folio-heading h2{font-size:22px}.dm-toolkit .folio-heading h2 .ui-icon{width:31px;height:31px}.dm-quest-entry h3,.dm-loot-entry h3{font-size:22px}.dm-tool-editor .form-grid.three{grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.dm-toolkit .campaign-actions button{min-height:44px}}
|
@media(max-width:700px){.dm-toolkit .folio-heading h2{font-size:22px}.dm-toolkit .folio-heading h2 .ui-icon{width:31px;height:31px}.dm-quest-entry h3,.dm-loot-entry h3{font-size:22px}.dm-tool-editor .form-grid.three{grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.dm-toolkit .campaign-actions button{min-height:44px}}
|
||||||
|
|
||||||
|
|
||||||
|
/* Character portrait: painted picture-frame on the same continuous parchment. */
|
||||||
|
.portrait-picker{display:flex;gap:16px;align-items:center;flex-wrap:wrap}
|
||||||
|
.portrait-frame{position:relative;display:block;flex:none;overflow:hidden;width:118px;height:146px;padding:0;border:3px solid #aa8549;border-radius:41% 41% 12px 12px / 19% 19% 12px 12px;background:radial-gradient(ellipse at 35% 25%,#f1dbac,#b99b64);box-shadow:0 0 0 2px #eed7a2,0 3px 0 3px #755a38,0 8px 17px #55422236;min-height:0;isolation:isolate}
|
||||||
|
.portrait-frame:hover{background:#dfcfad}
|
||||||
|
.portrait-frame img{width:100%;height:100%;display:block;object-fit:cover;object-position:center}
|
||||||
|
.portrait-initial{display:grid;place-items:center;width:100%;height:100%;font-family:Georgia,serif;font-size:56px;color:#684c2c;text-shadow:0 2px 0 #fff1d884}
|
||||||
|
.portrait-camera{position:absolute;bottom:5px;right:6px;display:grid;place-items:center;width:27px;height:27px;background:#284e3d;color:#f7ead0;border:1px solid #f8e3a2;border-radius:50%;box-shadow:0 2px 5px #36280e66;font-size:17px}
|
||||||
|
.portrait-picker-copy{flex:1;min-width:160px}
|
||||||
|
.portrait-picker-copy strong{display:block;font-family:Georgia,serif;font-weight:400;font-size:20px;color:#59452a}
|
||||||
|
.portrait-picker-copy p{font-size:12px;color:#7d6948;line-height:1.45;margin:5px 0 8px}
|
||||||
|
.portrait-picker-actions{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
|
||||||
|
.portrait-picker-actions .quiet{min-height:44px}
|
||||||
|
.portrait-fieldset{margin:12px 0 24px!important}
|
||||||
|
.portrait-fieldset .hint{font-size:11px;margin:15px 0 0}
|
||||||
|
.portrait-compact{flex:none}
|
||||||
|
.portrait-compact .portrait-frame{width:92px;height:116px}
|
||||||
|
.portrait-error{width:100%;color:#9d2e20;font-size:12px;margin:4px 0;line-height:1.5}
|
||||||
|
.visually-hidden-portrait{position:absolute;width:1px;height:1px;opacity:0;pointer-events:none}
|
||||||
|
@media(max-width:700px){.portrait-compact .portrait-frame{width:69px;height:89px}.portrait-compact .portrait-initial{font-size:35px}.portrait-picker:not(.portrait-compact) .portrait-frame{width:104px;height:132px}}
|
||||||
|
|||||||
@ -1,9 +1,26 @@
|
|||||||
import 'fake-indexeddb/auto';
|
import 'fake-indexeddb/auto';
|
||||||
import {test} from 'node:test';import assert from 'node:assert/strict';
|
import {test} from 'node:test';import assert from 'node:assert/strict';
|
||||||
import {newCharacter,changeHP,modifier,proficiency,skillBonus,applyRest,restPreview,characterSchema} from '../src/model.ts';
|
import {newCharacter,changeHP,modifier,proficiency,skillBonus,applyRest,restPreview,characterSchema,portraitSchema} from '../src/model.ts';
|
||||||
import {CharacterDB,saveCharacter,loadCharacters,deleteCharacter} from '../src/db.ts';
|
import {CharacterDB,saveCharacter,loadCharacters,deleteCharacter} from '../src/db.ts';
|
||||||
test('damage consumes temporary HP, clamps zero, healing clamps max',()=>{const c=newCharacter();c.hp={current:20,max:30,temp:5};changeHP(c,-3);assert.deepEqual(c.hp,{current:20,max:30,temp:2});changeHP(c,-7);assert.deepEqual(c.hp,{current:15,max:30,temp:0});changeHP(c,-100);assert.equal(c.hp.current,0);changeHP(c,100);assert.equal(c.hp.current,30)});
|
test('damage consumes temporary HP, clamps zero, healing clamps max',()=>{const c=newCharacter();c.hp={current:20,max:30,temp:5};changeHP(c,-3);assert.deepEqual(c.hp,{current:20,max:30,temp:2});changeHP(c,-7);assert.deepEqual(c.hp,{current:15,max:30,temp:0});changeHP(c,-100);assert.equal(c.hp.current,0);changeHP(c,100);assert.equal(c.hp.current,30)});
|
||||||
test('ability and proficiency calculations include expertise',()=>{assert.equal(modifier(9),-1);assert.equal(proficiency(1),2);assert.equal(proficiency(5),3);assert.equal(proficiency(20),6);const c=newCharacter();c.level=5;c.abilities.dex=18;c.skills.Stealth='expert';assert.equal(skillBonus(c,'Stealth'),10)});
|
test('ability and proficiency calculations include expertise',()=>{assert.equal(modifier(9),-1);assert.equal(proficiency(1),2);assert.equal(proficiency(5),3);assert.equal(proficiency(20),6);const c=newCharacter();c.level=5;c.abilities.dex=18;c.skills.Stealth='expert';assert.equal(skillBonus(c,'Stealth'),10)});
|
||||||
test('short and long rest reset only declared resources',()=>{const c=newCharacter();c.resources=[{id:'s',name:'Second Wind',current:0,max:1,reset:'short-rest'},{id:'l',name:'Slots',current:0,max:3,reset:'long-rest'},{id:'m',name:'Charges',current:0,max:2,reset:'manual'}];c.hp.current=3;c.hp.temp=4;assert.equal(restPreview(c,'short-rest').length,1);applyRest(c,'short-rest');assert.equal(c.hp.current,3);assert.deepEqual(c.resources.map(r=>r.current),[1,0,0]);applyRest(c,'long-rest');assert.deepEqual(c.resources.map(r=>r.current),[1,3,0]);assert.equal(c.hp.current,10);assert.equal(c.hp.temp,0)});
|
test('short and long rest reset only declared resources',()=>{const c=newCharacter();c.resources=[{id:'s',name:'Second Wind',current:0,max:1,reset:'short-rest'},{id:'l',name:'Slots',current:0,max:3,reset:'long-rest'},{id:'m',name:'Charges',current:0,max:2,reset:'manual'}];c.hp.current=3;c.hp.temp=4;assert.equal(restPreview(c,'short-rest').length,1);applyRest(c,'short-rest');assert.equal(c.hp.current,3);assert.deepEqual(c.resources.map(r=>r.current),[1,0,0]);applyRest(c,'long-rest');assert.deepEqual(c.resources.map(r=>r.current),[1,3,0]);assert.equal(c.hp.current,10);assert.equal(c.hp.temp,0)});
|
||||||
test('imports reject invalid characters and counters',()=>{const c=newCharacter();c.level=21;assert.equal(characterSchema.safeParse(c).success,false);c.level=1;c.resources=[{id:'x',name:'x',current:2,max:1,reset:'manual'}];assert.equal(characterSchema.safeParse(c).success,false)});
|
test('imports reject invalid characters and counters',()=>{const c=newCharacter();c.level=21;assert.equal(characterSchema.safeParse(c).success,false);c.level=1;c.resources=[{id:'x',name:'x',current:2,max:1,reset:'manual'}];assert.equal(characterSchema.safeParse(c).success,false)});
|
||||||
test('IndexedDB persists and separates characters by account',async()=>{const database=new CharacterDB('test-'+crypto.randomUUID()),c=newCharacter();c.name='First account';await saveCharacter('alice',c,database);const other=structuredClone(c);other.name='Second account';await saveCharacter('bob',other,database);assert.equal((await loadCharacters('alice',database))[0].name,'First account');assert.equal((await loadCharacters('bob',database))[0].name,'Second account');await deleteCharacter('alice',c.id,database);assert.equal((await loadCharacters('alice',database)).length,0);assert.equal((await loadCharacters('bob',database)).length,1);await database.delete()});
|
test('IndexedDB persists and separates characters by account',async()=>{const database=new CharacterDB('test-'+crypto.randomUUID()),c=newCharacter();c.name='First account';await saveCharacter('alice',c,database);const other=structuredClone(c);other.name='Second account';await saveCharacter('bob',other,database);assert.equal((await loadCharacters('alice',database))[0].name,'First account');assert.equal((await loadCharacters('bob',database))[0].name,'Second account');await deleteCharacter('alice',c.id,database);assert.equal((await loadCharacters('alice',database)).length,0);assert.equal((await loadCharacters('bob',database)).length,1);await database.delete()});
|
||||||
|
|
||||||
|
test('character portrait accepts compact JPEG/WebP image data, rejects unsafe and huge inputs',()=>{
|
||||||
|
const c=newCharacter(),portrait='data:image/webp;base64,'+'A'.repeat(24);
|
||||||
|
c.portrait=portrait;assert.equal(characterSchema.parse(c).portrait,portrait);
|
||||||
|
c.portrait=null;assert.equal(characterSchema.parse(c).portrait,null);
|
||||||
|
delete c.portrait;assert.equal(characterSchema.parse(c).portrait,undefined);
|
||||||
|
for(const invalid of ['https://another.site/image.png','data:image/svg+xml;base64,AAAA','data:image/png;base64,AAAA','data:image/webp;base64,@@@','data:image/jpeg;base64,'+'A'.repeat(320001)])
|
||||||
|
assert.equal(portraitSchema.safeParse(invalid).success,false);
|
||||||
|
});
|
||||||
|
test('portrait round trips through offline saves and JSON backup',async()=>{
|
||||||
|
const db=new CharacterDB('portrait-'+crypto.randomUUID()),c=newCharacter();
|
||||||
|
c.portrait='data:image/jpeg;base64,'+'a'.repeat(64);
|
||||||
|
await saveCharacter('alice',c,db);assert.equal((await loadCharacters('alice',db))[0].portrait,c.portrait);
|
||||||
|
const backup=JSON.stringify((await loadCharacters('alice',db))[0]);
|
||||||
|
assert.equal(characterSchema.parse(JSON.parse(backup)).portrait,c.portrait);
|
||||||
|
await db.delete();
|
||||||
|
});
|
||||||
|
|||||||
@ -4,3 +4,17 @@ function remote(){const rows=new Map<string,any>();const request=(async(url:stri
|
|||||||
test('existing device saves upload once, pull MCP edits, and delete using account revisions',async()=>{const database=new CharacterDB('sync-'+crypto.randomUUID()),r=remote(),c=newCharacter();await saveCharacter('alice',c,database);await syncCharacters('alice',database,r.request);assert.equal(r.rows.get(c.id).revision,1);await syncCharacters('alice',database,r.request);assert.equal(r.rows.get(c.id).revision,1);r.rows.set(c.id,{...r.rows.get(c.id),revision:2,character:{...c,name:'MCP edit'}});await syncCharacters('alice',database,r.request);assert.equal((await loadCharacters('alice',database))[0].name,'MCP edit');assert.equal(await saveCharacter('alice',(await loadCharacters('alice',database))[0],database),false);await deleteCharacter('alice',c.id,database);await syncCharacters('alice',database,r.request);assert.equal(r.rows.get(c.id).deleted,true);assert.equal((await loadCharacters('alice',database)).length,0);await database.delete()});
|
test('existing device saves upload once, pull MCP edits, and delete using account revisions',async()=>{const database=new CharacterDB('sync-'+crypto.randomUUID()),r=remote(),c=newCharacter();await saveCharacter('alice',c,database);await syncCharacters('alice',database,r.request);assert.equal(r.rows.get(c.id).revision,1);await syncCharacters('alice',database,r.request);assert.equal(r.rows.get(c.id).revision,1);r.rows.set(c.id,{...r.rows.get(c.id),revision:2,character:{...c,name:'MCP edit'}});await syncCharacters('alice',database,r.request);assert.equal((await loadCharacters('alice',database))[0].name,'MCP edit');assert.equal(await saveCharacter('alice',(await loadCharacters('alice',database))[0],database),false);await deleteCharacter('alice',c.id,database);await syncCharacters('alice',database,r.request);assert.equal(r.rows.get(c.id).deleted,true);assert.equal((await loadCharacters('alice',database)).length,0);await database.delete()});
|
||||||
test('offline conflict preserves local edits as a new copy and pulls remote sheet',async()=>{const database=new CharacterDB('sync-'+crypto.randomUUID()),r=remote(),c=newCharacter();await saveCharacter('alice',c,database);await syncCharacters('alice',database,r.request);await saveCharacter('alice',{...c,notes:'Offline edits'},database);r.rows.set(c.id,{...r.rows.get(c.id),revision:2,character:{...c,notes:'MCP edits'}});const result=await syncCharacters('alice',database,r.request);assert.equal(result.conflicts,1);assert.equal(result.characters.length,2);assert.equal(result.characters.find(x=>x.id===c.id)?.notes,'MCP edits');assert.equal(result.characters.find(x=>x.id!==c.id)?.notes,'Offline edits');await syncCharacters('alice',database,r.request);assert.equal(r.rows.size,2);await database.delete()});
|
test('offline conflict preserves local edits as a new copy and pulls remote sheet',async()=>{const database=new CharacterDB('sync-'+crypto.randomUUID()),r=remote(),c=newCharacter();await saveCharacter('alice',c,database);await syncCharacters('alice',database,r.request);await saveCharacter('alice',{...c,notes:'Offline edits'},database);r.rows.set(c.id,{...r.rows.get(c.id),revision:2,character:{...c,notes:'MCP edits'}});const result=await syncCharacters('alice',database,r.request);assert.equal(result.conflicts,1);assert.equal(result.characters.length,2);assert.equal(result.characters.find(x=>x.id===c.id)?.notes,'MCP edits');assert.equal(result.characters.find(x=>x.id!==c.id)?.notes,'Offline edits');await syncCharacters('alice',database,r.request);assert.equal(r.rows.size,2);await database.delete()});
|
||||||
test('network failure retains pending saves and does not touch another local account',async()=>{const database=new CharacterDB('sync-'+crypto.randomUUID()),c=newCharacter();await saveCharacter('alice',c,database);await saveCharacter('bob',{...c,name:'Bob'},database);await assert.rejects(syncCharacters('alice',database,(async()=>{throw new TypeError('offline')}) as typeof fetch));assert.equal((await loadCharacters('alice',database)).length,1);assert.equal((await loadCharacters('bob',database))[0].name,'Bob');await database.delete()});
|
test('network failure retains pending saves and does not touch another local account',async()=>{const database=new CharacterDB('sync-'+crypto.randomUUID()),c=newCharacter();await saveCharacter('alice',c,database);await saveCharacter('bob',{...c,name:'Bob'},database);await assert.rejects(syncCharacters('alice',database,(async()=>{throw new TypeError('offline')}) as typeof fetch));assert.equal((await loadCharacters('alice',database)).length,1);assert.equal((await loadCharacters('bob',database))[0].name,'Bob');await database.delete()});
|
||||||
|
|
||||||
|
test('portrait remains after sync and later edits, and explicit removal syncs',async()=>{
|
||||||
|
const database=new CharacterDB('portrait-sync-'+crypto.randomUUID()),r=remote(),c=newCharacter();
|
||||||
|
c.portrait='data:image/webp;base64,'+'a'.repeat(72);
|
||||||
|
await saveCharacter('alice',c,database);await syncCharacters('alice',database,r.request);
|
||||||
|
assert.equal(r.rows.get(c.id).character.portrait,c.portrait);
|
||||||
|
const edited=(await loadCharacters('alice',database))[0];edited.name='Updated bard';
|
||||||
|
await saveCharacter('alice',edited,database);await syncCharacters('alice',database,r.request);
|
||||||
|
assert.equal(r.rows.get(c.id).character.portrait,c.portrait);
|
||||||
|
edited.portrait=null;await saveCharacter('alice',edited,database);
|
||||||
|
await syncCharacters('alice',database,r.request);
|
||||||
|
assert.equal(r.rows.get(c.id).character.portrait,null);
|
||||||
|
await database.delete();
|
||||||
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user