import {randomUUID,randomBytes,createHash} from 'node:crypto'; import {z} from 'zod'; import {characterSchema,changeHP} from './model.mjs'; const id=z.string().min(1).max(200),text=z.string().max(12000),title=z.string().trim().min(1).max(120); 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: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)}), encounterStep:z.object({id,action:z.enum(['next','finish','damage','heal']),participant_id:id.optional(),amount:z.number().int().min(1).max(9999).optional()}), 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:[],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.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'].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={}; 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)} 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)} else if(action==='encounter'){dm(d,sub);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));d.encounters.push({id:randomUUID(),name:p.name,participants:p.participants.map(x=>({...x,id:randomUUID()})).sort((a,b)=>b.initiative-a.initiative),round:1,turn:0,status:'active'})} 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||!p.amount)throw new CampaignError('Participant and 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 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/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 and encounters. Private notes and DM secrets are filtered server-side.',{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.'],['encounterStep','advance_campaign_encounter','DM: advance turns, damage/heal a snapshot, or finish an encounter.']]){ 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)}) } 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)}); }