From 379afb9fb8006396217021fdd10cf52bc63629f8 Mon Sep 17 00:00:00 2001 From: Admin Date: Thu, 8 Oct 2026 02:45:46 +0000 Subject: [PATCH] Mirror private dnd-character-pwa a62203a: companion statistics and stable tracking --- server/mcp.mjs | 8 +++- server/storage.mjs | 2 +- src/App.vue | 8 +++- src/CompanionsFolio.vue | 83 ++++++++++++++++++++++++++++++++++++++++ src/model.ts | 61 ++++++++++++++++++++++++++++- src/style.css | 18 +++++++++ tests/companions.test.ts | 35 +++++++++++++++++ 7 files changed, 210 insertions(+), 5 deletions(-) create mode 100644 src/CompanionsFolio.vue create mode 100644 tests/companions.test.ts diff --git a/server/mcp.mjs b/server/mcp.mjs index 4c269ac..4ea284b 100644 --- a/server/mcp.mjs +++ b/server/mcp.mjs @@ -4,7 +4,7 @@ import {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js'; import {StreamableHTTPServerTransport} from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import {z} from 'zod'; 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}); export function characterMcp({storage,campaigns,catalogue,sub,scopes}){ 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('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('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('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))}]})); diff --git a/server/storage.mjs b/server/storage.mjs index b6438c8..3b5faa4 100644 --- a/server/storage.mjs +++ b/server/storage.mjs @@ -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 put(owner,id,character,revision,deleted=false){ // 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]); if(!result.rows[0])throw new Conflict();return result.rows[0]; diff --git a/src/App.vue b/src/App.vue index 3eb61da..c547257 100644 --- a/src/App.vue +++ b/src/App.vue @@ -5,9 +5,11 @@ import UiIcon from './UiIcon.vue'; import CampaignsView from './CampaignsView.vue'; import RulesPicker,{type RuleEntry} from './RulesPicker.vue'; import InventoryFolio from './InventoryFolio.vue'; +import CompanionsFolio from './CompanionsFolio.vue'; 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'}]; 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 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)); @@ -81,7 +83,11 @@ function openAction(action:Character['actions'][number]){selectedAction.value=ac - +
✦ ARROWSTACK CHARACTER v0.4 · Made for game night
diff --git a/src/CompanionsFolio.vue b/src/CompanionsFolio.vue new file mode 100644 index 0000000..1d288ab --- /dev/null +++ b/src/CompanionsFolio.vue @@ -0,0 +1,83 @@ + + + diff --git a/src/model.ts b/src/model.ts index 32bb9b5..f26cc77 100644 --- a/src/model.ts +++ b/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 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; + +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; +export type StableLocation=z.infer; +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 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; export const skills:Record={'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 proficiency=(level:number)=>2+Math.floor((level-1)/4); export const signed=(n:number)=>n>=0?`+${n}`:`${n}`; diff --git a/src/style.css b/src/style.css index bacd082..00fef49 100644 --- a/src/style.css +++ b/src/style.css @@ -137,3 +137,21 @@ input[type=checkbox]{accent-color:#3b603d} .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}} + + +/* 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}} diff --git a/tests/companions.test.ts b/tests/companions.test.ts new file mode 100644 index 0000000..2d05e02 --- /dev/null +++ b/tests/companions.test.ts @@ -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,[]); +});