diff --git a/server/campaigns.mjs b/server/campaigns.mjs index c628c1f..6e16b23 100644 --- a/server/campaigns.mjs +++ b/server/campaigns.mjs @@ -1,9 +1,12 @@ import {randomUUID,randomBytes,createHash} from 'node:crypto'; import {z} from 'zod'; -import {characterSchema,changeHP} from './model.mjs'; +import {characterSchema,changeHP,currencySchema,changeCurrency,addInventoryItem} from './model.mjs'; const id=z.string().min(1).max(200),text=z.string().max(12000),title=z.string().trim().min(1).max(120); const combatants=z.array(z.object({name:title,initiative:z.number().int().min(-50).max(100),hp:z.number().int().min(0).max(9999),maxHp:z.number().int().min(1).max(9999),ac:z.number().int().min(0).max(9999)}).refine(x=>x.hp<=x.maxHp)).min(1).max(50); const links=z.array(id).max(50).default([]); +const lootItem=z.object({name:title,quantity:z.number().int().min(1).max(9999),category:z.enum(['Weapon','Armor','Adventuring gear','Magic item','Other']),notes:text.default('')}); +const lootCoins={gp:0,sp:0,cp:0}; +const reward=z.object({id:id.optional(),title,description:text.default(''),recipient:z.object({owner:id,character_id:id}).nullable().default(null),items:z.array(lootItem).max(50).default([]),currency:currencySchema.default(lootCoins)}).refine(x=>x.items.length>0||x.currency.gp+x.currency.sp+x.currency.cp>0,'Parcel requires items or coins'); export const campaignInputs={ create:z.object({name:title,description:text.default('')}), invite:z.object({role:z.enum(['player','dm']).default('player')}), @@ -22,6 +25,11 @@ export const campaignInputs={ scene:z.object({id:id.optional(),session_id:id,title,description:text.default(''),dm_notes:text.default(''),npc_ids:links,note_ids:links,encounter_ids:links}), sceneStep:z.object({id,action:z.enum(['current','complete','reset','reveal','hide'])}), encounterStep:z.object({id,action:z.enum(['next','finish','damage','heal','conditions']),participant_id:id.optional(),amount:z.number().int().min(1).max(9999).optional(),conditions:z.array(z.string().trim().min(1).max(60)).max(12).optional()}), + quest:z.object({id:id.optional(),title,description:text.default(''),dm_notes:text.default(''),status:z.enum(['open','complete','failed']).default('open'),visible:z.boolean().default(false)}), + removeQuest:z.object({id}), + loot:reward, + lootStep:z.object({id,action:z.enum(['offer','withdraw'])}), + claimLoot:z.object({id,expected_campaign_revision:z.number().int().min(1),expected_character_revision:z.number().int().min(1)}), partyHp:z.object({owner:id,character_id:id,expected_revision:z.number().int().min(1),amount:z.number().int().min(-9999).max(9999)}) }; const hash=s=>createHash('sha256').update(s).digest('hex'); @@ -34,11 +42,13 @@ export class Campaigns{ async init(){await this.pool.query('CREATE TABLE IF NOT EXISTS campaigns(id text PRIMARY KEY,data jsonb NOT NULL,revision integer NOT NULL DEFAULT 1)')} async row(id){const r=(await this.pool.query('SELECT id,data,revision FROM campaigns WHERE id=$1',[id])).rows[0];if(!r)throw new CampaignError('Campaign not found',404);return r} async list(sub){const rows=(await this.pool.query('SELECT id,data,revision FROM campaigns')).rows;return rows.filter(r=>r.data.members.some(m=>m.sub===sub)).map(r=>({id:r.id,name:r.data.name,description:r.data.description,role:member(r.data,sub).role,revision:r.revision}))} - async create(sub,name,input){const p=campaignInputs.create.parse(input),cid=randomUUID(),data={...p,members:[{sub,name,role:'dm'}],pcs:[],notes:[],npcs:[],sessions:[],encounters:[],scenes:[],preparedEncounters:[],invites:[]};await this.pool.query('INSERT INTO campaigns(id,data) VALUES($1,$2)',[cid,data]);return {id:cid,revision:1,...p,role:'dm'}} + async create(sub,name,input){const p=campaignInputs.create.parse(input),cid=randomUUID(),data={...p,members:[{sub,name,role:'dm'}],pcs:[],notes:[],npcs:[],sessions:[],encounters:[],scenes:[],preparedEncounters:[],quests:[],loot:[],invites:[]};await this.pool.query('INSERT INTO campaigns(id,data) VALUES($1,$2)',[cid,data]);return {id:cid,revision:1,...p,role:'dm'}} async view(sub,cid){const r=await this.row(cid),m=member(r.data,sub),data=structuredClone(r.data);delete data.invites; data.encounters=data.encounters.map(e=>m.role==='dm'?e:{id:e.id,name:e.name,status:e.status,round:e.round,turn:e.turn,participants:e.participants,...(e.session_id?{session_id:e.session_id}:{})}); data.preparedEncounters=m.role==='dm'?(data.preparedEncounters||[]):[]; data.scenes=(data.scenes||[]).filter(s=>m.role==='dm'||s.revealed).map(s=>m.role==='dm'?s:{id:s.id,session_id:s.session_id,title:s.title,description:s.description,status:s.status,revealed:true}); + data.quests=(data.quests||[]).filter(q=>m.role==='dm'||q.visible).map(q=>m.role==='dm'?q:{id:q.id,title:q.title,description:q.description,status:q.status,visible:true}); + data.loot=(data.loot||[]).filter(l=>m.role==='dm'||(l.recipient?.owner===sub&&l.status!=='draft')).map(l=>m.role==='dm'?l:{id:l.id,title:l.title,description:l.description,recipient:l.recipient,items:l.items,currency:l.currency,status:l.status,claimed_at:l.claimed_at}); data.notes=data.notes.filter(n=>n.visibility==='party'||(n.visibility==='private'&&n.author===sub)||(n.visibility==='dm'&&m.role==='dm')); data.npcs=data.npcs.filter(n=>m.role==='dm'||n.visible).map(n=>m.role==='dm'?n:{id:n.id,name:n.name,hp:n.hp,maxHp:n.maxHp,ac:n.ac,initiative:n.initiative,visible:n.visible}); const party=[];for(const pc of data.pcs){const row=await this.storage.get(pc.owner,pc.id);if(row&&!row.deleted)party.push({...pc,revision:row.revision,character:shared(row.character)})} @@ -46,10 +56,10 @@ export class Campaigns{ } async mutate(sub,cid,expected,action,input,name='Adventurer'){ if(!Number.isInteger(expected)||expected<1)throw new CampaignError('A current campaign revision is required',400); - if(!['invite','assign','unassign','member','note','removeNote','npc','session','removeNpc','encounter','encounterStep','scene','sceneStep','preparedEncounter','startPrepared'].includes(action))throw new CampaignError('Unknown campaign action',400); + if(!['invite','assign','unassign','member','note','removeNote','npc','session','removeNpc','encounter','encounterStep','scene','sceneStep','preparedEncounter','startPrepared','quest','removeQuest','loot','lootStep'].includes(action))throw new CampaignError('Unknown campaign action',400); const r=await this.row(cid);if(r.revision!==expected)throw new CampaignError('Campaign changed. Reload before saving.',409); const d=structuredClone(r.data),p=campaignInputs[action]?.parse(input);if(!p)throw new CampaignError('Unknown campaign action',400); - member(d,sub);let extra={};d.scenes||=[];d.preparedEncounters||=[]; + member(d,sub);let extra={};d.scenes||=[];d.preparedEncounters||=[];d.quests||=[];d.loot||=[]; const sessionExists=sid=>{if(!d.sessions.some(s=>s.id===sid))throw new CampaignError('Session not found in this campaign',404)}; const snapshot=(name,participants,meta={})=>{if(d.encounters.filter(e=>e.status==='active').length>=10)throw new CampaignError('Finish an active encounter first',400);d.encounters=d.encounters.filter(e=>e.status==='active').concat(d.encounters.filter(e=>e.status==='complete').slice(-49));const encounter={id:randomUUID(),name,participants:participants.map(x=>({...x,id:randomUUID(),conditions:[]})).sort((a,b)=>b.initiative-a.initiative),round:1,turn:0,status:'active',...meta};d.encounters.push(encounter);return encounter}; if(action==='invite'){dm(d,sub);if(d.invites.filter(x=>x.expires>Date.now()&&!x.used).length>=50)throw new CampaignError('Too many pending invitations',400);const secret=randomBytes(24).toString('base64url');d.invites=d.invites.filter(x=>x.expires>Date.now()&&!x.used);d.invites.push({hash:hash(secret),role:p.role,inviter:sub,expires:Date.now()+7*86400000,used:false});extra={invite_code:`${cid}.${secret}`}} @@ -60,6 +70,10 @@ export class Campaigns{ else if(action==='removeNote'){const n=d.notes.find(n=>n.id===p.id);if(n?.visibility==='dm')dm(d,sub);if(!n||n.author!==sub)throw new CampaignError('Only the author can delete this note');d.notes=d.notes.filter(n=>n.id!==p.id);for(const s of d.scenes)s.note_ids=s.note_ids.filter(nid=>nid!==p.id)} else if(action==='npc'||action==='session'){dm(d,sub);const key=action==='npc'?'npcs':'sessions',items=d[key],old=p.id?items.find(n=>n.id===p.id):null;if(p.id&&!old)throw new CampaignError('Entry not found',404);if(!old&&items.length>=200)throw new CampaignError('Entry limit reached',400);const n={...p,id:p.id||randomUUID()};if(old)Object.assign(old,n);else items.push(n)} else if(action==='removeNpc'){dm(d,sub);d.npcs=d.npcs.filter(n=>n.id!==p.id);for(const s of d.scenes)s.npc_ids=s.npc_ids.filter(nid=>nid!==p.id)} + else if(action==='quest'){dm(d,sub);const old=p.id?d.quests.find(q=>q.id===p.id):null;if(p.id&&!old)throw new CampaignError('Quest not found',404);if(!old&&d.quests.length>=200)throw new CampaignError('Quest limit reached',400);const q={...p,id:p.id||randomUUID()};if(old)Object.assign(old,q);else d.quests.push(q)} + else if(action==='removeQuest'){dm(d,sub);if(!d.quests.some(q=>q.id===p.id))throw new CampaignError('Quest not found',404);d.quests=d.quests.filter(q=>q.id!==p.id)} + else if(action==='loot'){dm(d,sub);const old=p.id?d.loot.find(l=>l.id===p.id):null;if(p.id&&!old)throw new CampaignError('Loot parcel not found',404);if(old&&old.status!=='draft')throw new CampaignError('Withdraw the offer before editing',409);if(!old&&d.loot.length>=200)throw new CampaignError('Loot parcel limit reached',400);if(p.recipient&&!d.pcs.some(pc=>pc.owner===p.recipient.owner&&pc.id===p.recipient.character_id))throw new CampaignError('Choose a character assigned to this campaign',400);const parcel={...p,id:p.id||randomUUID(),status:'draft'};if(old)Object.assign(old,parcel);else d.loot.push(parcel)} + else if(action==='lootStep'){dm(d,sub);const parcel=d.loot.find(l=>l.id===p.id);if(!parcel)throw new CampaignError('Loot parcel not found',404);if(parcel.status==='claimed')throw new CampaignError('Claimed loot cannot be changed',409);if(p.action==='offer'){if(parcel.status!=='draft')throw new CampaignError('Parcel is already offered',409);if(!parcel.recipient||!d.pcs.some(pc=>pc.owner===parcel.recipient.owner&&pc.id===parcel.recipient.character_id))throw new CampaignError('Assign a party character before offering loot',400);parcel.status='offered'}else{if(parcel.status!=='offered')throw new CampaignError('Only offered loot can be withdrawn',409);parcel.status='draft'}} else if(action==='scene'){dm(d,sub);sessionExists(p.session_id);const old=p.id?d.scenes.find(s=>s.id===p.id):null;if(p.id&&!old)throw new CampaignError('Scene not found',404);if(old&&old.session_id!==p.session_id)throw new CampaignError('A scene stays in its original session',400);if(!old&&d.scenes.length>=200)throw new CampaignError('Scene limit reached',400); if(p.npc_ids.some(nid=>!d.npcs.some(n=>n.id===nid))||p.note_ids.some(nid=>!d.notes.some(n=>n.id===nid&&(n.visibility!=='private'||n.author===sub||old?.note_ids.includes(nid))))||p.encounter_ids.some(eid=>!d.preparedEncounters.some(e=>e.id===eid&&e.session_id===p.session_id)))throw new CampaignError('Linked entries must be accessible in this campaign and session',400); const scene={...p,id:p.id||randomUUID(),npc_ids:[...new Set(p.npc_ids)],note_ids:[...new Set(p.note_ids)],encounter_ids:[...new Set(p.encounter_ids)],status:old?.status||'planned',revealed:old?.revealed||false};if(old)Object.assign(old,scene);else d.scenes.push(scene)} @@ -72,6 +86,36 @@ export class Campaigns{ return {...extra,campaign:d.members.some(m=>m.sub===sub)?await this.view(sub,cid):null}; } async join(sub,name,input){const {code}=campaignInputs.join.parse(input),[cid,secret]=code.split('.');if(!cid||!secret)throw new CampaignError('Invalid invitation',400);const r=await this.row(cid),d=structuredClone(r.data),invite=d.invites.find(x=>x.hash===hash(secret)&&!x.used&&x.expires>Date.now());if(!invite)throw new CampaignError('Invitation expired or already used',400);dm(d,invite.inviter);if(d.members.some(m=>m.sub===sub))throw new CampaignError('You already belong to this campaign',400);if(d.members.length>=100)throw new CampaignError('Campaign member limit reached',400);d.members.push({sub,name,role:invite.role});invite.used=true;const updated=await this.pool.query('UPDATE campaigns SET data=$2,revision=revision+1 WHERE id=$1 AND revision=$3 RETURNING revision',[cid,d,r.revision]);if(!updated.rows[0])throw new CampaignError('Campaign changed. Try joining again.',409);return this.view(sub,cid)} + + async claimLoot(sub,cid,input){ + const p=campaignInputs.claimLoot.parse(input),client=await this.pool.connect(); + try{ + await client.query('BEGIN'); + const row=(await client.query('SELECT data,revision FROM campaigns WHERE id=$1 FOR UPDATE',[cid])).rows[0]; + if(!row)throw new CampaignError('Campaign not found',404); + member(row.data,sub); + if(row.revision!==p.expected_campaign_revision)throw new CampaignError('Campaign changed. Reload rewards before claiming.',409); + const d=structuredClone(row.data),parcel=(d.loot||[]).find(l=>l.id===p.id); + if(!parcel||parcel.recipient?.owner!==sub)throw new CampaignError('Reward not found',404); + if(parcel.status!=='offered')throw new CampaignError('Reward is no longer available',409); + const charId=parcel.recipient.character_id; + if(!d.pcs.some(pc=>pc.owner===sub&&pc.id===charId))throw new CampaignError('Character is no longer assigned to campaign'); + const rowPc=(await client.query('SELECT character,revision,deleted FROM character_sheets WHERE owner=$1 AND id=$2 FOR UPDATE',[sub,charId])).rows[0]; + if(!rowPc||rowPc.deleted)throw new CampaignError('Character no longer exists',404); + if(rowPc.revision!==p.expected_character_revision)throw new CampaignError('Character changed. Reload before claiming loot.',409); + const character=characterSchema.parse(rowPc.character); + for(const [currency,amount] of Object.entries(parcel.currency))if(amount)changeCurrency(character,currency,amount); + for(const item of parcel.items)addInventoryItem(character,{id:randomUUID(),name:item.name,quantity:item.quantity,category:item.category,equipped:false,attuned:false,weight:'',cost:'',notes:item.notes,source:'Campaign loot: '+parcel.title}); + const saved=characterSchema.parse(character); + const savedPc=(await client.query('UPDATE character_sheets SET character=$3,revision=revision+1 WHERE owner=$1 AND id=$2 AND revision=$4 RETURNING revision',[sub,charId,saved,p.expected_character_revision])).rows[0]; + if(!savedPc)throw new CampaignError('Character changed',409); + parcel.status='claimed';parcel.claimed_at=new Date().toISOString(); + const savedCampaign=(await client.query('UPDATE campaigns SET data=$2,revision=revision+1 WHERE id=$1 AND revision=$3 RETURNING revision',[cid,d,p.expected_campaign_revision])).rows[0]; + if(!savedCampaign)throw new CampaignError('Campaign changed',409); + await client.query('COMMIT'); + return {character_id:charId,character_revision:savedPc.revision,campaign_revision:savedCampaign.revision,status:'claimed'}; + }catch(e){await client.query('ROLLBACK');throw e}finally{client.release()} + } async partyHp(sub,cid,input){const p=campaignInputs.partyHp.parse(input),client=await this.pool.connect();try{await client.query('BEGIN');const r=(await client.query('SELECT data FROM campaigns WHERE id=$1 FOR UPDATE',[cid])).rows[0];if(!r)throw new CampaignError('Campaign not found',404);dm(r.data,sub);if(!r.data.pcs.some(pc=>pc.owner===p.owner&&pc.id===p.character_id&&pc.allowDmEdit))throw new CampaignError('The owner has not delegated combat editing to this campaign’s DMs');const row=(await client.query('SELECT character,revision,deleted FROM character_sheets WHERE owner=$1 AND id=$2 FOR UPDATE',[p.owner,p.character_id])).rows[0];if(!row||row.deleted)throw new CampaignError('Character not found',404);if(row.revision!==p.expected_revision)throw new CampaignError('Character changed. Reload before saving.',409);const c=characterSchema.parse(row.character);changeHP(c,p.amount);const updated=(await client.query('UPDATE character_sheets SET character=$3,revision=revision+1 WHERE owner=$1 AND id=$2 AND revision=$4 RETURNING revision',[p.owner,p.character_id,c,p.expected_revision])).rows[0];if(!updated)throw new CampaignError('Character changed',409);await client.query('COMMIT');return {character:shared(c),revision:updated.revision}}catch(e){await client.query('ROLLBACK');throw e}finally{client.release()}} } export function installCampaigns(app,{campaigns,origin}){ @@ -81,17 +125,19 @@ export function installCampaigns(app,{campaigns,origin}){ app.post('/api/campaigns',route(req=>campaigns.create(req.identity.sub,req.identity.name,req.body))); app.post('/api/campaigns/join',route(req=>campaigns.join(req.identity.sub,req.identity.name,req.body))); app.get('/api/campaigns/:id',route(req=>campaigns.view(req.identity.sub,req.params.id))); - app.post('/api/campaigns/:id/hp',route(req=>campaigns.partyHp(req.identity.sub,req.params.id,req.body))); + app.post('/api/campaigns/:id/loot/claim',route(req=>campaigns.claimLoot(req.identity.sub,req.params.id,req.body))); + app.post('/api/campaigns/:id/hp',route(req=>campaigns.partyHp(req.identity.sub,req.params.id,req.body))); app.post('/api/campaigns/:id/:action',route(req=>campaigns.mutate(req.identity.sub,req.params.id,req.body.revision,req.params.action,req.body.input,req.identity.name))); } export function registerCampaignTools({tool,campaigns,sub,allowed,read,write}){ const campaign_id=id,expected_revision=z.number().int().min(1); tool('list_campaigns','List campaigns you belong to and your campaign-specific player/DM role.',{},read,async()=>({campaigns:await campaigns.list(sub)})); - tool('get_campaign','Read your party, visible notes, NPCs, sessions, revealed scenes and running encounters. Prepared encounters and scene secrets are DM-only. Private notes and DM secrets are filtered server-side.',{campaign_id},read,async a=>({campaign:await campaigns.view(sub,a.campaign_id)})); + tool('get_campaign','Read your campaign with role-filtered quests, personal loot offers, notes, scenes, encounters and DM preparation. Hidden DM secrets stay private.',{campaign_id},read,async a=>({campaign:await campaigns.view(sub,a.campaign_id)})); tool('create_campaign','Create a campaign; you become its first DM.',campaignInputs.create.shape,write,async a=>{allowed();return {campaign:await campaigns.create(sub,'MCP adventurer',a)}}); tool('join_campaign','Redeem a single-use, seven-day invitation. Do not share the invitation code with unintended recipients.',campaignInputs.join.shape,write,async a=>{allowed();return {campaign:await campaigns.join(sub,'MCP adventurer',a)}}); - for(const [action,name,description] of [['invite','invite_campaign_member','DM: create a single-use player or co-DM invitation.'],['assign','assign_campaign_character','Assign your own PC and explicitly allow or revoke DM combat editing.'],['unassign','unassign_campaign_character','Remove your own PC from this campaign.'],['member','manage_campaign_member','DM: change a member role or remove a member; the last DM cannot be removed.'],['note','save_campaign_note','Save your own private, party-visible or DM-only note. Players cannot create DM-only notes.'],['removeNote','delete_campaign_note','Delete your own campaign note.'],['npc','save_campaign_npc','DM: create/edit an NPC; hidden by default. NPC notes remain DM-only even when visible.'],['removeNpc','delete_campaign_npc','DM: delete an NPC.'],['session','save_campaign_session','DM: plan/update a session and party-visible summary.'],['encounter','start_campaign_encounter','DM: start initiative tracking with HP snapshots. Encounter HP changes do not alter saved PC sheets.'],['scene','save_campaign_scene','DM: prepare a session scene with player-facing description, separate DM notes and linked NPCs, notes and prepared encounters. Saving does not reveal or start anything.'],['sceneStep','advance_campaign_scene','DM: explicitly mark a scene current/complete/reset or reveal/hide its title and description. DM notes and links stay private.'],['preparedEncounter','save_prepared_encounter','DM: save combatants for a session without starting combat or revealing the preparation.'],['startPrepared','start_prepared_encounter','DM: explicitly start a fresh initiative/HP snapshot from a prepared encounter. Participant names and stats become party-visible. Cannot start the same preparation twice while active.'],['encounterStep','advance_campaign_encounter','DM: advance turns, damage/heal a snapshot, set manual conditions, or finish an encounter. Conditions replace the participant’s list and do not automate rules or change saved sheets.']]){ - tool(name,description,{campaign_id,expected_revision,input:campaignInputs[action]}, {...write,destructiveHint:['removeNote','removeNpc','member'].includes(action)},async a=>{allowed();return campaigns.mutate(sub,a.campaign_id,a.expected_revision,action,a.input)}) + for(const [action,name,description] of [['invite','invite_campaign_member','DM: create a single-use player or co-DM invitation.'],['assign','assign_campaign_character','Assign your own PC and explicitly allow or revoke DM combat editing.'],['unassign','unassign_campaign_character','Remove your own PC from this campaign.'],['member','manage_campaign_member','DM: change a member role or remove a member; the last DM cannot be removed.'],['note','save_campaign_note','Save your own private, party-visible or DM-only note. Players cannot create DM-only notes.'],['removeNote','delete_campaign_note','Delete your own campaign note.'],['npc','save_campaign_npc','DM: create/edit an NPC; hidden by default. NPC notes remain DM-only even when visible.'],['removeNpc','delete_campaign_npc','DM: delete an NPC.'],['session','save_campaign_session','DM: plan/update a session and party-visible summary.'],['encounter','start_campaign_encounter','DM: start initiative tracking with HP snapshots. Encounter HP changes do not alter saved PC sheets.'],['scene','save_campaign_scene','DM: prepare a session scene with player-facing description, separate DM notes and linked NPCs, notes and prepared encounters. Saving does not reveal or start anything.'],['sceneStep','advance_campaign_scene','DM: explicitly mark a scene current/complete/reset or reveal/hide its title and description. DM notes and links stay private.'],['preparedEncounter','save_prepared_encounter','DM: save combatants for a session without starting combat or revealing the preparation.'],['startPrepared','start_prepared_encounter','DM: explicitly start a fresh initiative/HP snapshot from a prepared encounter. Participant names and stats become party-visible. Cannot start the same preparation twice while active.'],['quest','save_campaign_quest','DM: save or update a quest with DM-only secrets and explicit party visibility.'],['removeQuest','delete_campaign_quest','DM: remove a quest.'],['loot','save_campaign_loot','DM: prepare a draft parcel of coins or items for an assigned PC. Saving does not award anything.'],['lootStep','change_campaign_loot_offer','DM: explicitly offer or withdraw a parcel; claimed rewards are immutable.'],['encounterStep','advance_campaign_encounter','DM: advance turns, damage/heal a snapshot, set manual conditions, or finish an encounter. Conditions replace the participant’s list and do not automate rules or change saved sheets.']]){ + tool(name,description,{campaign_id,expected_revision,input:campaignInputs[action]}, {...write,destructiveHint:['removeNote','removeNpc','removeQuest','member'].includes(action)},async a=>{allowed();return campaigns.mutate(sub,a.campaign_id,a.expected_revision,action,a.input)}) } - tool('change_party_hit_points','DM: update actual PC HP only when its owner delegated combat editing. Requires the PC revision from get_campaign.',{campaign_id,...campaignInputs.partyHp.shape},write,async a=>{allowed();return campaigns.partyHp(sub,a.campaign_id,a)}); + tool('claim_campaign_loot','Claim an offered parcel for an assigned PC; atomically add items and coins to their private sheet and prevent duplicate claims.',{campaign_id,...campaignInputs.claimLoot.shape},write,async a=>{allowed();return campaigns.claimLoot(sub,a.campaign_id,a)}); + tool('change_party_hit_points','DM: update actual PC HP only when its owner delegated combat editing. Requires the PC revision from get_campaign.',{campaign_id,...campaignInputs.partyHp.shape},write,async a=>{allowed();return campaigns.partyHp(sub,a.campaign_id,a)}); } diff --git a/src/CampaignsView.vue b/src/CampaignsView.vue index 4d2dd75..4bc81de 100644 --- a/src/CampaignsView.vue +++ b/src/CampaignsView.vue @@ -2,6 +2,7 @@ import {ref,onMounted,onUnmounted,computed} from 'vue';import UiIcon from './UiIcon.vue';import type {Character} from './model'; const props=defineProps<{characters:Character[],syncCharacters:()=>Promise}>(); import SessionPreparation from './SessionPreparation.vue'; +import DmToolkit from './DmToolkit.vue'; import type {Campaign,Note,Npc,Session,Participant,Encounter} from './campaign-types'; const list=ref<{id:string,name:string,description:string,role:string,revision:number}[]>([]),current=ref(null),busy=ref(false),error=ref(''),notice=ref(''),inviteCode=ref(''),tab=ref('Session'),journalTab=ref('Notes'),self=ref(''); const newName=ref(''),description=ref(''),joinCode=ref(''),inviteRole=ref('player'),selectedPc=ref(''),delegate=ref(false),hpAmount=ref(1); @@ -33,6 +34,23 @@ async function create(){await run(async()=>{const result=await request('',{name: async function join(){await run(async()=>{current.value=await request('/join',{code:joinCode.value.trim()});joinCode.value='';resetWorkspace();await refreshList()})} async function action(name:string,input:unknown){if(!current.value)return;await run(async()=>{const result=await request(`/${current.value!.id}/${name}`,{revision:current.value!.revision,input});current.value=result.campaign;if(result.invite_code)inviteCode.value=result.invite_code;notice.value='Campaign saved.';await refreshList()})} async function assign(){if(!current.value)return;await run(async()=>{await props.syncCharacters();const result=await request(`/${current.value!.id}/assign`,{revision:current.value!.revision,input:{character_id:selectedPc.value,allow_dm_edit:delegate.value}});current.value=result.campaign;notice.value='Character shared with the party.';await refreshList()})} +async function claimLoot(id:string){ + if(!current.value||busy.value)return false; + let accepted=false; + await run(async()=>{ + await props.syncCharacters(); + const campaign=current.value!,offer=campaign.loot.find(l=>l.id===id&&l.status==='offered'); + if(!offer||offer.recipient?.owner!==self.value)throw new Error('This reward is not available to you.'); + const pc=campaign.party.find(p=>p.owner===self.value&&p.id===offer.recipient?.character_id); + if(!pc)throw new Error('The character is no longer assigned to your party.'); + await request('/'+campaign.id+'/loot/claim',{id,expected_campaign_revision:campaign.revision,expected_character_revision:pc.revision}); + await props.syncCharacters(); + current.value=await request('/'+campaign.id); + notice.value='Loot claimed and added to your character inventory and coin purse.'; + accepted=true; + }); + return accepted; +} async function partyHp(pc:Campaign['party'][number],amount:number){await run(async()=>{await request('/'+current.value!.id+'/hp',{owner:pc.owner,character_id:pc.id,expected_revision:pc.revision,amount});current.value=await request('/'+current.value!.id);notice.value='Saved PC hit points updated.'})} function editNote(n:Note){note.value={id:n.id,title:n.title,body:n.body,visibility:n.visibility};tab.value='Journal';journalTab.value='Notes'} function editNpc(n:Npc){npc.value={...n,notes:n.notes||''}} @@ -59,11 +77,12 @@ onMounted(()=>void run(async()=>{const identity=await fetch('/api/session').then

{{isDm?'DUNGEON MASTER':'PLAYER'}}

{{current.name}}

{{current.description}}

-
+
+