import {randomUUID,randomBytes,createHash} from 'node:crypto'; import {z} from 'zod'; 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')}), join:z.object({code:z.string().max(400)}), assign:z.object({character_id:id,allow_dm_edit:z.boolean().default(false)}), unassign:z.object({character_id:id}), member:z.object({sub:id,role:z.enum(['player','dm','remove'])}), note:z.object({id:id.optional(),title,body:text,visibility:z.enum(['private','party','dm'])}), removeNote:z.object({id}), npc:z.object({id:id.optional(),name:title,hp:z.number().int().min(0).max(9999),maxHp:z.number().int().min(1).max(9999),ac:z.number().int().min(0).max(9999),initiative:z.number().int().min(-50).max(100).default(0),notes:text.default(''),visible:z.boolean().default(false)}).refine(x=>x.hp<=x.maxHp), removeNpc:z.object({id}), session:z.object({id:id.optional(),title,date:z.string().max(60).default(''),summary:text.default(''),status:z.enum(['planned','active','complete']).default('planned')}), encounter:z.object({name:title,participants:combatants,session_id:id.optional()}), preparedEncounter:z.object({id:id.optional(),session_id:id,name:title,participants:combatants}), startPrepared:z.object({id,scene_id:id.optional()}), 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'); export class CampaignError extends Error{constructor(message,status=403){super(message);this.status=status}} const member=(data,sub)=>{const m=data.members.find(m=>m.sub===sub);if(!m)throw new CampaignError('Campaign access denied');return m}; const dm=(data,sub)=>{if(member(data,sub).role!=='dm')throw new CampaignError('This action requires a campaign DM')}; const shared=c=>({id:c.id,name:c.name,className:c.className,level:c.level,species:c.species,abilities:c.abilities,hp:c.hp,ac:c.ac,speed:c.speed}); export class Campaigns{ constructor(storage){this.storage=storage;this.pool=storage.pool} 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:[],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)})} delete data.pcs;return {id:r.id,revision:r.revision,role:m.role,...data,party}; } 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','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||=[];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}`}} else if(action==='assign'){const sheet=await this.storage.get(sub,p.character_id);if(!sheet||sheet.deleted)throw new CampaignError('Only your own saved character can be assigned',404);const existing=d.pcs.find(x=>x.owner===sub&&x.id===p.character_id);if(existing)existing.allowDmEdit=p.allow_dm_edit;else{if(d.pcs.length>=100)throw new CampaignError('Party limit reached',400);d.pcs.push({owner:sub,id:p.character_id,allowDmEdit:p.allow_dm_edit})}} else if(action==='unassign')d.pcs=d.pcs.filter(x=>!(x.owner===sub&&x.id===p.character_id)); else if(action==='member'){dm(d,sub);const target=member(d,p.sub);if(target.role==='dm'&&p.role!=='dm'&&d.members.filter(x=>x.role==='dm').length===1)throw new CampaignError('A campaign must retain at least one DM',400);if(p.role==='remove'){d.members=d.members.filter(x=>x.sub!==p.sub);d.pcs=d.pcs.filter(x=>x.owner!==p.sub)}else target.role=p.role} else if(action==='note'){if(p.visibility==='dm')dm(d,sub);const old=p.id?d.notes.find(n=>n.id===p.id):null;if(p.id&&!old)throw new CampaignError('Note not found',404);if(old?.visibility==='dm')dm(d,sub);if(old&&old.author!==sub)throw new CampaignError('Only the author can edit this note');if(!old&&d.notes.length>=200)throw new CampaignError('Note limit reached',400);const n={...p,id:p.id||randomUUID(),author:sub};if(old)Object.assign(old,n);else d.notes.push(n)} 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)} else if(action==='sceneStep'){dm(d,sub);const scene=d.scenes.find(s=>s.id===p.id);if(!scene)throw new CampaignError('Scene not found',404);if(p.action==='reveal')scene.revealed=true;else if(p.action==='hide')scene.revealed=false;else if(p.action==='current'){for(const other of d.scenes.filter(s=>s.session_id===scene.session_id&&s.status==='current'))other.status='planned';scene.status='current'}else scene.status=p.action==='complete'?'complete':'planned'} else if(action==='preparedEncounter'){dm(d,sub);sessionExists(p.session_id);const old=p.id?d.preparedEncounters.find(e=>e.id===p.id):null;if(p.id&&!old)throw new CampaignError('Prepared encounter not found',404);if(old&&old.session_id!==p.session_id)throw new CampaignError('A prepared encounter stays in its original session',400);if(!old&&d.preparedEncounters.length>=200)throw new CampaignError('Prepared encounter limit reached',400);const prepared={...p,id:p.id||randomUUID()};if(old)Object.assign(old,prepared);else d.preparedEncounters.push(prepared)} else if(action==='startPrepared'){dm(d,sub);const prepared=d.preparedEncounters.find(e=>e.id===p.id);if(!prepared)throw new CampaignError('Prepared encounter not found',404);if(d.encounters.some(e=>e.prepared_id===p.id&&e.status==='active'))throw new CampaignError('This prepared encounter is already running',409);if(p.scene_id&&!d.scenes.some(s=>s.id===p.scene_id&&s.session_id===prepared.session_id&&s.encounter_ids.includes(prepared.id)))throw new CampaignError('Encounter is not linked to this scene',400);const e=snapshot(prepared.name,prepared.participants,{session_id:prepared.session_id,prepared_id:prepared.id,...(p.scene_id?{scene_id:p.scene_id}:{})});extra={encounter_id:e.id}} else if(action==='encounter'){dm(d,sub);if(p.session_id)sessionExists(p.session_id);snapshot(p.name,p.participants,p.session_id?{session_id:p.session_id}:{})} else if(action==='encounterStep'){dm(d,sub);const e=d.encounters.find(x=>x.id===p.id);if(!e||e.status!=='active')throw new CampaignError('Active encounter not found',404);if(p.action==='finish')e.status='complete';else if(p.action==='next'){e.turn=(e.turn+1)%e.participants.length;if(e.turn===0)e.round++}else{const participant=e.participants.find(x=>x.id===p.participant_id);if(!participant)throw new CampaignError('Participant required',400);if(p.action==='conditions'){if(!p.conditions)throw new CampaignError('Conditions required',400);participant.conditions=[...new Set(p.conditions)]}else{if(!p.amount)throw new CampaignError('Amount required',400);participant.hp=p.action==='damage'?Math.max(0,participant.hp-p.amount):Math.min(participant.maxHp,participant.hp+p.amount)}}} const updated=await this.pool.query('UPDATE campaigns SET data=$2,revision=revision+1 WHERE id=$1 AND revision=$3 RETURNING revision',[cid,d,expected]);if(!updated.rows[0])throw new CampaignError('Campaign changed. Reload before saving.',409); 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}){ app.use('/api/campaigns',(req,res,next)=>{if(!req.identity)return res.status(401).json({error:'Sign in required'});if(req.method!=='GET'&&req.get('origin')!==origin)return res.sendStatus(403);next()}); const route=fn=>async(req,res,next)=>{try{res.json(await fn(req))}catch(e){if(e.name==='ZodError')return res.status(400).json({error:'Invalid campaign values'});if(e instanceof CampaignError)return res.status(e.status).json({error:e.message});next(e)}}; app.get('/api/campaigns',route(async req=>({campaigns:await campaigns.list(req.identity.sub)}))); 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/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 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.'],['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('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)}); }