Mirror private dnd-character-pwa a62203a: companion statistics and stable tracking
This commit is contained in:
parent
4ec9e97716
commit
379afb9fb8
@ -4,7 +4,7 @@ import {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js';
|
|||||||
import {StreamableHTTPServerTransport} from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
import {StreamableHTTPServerTransport} from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||||
import {z} from 'zod';
|
import {z} from 'zod';
|
||||||
import {randomInt,randomUUID} from 'node:crypto';
|
import {randomInt,randomUUID} from 'node:crypto';
|
||||||
import {characterSchema,newCharacter,changeHP,applyRest} from './model.mjs';
|
import {characterSchema,newCharacter,changeHP,applyRest,companionSchema,stableLocationSchema,addCompanion,stableCompanion,retrieveCompanion,changeCompanionHP} 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.8.0'});
|
const server=new McpServer({name:'arrowstack-character',version:'0.8.0'});
|
||||||
@ -21,6 +21,12 @@ export function characterMcp({storage,campaigns,catalogue,sub,scopes}){
|
|||||||
tool('change_hit_points','Apply signed HP change: negative damages temporary HP first; positive heals up to maximum.',{id,expected_revision:revision,amount:z.number().int().min(-9999).max(9999)},write,async a=>mutate(a,c=>changeHP(c,a.amount)));
|
tool('change_hit_points','Apply signed HP change: negative damages temporary HP first; positive heals up to maximum.',{id,expected_revision:revision,amount:z.number().int().min(-9999).max(9999)},write,async a=>mutate(a,c=>changeHP(c,a.amount)));
|
||||||
tool('apply_rest','Apply declared short/long-rest resource resets. Long rest also restores HP and clears temporary HP. Hit dice and edition/class-specific rules remain manual.',{id,expected_revision:revision,type:z.enum(['short-rest','long-rest'])},write,async a=>mutate(a,c=>applyRest(c,a.type)));
|
tool('apply_rest','Apply declared short/long-rest resource resets. Long rest also restores HP and clears temporary HP. Hit dice and edition/class-specific rules remain manual.',{id,expected_revision:revision,type:z.enum(['short-rest','long-rest'])},write,async a=>mutate(a,c=>applyRest(c,a.type)));
|
||||||
tool('set_resource','Set remaining uses for one resource within its maximum.',{id,expected_revision:revision,resource_id:id,remaining:z.number().int().min(0).max(9999)},write,async a=>mutate(a,c=>{const r=c.resources.find(r=>r.id===a.resource_id);if(!r||a.remaining>r.max)throw new Error('Resource missing or remaining uses exceed maximum');r.current=a.remaining}));
|
tool('set_resource','Set remaining uses for one resource within its maximum.',{id,expected_revision:revision,resource_id:id,remaining:z.number().int().min(0).max(9999)},write,async a=>mutate(a,c=>{const r=c.resources.find(r=>r.id===a.resource_id);if(!r||a.remaining>r.max)throw new Error('Resource missing or remaining uses exceed maximum');r.current=a.remaining}));
|
||||||
|
|
||||||
|
tool('list_companions','Read horses, mounts and pets, including stats, current whereabouts and last stable.',{id},read,async({id})=>{const row=await get(id);return {revision:row.revision,companions:row.character.companions||[]}});
|
||||||
|
tool('add_companion','Add a mount or pet using complete validated companion details.',{id,expected_revision:revision,companion:companionSchema},write,async a=>mutate(a,c=>addCompanion(c,a.companion)));
|
||||||
|
tool('stable_companion','Record where and when a mount or pet was stabled, preserving the last stable after collection.',{id,expected_revision:revision,companion_id:id,stable:stableLocationSchema},write,async a=>mutate(a,c=>{const animal=(c.companions||[]).find(x=>x.id===a.companion_id);if(!animal)throw new Error('Companion not found');stableCompanion(animal,a.stable)}));
|
||||||
|
tool('retrieve_companion','Mark a mount or pet as travelling with the party without forgetting its last stable.',{id,expected_revision:revision,companion_id:id},write,async a=>mutate(a,c=>{const animal=(c.companions||[]).find(x=>x.id===a.companion_id);if(!animal)throw new Error('Companion not found');retrieveCompanion(animal)}));
|
||||||
|
tool('change_companion_hp','Apply damage or healing to a named companion up to its HP maximum.',{id,expected_revision:revision,companion_id:id,amount:z.number().int().min(-9999).max(9999)},write,async a=>mutate(a,c=>{const animal=(c.companions||[]).find(x=>x.id===a.companion_id);if(!animal)throw new Error('Companion not found');changeCompanionHP(animal,a.amount)}));
|
||||||
tool('delete_character','Delete a saved character across synced devices. Export a backup before deletion.',{id,expected_revision:revision},{...write,destructiveHint:true},async({id,expected_revision})=>{allowed();await get(id);return {sheet:await storage.put(sub,id,null,expected_revision,true)}});
|
tool('delete_character','Delete a saved character across synced devices. Export a backup before deletion.',{id,expected_revision:revision},{...write,destructiveHint:true},async({id,expected_revision})=>{allowed();await get(id);return {sheet:await storage.put(sub,id,null,expected_revision,true)}});
|
||||||
tool('roll_dice','Roll cryptographically random dice. Does not change the character.',{count:z.number().int().min(1).max(100).default(1),sides:z.number().int().min(2).max(1000).default(20),bonus:z.number().int().min(-10000).max(10000).default(0)},{...read,idempotentHint:false},async({count,sides,bonus})=>{const rolls=Array.from({length:count},()=>randomInt(1,sides+1));return {rolls,bonus,total:rolls.reduce((a,b)=>a+b,bonus)}});
|
tool('roll_dice','Roll cryptographically random dice. Does not change the character.',{count:z.number().int().min(1).max(100).default(1),sides:z.number().int().min(2).max(1000).default(20),bonus:z.number().int().min(-10000).max(10000).default(0)},{...read,idempotentHint:false},async({count,sides,bonus})=>{const rolls=Array.from({length:count},()=>randomInt(1,sides+1));return {rolls,bonus,total:rolls.reduce((a,b)=>a+b,bonus)}});
|
||||||
server.registerResource('characters','character://collection',{description:'Your saved character collection',mimeType:'application/json'},async uri=>({contents:[{uri:uri.href,mimeType:'application/json',text:JSON.stringify((await storage.list(sub)).filter(r=>!r.deleted))}]}));
|
server.registerResource('characters','character://collection',{description:'Your saved character collection',mimeType:'application/json'},async uri=>({contents:[{uri:uri.href,mimeType:'application/json',text:JSON.stringify((await storage.list(sub)).filter(r=>!r.deleted))}]}));
|
||||||
|
|||||||
@ -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)){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]}}
|
if(character&&!deleted&&revision>0&&(character.inventoryItems===undefined||character.currency===undefined||character.companions===undefined)){const prior=await this.get(owner,id);for(const key of ['inventoryItems','currency','companions'])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];
|
||||||
|
|||||||
@ -5,9 +5,11 @@ import UiIcon from './UiIcon.vue';
|
|||||||
import CampaignsView from './CampaignsView.vue';
|
import CampaignsView from './CampaignsView.vue';
|
||||||
import RulesPicker,{type RuleEntry} from './RulesPicker.vue';
|
import RulesPicker,{type RuleEntry} from './RulesPicker.vue';
|
||||||
import InventoryFolio from './InventoryFolio.vue';
|
import InventoryFolio from './InventoryFolio.vue';
|
||||||
|
import CompanionsFolio from './CompanionsFolio.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,applyRest,type Character} from './model';
|
import {abilities,modifier,proficiency,signed,skills,skillBonus,addInventoryItem,changeHP,newCharacter,characterSchema,restPreview,applyRest,type Character} from './model';
|
||||||
|
const gearSection=ref<'equipment'|'companions'>('equipment');
|
||||||
const campaignMode=ref(false),catalogueMode=ref<'action'|'spell'|'item'|null>(null);
|
const campaignMode=ref(false),catalogueMode=ref<'action'|'spell'|'item'|null>(null);
|
||||||
const store=useCharacters(),activeId=ref(''),tab=ref('Sheet'),drawer=ref(''),error=ref(''),message=ref(''),user=ref<{sub:string,name:string,expiresAt:number}|null>(null),loading=ref(true),offline=ref(!navigator.onLine);
|
const store=useCharacters(),activeId=ref(''),tab=ref('Sheet'),drawer=ref(''),error=ref(''),message=ref(''),user=ref<{sub:string,name:string,expiresAt:number}|null>(null),loading=ref(true),offline=ref(!navigator.onLine);
|
||||||
const c=computed(()=>store.all.find(x=>x.id===activeId.value));
|
const c=computed(()=>store.all.find(x=>x.id===activeId.value));
|
||||||
@ -81,7 +83,11 @@ function openAction(action:Character['actions'][number]){selectedAction.value=ac
|
|||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
<InventoryFolio v-if="tab==='Gear'" :character="c" @browse="catalogueMode='item'" />
|
<template v-if="tab==='Gear'">
|
||||||
|
<nav class="gear-subnav" aria-label="Gear sections"><button :class="{active:gearSection==='equipment'}" :aria-pressed="gearSection==='equipment'" @click="gearSection='equipment'"><UiIcon name="gear" />Equipment</button><button :class="{active:gearSection==='companions'}" :aria-pressed="gearSection==='companions'" @click="gearSection='companions'"><UiIcon name="speed" />Mounts & pets <span class="gear-count">{{c.companions?.length||0}}</span></button></nav>
|
||||||
|
<InventoryFolio v-if="gearSection==='equipment'" :character="c" @browse="catalogueMode='item'" />
|
||||||
|
<CompanionsFolio v-else :character="c" />
|
||||||
|
</template>
|
||||||
<template v-if="tab==='More'"><section class="panel"><div class="section-title"><h2><UiIcon name="notes" />Your story</h2><span class="chip">Saved automatically</span></div><textarea v-model="c.notes" rows="8" placeholder="Backstory, allies, session notes…" maxlength="12000"></textarea></section><section class="panel"><h2>Character & account</h2><p class="hint">Signed in as {{user.name}}. Characters sync to your Arrowstack account and stay available on this device for offline play. Export backups to keep an extra copy.</p><div class="settings-actions"><button @click="edit">Edit character</button><button @click="exportCharacter">Export JSON backup</button><label class="button">Import JSON<input type="file" accept="application/json,.json" hidden @change="importCharacter"></label><button @click="create">New character</button><button @click="drawer='Manage resources'">Manage resources</button><button class="danger-text" @click="remove">Delete character</button><button @click="logout" :disabled="offline">Sign out</button></div><p class="hint">Offline play lasts until your 12-hour sign-in session expires. Reconnect to sign in again. Attack modifiers, spell details and resource reset rules are entered manually; automated class rules are a future feature.</p></section></template>
|
<template v-if="tab==='More'"><section class="panel"><div class="section-title"><h2><UiIcon name="notes" />Your story</h2><span class="chip">Saved automatically</span></div><textarea v-model="c.notes" rows="8" placeholder="Backstory, allies, session notes…" maxlength="12000"></textarea></section><section class="panel"><h2>Character & account</h2><p class="hint">Signed in as {{user.name}}. Characters sync to your Arrowstack account and stay available on this device for offline play. Export backups to keep an extra copy.</p><div class="settings-actions"><button @click="edit">Edit character</button><button @click="exportCharacter">Export JSON backup</button><label class="button">Import JSON<input type="file" accept="application/json,.json" hidden @change="importCharacter"></label><button @click="create">New character</button><button @click="drawer='Manage resources'">Manage resources</button><button class="danger-text" @click="remove">Delete character</button><button @click="logout" :disabled="offline">Sign out</button></div><p class="hint">Offline play lasts until your 12-hour sign-in session expires. Reconnect to sign in again. Attack modifiers, spell details and resource reset rules are entered manually; automated class rules are a future feature.</p></section></template>
|
||||||
</template>
|
</template>
|
||||||
<footer v-if="campaignMode||!['Actions','Spells','Gear'].includes(tab)">✦ ARROWSTACK CHARACTER <span>v0.4 · Made for game night</span></footer>
|
<footer v-if="campaignMode||!['Actions','Spells','Gear'].includes(tab)">✦ ARROWSTACK CHARACTER <span>v0.4 · Made for game night</span></footer>
|
||||||
|
|||||||
83
src/CompanionsFolio.vue
Normal file
83
src/CompanionsFolio.vue
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {computed,ref,watch} from 'vue';
|
||||||
|
import UiIcon from './UiIcon.vue';
|
||||||
|
import {abilities,modifier,signed,companionSchema,createCompanion,addCompanion,stableCompanion,retrieveCompanion,changeCompanionHP,type Character,type Companion,type StableLocation} from './model';
|
||||||
|
|
||||||
|
const props=defineProps<{character:Character}>();
|
||||||
|
const animals=computed(()=>props.character.companions||[]);
|
||||||
|
const editing=ref(false),editingId=ref<string|null>(null),draft=ref<Companion>(createCompanion()),preset=ref<'riding-horse'|'warhorse'|'pony'|'mastiff'|'custom'>('custom');
|
||||||
|
const stabling=ref(false),stableId=ref<string|null>(null),stableDraft=ref<StableLocation>({name:'',settlement:'',date:today()});
|
||||||
|
const error=ref(''),stableError=ref('');
|
||||||
|
const kinds:Companion['kind'][]=['horse','mount','pet'];
|
||||||
|
function today(){const now=new Date();return new Date(now.getTime()-now.getTimezoneOffset()*60000).toISOString().slice(0,10)}
|
||||||
|
function label(kind:Companion['kind']){return kind==='horse'?'Horse':kind==='mount'?'Mount':'Pet'}
|
||||||
|
function showError(e:unknown){return e instanceof Error&&'issues' in e?'Please check the name and statistics. Current HP cannot exceed maximum HP.':e instanceof Error?e.message:'Please check the companion details.'}
|
||||||
|
function add(kind:Companion['kind']){
|
||||||
|
preset.value=kind==='horse'?'riding-horse':kind==='pet'?'mastiff':'pony';
|
||||||
|
draft.value=createCompanion(kind,preset.value);editingId.value=null;error.value='';editing.value=true;
|
||||||
|
}
|
||||||
|
function edit(animal:Companion){draft.value=structuredClone(JSON.parse(JSON.stringify(animal)));editingId.value=animal.id;preset.value='custom';error.value='';editing.value=true}
|
||||||
|
function applyPreset(){
|
||||||
|
const example=createCompanion(draft.value.kind,preset.value);
|
||||||
|
draft.value={...draft.value,species:example.species,ac:example.ac,hp:example.hp,speed:example.speed,abilities:example.abilities,carryingCapacity:example.carryingCapacity};
|
||||||
|
}
|
||||||
|
function save(){
|
||||||
|
try{
|
||||||
|
const value=companionSchema.parse(draft.value);
|
||||||
|
if(editingId.value){
|
||||||
|
if(!animals.value.some(a=>a.id===editingId.value))throw new Error('This companion was changed. Reopen the editor.');
|
||||||
|
props.character.companions=animals.value.map(a=>a.id===editingId.value?value:a);
|
||||||
|
}else addCompanion(props.character,value);
|
||||||
|
editing.value=false;error.value='';
|
||||||
|
}catch(e){error.value=showError(e)}
|
||||||
|
}
|
||||||
|
function remove(animal:Companion){if(confirm('Remove '+animal.name+' from this character?'))props.character.companions=animals.value.filter(a=>a.id!==animal.id)}
|
||||||
|
function stableEditor(animal:Companion){
|
||||||
|
stableId.value=animal.id;stableDraft.value=animal.lastStabled?{...animal.lastStabled}:{name:'',settlement:'',date:today()};
|
||||||
|
stableError.value='';stabling.value=true;
|
||||||
|
}
|
||||||
|
function saveStable(){
|
||||||
|
const animal=animals.value.find(a=>a.id===stableId.value);
|
||||||
|
if(!animal){stableError.value='Companion not found. Reopen the stable record.';return}
|
||||||
|
try{stableCompanion(animal,stableDraft.value);stableError.value='';stabling.value=false}catch(e){stableError.value=showError(e)}
|
||||||
|
}
|
||||||
|
function collect(animal:Companion){retrieveCompanion(animal)}
|
||||||
|
function hp(animal:Companion,amount:number){changeCompanionHP(animal,amount)}
|
||||||
|
watch(()=>props.character.id,()=>{editing.value=false;stabling.value=false;error.value='';stableError.value=''});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="actions-folio companions-folio" aria-labelledby="companions-heading">
|
||||||
|
<div class="folio-heading"><h2 id="companions-heading"><UiIcon name="speed" />Mounts & pets</h2><div class="folio-heading-tools"><button class="quiet folio-add" @click="add('horse')">+ Horse</button><button class="quiet folio-add" @click="add('mount')">+ Mount</button><button class="quiet folio-add" @click="add('pet')">+ Pet</button></div></div>
|
||||||
|
<p class="folio-spell-hint">Keep their stats, whereabouts and the last place you stabled them. These records stay with your character, even offline.</p>
|
||||||
|
<div v-if="!animals.length" class="folio-empty"><UiIcon name="speed" /><h3>Every adventurer needs a companion.</h3><p>Whether it's a trusty steed, a stubborn mule or a curious familiar, give them a place in your story.</p><button class="primary" @click="add('horse')">Add your first companion</button></div>
|
||||||
|
<article v-for="animal in animals" :key="animal.id" class="companion-entry">
|
||||||
|
<div class="companion-heading"><span class="companion-emblem" aria-hidden="true">{{animal.kind==='horse'?'♞':animal.kind==='mount'?'✦':'♡'}}</span><div class="companion-intro"><p class="eyebrow">{{label(animal.kind)}} · {{animal.species||'Custom companion'}}</p><h3>{{animal.name}}</h3></div><span class="companion-status" :class="animal.status">{{animal.status==='travelling'?'With party':animal.status==='stabled'?'Stabled':'Elsewhere'}}</span></div>
|
||||||
|
<div class="companion-location"><div><small>Current whereabouts</small><strong>{{animal.currentLocation||'Not recorded'}}</strong></div><div><small>Last stabled</small><strong v-if="animal.lastStabled">{{animal.lastStabled.name}}{{animal.lastStabled.settlement?', '+animal.lastStabled.settlement:''}}</strong><strong v-else>Not yet recorded</strong><span v-if="animal.lastStabled?.date">{{animal.lastStabled.date}}</span></div></div>
|
||||||
|
<div class="companion-stats"><div><small>AC</small><strong>{{animal.ac}}</strong></div><div><small>Speed</small><strong>{{animal.speed}} <em>ft</em></strong></div><div><small>Carry</small><strong>{{animal.carryingCapacity||'—'}} <em v-if="animal.carryingCapacity">lb</em></strong></div><div class="companion-hp"><small>Hit points</small><div><button class="quiet" @click="hp(animal,-1)" :disabled="animal.hp.current===0" :aria-label="'Damage '+animal.name+' by one HP'">−</button><strong>{{animal.hp.current}} / {{animal.hp.max}}</strong><button class="quiet" @click="hp(animal,1)" :disabled="animal.hp.current===animal.hp.max" :aria-label="'Heal '+animal.name+' by one HP'">+</button></div></div></div>
|
||||||
|
<details class="folio-options companion-details"><summary>Abilities, traits & notes</summary><div class="companion-abilities"><div v-for="a in abilities" :key="a"><small>{{a.toUpperCase()}}</small><strong>{{animal.abilities[a]}}</strong><span>{{signed(modifier(animal.abilities[a]))}}</span></div></div><p v-if="animal.traits" class="folio-action-notes">{{animal.traits}}</p><p v-if="animal.notes" class="folio-action-notes">{{animal.notes}}</p><p v-if="!animal.traits&&!animal.notes" class="hint">Add attacks, abilities, tack, bonds or feeding notes in the editor.</p></details>
|
||||||
|
<div class="companion-actions"><button class="quiet" @click="stableEditor(animal)">Record stable</button><button v-if="animal.status==='stabled'" class="quiet" @click="collect(animal)">Collect animal</button><button class="quiet" @click="edit(animal)">Edit stats</button><button class="quiet danger-text" @click="remove(animal)">Remove</button></div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div v-if="editing" class="overlay" @click.self="editing=false"><section class="drawer companion-drawer" role="dialog" aria-modal="true" :aria-label="editingId?'Edit companion':'Add companion'"><div class="section-title drawer-heading"><h2><UiIcon name="speed" />{{editingId?'Edit companion':'New companion'}}</h2><button class="close" @click="editing=false" aria-label="Close companion editor">×</button></div>
|
||||||
|
<p v-if="error" class="notice danger" role="alert">{{error}}</p>
|
||||||
|
<form @submit.prevent="save">
|
||||||
|
<div class="form-grid"><label>Name<input v-model="draft.name" maxlength="120" required placeholder="Biscuit"></label><label>Kind<select v-model="draft.kind"><option v-for="kind in kinds" :key="kind" :value="kind">{{label(kind)}}</option></select></label></div>
|
||||||
|
<label>Starting stat block<select v-model="preset" @change="applyPreset"><option value="custom">Custom (leave current stats)</option><option value="riding-horse">Riding horse</option><option value="warhorse">Warhorse</option><option value="pony">Pony</option><option value="mastiff">Mastiff</option></select></label>
|
||||||
|
<p class="hint">Starter stats are editable. Your DM can adjust them for your game.</p>
|
||||||
|
<label>Creature / species<input v-model="draft.species" maxlength="120" placeholder="Riding horse"></label>
|
||||||
|
<div class="form-grid companion-vitals"><label>Armour class<input type="number" v-model.number="draft.ac" min="0" max="50" required></label><label>Current HP<input type="number" v-model.number="draft.hp.current" min="0" :max="draft.hp.max" required></label><label>Max HP<input type="number" v-model.number="draft.hp.max" min="1" max="9999" required></label><label>Speed (ft)<input type="number" v-model.number="draft.speed" min="0" max="500" required></label><label>Carry (lb)<input type="number" v-model.number="draft.carryingCapacity" min="0" max="99999" required></label></div>
|
||||||
|
<fieldset class="folio-section"><legend>Ability scores</legend><div class="form-grid three"><label v-for="a in abilities" :key="a">{{a.toUpperCase()}}<input type="number" v-model.number="draft.abilities[a]" min="1" max="30" required></label></div></fieldset>
|
||||||
|
<div class="form-grid"><label>Current status<select v-model="draft.status"><option value="travelling">Travelling</option><option value="stabled">Stabled</option><option value="elsewhere">Elsewhere</option></select></label><label>Current whereabouts<input v-model="draft.currentLocation" maxlength="200" placeholder="With the party"></label></div>
|
||||||
|
<p class="hint">Use “Record stable” on the companion card to save the stable's name, town and date.</p>
|
||||||
|
<label>Traits & actions<textarea v-model="draft.traits" rows="3" maxlength="12000" placeholder="Special abilities, attacks, senses…"></textarea></label>
|
||||||
|
<label>Care & story notes<textarea v-model="draft.notes" rows="3" maxlength="12000" placeholder="Temperament, tack, supplies, bonds…"></textarea></label>
|
||||||
|
<button class="primary wide">{{editingId?'Save companion':'Add companion'}}</button>
|
||||||
|
</form></section></div>
|
||||||
|
|
||||||
|
<div v-if="stabling" class="overlay" @click.self="stabling=false"><section class="drawer companion-drawer" role="dialog" aria-modal="true" aria-label="Record last stable"><div class="section-title drawer-heading"><h2><UiIcon name="speed" />Record stable</h2><button class="close" @click="stabling=false" aria-label="Close stable editor">×</button></div>
|
||||||
|
<p class="hint">Where did you leave this companion? Their last stable remains recorded after you collect them.</p><p v-if="stableError" class="notice danger" role="alert">{{stableError}}</p>
|
||||||
|
<form @submit.prevent="saveStable"><label>Stable or keeper<input v-model="stableDraft.name" maxlength="120" required placeholder="The Copper Horseshoe"></label><label>Town / region<input v-model="stableDraft.settlement" maxlength="120" placeholder="Neverwinter"></label><label>Date stabled<input type="date" v-model="stableDraft.date" required></label><button class="primary wide">Save stable & mark as stabled</button></form>
|
||||||
|
</section></div>
|
||||||
|
</template>
|
||||||
61
src/model.ts
61
src/model.ts
@ -14,11 +14,68 @@ export const resourceSchema=z.object({id:z.string(),name:text,current:counter,ma
|
|||||||
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 stableLocationSchema=z.object({
|
||||||
|
name:z.string().trim().min(1).max(120),
|
||||||
|
settlement:z.string().trim().max(120),
|
||||||
|
date:z.string().regex(/^\d{4}-\d{2}-\d{2}$/,'Use a YYYY-MM-DD date')
|
||||||
|
});
|
||||||
|
export const companionSchema=z.object({
|
||||||
|
id:z.string().min(1).max(200),
|
||||||
|
name:z.string().trim().min(1).max(120),
|
||||||
|
kind:z.enum(['horse','mount','pet']),
|
||||||
|
species:z.string().trim().max(120),
|
||||||
|
ac:z.number().int().min(0).max(50),
|
||||||
|
hp:z.object({current:counter,max:z.number().int().min(1).max(9999)}).refine(h=>h.current<=h.max,'Current HP exceeds maximum'),
|
||||||
|
speed:z.number().int().min(0).max(500),
|
||||||
|
abilities:z.object({str:score,dex:score,con:score,int:score,wis:score,cha:score}),
|
||||||
|
carryingCapacity:z.number().int().min(0).max(99999),
|
||||||
|
traits:text,
|
||||||
|
notes:text,
|
||||||
|
status:z.enum(['travelling','stabled','elsewhere']),
|
||||||
|
currentLocation:z.string().trim().max(200),
|
||||||
|
lastStabled:stableLocationSchema.nullable()
|
||||||
|
});
|
||||||
|
export type Companion=z.infer<typeof companionSchema>;
|
||||||
|
export type StableLocation=z.infer<typeof stableLocationSchema>;
|
||||||
|
export function createCompanion(kind:Companion['kind']='horse',template:'riding-horse'|'warhorse'|'pony'|'mastiff'|'custom'='custom'):Companion{
|
||||||
|
const presets={
|
||||||
|
'riding-horse':{species:'Riding horse',ac:10,hp:13,speed:60,str:16,dex:10,con:12,int:2,wis:11,cha:7,carryingCapacity:480},
|
||||||
|
warhorse:{species:'Warhorse',ac:11,hp:19,speed:60,str:18,dex:12,con:13,int:2,wis:12,cha:7,carryingCapacity:540},
|
||||||
|
pony:{species:'Pony',ac:10,hp:11,speed:40,str:15,dex:10,con:13,int:2,wis:11,cha:7,carryingCapacity:450},
|
||||||
|
mastiff:{species:'Mastiff',ac:12,hp:5,speed:40,str:13,dex:14,con:12,int:3,wis:12,cha:7,carryingCapacity:0},
|
||||||
|
custom:{species:'',ac:10,hp:5,speed:30,str:10,dex:10,con:10,int:10,wis:10,cha:10,carryingCapacity:0}
|
||||||
|
};
|
||||||
|
const s=presets[template];
|
||||||
|
return {id:crypto.randomUUID(),name:'',kind,species:s.species,ac:s.ac,hp:{current:s.hp,max:s.hp},speed:s.speed,
|
||||||
|
abilities:{str:s.str,dex:s.dex,con:s.con,int:s.int,wis:s.wis,cha:s.cha},carryingCapacity:s.carryingCapacity,
|
||||||
|
traits:'',notes:'',status:'travelling',currentLocation:'With the party',lastStabled:null};
|
||||||
|
}
|
||||||
|
export function addCompanion(c:Character,entry:Companion){
|
||||||
|
const animal=companionSchema.parse(entry),existing=c.companions||[];
|
||||||
|
if(existing.length>=100)throw new Error('You can track up to 100 companions.');
|
||||||
|
if(existing.some(x=>x.id===animal.id))throw new Error('Companion id already exists.');
|
||||||
|
c.companions=[...existing,animal];
|
||||||
|
return animal;
|
||||||
|
}
|
||||||
|
export function stableCompanion(animal:Companion,stable:StableLocation){
|
||||||
|
const location=stableLocationSchema.parse(stable);
|
||||||
|
animal.lastStabled=location;animal.status='stabled';
|
||||||
|
animal.currentLocation=[location.name,location.settlement].filter(Boolean).join(', ');
|
||||||
|
}
|
||||||
|
export function retrieveCompanion(animal:Companion){
|
||||||
|
animal.status='travelling';animal.currentLocation='With the party';
|
||||||
|
}
|
||||||
|
export function changeCompanionHP(animal:Companion,amount:number){
|
||||||
|
if(!Number.isInteger(amount)||Math.abs(amount)>9999)throw new Error('HP changes must be whole numbers up to 9999.');
|
||||||
|
animal.hp.current=Math.max(0,Math.min(animal.hp.max,animal.hp.current+amount));
|
||||||
|
}
|
||||||
|
|
||||||
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,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 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(),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:[],inventory:'',currency:{gp:0,sp:0,cp:0},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:[],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}`;
|
||||||
|
|||||||
@ -137,3 +137,21 @@ input[type=checkbox]{accent-color:#3b603d}
|
|||||||
.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}}
|
.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}}
|
||||||
|
|
||||||
|
|
||||||
|
/* Mounts and pets live on the same continuous parchment as equipment. */
|
||||||
|
.gear-subnav{display:flex;gap:12px;margin:10px 0 24px;border-bottom:1px solid #ad8d5180;padding-bottom:12px}
|
||||||
|
.gear-subnav button{display:inline-flex;align-items:center;gap:8px;min-height:46px;padding:9px 14px;border:1px solid #9e7e4766;background:#e9d4a466;color:#58472d;border-radius:6px;font-family:Georgia,serif;font-size:17px;cursor:pointer}
|
||||||
|
.gear-subnav button.active{background:#dfc18b;border-color:#927038;box-shadow:inset 0 0 0 1px #fff3d880}
|
||||||
|
.gear-subnav .ui-icon{width:27px;height:27px}.gear-count{display:inline-grid;place-items:center;min-width:24px;border-radius:20px;padding:2px 6px;background:#97734230;font-family:system-ui;font-size:12px}
|
||||||
|
.companions-folio .folio-heading{flex-wrap:wrap}.companions-folio .folio-heading-tools{display:flex;gap:8px;flex-wrap:wrap}
|
||||||
|
.companion-entry{padding:20px 0 24px;border-bottom:1px solid #ad8d5180}
|
||||||
|
.companion-heading{display:flex;align-items:center;gap:12px}.companion-emblem{width:48px;height:48px;border:2px solid #8c6e3b;border-radius:50%;display:grid;place-items:center;flex-shrink:0;background:#d5b47755;color:#72552e;font-size:29px}
|
||||||
|
.companion-intro{flex:1;min-width:0}.companion-intro .eyebrow{font-size:10px;margin:0 0 5px;letter-spacing:1px}.companion-intro h3{font-family:Georgia,serif;font-size:27px;font-weight:400;margin:0;overflow-wrap:anywhere}
|
||||||
|
.companion-status{font-size:11px;color:#5c522e;background:#d9bf8670;border:1px solid #9e7e4766;border-radius:20px;padding:6px 9px;white-space:nowrap}.companion-status.stabled{background:#d0d8b099;color:#395b41}
|
||||||
|
.companion-location{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;margin:18px 0;padding:14px 0;border-top:1px solid #b99c6850;border-bottom:1px solid #b99c6850}.companion-location>div{min-width:0}.companion-location small,.companion-stats small{display:block;text-transform:uppercase;font-size:10px;letter-spacing:1px;color:#756044;margin-bottom:6px}.companion-location strong{font-family:Georgia,serif;font-size:17px;font-weight:400;line-height:1.4;display:block;overflow-wrap:anywhere}.companion-location span{font-size:11px;display:block;margin-top:6px;color:#71634c}
|
||||||
|
.companion-stats{display:grid;grid-template-columns:0.75fr 1fr 1fr 1.8fr;gap:12px;align-items:center}.companion-stats strong{font-family:Georgia,serif;font-size:22px;font-weight:400;white-space:nowrap}.companion-stats em{font-style:normal;font-size:12px}.companion-hp>div{display:flex;align-items:center;gap:7px}.companion-hp button{font-size:20px;min-width:40px;min-height:44px;padding:4px 8px}.companion-hp strong{font-size:18px}
|
||||||
|
.companion-details{margin-top:18px}.companion-abilities{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:5px;margin:8px 0 14px}.companion-abilities>div{text-align:center;border:1px solid #b08c5670;padding:8px 2px;border-radius:4px;background:#ffedbd44}.companion-abilities small,.companion-abilities span{display:block;color:#725f3f;font-size:10px}.companion-abilities strong{display:block;font-family:Georgia,serif;font-size:21px;font-weight:400}
|
||||||
|
.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}}
|
||||||
|
|||||||
35
tests/companions.test.ts
Normal file
35
tests/companions.test.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
|
||||||
|
import {test} from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {newDb} from 'pg-mem';
|
||||||
|
import {Storage} from '../server/storage.mjs';
|
||||||
|
import {newCharacter,characterSchema,createCompanion,addCompanion,stableCompanion,retrieveCompanion,changeCompanionHP} from '../src/model.ts';
|
||||||
|
|
||||||
|
test('horse stats, stabling and retrieval preserve the last recorded stable',()=>{
|
||||||
|
const c=newCharacter(),entry=createCompanion('horse','riding-horse');entry.name='Biscuit';
|
||||||
|
const horse=addCompanion(c,entry);
|
||||||
|
assert.equal(horse.speed,60);assert.equal(horse.carryingCapacity,480);assert.equal(horse.abilities.str,16);
|
||||||
|
stableCompanion(horse,{name:'The Copper Horseshoe',settlement:'Neverwinter',date:'2026-10-08'});
|
||||||
|
assert.equal(horse.status,'stabled');assert.equal(horse.currentLocation,'The Copper Horseshoe, Neverwinter');
|
||||||
|
retrieveCompanion(horse);assert.equal(horse.status,'travelling');assert.equal(horse.lastStabled?.name,'The Copper Horseshoe');
|
||||||
|
changeCompanionHP(horse,-99);assert.equal(horse.hp.current,0);changeCompanionHP(horse,99);assert.equal(horse.hp.current,horse.hp.max);
|
||||||
|
assert.deepEqual(characterSchema.parse(c).companions,[horse]);
|
||||||
|
});
|
||||||
|
test('pets, mounts and legacy characters validate without losing old saves',()=>{
|
||||||
|
const c=newCharacter();delete c.companions;
|
||||||
|
assert.equal(characterSchema.parse(c).companions,undefined);
|
||||||
|
const mastiff=createCompanion('pet','mastiff');mastiff.name='Ruff';addCompanion(c,mastiff);
|
||||||
|
assert.throws(()=>addCompanion(c,mastiff));assert.throws(()=>stableCompanion(mastiff,{name:'',settlement:'',date:'2026-10-08'}));
|
||||||
|
assert.equal(characterSchema.safeParse({...c,companions:[mastiff,mastiff]}).success,false);
|
||||||
|
assert.equal(characterSchema.safeParse({...c,companions:[{...mastiff,hp:{current:10,max:5}}]}).success,false);
|
||||||
|
});
|
||||||
|
test('old clients cannot clear companions by omitting the field; an explicit empty list removes them',async()=>{
|
||||||
|
const pg=newDb().adapters.createPg(),storage=new Storage(new pg.Pool());await storage.init();
|
||||||
|
const c=newCharacter(),pony=createCompanion('mount','pony');pony.name='Apple';addCompanion(c,pony);
|
||||||
|
await storage.put('alice',c.id,c,0);
|
||||||
|
const old={...c,name:'Old client'};delete old.companions;
|
||||||
|
await storage.put('alice',c.id,old,1);
|
||||||
|
assert.equal((await storage.get('alice',c.id)).character.companions[0].name,'Apple');
|
||||||
|
await storage.put('alice',c.id,{...old,companions:[]},2);
|
||||||
|
assert.deepEqual((await storage.get('alice',c.id)).character.companions,[]);
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user