81 lines
14 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} 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)});
}