144 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)});
}