Release session preparation v0.7.0

This commit is contained in:
Admin 2026-10-07 22:29:40 +00:00
parent a67509b42e
commit e7e6366c42
11 changed files with 129 additions and 28 deletions

View File

@ -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. 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 ## Official rules catalogue
@ -57,3 +57,6 @@ Gear uses structured inventory entries with quantities, equipment and attunement
## DM fieldbook ## 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. 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.

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "dnd-character-pwa", "name": "dnd-character-pwa",
"version": "0.6.0", "version": "0.7.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "dnd-character-pwa", "name": "dnd-character-pwa",
"version": "0.6.0", "version": "0.7.0",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.32.1", "@modelcontextprotocol/sdk": "^1.32.1",
"dexie": "^4.2.0", "dexie": "^4.2.0",

View File

@ -1,6 +1,6 @@
{ {
"name": "dnd-character-pwa", "name": "dnd-character-pwa",
"version": "0.6.0", "version": "0.7.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@ -2,6 +2,8 @@ import {randomUUID,randomBytes,createHash} from 'node:crypto';
import {z} from 'zod'; import {z} from 'zod';
import {characterSchema,changeHP} from './model.mjs'; 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 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={ export const campaignInputs={
create:z.object({name:title,description:text.default('')}), create:z.object({name:title,description:text.default('')}),
invite:z.object({role:z.enum(['player','dm']).default('player')}), 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), 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}), 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')}), 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()}), 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)}) 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 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 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 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; 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.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}); 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)})} 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'){ 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(!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 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); 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}`}} 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==='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==='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==='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==='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==='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==='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==='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==='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)}}} 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); 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}; 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}){ export function registerCampaignTools({tool,campaigns,sub,allowed,read,write}){
const campaign_id=id,expected_revision=z.number().int().min(1); 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('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('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)}}); 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(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)}); 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)});

View File

@ -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 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):''}; 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.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/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.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()}); 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()});

View File

@ -7,7 +7,7 @@ import {randomInt,randomUUID} from 'node:crypto';
import {characterSchema,newCharacter,changeHP,applyRest} from './model.mjs'; 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}); 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}){ 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 read={readOnlyHint:true,destructiveHint:false,idempotentHint:true,openWorldHint:false};
const write={readOnlyHint:false,destructiveHint:false,idempotentHint:false,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.')}; const allowed=()=>{if(!scopes.includes('characters:write'))throw new Error('Reconnect with characters:write permission to edit sheets.')};

View File

@ -1,13 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import {ref,onMounted,onUnmounted,computed} from 'vue';import UiIcon from './UiIcon.vue';import type {Character} from './model'; import {ref,onMounted,onUnmounted,computed} from 'vue';import UiIcon from './UiIcon.vue';import type {Character} from './model';
const props=defineProps<{characters:Character[],syncCharacters:()=>Promise<void>}>(); const props=defineProps<{characters:Character[],syncCharacters:()=>Promise<void>}>();
type Member={sub:string,name:string,role:'dm'|'player'}; import SessionPreparation from './SessionPreparation.vue';
type Npc={id:string,name:string,hp:number,maxHp:number,ac:number,initiative:number,notes?:string,visible:boolean}; import type {Campaign,Note,Npc,Session,Participant,Encounter} from './campaign-types';
type Note={id:string,title:string,body:string,visibility:'private'|'party'|'dm',author:string};
type Session={id:string,title:string,date:string,summary:string,status:'planned'|'active'|'complete'};
type Participant={id:string,name:string,hp:number,maxHp:number,ac:number,initiative:number,conditions?:string[]};
type Encounter={id:string,name:string,status:string,round:number,turn:number,participants:Participant[]};
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[]};
const list=ref<{id:string,name:string,description:string,role:string,revision:number}[]>([]),current=ref<Campaign|null>(null),busy=ref(false),error=ref(''),notice=ref(''),inviteCode=ref(''),tab=ref('Session'),journalTab=ref('Notes'),self=ref(''); const list=ref<{id:string,name:string,description:string,role:string,revision:number}[]>([]),current=ref<Campaign|null>(null),busy=ref(false),error=ref(''),notice=ref(''),inviteCode=ref(''),tab=ref('Session'),journalTab=ref('Notes'),self=ref('');
const newName=ref(''),description=ref(''),joinCode=ref(''),inviteRole=ref('player'),selectedPc=ref(''),delegate=ref(false),hpAmount=ref(1); const newName=ref(''),description=ref(''),joinCode=ref(''),inviteRole=ref('player'),selectedPc=ref(''),delegate=ref(false),hpAmount=ref(1);
const note=ref<{id?:string,title:string,body:string,visibility:'private'|'party'|'dm'}>({title:'',body:'',visibility:'private'}); const note=ref<{id?:string,title:string,body:string,visibility:'private'|'party'|'dm'}>({title:'',body:'',visibility:'private'});
@ -15,12 +10,15 @@ const npc=ref<{id?:string,name:string,hp:number,maxHp:number,ac:number,initiativ
const session=ref<{id?:string,title:string,date:string,summary:string,status:'planned'|'active'|'complete'}>({title:'',date:'',summary:'',status:'planned'}); const session=ref<{id?:string,title:string,date:string,summary:string,status:'planned'|'active'|'complete'}>({title:'',date:'',summary:'',status:'planned'});
const encounterName=ref(''),participants=ref<Omit<Participant,'id'>[]>([]),customName=ref(''); const encounterName=ref(''),participants=ref<Omit<Participant,'id'>[]>([]),customName=ref('');
const isDm=computed(()=>current.value?.role==='dm'); const isDm=computed(()=>current.value?.role==='dm');
const activeEncounters=computed(()=>current.value?.encounters.filter(e=>e.status==='active')||[]),pastEncounters=computed(()=>current.value?.encounters.filter(e=>e.status==='complete').slice().reverse()||[]); const selectedSessionId=ref('');const workingSession=computed(()=>current.value?.sessions.find(s=>s.id===selectedSessionId.value)||current.value?.sessions.find(s=>s.status==='active')||current.value?.sessions.find(s=>s.status==='planned')||current.value?.sessions[0]);
async function prepareAction(name:string,input:unknown){if(busy.value)return false;await action(name,input);return !error.value}
const activeEncounters=computed(()=>current.value?.encounters.filter(e=>e.status==='active'&&(!e.session_id||e.session_id===workingSession.value?.id))||[]),pastEncounters=computed(()=>current.value?.encounters.filter(e=>e.status==='complete').slice().reverse()||[]);
const upcomingSessions=computed(()=>current.value?.sessions.filter(s=>s.status!=='complete')||[]),pastSessions=computed(()=>current.value?.sessions.filter(s=>s.status==='complete').slice().reverse()||[]); const upcomingSessions=computed(()=>current.value?.sessions.filter(s=>s.status!=='complete')||[]),pastSessions=computed(()=>current.value?.sessions.filter(s=>s.status==='complete').slice().reverse()||[]);
const quickTitle=ref(''),quickBody=ref(''),showEncounter=ref(false),showSession=ref(false),conditionDraft=ref<Record<string,string>>({}); const quickTitle=ref(''),quickBody=ref(''),showEncounter=ref(false),showSession=ref(false),conditionDraft=ref<Record<string,string>>({});
const conditionChoices=['Blinded','Charmed','Deafened','Frightened','Grappled','Incapacitated','Invisible','Paralyzed','Petrified','Poisoned','Prone','Restrained','Stunned','Unconscious']; const conditionChoices=['Blinded','Charmed','Deafened','Frightened','Grappled','Incapacitated','Invisible','Paralyzed','Petrified','Poisoned','Prone','Restrained','Stunned','Unconscious'];
const partyOwner=(owner:string)=>current.value?.members.find(m=>m.sub===owner)?.name||'Party member'; const partyOwner=(owner:string)=>current.value?.members.find(m=>m.sub===owner)?.name||'Party member';
function resetWorkspace(){tab.value=current.value?.role==='dm'?'Session':'Party';journalTab.value='Notes';inviteCode.value='';notice.value='';quickTitle.value='';quickBody.value='';conditionDraft.value={};participants.value=[];encounterName.value='';showEncounter.value=false;showSession.value=false;note.value={title:'',body:'',visibility:'private'};npc.value={name:'',hp:10,maxHp:10,ac:10,initiative:0,notes:'',visible:false};session.value={title:'',date:'',summary:'',status:'planned'}} function resetWorkspace(){selectedSessionId.value='';tab.value=current.value?.role==='dm'?'Session':'Party';journalTab.value='Notes';inviteCode.value='';notice.value='';quickTitle.value='';quickBody.value='';conditionDraft.value={};participants.value=[];encounterName.value='';showEncounter.value=false;showSession.value=false;note.value={title:'',body:'',visibility:'private'};npc.value={name:'',hp:10,maxHp:10,ac:10,initiative:0,notes:'',visible:false};session.value={title:'',date:'',summary:'',status:'planned'}}
async function quickNote(){await action('note',{title:quickTitle.value,body:quickBody.value,visibility:'dm'});if(!error.value){quickTitle.value='';quickBody.value=''}} async function quickNote(){await action('note',{title:quickTitle.value,body:quickBody.value,visibility:'dm'});if(!error.value){quickTitle.value='';quickBody.value=''}}
async function setCondition(e:Encounter,p:Participant,condition:string,remove=false){const value=condition.trim();if(!value)return;const values=remove?(p.conditions||[]).filter(c=>c!==value):[...new Set([...(p.conditions||[]),value])];await action('encounterStep',{id:e.id,action:'conditions',participant_id:p.id,conditions:values});if(!error.value)conditionDraft.value[p.id]=''} async function setCondition(e:Encounter,p:Participant,condition:string,remove=false){const value=condition.trim();if(!value)return;const values=remove?(p.conditions||[]).filter(c=>c!==value):[...new Set([...(p.conditions||[]),value])];await action('encounterStep',{id:e.id,action:'conditions',participant_id:p.id,conditions:values});if(!error.value)conditionDraft.value[p.id]=''}
async function setSessionStatus(s:Session,status:Session['status']){await action('session',{...s,status})} async function setSessionStatus(s:Session,status:Session['status']){await action('session',{...s,status})}
@ -43,7 +41,7 @@ async function saveNote(){await action('note',note.value);if(!error.value)note.v
async function saveNpc(){await action('npc',npc.value);if(!error.value)npc.value={name:'',hp:10,maxHp:10,ac:10,initiative:0,notes:'',visible:false}} async function saveNpc(){await action('npc',npc.value);if(!error.value)npc.value={name:'',hp:10,maxHp:10,ac:10,initiative:0,notes:'',visible:false}}
async function saveSession(){await action('session',session.value);if(!error.value){session.value={title:'',date:'',summary:'',status:'planned'};showSession.value=false}} async function saveSession(){await action('session',session.value);if(!error.value){session.value={title:'',date:'',summary:'',status:'planned'};showSession.value=false}}
function addCombatant(name:string,hp=10,maxHp=10,ac=10,initiative=0){if(name.trim()&&participants.value.length<50)participants.value.push({name:name.trim(),hp,maxHp,ac,initiative});customName.value=''} function addCombatant(name:string,hp=10,maxHp=10,ac=10,initiative=0){if(name.trim()&&participants.value.length<50)participants.value.push({name:name.trim(),hp,maxHp,ac,initiative});customName.value=''}
async function start(){await action('encounter',{name:encounterName.value,participants:participants.value});if(!error.value){participants.value=[];encounterName.value='';showEncounter.value=false}} async function start(){await action('encounter',{name:encounterName.value,participants:participants.value,session_id:workingSession.value?.id});if(!error.value){participants.value=[];encounterName.value='';showEncounter.value=false}}
async function step(id:string,actionName:string,participantId?:string){await action('encounterStep',{id,action:actionName,participant_id:participantId,amount:participantId?hpAmount.value:undefined})} async function step(id:string,actionName:string,participantId?:string){await action('encounterStep',{id,action:actionName,participant_id:participantId,amount:participantId?hpAmount.value:undefined})}
async function copyInvite(){try{await navigator.clipboard.writeText(inviteCode.value);notice.value='Invitation copied. Share it with your intended campaign member.'}catch{notice.value='Select the invitation code to copy it.'}} async function copyInvite(){try{await navigator.clipboard.writeText(inviteCode.value);notice.value='Invitation copied. Share it with your intended campaign member.'}catch{notice.value='Select the invitation code to copy it.'}}
onMounted(()=>void run(async()=>{const identity=await fetch('/api/session').then(r=>r.json());self.value=identity.sub;await refreshList()})); onMounted(()=>void run(async()=>{const identity=await fetch('/api/session').then(r=>r.json());self.value=identity.sub;await refreshList()}));
@ -75,6 +73,7 @@ onMounted(()=>void run(async()=>{const identity=await fetch('/api/session').then
<p v-if="!current.npcs.length" class="hint">No visible NPCs yet.</p><form v-if="isDm" class="panel" @submit.prevent="saveNpc"><h2><UiIcon name="con" />{{npc.id?'Edit NPC':'Add an NPC or monster'}}</h2><label>Name<input v-model="npc.name" required maxlength="120"></label><div class="form-grid"><label>Current HP<input type="number" v-model.number="npc.hp" min="0" :max="npc.maxHp" required></label><label>Maximum HP<input type="number" v-model.number="npc.maxHp" min="1" max="9999" required></label><label>Armour class<input type="number" v-model.number="npc.ac" min="0" max="9999" required></label><label>Initiative<input type="number" v-model.number="npc.initiative" min="-50" max="100" required></label></div><label>DM notes<textarea v-model="npc.notes" rows="4" maxlength="12000"></textarea></label><label class="inline-check"><input type="checkbox" v-model="npc.visible">Show combat stats to the party (notes stay DM-only)</label><button class="primary wide" :disabled="busy">Save NPC</button></form> <p v-if="!current.npcs.length" class="hint">No visible NPCs yet.</p><form v-if="isDm" class="panel" @submit.prevent="saveNpc"><h2><UiIcon name="con" />{{npc.id?'Edit NPC':'Add an NPC or monster'}}</h2><label>Name<input v-model="npc.name" required maxlength="120"></label><div class="form-grid"><label>Current HP<input type="number" v-model.number="npc.hp" min="0" :max="npc.maxHp" required></label><label>Maximum HP<input type="number" v-model.number="npc.maxHp" min="1" max="9999" required></label><label>Armour class<input type="number" v-model.number="npc.ac" min="0" max="9999" required></label><label>Initiative<input type="number" v-model.number="npc.initiative" min="-50" max="100" required></label></div><label>DM notes<textarea v-model="npc.notes" rows="4" maxlength="12000"></textarea></label><label class="inline-check"><input type="checkbox" v-model="npc.visible">Show combat stats to the party (notes stay DM-only)</label><button class="primary wide" :disabled="busy">Save NPC</button></form>
</template> </template>
<template v-if="tab==='Session'"> <template v-if="tab==='Session'">
<label v-if="current.sessions.length" class="working-session-label">Session fieldbook<select :value="workingSession?.id" @change="selectedSessionId=($event.target as HTMLSelectElement).value"><option v-for="s in current.sessions" :key="s.id" :value="s.id">{{s.title}} · {{s.status}}</option></select></label>
<div class="folio-heading"><h2><UiIcon name="initiative" />At the table</h2><button v-if="isDm" class="quiet" @click="showEncounter=!showEncounter" :aria-expanded="showEncounter">{{showEncounter?'Close builder':'+ Encounter'}}</button></div> <div class="folio-heading"><h2><UiIcon name="initiative" />At the table</h2><button v-if="isDm" class="quiet" @click="showEncounter=!showEncounter" :aria-expanded="showEncounter">{{showEncounter?'Close builder':'+ Encounter'}}</button></div>
<p v-if="!activeEncounters.length" class="hint">No encounter in progress. {{isDm?'Choose a session below or bring your next scene to life.':'Your DM will start initiative when combat begins.'}}</p> <p v-if="!activeEncounters.length" class="hint">No encounter in progress. {{isDm?'Choose a session below or bring your next scene to life.':'Your DM will start initiative when combat begins.'}}</p>
<section class="panel active-encounter" v-for="e in activeEncounters" :key="e.id"><div class="section-title"><h2><UiIcon name="actions" />{{e.name}}</h2><span class="chip">Round {{e.round}}</span></div><p class="encounter-turn" aria-live="polite">{{e.participants[e.turn]?.name}}’s turn <small>{{e.turn+1}} / {{e.participants.length}}</small></p><p class="hint">Encounter HP and conditions stay in this scene. Saved character sheets are unchanged.</p> <section class="panel active-encounter" v-for="e in activeEncounters" :key="e.id"><div class="section-title"><h2><UiIcon name="actions" />{{e.name}}</h2><span class="chip">Round {{e.round}}</span></div><p class="encounter-turn" aria-live="polite">{{e.participants[e.turn]?.name}}’s turn <small>{{e.turn+1}} / {{e.participants.length}}</small></p><p class="hint">Encounter HP and conditions stay in this scene. Saved character sheets are unchanged.</p>
@ -86,11 +85,12 @@ onMounted(()=>void run(async()=>{const identity=await fetch('/api/session').then
<details v-if="isDm" class="fieldbook-fold" :open="showEncounter" @toggle="showEncounter=($event.target as HTMLDetailsElement).open"><summary><UiIcon name="actions" />Build an encounter</summary> <details v-if="isDm" class="fieldbook-fold" :open="showEncounter" @toggle="showEncounter=($event.target as HTMLDetailsElement).open"><summary><UiIcon name="actions" />Build an encounter</summary>
<form class="panel" @submit.prevent="start"><h2><UiIcon name="initiative" />Start an encounter</h2><p class="hint">Combatant names, stats and conditions are visible to the campaign. NPC notes stay behind the screen.</p><label>Name<input v-model="encounterName" required maxlength="120"></label><div class="campaign-actions"><button type="button" v-for="pc in current.party" :key="pc.owner+pc.id" @click="addCombatant(pc.character.name,pc.character.hp.current,pc.character.hp.max,pc.character.ac)">+ {{pc.character.name}}</button><button type="button" v-for="n in current.npcs" :key="n.id" @click="addCombatant(n.name,n.hp,n.maxHp,n.ac,n.initiative)">+ {{n.name}}</button></div><div class="campaign-actions"><input v-model="customName" placeholder="Other combatant" maxlength="120"><button type="button" @click="addCombatant(customName)" :disabled="!customName.trim()">Add</button></div><fieldset class="folio-section" v-for="(p,index) in participants" :key="index"><legend>{{p.name}}</legend><div class="form-grid"><label>Initiative<input type="number" v-model.number="p.initiative" min="-50" max="100" required></label><label>Current HP<input type="number" v-model.number="p.hp" min="0" :max="p.maxHp" required></label><label>Maximum HP<input type="number" v-model.number="p.maxHp" min="1" max="9999" required></label><label>AC<input type="number" v-model.number="p.ac" min="0" max="9999" required></label></div><button type="button" class="quiet" @click="participants.splice(index,1)">Remove</button></fieldset><button class="primary wide" :disabled="busy||!participants.length">Start initiative</button></form> <form class="panel" @submit.prevent="start"><h2><UiIcon name="initiative" />Start an encounter</h2><p class="hint">Combatant names, stats and conditions are visible to the campaign. NPC notes stay behind the screen.</p><label>Name<input v-model="encounterName" required maxlength="120"></label><div class="campaign-actions"><button type="button" v-for="pc in current.party" :key="pc.owner+pc.id" @click="addCombatant(pc.character.name,pc.character.hp.current,pc.character.hp.max,pc.character.ac)">+ {{pc.character.name}}</button><button type="button" v-for="n in current.npcs" :key="n.id" @click="addCombatant(n.name,n.hp,n.maxHp,n.ac,n.initiative)">+ {{n.name}}</button></div><div class="campaign-actions"><input v-model="customName" placeholder="Other combatant" maxlength="120"><button type="button" @click="addCombatant(customName)" :disabled="!customName.trim()">Add</button></div><fieldset class="folio-section" v-for="(p,index) in participants" :key="index"><legend>{{p.name}}</legend><div class="form-grid"><label>Initiative<input type="number" v-model.number="p.initiative" min="-50" max="100" required></label><label>Current HP<input type="number" v-model.number="p.hp" min="0" :max="p.maxHp" required></label><label>Maximum HP<input type="number" v-model.number="p.maxHp" min="1" max="9999" required></label><label>AC<input type="number" v-model.number="p.ac" min="0" max="9999" required></label></div><button type="button" class="quiet" @click="participants.splice(index,1)">Remove</button></fieldset><button class="primary wide" :disabled="busy||!participants.length">Start initiative</button></form>
</details> </details>
<section class="session-agenda"><div class="folio-heading"><h2><UiIcon name="long-rest" />Sessions</h2><button v-if="isDm" class="quiet" @click="session={title:'',date:'',summary:'',status:'planned'};showSession=!showSession" :aria-expanded="showSession">+ Plan</button></div><p v-if="!upcomingSessions.length" class="hint">No upcoming sessions yet.</p><article class="agenda-entry" v-for="s in upcomingSessions" :key="s.id"><div class="section-title"><h3>{{s.title}}</h3><span class="chip">{{s.status==='active'?'At the table':'Planned'}}</span></div><p v-if="s.date" class="hint">{{s.date}}</p><p class="campaign-prose">{{s.summary}}</p><div v-if="isDm" class="campaign-actions"><button v-if="s.status==='planned'" class="quiet" @click="setSessionStatus(s,'active')" :disabled="busy">Begin session</button><button v-else class="quiet" @click="setSessionStatus(s,'complete')" :disabled="busy">Complete session</button><button class="quiet" @click="editSession(s)">Edit</button></div></article></section> <SessionPreparation :campaign="current" :session-id="workingSession?.id||''" :busy="busy" :self="self" :mutate="prepareAction" />
<section class="session-agenda"><div class="folio-heading"><h2><UiIcon name="long-rest" />Sessions</h2><button v-if="isDm" class="quiet" @click="session={title:'',date:'',summary:'',status:'planned'};showSession=!showSession" :aria-expanded="showSession">+ Plan</button></div><p v-if="!upcomingSessions.length" class="hint">No upcoming sessions yet.</p><article class="agenda-entry" v-for="s in upcomingSessions" :key="s.id"><div class="section-title"><h3>{{s.title}}</h3><span class="chip">{{s.status==='active'?'At the table':'Planned'}}</span></div><p v-if="s.date" class="hint">{{s.date}}</p><p class="campaign-prose">{{s.summary}}</p><div class="campaign-actions"><button class="quiet" @click="selectedSessionId=s.id">Open fieldbook</button><template v-if="isDm"><button v-if="s.status==='planned'" class="quiet" @click="setSessionStatus(s,'active')" :disabled="busy">Begin session</button><button v-else class="quiet" @click="setSessionStatus(s,'complete')" :disabled="busy">Complete session</button><button class="quiet" @click="editSession(s)">Edit</button></template></div></article></section>
<details v-if="isDm" class="fieldbook-fold" :open="showSession" @toggle="showSession=($event.target as HTMLDetailsElement).open"><summary><UiIcon name="notes" />{{session.id?'Edit session':'Plan a session'}}</summary> <details v-if="isDm" class="fieldbook-fold" :open="showSession" @toggle="showSession=($event.target as HTMLDetailsElement).open"><summary><UiIcon name="notes" />{{session.id?'Edit session':'Plan a session'}}</summary>
<form class="panel" @submit.prevent="saveSession"><h2><UiIcon name="long-rest" />{{session.id?'Edit session':'Plan a session'}}</h2><label>Title<input v-model="session.title" required maxlength="120"></label><div class="form-grid"><label>Date<input type="date" v-model="session.date"></label><label>Status<select v-model="session.status"><option>planned</option><option>active</option><option>complete</option></select></label></div><label>Party-visible summary<textarea v-model="session.summary" rows="5" maxlength="12000"></textarea></label><button class="primary wide" :disabled="busy">Save session</button></form> </details> <form class="panel" @submit.prevent="saveSession"><h2><UiIcon name="long-rest" />{{session.id?'Edit session':'Plan a session'}}</h2><label>Title<input v-model="session.title" required maxlength="120"></label><div class="form-grid"><label>Date<input type="date" v-model="session.date"></label><label>Status<select v-model="session.status"><option>planned</option><option>active</option><option>complete</option></select></label></div><label>Party-visible summary<textarea v-model="session.summary" rows="5" maxlength="12000"></textarea></label><button class="primary wide" :disabled="busy">Save session</button></form> </details>
<form v-if="isDm" class="panel session-quick-note" @submit.prevent="quickNote"><h2><UiIcon name="notes" />Behind the screen</h2><p class="hint">Quick notes shared only with campaign DMs. Find them again in Journal.</p><label>Title<input v-model="quickTitle" maxlength="120" required placeholder="The stranger at the inn"></label><label>DM note<textarea v-model="quickBody" rows="3" maxlength="12000" placeholder="Secrets, loose threads, things to remember…"></textarea></label><button class="primary" :disabled="busy">Save DM note</button></form> <form v-if="isDm" class="panel session-quick-note" @submit.prevent="quickNote"><h2><UiIcon name="notes" />Behind the screen</h2><p class="hint">Quick notes shared only with campaign DMs. Find them again in Journal.</p><label>Title<input v-model="quickTitle" maxlength="120" required placeholder="The stranger at the inn"></label><label>DM note<textarea v-model="quickBody" rows="3" maxlength="12000" placeholder="Secrets, loose threads, things to remember…"></textarea></label><button class="primary" :disabled="busy">Save DM note</button></form>
<details class="fieldbook-fold" v-if="pastSessions.length||pastEncounters.length"><summary><UiIcon name="notes" />Past adventures</summary><article class="agenda-entry" v-for="s in pastSessions" :key="s.id"><h3>{{s.title}}</h3><p class="hint">{{s.date}} · Completed session</p><p class="campaign-prose">{{s.summary}}</p><button v-if="isDm" class="quiet" @click="editSession(s)">Edit summary</button></article><details class="past-encounter" v-for="e in pastEncounters" :key="e.id"><summary>{{e.name}} · {{e.round}} {{e.round===1?'round':'rounds'}}</summary><p v-for="p in e.participants" :key="p.id">{{p.name}} · HP {{p.hp}} / {{p.maxHp}}<small v-if="p.conditions?.length"> · {{p.conditions.join(', ')}}</small></p></details></details> <details class="fieldbook-fold" v-if="pastSessions.length||pastEncounters.length"><summary><UiIcon name="notes" />Past adventures</summary><article class="agenda-entry" v-for="s in pastSessions" :key="s.id"><h3>{{s.title}}</h3><p class="hint">{{s.date}} · Completed session</p><p class="campaign-prose">{{s.summary}}</p><button class="quiet" @click="selectedSessionId=s.id">Open fieldbook</button><button v-if="isDm" class="quiet" @click="editSession(s)">Edit summary</button></article><details class="past-encounter" v-for="e in pastEncounters" :key="e.id"><summary>{{e.name}} · {{e.round}} {{e.round===1?'round':'rounds'}}</summary><p v-for="p in e.participants" :key="p.id">{{p.name}} · HP {{p.hp}} / {{p.maxHp}}<small v-if="p.conditions?.length"> · {{p.conditions.join(', ')}}</small></p></details></details>
</template> </template>
<template v-if="tab==='Campaign'"> <template v-if="tab==='Campaign'">
<p class="hint">{{current.members.length}} at the table · {{current.party.length}} assigned PCs · {{current.members.filter(m=>m.role==='dm').length}} {{current.members.filter(m=>m.role==='dm').length===1?'DM':'DMs'}}</p><p v-if="isDm" class="hint">Invite players, then ask them to assign their own characters from Party. Each owner chooses whether you may edit saved HP.</p> <p class="hint">{{current.members.length}} at the table · {{current.party.length}} assigned PCs · {{current.members.filter(m=>m.role==='dm').length}} {{current.members.filter(m=>m.role==='dm').length===1?'DM':'DMs'}}</p><p v-if="isDm" class="hint">Invite players, then ask them to assign their own characters from Party. Each owner chooses whether you may edit saved HP.</p>

View File

@ -0,0 +1,32 @@
<script setup lang="ts">
import {computed,ref,watch} from 'vue';import UiIcon from './UiIcon.vue';import type {Campaign,Scene,PreparedEncounter,Participant} from './campaign-types';
const props=defineProps<{campaign:Campaign,sessionId:string,busy:boolean,self:string,mutate:(name:string,input:unknown)=>Promise<boolean>}>();
const isDm=computed(()=>props.campaign.role==='dm'),selected=computed(()=>props.campaign.sessions.find(s=>s.id===props.sessionId));
const scenes=computed(()=>(props.campaign.scenes||[]).filter(s=>s.session_id===props.sessionId)),prepared=computed(()=>(props.campaign.preparedEncounters||[]).filter(e=>e.session_id===props.sessionId));
const availableNotes=computed(()=>props.campaign.notes.filter(n=>n.visibility!=='private'||n.author===props.self));
const editingScene=ref(false),editingPrepared=ref(false),customName=ref('');
const blankScene=()=>({id:undefined as string|undefined,title:'',description:'',dm_notes:'',npc_ids:[] as string[],note_ids:[] as string[],encounter_ids:[] as string[]});
const scene=ref(blankScene()),plan=ref({id:undefined as string|undefined,name:'',participants:[] as Omit<Participant,'id'>[]});
watch(()=>[props.sessionId,props.campaign.id],()=>{editingScene.value=false;editingPrepared.value=false;scene.value=blankScene();plan.value={id:undefined,name:'',participants:[]};customName.value=''});
function editScene(s?:Scene){scene.value=s?{id:s.id,title:s.title,description:s.description,dm_notes:s.dm_notes||'',npc_ids:[...(s.npc_ids||[])],note_ids:[...(s.note_ids||[])],encounter_ids:[...(s.encounter_ids||[])]}:blankScene();editingScene.value=true}
function editPrepared(p?:PreparedEncounter){plan.value=p?{id:p.id,name:p.name,participants:JSON.parse(JSON.stringify(p.participants))}:{id:undefined,name:'',participants:[]};editingPrepared.value=true}
async function saveScene(){if(await props.mutate('scene',{...scene.value,session_id:props.sessionId})){editingScene.value=false;scene.value=blankScene()}}
async function savePrepared(){if(await props.mutate('preparedEncounter',{...plan.value,session_id:props.sessionId})){editingPrepared.value=false;plan.value={id:undefined,name:'',participants:[]}}}
function add(name:string,hp=10,maxHp=10,ac=10,initiative=0){if(name.trim()&&plan.value.participants.length<50){plan.value.participants.push({name:name.trim(),hp,maxHp,ac,initiative});customName.value=''}}
const running=(id:string)=>props.campaign.encounters.some(e=>e.prepared_id===id&&e.status==='active');
const linkedNpc=(s:Scene)=>props.campaign.npcs.filter(n=>s.npc_ids?.includes(n.id));
const linkedNotes=(s:Scene)=>availableNotes.value.filter(n=>s.note_ids?.includes(n.id));
const linkedEncounters=(s:Scene)=>prepared.value.filter(e=>s.encounter_ids?.includes(e.id));
</script>
<template>
<section v-if="selected" class="session-preparation" aria-labelledby="scenes-heading"><div class="folio-heading"><h2 id="scenes-heading"><UiIcon name="notes" />{{isDm?'Session scenes':'Revealed scenes'}}</h2><button v-if="isDm" class="quiet" @click="editScene()" :disabled="busy">+ Scene</button></div><p class="hint">{{selected.title}} · {{isDm?'Prepare privately, then choose what the party sees.':'Descriptions your DM has shared with the party.'}}</p><p v-if="!scenes.length" class="hint">{{isDm?'Add the places, conversations and discoveries you expect tonight.':'No scenes revealed for this session yet.'}}</p>
<article class="scene-entry" v-for="(s,index) in scenes" :key="s.id" :class="{current:s.status==='current'}"><div class="section-title"><h3><span class="scene-number">{{index+1}}.</span>{{s.title}}</h3><span class="chip">{{s.status==='current'?'Current scene':s.status==='complete'?'Complete':'Prepared'}}</span></div><p v-if="isDm" class="hint">{{s.revealed?'Title and description shared with the party':'Hidden from the party'}}</p><p class="campaign-prose" v-if="s.description">{{s.description}}</p>
<template v-if="isDm"><div class="campaign-actions scene-controls"><button class="quiet" v-if="s.status!=='current'" @click="mutate('sceneStep',{id:s.id,action:'current'})" :disabled="busy">Make current</button><button class="quiet" v-if="s.status==='current'" @click="mutate('sceneStep',{id:s.id,action:'complete'})" :disabled="busy">Complete scene</button><button class="quiet" @click="mutate('sceneStep',{id:s.id,action:s.revealed?'hide':'reveal'})" :disabled="busy">{{s.revealed?'Hide description':'Reveal description'}}</button><button class="quiet" @click="editScene(s)" :disabled="busy">Edit</button></div>
<details class="scene-screen"><summary>Behind the screen</summary><p class="campaign-prose" v-if="s.dm_notes">{{s.dm_notes}}</p><p v-if="!s.dm_notes&&!linkedNpc(s).length&&!linkedNotes(s).length&&!linkedEncounters(s).length" class="hint">No private notes or links yet.</p><details class="scene-link" v-for="n in linkedNpc(s)" :key="n.id"><summary><UiIcon name="con" />{{n.name}}</summary><p class="hint">HP {{n.hp}} / {{n.maxHp}} · AC {{n.ac}}</p><p class="campaign-prose">{{n.notes}}</p></details><details class="scene-link" v-for="n in linkedNotes(s)" :key="n.id"><summary><UiIcon name="notes" />{{n.title}}</summary><p class="hint">{{n.visibility==='dm'?'DM only':n.visibility==='private'?'Only you':'Party-visible journal note'}}</p><p class="campaign-prose">{{n.body}}</p></details><div class="prepared-row" v-for="e in linkedEncounters(s)" :key="e.id"><span>{{e.name}}</span><button class="quiet" @click="mutate('startPrepared',{id:e.id,scene_id:s.id})" :disabled="busy||running(e.id)">{{running(e.id)?'Running':'Start combat →'}}</button></div></details></template></article>
<form v-if="isDm&&editingScene" class="panel preparation-editor" @submit.prevent="saveScene"><div class="section-title"><h2>{{scene.id?'Edit scene':'Prepare a scene'}}</h2><button type="button" class="quiet" @click="editingScene=false">Cancel</button></div><label>Title<input v-model="scene.title" maxlength="120" required></label><label>Player-facing description<textarea v-model="scene.description" rows="4" maxlength="12000" placeholder="What the party sees and hears…"></textarea></label><label>DM notes<textarea v-model="scene.dm_notes" rows="4" maxlength="12000" placeholder="Secrets, motivations and possible outcomes…"></textarea></label>
<details class="scene-screen"><summary>Link NPCs, notes and encounters</summary><p class="hint">Links stay DM-only. Revealing this scene shares its title and description.</p><fieldset class="folio-section" v-if="campaign.npcs.length"><legend>NPCs</legend><label class="inline-check" v-for="n in campaign.npcs" :key="n.id"><input type="checkbox" v-model="scene.npc_ids" :value="n.id">{{n.name}}</label></fieldset><fieldset class="folio-section" v-if="availableNotes.length"><legend>Journal notes</legend><label class="inline-check" v-for="n in availableNotes" :key="n.id"><input type="checkbox" v-model="scene.note_ids" :value="n.id">{{n.title}}</label></fieldset><fieldset class="folio-section" v-if="prepared.length"><legend>Prepared encounters</legend><label class="inline-check" v-for="e in prepared" :key="e.id"><input type="checkbox" v-model="scene.encounter_ids" :value="e.id">{{e.name}}</label></fieldset><p class="hint" v-if="!prepared.length">Save a prepared encounter below to link it here.</p></details><button class="primary" :disabled="busy">{{scene.id?'Save scene':'Save scene privately'}}</button><p class="hint" v-if="scene.id">Editing preserves the scene’s current reveal state.</p></form>
<template v-if="isDm"><div class="folio-heading"><h2><UiIcon name="actions" />Prepared encounters</h2><button class="quiet" @click="editPrepared()" :disabled="busy">+ Prepare</button></div><p class="hint">Save combatants before game night. Starting combat shares their names and stats and creates a fresh snapshot.</p><p class="hint" v-if="!prepared.length">No encounters prepared for this session.</p><details class="prepared-entry" v-for="e in prepared" :key="e.id"><summary>{{e.name}} <small>{{e.participants.length}} combatants</small></summary><p v-for="(p,index) in e.participants" :key="index">{{p.name}} · Initiative {{p.initiative}} · HP {{p.hp}} / {{p.maxHp}} · AC {{p.ac}}</p><div class="campaign-actions"><button class="quiet" @click="editPrepared(e)" :disabled="busy">Edit preparation</button><button class="primary" @click="mutate('startPrepared',{id:e.id})" :disabled="busy||running(e.id)">{{running(e.id)?'Already running':'Start combat →'}}</button></div></details>
<form v-if="editingPrepared" class="panel preparation-editor" @submit.prevent="savePrepared"><div class="section-title"><h2>{{plan.id?'Edit preparation':'Prepare an encounter'}}</h2><button type="button" class="quiet" @click="editingPrepared=false">Cancel</button></div><label>Name<input v-model="plan.name" maxlength="120" required></label><div class="campaign-actions"><button type="button" class="quiet" v-for="pc in campaign.party" :key="pc.owner+pc.id" @click="add(pc.character.name,pc.character.hp.current,pc.character.hp.max,pc.character.ac)" :disabled="plan.participants.length>=50">+ {{pc.character.name}}</button><button type="button" class="quiet" v-for="n in campaign.npcs" :key="n.id" @click="add(n.name,n.hp,n.maxHp,n.ac,n.initiative)" :disabled="plan.participants.length>=50">+ {{n.name}}</button></div><label>Other combatant<input v-model="customName" maxlength="120" placeholder="Mountain troll"></label><button type="button" class="quiet" @click="add(customName)" :disabled="!customName.trim()||plan.participants.length>=50">+ Add combatant</button><fieldset class="folio-section" v-for="(p,index) in plan.participants" :key="index"><legend>{{p.name}}</legend><div class="form-grid"><label>Initiative<input type="number" v-model.number="p.initiative" min="-50" max="100" required></label><label>Current HP<input type="number" v-model.number="p.hp" min="0" :max="p.maxHp" required></label><label>Maximum HP<input type="number" v-model.number="p.maxHp" min="1" max="9999" required></label><label>AC<input type="number" v-model.number="p.ac" min="0" max="9999" required></label></div><button type="button" class="quiet" @click="plan.participants.splice(index,1)">Remove</button></fieldset><button class="primary" :disabled="busy||!plan.participants.length">Save preparation</button></form></template>
</section>
<p v-else class="hint">{{isDm?'Plan a session below to start preparing scenes and encounters.':'No sessions planned yet.'}}</p>
</template>

10
src/campaign-types.ts Normal file
View File

@ -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<Participant,'id'>[]};
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[]};

View File

@ -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} .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: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}} @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}}

View File

@ -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('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('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<void>(r=>server.close(()=>r()))}}); 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<void>(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()=>{ 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); 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']}}; 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()])} 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()])}
});