From e7e6366c4212506d3ec951ae962dc4770489d289 Mon Sep 17 00:00:00 2001 From: Admin Date: Wed, 7 Oct 2026 22:29:40 +0000 Subject: [PATCH] Release session preparation v0.7.0 --- README.md | 5 ++++- package-lock.json | 4 ++-- package.json | 2 +- server/campaigns.mjs | 35 ++++++++++++++++++++++++++--------- server/index.mjs | 2 +- server/mcp.mjs | 2 +- src/CampaignsView.vue | 24 ++++++++++++------------ src/SessionPreparation.vue | 32 ++++++++++++++++++++++++++++++++ src/campaign-types.ts | 10 ++++++++++ src/style.css | 4 ++++ tests/campaigns.test.ts | 37 ++++++++++++++++++++++++++++++++++++- 11 files changed, 129 insertions(+), 28 deletions(-) create mode 100644 src/SessionPreparation.vue create mode 100644 src/campaign-types.ts diff --git a/README.md b/README.md index 491823f..6e72c8a 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ Owners explicitly assign saved PCs and can revoke optional DM HP delegation. Par Encounter participants and HP are party-visible snapshots; snapshot damage does not alter saved PC sheets. Actual party HP editing needs current DM membership, explicit owner delegation and the latest sheet revision. Campaign writes use optimistic revisions; stale writes require a refresh. Campaigns are online-only; existing personal sheets retain offline support. -Sixteen additional MCP tools cover campaigns, invites, membership, PC assignment, notes, NPCs, sessions, encounters and delegated HP. Existing characters:read / characters:write scopes also gate campaign operations, with membership and role checks on every call. Consent discloses both characters and campaigns. The private plugin retains its identity and endpoint. +Twenty additional MCP tools cover campaigns, invites, membership, PC assignment, notes, NPCs, sessions, scenes, prepared encounters, combat and delegated HP. Existing characters:read / characters:write scopes also gate campaign operations, with membership and role checks on every call. Consent discloses both characters and campaigns. The private plugin retains its identity and endpoint. ## Official rules catalogue @@ -57,3 +57,6 @@ Gear uses structured inventory entries with quantities, equipment and attunement ## DM fieldbook Campaigns open to Session for DMs and Party for players. Session combines active initiative, snapshot damage/healing, manual conditions, a session agenda, DM-only quick notes and completed session/encounter history. Party shows owner names and saved-HP delegation. Campaign holds single-use player/co-DM invitations and member management; Journal holds notes/handouts and NPCs. Condition markers do not automate effects or update saved characters. Existing encounters without a condition list remain usable. The existing MCP advance_campaign_encounter tool accepts action `conditions`, participant_id and a replacement conditions array (empty clears), guarded by campaign DM role and revision. Loot proposals and richer adventure preparation are later stages. + +## Session preparation +Select a session fieldbook, prepare scenes and link campaign NPCs, accessible journal notes and that session’s prepared encounters. Scenes are ordered by creation and may be marked current or complete. Making a scene current does not reveal it. Explicit reveal/hide controls share only title and description; scene secrets/links and all encounter preparation are filtered server-side for players. Starting a preparation creates an independent fresh combat snapshot with a session association; repeated or stale starts cannot create another active instance of the same preparation. Templates retain their original HP and can be reused after combat finishes. NPC and note links are pruned when those entries are deleted. Existing campaigns gain empty additive collections without a destructive migration. Four additional MCP tools use the same campaign-role, write-scope and revision checks: save_campaign_scene, advance_campaign_scene, save_prepared_encounter and start_prepared_encounter (31 total with catalogue enabled). Loot acceptance remains the next stage. diff --git a/package-lock.json b/package-lock.json index 760476b..a7f9adf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dnd-character-pwa", - "version": "0.6.0", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dnd-character-pwa", - "version": "0.6.0", + "version": "0.7.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.32.1", "dexie": "^4.2.0", diff --git a/package.json b/package.json index e2a92af..deaa452 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dnd-character-pwa", - "version": "0.6.0", + "version": "0.7.0", "private": true, "type": "module", "scripts": { diff --git a/server/campaigns.mjs b/server/campaigns.mjs index dee4d7b..c628c1f 100644 --- a/server/campaigns.mjs +++ b/server/campaigns.mjs @@ -2,6 +2,8 @@ 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); +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([]); export const campaignInputs={ create:z.object({name:title,description:text.default('')}), invite:z.object({role:z.enum(['player','dm']).default('player')}), @@ -14,7 +16,11 @@ export const campaignInputs={ 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)}), + 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()}), partyHp:z.object({owner:id,character_id:id,expected_revision:z.number().int().min(1),amount:z.number().int().min(-9999).max(9999)}) }; @@ -28,8 +34,11 @@ 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:[],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:[],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.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)})} @@ -37,19 +46,27 @@ 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'].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'].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={}; + member(d,sub);let extra={};d.scenes||=[];d.preparedEncounters||=[]; + 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)} + 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)} - 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(),conditions:[]})).sort((a,b)=>b.initiative-a.initiative),round:1,turn:0,status:'active'})} + 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==='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}; @@ -70,10 +87,10 @@ export function installCampaigns(app,{campaigns,origin}){ 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('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('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, set manual conditions, or finish an encounter. Conditions replace the participant’s list and do not automate rules or change saved sheets.']]){ + 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)}) } 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/server/index.mjs b/server/index.mjs index f28f603..8f59442 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -19,7 +19,7 @@ let config;async function provider(){return config??=await oidc.discovery(new UR const cookie=(res,name,value,seconds)=>res.append('Set-Cookie',`${name}=${value}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${seconds}`); const readCookie=(req,name)=>{const raw=(req.headers.cookie||'').split(';').map(x=>x.trim()).find(x=>x.startsWith(`${name}=`));return raw?raw.slice(name.length+1):''}; app.use((req,res,next)=>{res.set({'X-Content-Type-Options':'nosniff','Referrer-Policy':'same-origin','Content-Security-Policy':"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",'Cache-Control':'no-store'});next()}); -app.get('/health',(_,res)=>res.json({status:'ok',version:'0.6.0',catalogue:catalogue.summary,revision:process.env.SOURCE_COMMIT||'unknown'})); +app.get('/health',(_,res)=>res.json({status:'ok',version:'0.7.0',catalogue:catalogue.summary,revision:process.env.SOURCE_COMMIT||'unknown'})); app.get('/auth/login',async(req,res,next)=>{try{const pkce=oidc.randomPKCECodeVerifier(),state=oidc.randomState(),nonce=oidc.randomNonce();cookie(res,'__Host-character-flow',await codec.seal({purpose:'character-flow',pkce,state,nonce},600),600);const url=oidc.buildAuthorizationUrl(await provider(),{redirect_uri:`${origin}/auth/callback`,scope:'openid profile',state,nonce,code_challenge:await oidc.calculatePKCECodeChallenge(pkce),code_challenge_method:'S256'});res.redirect(url.href)}catch(e){next(e)}}); app.get('/auth/callback',async(req,res,next)=>{try{const flow=await codec.open(readCookie(req,'__Host-character-flow'));cookie(res,'__Host-character-flow','',0);if(!flow||typeof flow.pkce!=='string'||typeof flow.state!=='string'||typeof flow.nonce!=='string')return res.status(400).send('Sign-in expired. Start again at /auth/login.');const url=new URL(req.originalUrl,origin);const tokens=await oidc.authorizationCodeGrant(await provider(),url,{pkceCodeVerifier:flow.pkce,expectedState:flow.state,expectedNonce:flow.nonce});const claims=tokens.claims();if(!claims?.sub)throw new Error('Missing identity');cookie(res,'__Host-character-session',await codec.seal({purpose:'character-session',sub:claims.sub,name:claims.name||claims.preferred_username||'Adventurer'}),43200);const back=await codec.open(readCookie(req,'__Host-character-mcp-return'));cookie(res,'__Host-character-mcp-return','',0);res.redirect(back?.purpose==='mcp-return'?`/mcp/consent?ticket=${encodeURIComponent(back.ticket)}`:'/')}catch(e){next(e)}}); app.use(async(req,res,next)=>{const session=await codec.open(readCookie(req,'__Host-character-session'));req.identity=session?.sub&&typeof session.name==='string'&&(!session.purpose||session.purpose==='character-session')?session:null;next()}); diff --git a/server/mcp.mjs b/server/mcp.mjs index 63d4375..2e62b48 100644 --- a/server/mcp.mjs +++ b/server/mcp.mjs @@ -7,7 +7,7 @@ import {randomInt,randomUUID} from 'node:crypto'; import {characterSchema,newCharacter,changeHP,applyRest} from './model.mjs'; const id=z.string().min(1).max(200),revision=z.number().int().min(1),result=data=>({content:[{type:'text',text:JSON.stringify(data)}],structuredContent:data}); export function characterMcp({storage,campaigns,catalogue,sub,scopes}){ - const server=new McpServer({name:'arrowstack-character',version:'0.6.0'}); + const server=new McpServer({name:'arrowstack-character',version:'0.7.0'}); const read={readOnlyHint:true,destructiveHint:false,idempotentHint:true,openWorldHint:false}; const write={readOnlyHint:false,destructiveHint:false,idempotentHint:false,openWorldHint:false}; const allowed=()=>{if(!scopes.includes('characters:write'))throw new Error('Reconnect with characters:write permission to edit sheets.')}; diff --git a/src/CampaignsView.vue b/src/CampaignsView.vue index d33bd6f..4d2dd75 100644 --- a/src/CampaignsView.vue +++ b/src/CampaignsView.vue @@ -1,13 +1,8 @@ + diff --git a/src/campaign-types.ts b/src/campaign-types.ts new file mode 100644 index 0000000..0770a85 --- /dev/null +++ b/src/campaign-types.ts @@ -0,0 +1,10 @@ +import type {Character} from './model'; +export type Member={sub:string,name:string,role:'dm'|'player'}; +export type Npc={id:string,name:string,hp:number,maxHp:number,ac:number,initiative:number,notes?:string,visible:boolean}; +export type Note={id:string,title:string,body:string,visibility:'private'|'party'|'dm',author:string}; +export type Session={id:string,title:string,date:string,summary:string,status:'planned'|'active'|'complete'}; +export type Participant={id:string,name:string,hp:number,maxHp:number,ac:number,initiative:number,conditions?:string[]}; +export type Encounter={id:string,name:string,status:string,round:number,turn:number,participants:Participant[],session_id?:string,prepared_id?:string,scene_id?:string}; +export type PreparedEncounter={id:string,session_id:string,name:string,participants:Omit[]}; +export type Scene={id:string,session_id:string,title:string,description:string,dm_notes?:string,npc_ids?:string[],note_ids?:string[],encounter_ids?:string[],status:'planned'|'current'|'complete',revealed:boolean}; +export type Campaign={id:string,name:string,description:string,revision:number,role:'dm'|'player',members:Member[],party:{owner:string,id:string,revision:number,allowDmEdit:boolean,character:Character}[],notes:Note[],npcs:Npc[],sessions:Session[],encounters:Encounter[],scenes?:Scene[],preparedEncounters?:PreparedEncounter[]}; diff --git a/src/style.css b/src/style.css index 0be9163..702b394 100644 --- a/src/style.css +++ b/src/style.css @@ -128,3 +128,7 @@ input[type=checkbox]{accent-color:#3b603d} .agenda-entry{border-bottom:1px solid #ad8d5180;padding:16px 0}.agenda-entry h3{font-family:Georgia,serif;font-size:25px;font-weight:400;margin:0}.session-agenda{margin:26px 0}.campaign-workspace .campaign-prose{overflow-wrap:anywhere}.past-encounter{padding:14px 0;border-bottom:1px solid #ad8d5140}.past-encounter summary{min-height:44px;cursor:pointer;font-family:Georgia,serif;font-size:22px}.past-encounter p{font-size:13px}.session-quick-note textarea{background:#fff3d366} @media(max-width:700px){.fieldbook-tabs button{flex-direction:column;gap:4px;font-size:11px;min-height:68px}.fieldbook-tabs button .ui-icon{width:28px;height:28px}.campaign-workspace .hero h1{font-size:34px}.campaign-workspace .section-title h2{font-size:24px}.campaign-workspace .folio-heading h2{font-size:27px}.campaign-workspace .campaign-emblem{width:50px;height:58px}} @media(max-width:420px){.combatant-line{flex-wrap:wrap;gap:8px}.combatant-line .campaign-actions{width:100%;margin-left:36px}.combatant-line .quiet{padding:6px 12px 6px 0}.combatant-copy b{font-size:22px}.combatant-options,.condition-chips{margin-left:36px}.encounter-controls label{flex-basis:100%;max-width:none}.campaign-workspace .section-title h2{font-size:22px}.campaign-workspace .folio-heading h2{font-size:25px}} + +/* Prepared session scenes share the same open parchment surface. */ +.working-session-label{max-width:560px;margin-bottom:26px;color:#6d6246;font-size:12px}.working-session-label select{margin-top:8px} +.session-preparation{margin:28px 0 34px}.scene-entry{padding:18px 0;border-bottom:1px solid #ad8d5180}.scene-entry.current{border-left:3px solid #547047;padding-left:14px;background:#dce0b430}.scene-entry h3{font-family:Georgia,serif;font-size:26px;font-weight:400;margin:0;overflow-wrap:anywhere}.scene-number{margin-right:10px;color:#94733e}.scene-entry .section-title{align-items:flex-start;flex-wrap:wrap}.scene-controls{gap:6px 14px}.scene-screen{margin:10px 0 14px}.scene-screen>summary,.scene-link>summary,.prepared-entry>summary{cursor:pointer;min-height:44px;padding:12px 0;color:#6d6246}.scene-screen>summary:focus-visible,.scene-link>summary:focus-visible,.prepared-entry>summary:focus-visible{outline:3px solid #b78a3e;outline-offset:3px}.scene-screen>.campaign-prose{border-left:2px solid #a3874e;padding:10px 14px;background:#c19d4418}.scene-link{border-top:1px solid #ad8d5140}.scene-link .ui-icon{width:25px;height:25px;margin-right:8px}.prepared-row{display:flex;gap:12px;align-items:center;justify-content:space-between;border-top:1px solid #ad8d5140;min-height:54px}.prepared-row span{font-family:Georgia,serif;font-size:21px;overflow-wrap:anywhere}.prepared-entry{padding:10px 0;border-bottom:1px solid #ad8d5180}.prepared-entry>summary{font-family:Georgia,serif;font-size:24px;overflow-wrap:anywhere}.prepared-entry summary small{font-family:system-ui;font-size:11px;margin-left:10px;color:#6d6246}.prepared-entry p{font-size:13px;line-height:1.6}.preparation-editor{border-left:2px solid #ad8d5166!important;padding-left:16px!important}.preparation-editor .section-title{flex-wrap:wrap}.session-preparation>.folio-heading{margin-top:26px}.session-preparation .folio-heading h2{font-size:27px}@media(max-width:420px){.session-preparation .folio-heading h2{font-size:23px}.session-preparation .folio-heading h2 .ui-icon{width:30px;height:30px}.session-preparation .folio-heading .quiet{font-size:12px}.scene-entry h3{font-size:24px}.prepared-entry>summary{font-size:22px}.prepared-entry summary small{display:block;margin:6px 0 0}.prepared-row{flex-wrap:wrap;gap:0;padding-top:10px}} diff --git a/tests/campaigns.test.ts b/tests/campaigns.test.ts index 2ab6465..c3b1369 100644 --- a/tests/campaigns.test.ts +++ b/tests/campaigns.test.ts @@ -7,7 +7,7 @@ test('private notes, DM secrets and hidden NPCs remain filtered after role chang test('delegated saved HP requires current DM role and owner permission, and preserves private data',async()=>{const {storage,campaigns,c,act}=await fixture(),pc=newCharacter();pc.notes='Private';pc.hp.temp=3;await storage.put('player',pc.id,pc,0);await act('player','assign',{character_id:pc.id});const input={owner:'player',character_id:pc.id,expected_revision:1,amount:-5};await assert.rejects(campaigns.partyHp('dm',c.id,input));await act('player','assign',{character_id:pc.id,allow_dm_edit:true});await assert.rejects(campaigns.partyHp('player',c.id,input));const changed=await campaigns.partyHp('dm',c.id,input);assert.equal(changed.revision,2);assert.deepEqual(changed.character.hp,{current:8,max:10,temp:0});assert.equal(changed.character.notes,undefined);assert.equal((await storage.get('player',pc.id)).character.notes,'Private');await assert.rejects(campaigns.partyHp('dm',c.id,input));await act('player','assign',{character_id:pc.id,allow_dm_edit:false});await assert.rejects(campaigns.partyHp('dm',c.id,{...input,expected_revision:2}));await act('dm','member',{sub:'player',role:'remove'});await assert.rejects(campaigns.view('player',c.id));assert.equal((await campaigns.view('dm',c.id)).party.length,0)}); test('DM encounter snapshots sort initiative, wrap rounds and clamp HP without touching saved PCs',async()=>{const {storage,campaigns,c,act}=await fixture(),pc=newCharacter();await storage.put('player',pc.id,pc,0);await assert.rejects(act('player','session',{title:'No'}));await assert.rejects(act('player','npc',{name:'No',hp:1,maxHp:1,ac:1}));await assert.rejects(act('player','invite',{}));await act('dm','session',{title:'Session 1',summary:'Meet at the inn'});await act('dm','encounter',{name:'Ambush',participants:[{name:'PC',initiative:8,hp:10,maxHp:10,ac:10},{name:'Goblin',initiative:15,hp:5,maxHp:5,ac:12}]});let encounter=(await campaigns.view('player',c.id)).encounters[0];assert.equal(encounter.participants[0].name,'Goblin');await assert.rejects(act('player','encounterStep',{id:encounter.id,action:'next'}));await act('dm','encounterStep',{id:encounter.id,action:'next'});await act('dm','encounterStep',{id:encounter.id,action:'next'});await act('dm','encounterStep',{id:encounter.id,action:'damage',participant_id:encounter.participants[0].id,amount:50});encounter=(await campaigns.view('player',c.id)).encounters[0];assert.equal(encounter.round,2);assert.equal(encounter.turn,0);assert.equal(encounter.participants[0].hp,0);assert.equal((await storage.get('player',pc.id)).character.hp.current,10);const old=(await campaigns.row(c.id)).revision;await act('dm','encounterStep',{id:encounter.id,action:'finish'});await assert.rejects(campaigns.mutate('dm',c.id,old,'note',{title:'Stale',body:'',visibility:'party'}));assert.equal((await campaigns.view('dm',c.id)).notes.length,0)}); test('HTTP campaign routes deny unsigned users and cross-origin writes',async()=>{const {campaigns}=await fixture(),app=express();app.use(express.json());app.use((req:any,res,next)=>{if(req.get('x-test-user'))req.identity={sub:req.get('x-test-user'),name:'Test'};next()});installCampaigns(app,{campaigns,origin:'https://dnd.example.test'});const server=app.listen(0,'127.0.0.1');await once(server,'listening');const base=`http://127.0.0.1:${(server.address() as any).port}`;try{assert.equal((await fetch(base+'/api/campaigns')).status,401);assert.equal((await fetch(base+'/api/campaigns',{method:'POST',headers:{'x-test-user':'dm','content-type':'application/json',origin:'https://evil.test'},body:JSON.stringify({name:'No'})})).status,403);assert.equal((await fetch(base+'/api/campaigns',{headers:{'x-test-user':'dm'}})).status,200)}finally{await new Promise(r=>server.close(()=>r()))}}); -test('MCP exposes campaign tools and enforces read scopes and campaign membership',async()=>{const {storage,campaigns,c}=await fixture();async function connect(sub:string,scopes:string[]){const server=characterMcp({storage,campaigns,sub,scopes}),client=new Client({name:'campaign-test',version:'1'});const [a,b]=InMemoryTransport.createLinkedPair();await server.connect(a);await client.connect(b);return client}const read=await connect('dm',['characters:read']),player=await connect('player',['characters:read','characters:write']),stranger=await connect('stranger',['characters:read']);try{assert.equal((await read.listTools()).tools.length,25);assert.equal((await read.callTool({name:'create_campaign',arguments:{name:'Denied'}})).isError,true);assert.equal((await stranger.callTool({name:'get_campaign',arguments:{campaign_id:c.id}})).isError,true);assert.equal((await player.callTool({name:'invite_campaign_member',arguments:{campaign_id:c.id,expected_revision:(await campaigns.row(c.id)).revision,input:{role:'dm'}}})).isError,true);assert.equal((await read.callTool({name:'get_campaign',arguments:{campaign_id:c.id}})).isError,undefined)}finally{await Promise.all([read.close(),player.close(),stranger.close()])}}); +test('MCP exposes campaign tools and enforces read scopes and campaign membership',async()=>{const {storage,campaigns,c}=await fixture();async function connect(sub:string,scopes:string[]){const server=characterMcp({storage,campaigns,sub,scopes}),client=new Client({name:'campaign-test',version:'1'});const [a,b]=InMemoryTransport.createLinkedPair();await server.connect(a);await client.connect(b);return client}const read=await connect('dm',['characters:read']),player=await connect('player',['characters:read','characters:write']),stranger=await connect('stranger',['characters:read']);try{assert.equal((await read.listTools()).tools.length,29);assert.equal((await read.callTool({name:'create_campaign',arguments:{name:'Denied'}})).isError,true);assert.equal((await stranger.callTool({name:'get_campaign',arguments:{campaign_id:c.id}})).isError,true);assert.equal((await player.callTool({name:'invite_campaign_member',arguments:{campaign_id:c.id,expected_revision:(await campaigns.row(c.id)).revision,input:{role:'dm'}}})).isError,true);assert.equal((await read.callTool({name:'get_campaign',arguments:{campaign_id:c.id}})).isError,undefined)}finally{await Promise.all([read.close(),player.close(),stranger.close()])}}); test('DM conditions are manual snapshots, bounded, revision guarded and removed explicitly',async()=>{ const {storage,campaigns,c,act}=await fixture(),pc=newCharacter();await storage.put('player',pc.id,pc,0); @@ -33,3 +33,38 @@ test('MCP changes encounter conditions for DMs and denies player and read-only c const args={campaign_id:c.id,expected_revision:(await campaigns.row(c.id)).revision,input:{id:e.id,action:'conditions',participant_id:e.participants[0].id,conditions:['Prone']}}; try{assert.equal((await player.callTool({name:'advance_campaign_encounter',arguments:args})).isError,true);assert.equal((await read.callTool({name:'advance_campaign_encounter',arguments:args})).isError,true);assert.equal((await dm.callTool({name:'advance_campaign_encounter',arguments:args})).isError,undefined);assert.deepEqual((await campaigns.view('player',c.id)).encounters[0].participants[0].conditions,['Prone']);assert.equal((await dm.callTool({name:'advance_campaign_encounter',arguments:args})).isError,true)}finally{await Promise.all([dm.close(),player.close(),read.close()])} }); + +async function prepFixture(){const f=await fixture();await f.act('dm','session',{title:'Mountain night'});const session=(await f.campaigns.view('dm',f.c.id)).sessions[0];await f.act('dm','preparedEncounter',{session_id:session.id,name:'Secret mountain troll',participants:[{name:'Troll',initiative:15,hp:20,maxHp:20,ac:13}]});const plan=(await f.campaigns.view('dm',f.c.id)).preparedEncounters[0];return {...f,session,plan}} +test('scenes reveal only descriptions; links and preparation stay private through role changes',async()=>{ + const {campaigns,c,act,session,plan}=await prepFixture();await act('dm','npc',{name:'Hidden spy',hp:5,maxHp:5,ac:10,notes:'Secret allegiance'});await act('dm','note',{title:'Secret map',body:'Trap door',visibility:'private'});let v=await campaigns.view('dm',c.id); + await act('dm','scene',{session_id:session.id,title:'The mountain inn',description:'A warm fire',dm_notes:'The innkeeper is the spy',npc_ids:[v.npcs[0].id],note_ids:[v.notes[0].id],encounter_ids:[plan.id]});const scene=(await campaigns.view('dm',c.id)).scenes[0]; + let player=await campaigns.view('player',c.id);assert.deepEqual(player.scenes,[]);assert.deepEqual(player.preparedEncounters,[]);assert.equal(player.encounters.length,0); + await assert.rejects(act('player','sceneStep',{id:scene.id,action:'reveal'}));await act('dm','sceneStep',{id:scene.id,action:'current'});assert.equal((await campaigns.view('player',c.id)).scenes.length,0); + await act('dm','sceneStep',{id:scene.id,action:'reveal'});player=await campaigns.view('player',c.id);assert.equal(player.scenes[0].description,'A warm fire');for(const key of ['dm_notes','npc_ids','note_ids','encounter_ids'])assert.equal(player.scenes[0][key],undefined);assert.equal(player.npcs.length,0);assert.equal(player.notes.length,0); + const inv=await act('dm','invite',{role:'dm'});await campaigns.join('co','Co-DM',{code:inv.invite_code});await act('co','scene',{...scene,title:'Edited by co-DM'});assert.equal((await campaigns.view('co',c.id)).notes.length,0);assert.equal((await campaigns.view('co',c.id)).scenes[0].note_ids.length,1);await act('co','member',{sub:'dm',role:'player'});const demoted=await campaigns.view('dm',c.id);assert.equal(demoted.scenes[0].dm_notes,undefined);assert.deepEqual(demoted.preparedEncounters,[]);await act('co','sceneStep',{id:scene.id,action:'hide'});assert.deepEqual((await campaigns.view('player',c.id)).scenes,[]); +}); +test('prepared combat starts fresh once, preserves preparation, and rejects stale or cross-session starts',async()=>{ + const {storage,campaigns,c,act,session,plan}=await prepFixture();const pc=newCharacter();await storage.put('player',pc.id,pc,0); + await act('dm','scene',{session_id:session.id,title:'Pass',encounter_ids:[plan.id]});const scene=(await campaigns.view('dm',c.id)).scenes[0]; + await assert.rejects(act('player','startPrepared',{id:plan.id}));await assert.rejects(act('stranger','startPrepared',{id:plan.id}));await assert.rejects(act('dm','startPrepared',{id:plan.id,scene_id:'elsewhere'})); + const revision=(await campaigns.row(c.id)).revision;await act('dm','startPrepared',{id:plan.id,scene_id:scene.id});const e=(await campaigns.view('dm',c.id)).encounters[0];assert.equal(e.session_id,session.id);assert.equal(e.scene_id,scene.id);assert.equal(e.prepared_id,plan.id);assert.equal(e.round,1);assert.deepEqual(e.participants[0].conditions,[]); + await assert.rejects(campaigns.mutate('dm',c.id,revision,'startPrepared',{id:plan.id}));await assert.rejects(act('dm','startPrepared',{id:plan.id}));assert.equal((await campaigns.view('dm',c.id)).encounters.length,1); + const player=(await campaigns.view('player',c.id));assert.equal(player.encounters[0].name,plan.name);assert.equal(player.encounters[0].prepared_id,undefined);assert.equal(player.encounters[0].scene_id,undefined);assert.deepEqual(player.scenes,[]); + await act('dm','encounterStep',{id:e.id,action:'damage',participant_id:e.participants[0].id,amount:7});assert.equal((await campaigns.view('dm',c.id)).preparedEncounters[0].participants[0].hp,20);assert.equal((await storage.get('player',pc.id)).character.hp.current,10); + await act('dm','encounterStep',{id:e.id,action:'finish'});await act('dm','startPrepared',{id:plan.id});const fresh=(await campaigns.view('dm',c.id)).encounters[1];assert.notEqual(fresh.participants[0].id,e.participants[0].id);assert.equal(fresh.participants[0].hp,20);assert.equal(fresh.round,1); +}); +test('scene links require campaign/session access and current scenes are exclusive per session',async()=>{ + const {campaigns,c,act,session,plan}=await prepFixture();await act('dm','session',{title:'Later night'});const later=(await campaigns.view('dm',c.id)).sessions[1]; + await assert.rejects(act('dm','scene',{session_id:later.id,title:'Wrong',encounter_ids:[plan.id]}));await assert.rejects(act('dm','preparedEncounter',{session_id:'foreign-session',name:'No',participants:plan.participants}));await act('player','note',{title:'Private player secret',body:'Never share',visibility:'private'});const row=await campaigns.row(c.id);await assert.rejects(act('dm','scene',{session_id:session.id,title:'No',note_ids:[row.data.notes[0].id]})); + for(const title of ['First','Second'])await act('dm','scene',{session_id:session.id,title});let scenes=(await campaigns.view('dm',c.id)).scenes; + await act('dm','sceneStep',{id:scenes[0].id,action:'current'});await act('dm','sceneStep',{id:scenes[1].id,action:'current'});scenes=(await campaigns.view('dm',c.id)).scenes;assert.equal(scenes.filter((s:any)=>s.status==='current').length,1);assert.equal(scenes[0].status,'planned');await act('dm','sceneStep',{id:scenes[1].id,action:'reveal'}); + await act('dm','scene',{id:scenes[1].id,session_id:session.id,title:'Edited'});const edited=(await campaigns.view('dm',c.id)).scenes[1];assert.equal(edited.revealed,true);assert.equal(edited.status,'current');await assert.rejects(act('dm','scene',{id:edited.id,session_id:later.id,title:'Moved'})); + // Legacy campaigns get empty additive collections without overwriting existing content. + const legacy=await campaigns.row(c.id);delete legacy.data.scenes;delete legacy.data.preparedEncounters;await campaigns.pool.query('UPDATE campaigns SET data=$2 WHERE id=$1',[c.id,legacy.data]);assert.deepEqual((await campaigns.view('dm',c.id)).scenes,[]);await act('dm','scene',{session_id:session.id,title:'New scene'});assert.equal((await campaigns.view('dm',c.id)).preparedEncounters.length,0); +}); +test('MCP prepares privately and explicitly reveals/starts with role and write-scope checks',async()=>{ + const {storage,campaigns,c,session}=await prepFixture();async function connect(sub:string,scopes:string[]){const server=characterMcp({storage,campaigns,sub,scopes}),client=new Client({name:'prep-test',version:'1'});const [a,b]=InMemoryTransport.createLinkedPair();await server.connect(a);await client.connect(b);return client} + const dm=await connect('dm',['characters:read','characters:write']),read=await connect('dm',['characters:read']),player=await connect('player',['characters:read','characters:write']); + const args=async(input:any)=>({campaign_id:c.id,expected_revision:(await campaigns.row(c.id)).revision,input}); + try{const input={session_id:session.id,title:'Secret scene',dm_notes:'Secret password'};assert.equal((await read.callTool({name:'save_campaign_scene',arguments:await args(input)})).isError,true);assert.equal((await player.callTool({name:'save_campaign_scene',arguments:await args(input)})).isError,true);assert.equal((await dm.callTool({name:'save_campaign_scene',arguments:await args(input)})).isError,undefined);const scene=(await campaigns.view('dm',c.id)).scenes[0];assert.equal((await dm.callTool({name:'advance_campaign_scene',arguments:await args({id:scene.id,action:'reveal'})})).isError,undefined);const result:any=await player.callTool({name:'get_campaign',arguments:{campaign_id:c.id}});assert.equal(result.structuredContent.campaign.scenes[0].dm_notes,undefined);const prepared=(await campaigns.view('dm',c.id)).preparedEncounters[0];assert.equal((await dm.callTool({name:'start_prepared_encounter',arguments:await args({id:prepared.id})})).isError,undefined)}finally{await Promise.all([dm.close(),read.close(),player.close()])} +});