Mirror canonical DnD DM quest and loot toolkit from bab5b54
This commit is contained in:
parent
29b2d5567e
commit
f78591da0b
@ -1,9 +1,12 @@
|
||||
import {randomUUID,randomBytes,createHash} from 'node:crypto';
|
||||
import {z} from 'zod';
|
||||
import {characterSchema,changeHP} from './model.mjs';
|
||||
import {characterSchema,changeHP,currencySchema,changeCurrency,addInventoryItem} from './model.mjs';
|
||||
const id=z.string().min(1).max(200),text=z.string().max(12000),title=z.string().trim().min(1).max(120);
|
||||
const combatants=z.array(z.object({name:title,initiative:z.number().int().min(-50).max(100),hp:z.number().int().min(0).max(9999),maxHp:z.number().int().min(1).max(9999),ac:z.number().int().min(0).max(9999)}).refine(x=>x.hp<=x.maxHp)).min(1).max(50);
|
||||
const links=z.array(id).max(50).default([]);
|
||||
const lootItem=z.object({name:title,quantity:z.number().int().min(1).max(9999),category:z.enum(['Weapon','Armor','Adventuring gear','Magic item','Other']),notes:text.default('')});
|
||||
const lootCoins={gp:0,sp:0,cp:0};
|
||||
const reward=z.object({id:id.optional(),title,description:text.default(''),recipient:z.object({owner:id,character_id:id}).nullable().default(null),items:z.array(lootItem).max(50).default([]),currency:currencySchema.default(lootCoins)}).refine(x=>x.items.length>0||x.currency.gp+x.currency.sp+x.currency.cp>0,'Parcel requires items or coins');
|
||||
export const campaignInputs={
|
||||
create:z.object({name:title,description:text.default('')}),
|
||||
invite:z.object({role:z.enum(['player','dm']).default('player')}),
|
||||
@ -22,6 +25,11 @@ export const campaignInputs={
|
||||
scene:z.object({id:id.optional(),session_id:id,title,description:text.default(''),dm_notes:text.default(''),npc_ids:links,note_ids:links,encounter_ids:links}),
|
||||
sceneStep:z.object({id,action:z.enum(['current','complete','reset','reveal','hide'])}),
|
||||
encounterStep:z.object({id,action:z.enum(['next','finish','damage','heal','conditions']),participant_id:id.optional(),amount:z.number().int().min(1).max(9999).optional(),conditions:z.array(z.string().trim().min(1).max(60)).max(12).optional()}),
|
||||
quest:z.object({id:id.optional(),title,description:text.default(''),dm_notes:text.default(''),status:z.enum(['open','complete','failed']).default('open'),visible:z.boolean().default(false)}),
|
||||
removeQuest:z.object({id}),
|
||||
loot:reward,
|
||||
lootStep:z.object({id,action:z.enum(['offer','withdraw'])}),
|
||||
claimLoot:z.object({id,expected_campaign_revision:z.number().int().min(1),expected_character_revision:z.number().int().min(1)}),
|
||||
partyHp:z.object({owner:id,character_id:id,expected_revision:z.number().int().min(1),amount:z.number().int().min(-9999).max(9999)})
|
||||
};
|
||||
const hash=s=>createHash('sha256').update(s).digest('hex');
|
||||
@ -34,11 +42,13 @@ export class Campaigns{
|
||||
async init(){await this.pool.query('CREATE TABLE IF NOT EXISTS campaigns(id text PRIMARY KEY,data jsonb NOT NULL,revision integer NOT NULL DEFAULT 1)')}
|
||||
async row(id){const r=(await this.pool.query('SELECT id,data,revision FROM campaigns WHERE id=$1',[id])).rows[0];if(!r)throw new CampaignError('Campaign not found',404);return r}
|
||||
async list(sub){const rows=(await this.pool.query('SELECT id,data,revision FROM campaigns')).rows;return rows.filter(r=>r.data.members.some(m=>m.sub===sub)).map(r=>({id:r.id,name:r.data.name,description:r.data.description,role:member(r.data,sub).role,revision:r.revision}))}
|
||||
async create(sub,name,input){const p=campaignInputs.create.parse(input),cid=randomUUID(),data={...p,members:[{sub,name,role:'dm'}],pcs:[],notes:[],npcs:[],sessions:[],encounters:[],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 create(sub,name,input){const p=campaignInputs.create.parse(input),cid=randomUUID(),data={...p,members:[{sub,name,role:'dm'}],pcs:[],notes:[],npcs:[],sessions:[],encounters:[],scenes:[],preparedEncounters:[],quests:[],loot:[],invites:[]};await this.pool.query('INSERT INTO campaigns(id,data) VALUES($1,$2)',[cid,data]);return {id:cid,revision:1,...p,role:'dm'}}
|
||||
async view(sub,cid){const r=await this.row(cid),m=member(r.data,sub),data=structuredClone(r.data);delete data.invites;
|
||||
data.encounters=data.encounters.map(e=>m.role==='dm'?e:{id:e.id,name:e.name,status:e.status,round:e.round,turn:e.turn,participants:e.participants,...(e.session_id?{session_id:e.session_id}:{})});
|
||||
data.preparedEncounters=m.role==='dm'?(data.preparedEncounters||[]):[];
|
||||
data.scenes=(data.scenes||[]).filter(s=>m.role==='dm'||s.revealed).map(s=>m.role==='dm'?s:{id:s.id,session_id:s.session_id,title:s.title,description:s.description,status:s.status,revealed:true});
|
||||
data.quests=(data.quests||[]).filter(q=>m.role==='dm'||q.visible).map(q=>m.role==='dm'?q:{id:q.id,title:q.title,description:q.description,status:q.status,visible:true});
|
||||
data.loot=(data.loot||[]).filter(l=>m.role==='dm'||(l.recipient?.owner===sub&&l.status!=='draft')).map(l=>m.role==='dm'?l:{id:l.id,title:l.title,description:l.description,recipient:l.recipient,items:l.items,currency:l.currency,status:l.status,claimed_at:l.claimed_at});
|
||||
data.notes=data.notes.filter(n=>n.visibility==='party'||(n.visibility==='private'&&n.author===sub)||(n.visibility==='dm'&&m.role==='dm'));
|
||||
data.npcs=data.npcs.filter(n=>m.role==='dm'||n.visible).map(n=>m.role==='dm'?n:{id:n.id,name:n.name,hp:n.hp,maxHp:n.maxHp,ac:n.ac,initiative:n.initiative,visible:n.visible});
|
||||
const party=[];for(const pc of data.pcs){const row=await this.storage.get(pc.owner,pc.id);if(row&&!row.deleted)party.push({...pc,revision:row.revision,character:shared(row.character)})}
|
||||
@ -46,10 +56,10 @@ export class Campaigns{
|
||||
}
|
||||
async mutate(sub,cid,expected,action,input,name='Adventurer'){
|
||||
if(!Number.isInteger(expected)||expected<1)throw new CampaignError('A current campaign revision is required',400);
|
||||
if(!['invite','assign','unassign','member','note','removeNote','npc','session','removeNpc','encounter','encounterStep','scene','sceneStep','preparedEncounter','startPrepared'].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','quest','removeQuest','loot','lootStep'].includes(action))throw new CampaignError('Unknown campaign action',400);
|
||||
const r=await this.row(cid);if(r.revision!==expected)throw new CampaignError('Campaign changed. Reload before saving.',409);
|
||||
const d=structuredClone(r.data),p=campaignInputs[action]?.parse(input);if(!p)throw new CampaignError('Unknown campaign action',400);
|
||||
member(d,sub);let extra={};d.scenes||=[];d.preparedEncounters||=[];
|
||||
member(d,sub);let extra={};d.scenes||=[];d.preparedEncounters||=[];d.quests||=[];d.loot||=[];
|
||||
const sessionExists=sid=>{if(!d.sessions.some(s=>s.id===sid))throw new CampaignError('Session not found in this campaign',404)};
|
||||
const snapshot=(name,participants,meta={})=>{if(d.encounters.filter(e=>e.status==='active').length>=10)throw new CampaignError('Finish an active encounter first',400);d.encounters=d.encounters.filter(e=>e.status==='active').concat(d.encounters.filter(e=>e.status==='complete').slice(-49));const encounter={id:randomUUID(),name,participants:participants.map(x=>({...x,id:randomUUID(),conditions:[]})).sort((a,b)=>b.initiative-a.initiative),round:1,turn:0,status:'active',...meta};d.encounters.push(encounter);return encounter};
|
||||
if(action==='invite'){dm(d,sub);if(d.invites.filter(x=>x.expires>Date.now()&&!x.used).length>=50)throw new CampaignError('Too many pending invitations',400);const secret=randomBytes(24).toString('base64url');d.invites=d.invites.filter(x=>x.expires>Date.now()&&!x.used);d.invites.push({hash:hash(secret),role:p.role,inviter:sub,expires:Date.now()+7*86400000,used:false});extra={invite_code:`${cid}.${secret}`}}
|
||||
@ -60,6 +70,10 @@ export class Campaigns{
|
||||
else if(action==='removeNote'){const n=d.notes.find(n=>n.id===p.id);if(n?.visibility==='dm')dm(d,sub);if(!n||n.author!==sub)throw new CampaignError('Only the author can delete this note');d.notes=d.notes.filter(n=>n.id!==p.id);for(const s of d.scenes)s.note_ids=s.note_ids.filter(nid=>nid!==p.id)}
|
||||
else if(action==='npc'||action==='session'){dm(d,sub);const key=action==='npc'?'npcs':'sessions',items=d[key],old=p.id?items.find(n=>n.id===p.id):null;if(p.id&&!old)throw new CampaignError('Entry not found',404);if(!old&&items.length>=200)throw new CampaignError('Entry limit reached',400);const n={...p,id:p.id||randomUUID()};if(old)Object.assign(old,n);else items.push(n)}
|
||||
else if(action==='removeNpc'){dm(d,sub);d.npcs=d.npcs.filter(n=>n.id!==p.id);for(const s of d.scenes)s.npc_ids=s.npc_ids.filter(nid=>nid!==p.id)}
|
||||
else if(action==='quest'){dm(d,sub);const old=p.id?d.quests.find(q=>q.id===p.id):null;if(p.id&&!old)throw new CampaignError('Quest not found',404);if(!old&&d.quests.length>=200)throw new CampaignError('Quest limit reached',400);const q={...p,id:p.id||randomUUID()};if(old)Object.assign(old,q);else d.quests.push(q)}
|
||||
else if(action==='removeQuest'){dm(d,sub);if(!d.quests.some(q=>q.id===p.id))throw new CampaignError('Quest not found',404);d.quests=d.quests.filter(q=>q.id!==p.id)}
|
||||
else if(action==='loot'){dm(d,sub);const old=p.id?d.loot.find(l=>l.id===p.id):null;if(p.id&&!old)throw new CampaignError('Loot parcel not found',404);if(old&&old.status!=='draft')throw new CampaignError('Withdraw the offer before editing',409);if(!old&&d.loot.length>=200)throw new CampaignError('Loot parcel limit reached',400);if(p.recipient&&!d.pcs.some(pc=>pc.owner===p.recipient.owner&&pc.id===p.recipient.character_id))throw new CampaignError('Choose a character assigned to this campaign',400);const parcel={...p,id:p.id||randomUUID(),status:'draft'};if(old)Object.assign(old,parcel);else d.loot.push(parcel)}
|
||||
else if(action==='lootStep'){dm(d,sub);const parcel=d.loot.find(l=>l.id===p.id);if(!parcel)throw new CampaignError('Loot parcel not found',404);if(parcel.status==='claimed')throw new CampaignError('Claimed loot cannot be changed',409);if(p.action==='offer'){if(parcel.status!=='draft')throw new CampaignError('Parcel is already offered',409);if(!parcel.recipient||!d.pcs.some(pc=>pc.owner===parcel.recipient.owner&&pc.id===parcel.recipient.character_id))throw new CampaignError('Assign a party character before offering loot',400);parcel.status='offered'}else{if(parcel.status!=='offered')throw new CampaignError('Only offered loot can be withdrawn',409);parcel.status='draft'}}
|
||||
else if(action==='scene'){dm(d,sub);sessionExists(p.session_id);const old=p.id?d.scenes.find(s=>s.id===p.id):null;if(p.id&&!old)throw new CampaignError('Scene not found',404);if(old&&old.session_id!==p.session_id)throw new CampaignError('A scene stays in its original session',400);if(!old&&d.scenes.length>=200)throw new CampaignError('Scene limit reached',400);
|
||||
if(p.npc_ids.some(nid=>!d.npcs.some(n=>n.id===nid))||p.note_ids.some(nid=>!d.notes.some(n=>n.id===nid&&(n.visibility!=='private'||n.author===sub||old?.note_ids.includes(nid))))||p.encounter_ids.some(eid=>!d.preparedEncounters.some(e=>e.id===eid&&e.session_id===p.session_id)))throw new CampaignError('Linked entries must be accessible in this campaign and session',400);
|
||||
const scene={...p,id:p.id||randomUUID(),npc_ids:[...new Set(p.npc_ids)],note_ids:[...new Set(p.note_ids)],encounter_ids:[...new Set(p.encounter_ids)],status:old?.status||'planned',revealed:old?.revealed||false};if(old)Object.assign(old,scene);else d.scenes.push(scene)}
|
||||
@ -72,6 +86,36 @@ export class Campaigns{
|
||||
return {...extra,campaign:d.members.some(m=>m.sub===sub)?await this.view(sub,cid):null};
|
||||
}
|
||||
async join(sub,name,input){const {code}=campaignInputs.join.parse(input),[cid,secret]=code.split('.');if(!cid||!secret)throw new CampaignError('Invalid invitation',400);const r=await this.row(cid),d=structuredClone(r.data),invite=d.invites.find(x=>x.hash===hash(secret)&&!x.used&&x.expires>Date.now());if(!invite)throw new CampaignError('Invitation expired or already used',400);dm(d,invite.inviter);if(d.members.some(m=>m.sub===sub))throw new CampaignError('You already belong to this campaign',400);if(d.members.length>=100)throw new CampaignError('Campaign member limit reached',400);d.members.push({sub,name,role:invite.role});invite.used=true;const updated=await this.pool.query('UPDATE campaigns SET data=$2,revision=revision+1 WHERE id=$1 AND revision=$3 RETURNING revision',[cid,d,r.revision]);if(!updated.rows[0])throw new CampaignError('Campaign changed. Try joining again.',409);return this.view(sub,cid)}
|
||||
|
||||
async claimLoot(sub,cid,input){
|
||||
const p=campaignInputs.claimLoot.parse(input),client=await this.pool.connect();
|
||||
try{
|
||||
await client.query('BEGIN');
|
||||
const row=(await client.query('SELECT data,revision FROM campaigns WHERE id=$1 FOR UPDATE',[cid])).rows[0];
|
||||
if(!row)throw new CampaignError('Campaign not found',404);
|
||||
member(row.data,sub);
|
||||
if(row.revision!==p.expected_campaign_revision)throw new CampaignError('Campaign changed. Reload rewards before claiming.',409);
|
||||
const d=structuredClone(row.data),parcel=(d.loot||[]).find(l=>l.id===p.id);
|
||||
if(!parcel||parcel.recipient?.owner!==sub)throw new CampaignError('Reward not found',404);
|
||||
if(parcel.status!=='offered')throw new CampaignError('Reward is no longer available',409);
|
||||
const charId=parcel.recipient.character_id;
|
||||
if(!d.pcs.some(pc=>pc.owner===sub&&pc.id===charId))throw new CampaignError('Character is no longer assigned to campaign');
|
||||
const rowPc=(await client.query('SELECT character,revision,deleted FROM character_sheets WHERE owner=$1 AND id=$2 FOR UPDATE',[sub,charId])).rows[0];
|
||||
if(!rowPc||rowPc.deleted)throw new CampaignError('Character no longer exists',404);
|
||||
if(rowPc.revision!==p.expected_character_revision)throw new CampaignError('Character changed. Reload before claiming loot.',409);
|
||||
const character=characterSchema.parse(rowPc.character);
|
||||
for(const [currency,amount] of Object.entries(parcel.currency))if(amount)changeCurrency(character,currency,amount);
|
||||
for(const item of parcel.items)addInventoryItem(character,{id:randomUUID(),name:item.name,quantity:item.quantity,category:item.category,equipped:false,attuned:false,weight:'',cost:'',notes:item.notes,source:'Campaign loot: '+parcel.title});
|
||||
const saved=characterSchema.parse(character);
|
||||
const savedPc=(await client.query('UPDATE character_sheets SET character=$3,revision=revision+1 WHERE owner=$1 AND id=$2 AND revision=$4 RETURNING revision',[sub,charId,saved,p.expected_character_revision])).rows[0];
|
||||
if(!savedPc)throw new CampaignError('Character changed',409);
|
||||
parcel.status='claimed';parcel.claimed_at=new Date().toISOString();
|
||||
const savedCampaign=(await client.query('UPDATE campaigns SET data=$2,revision=revision+1 WHERE id=$1 AND revision=$3 RETURNING revision',[cid,d,p.expected_campaign_revision])).rows[0];
|
||||
if(!savedCampaign)throw new CampaignError('Campaign changed',409);
|
||||
await client.query('COMMIT');
|
||||
return {character_id:charId,character_revision:savedPc.revision,campaign_revision:savedCampaign.revision,status:'claimed'};
|
||||
}catch(e){await client.query('ROLLBACK');throw e}finally{client.release()}
|
||||
}
|
||||
async partyHp(sub,cid,input){const p=campaignInputs.partyHp.parse(input),client=await this.pool.connect();try{await client.query('BEGIN');const r=(await client.query('SELECT data FROM campaigns WHERE id=$1 FOR UPDATE',[cid])).rows[0];if(!r)throw new CampaignError('Campaign not found',404);dm(r.data,sub);if(!r.data.pcs.some(pc=>pc.owner===p.owner&&pc.id===p.character_id&&pc.allowDmEdit))throw new CampaignError('The owner has not delegated combat editing to this campaign’s DMs');const row=(await client.query('SELECT character,revision,deleted FROM character_sheets WHERE owner=$1 AND id=$2 FOR UPDATE',[p.owner,p.character_id])).rows[0];if(!row||row.deleted)throw new CampaignError('Character not found',404);if(row.revision!==p.expected_revision)throw new CampaignError('Character changed. Reload before saving.',409);const c=characterSchema.parse(row.character);changeHP(c,p.amount);const updated=(await client.query('UPDATE character_sheets SET character=$3,revision=revision+1 WHERE owner=$1 AND id=$2 AND revision=$4 RETURNING revision',[p.owner,p.character_id,c,p.expected_revision])).rows[0];if(!updated)throw new CampaignError('Character changed',409);await client.query('COMMIT');return {character:shared(c),revision:updated.revision}}catch(e){await client.query('ROLLBACK');throw e}finally{client.release()}}
|
||||
}
|
||||
export function installCampaigns(app,{campaigns,origin}){
|
||||
@ -81,17 +125,19 @@ export function installCampaigns(app,{campaigns,origin}){
|
||||
app.post('/api/campaigns',route(req=>campaigns.create(req.identity.sub,req.identity.name,req.body)));
|
||||
app.post('/api/campaigns/join',route(req=>campaigns.join(req.identity.sub,req.identity.name,req.body)));
|
||||
app.get('/api/campaigns/:id',route(req=>campaigns.view(req.identity.sub,req.params.id)));
|
||||
app.post('/api/campaigns/:id/loot/claim',route(req=>campaigns.claimLoot(req.identity.sub,req.params.id,req.body)));
|
||||
app.post('/api/campaigns/:id/hp',route(req=>campaigns.partyHp(req.identity.sub,req.params.id,req.body)));
|
||||
app.post('/api/campaigns/:id/:action',route(req=>campaigns.mutate(req.identity.sub,req.params.id,req.body.revision,req.params.action,req.body.input,req.identity.name)));
|
||||
}
|
||||
export function registerCampaignTools({tool,campaigns,sub,allowed,read,write}){
|
||||
const campaign_id=id,expected_revision=z.number().int().min(1);
|
||||
tool('list_campaigns','List campaigns you belong to and your campaign-specific player/DM role.',{},read,async()=>({campaigns:await campaigns.list(sub)}));
|
||||
tool('get_campaign','Read your 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('get_campaign','Read your campaign with role-filtered quests, personal loot offers, notes, scenes, encounters and DM preparation. Hidden DM secrets stay private.',{campaign_id},read,async a=>({campaign:await campaigns.view(sub,a.campaign_id)}));
|
||||
tool('create_campaign','Create a campaign; you become its first DM.',campaignInputs.create.shape,write,async a=>{allowed();return {campaign:await campaigns.create(sub,'MCP adventurer',a)}});
|
||||
tool('join_campaign','Redeem a single-use, seven-day invitation. Do not share the invitation code with unintended recipients.',campaignInputs.join.shape,write,async a=>{allowed();return {campaign:await campaigns.join(sub,'MCP adventurer',a)}});
|
||||
for(const [action,name,description] of [['invite','invite_campaign_member','DM: create a single-use player or co-DM invitation.'],['assign','assign_campaign_character','Assign your own PC and explicitly allow or revoke DM combat editing.'],['unassign','unassign_campaign_character','Remove your own PC from this campaign.'],['member','manage_campaign_member','DM: change a member role or remove a member; the last DM cannot be removed.'],['note','save_campaign_note','Save your own private, party-visible or DM-only note. Players cannot create DM-only notes.'],['removeNote','delete_campaign_note','Delete your own campaign note.'],['npc','save_campaign_npc','DM: create/edit an NPC; hidden by default. NPC notes remain DM-only even when visible.'],['removeNpc','delete_campaign_npc','DM: delete an NPC.'],['session','save_campaign_session','DM: plan/update a session and party-visible summary.'],['encounter','start_campaign_encounter','DM: start initiative tracking with HP snapshots. Encounter HP changes do not alter saved PC sheets.'],['scene','save_campaign_scene','DM: prepare a session scene with player-facing description, separate DM notes and linked NPCs, notes and prepared encounters. Saving does not reveal or start anything.'],['sceneStep','advance_campaign_scene','DM: explicitly mark a scene current/complete/reset or reveal/hide its title and description. DM notes and links stay private.'],['preparedEncounter','save_prepared_encounter','DM: save combatants for a session without starting combat or revealing the preparation.'],['startPrepared','start_prepared_encounter','DM: explicitly start a fresh initiative/HP snapshot from a prepared encounter. Participant names and stats become party-visible. Cannot start the same preparation twice while active.'],['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)})
|
||||
for(const [action,name,description] of [['invite','invite_campaign_member','DM: create a single-use player or co-DM invitation.'],['assign','assign_campaign_character','Assign your own PC and explicitly allow or revoke DM combat editing.'],['unassign','unassign_campaign_character','Remove your own PC from this campaign.'],['member','manage_campaign_member','DM: change a member role or remove a member; the last DM cannot be removed.'],['note','save_campaign_note','Save your own private, party-visible or DM-only note. Players cannot create DM-only notes.'],['removeNote','delete_campaign_note','Delete your own campaign note.'],['npc','save_campaign_npc','DM: create/edit an NPC; hidden by default. NPC notes remain DM-only even when visible.'],['removeNpc','delete_campaign_npc','DM: delete an NPC.'],['session','save_campaign_session','DM: plan/update a session and party-visible summary.'],['encounter','start_campaign_encounter','DM: start initiative tracking with HP snapshots. Encounter HP changes do not alter saved PC sheets.'],['scene','save_campaign_scene','DM: prepare a session scene with player-facing description, separate DM notes and linked NPCs, notes and prepared encounters. Saving does not reveal or start anything.'],['sceneStep','advance_campaign_scene','DM: explicitly mark a scene current/complete/reset or reveal/hide its title and description. DM notes and links stay private.'],['preparedEncounter','save_prepared_encounter','DM: save combatants for a session without starting combat or revealing the preparation.'],['startPrepared','start_prepared_encounter','DM: explicitly start a fresh initiative/HP snapshot from a prepared encounter. Participant names and stats become party-visible. Cannot start the same preparation twice while active.'],['quest','save_campaign_quest','DM: save or update a quest with DM-only secrets and explicit party visibility.'],['removeQuest','delete_campaign_quest','DM: remove a quest.'],['loot','save_campaign_loot','DM: prepare a draft parcel of coins or items for an assigned PC. Saving does not award anything.'],['lootStep','change_campaign_loot_offer','DM: explicitly offer or withdraw a parcel; claimed rewards are immutable.'],['encounterStep','advance_campaign_encounter','DM: advance turns, damage/heal a snapshot, set manual conditions, or finish an encounter. Conditions replace the participant’s list and do not automate rules or change saved sheets.']]){
|
||||
tool(name,description,{campaign_id,expected_revision,input:campaignInputs[action]}, {...write,destructiveHint:['removeNote','removeNpc','removeQuest','member'].includes(action)},async a=>{allowed();return campaigns.mutate(sub,a.campaign_id,a.expected_revision,action,a.input)})
|
||||
}
|
||||
tool('claim_campaign_loot','Claim an offered parcel for an assigned PC; atomically add items and coins to their private sheet and prevent duplicate claims.',{campaign_id,...campaignInputs.claimLoot.shape},write,async a=>{allowed();return campaigns.claimLoot(sub,a.campaign_id,a)});
|
||||
tool('change_party_hit_points','DM: update actual PC HP only when its owner delegated combat editing. Requires the PC revision from get_campaign.',{campaign_id,...campaignInputs.partyHp.shape},write,async a=>{allowed();return campaigns.partyHp(sub,a.campaign_id,a)});
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
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>}>();
|
||||
import SessionPreparation from './SessionPreparation.vue';
|
||||
import DmToolkit from './DmToolkit.vue';
|
||||
import type {Campaign,Note,Npc,Session,Participant,Encounter} from './campaign-types';
|
||||
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);
|
||||
@ -33,6 +34,23 @@ async function create(){await run(async()=>{const result=await request('',{name:
|
||||
async function join(){await run(async()=>{current.value=await request('/join',{code:joinCode.value.trim()});joinCode.value='';resetWorkspace();await refreshList()})}
|
||||
async function action(name:string,input:unknown){if(!current.value)return;await run(async()=>{const result=await request(`/${current.value!.id}/${name}`,{revision:current.value!.revision,input});current.value=result.campaign;if(result.invite_code)inviteCode.value=result.invite_code;notice.value='Campaign saved.';await refreshList()})}
|
||||
async function assign(){if(!current.value)return;await run(async()=>{await props.syncCharacters();const result=await request(`/${current.value!.id}/assign`,{revision:current.value!.revision,input:{character_id:selectedPc.value,allow_dm_edit:delegate.value}});current.value=result.campaign;notice.value='Character shared with the party.';await refreshList()})}
|
||||
async function claimLoot(id:string){
|
||||
if(!current.value||busy.value)return false;
|
||||
let accepted=false;
|
||||
await run(async()=>{
|
||||
await props.syncCharacters();
|
||||
const campaign=current.value!,offer=campaign.loot.find(l=>l.id===id&&l.status==='offered');
|
||||
if(!offer||offer.recipient?.owner!==self.value)throw new Error('This reward is not available to you.');
|
||||
const pc=campaign.party.find(p=>p.owner===self.value&&p.id===offer.recipient?.character_id);
|
||||
if(!pc)throw new Error('The character is no longer assigned to your party.');
|
||||
await request('/'+campaign.id+'/loot/claim',{id,expected_campaign_revision:campaign.revision,expected_character_revision:pc.revision});
|
||||
await props.syncCharacters();
|
||||
current.value=await request('/'+campaign.id);
|
||||
notice.value='Loot claimed and added to your character inventory and coin purse.';
|
||||
accepted=true;
|
||||
});
|
||||
return accepted;
|
||||
}
|
||||
async function partyHp(pc:Campaign['party'][number],amount:number){await run(async()=>{await request('/'+current.value!.id+'/hp',{owner:pc.owner,character_id:pc.id,expected_revision:pc.revision,amount});current.value=await request('/'+current.value!.id);notice.value='Saved PC hit points updated.'})}
|
||||
function editNote(n:Note){note.value={id:n.id,title:n.title,body:n.body,visibility:n.visibility};tab.value='Journal';journalTab.value='Notes'}
|
||||
function editNpc(n:Npc){npc.value={...n,notes:n.notes||''}}
|
||||
@ -59,11 +77,12 @@ onMounted(()=>void run(async()=>{const identity=await fetch('/api/session').then
|
||||
<div class="campaign-toolbar"><button class="quiet" @click="current=null;inviteCode=''">← All campaigns</button><button class="quiet" @click="reload" :disabled="busy">Refresh</button></div>
|
||||
<section class="hero"><UiIcon name="notes" class="campaign-emblem" /><div><p class="eyebrow">{{isDm?'DUNGEON MASTER':'PLAYER'}}</p><h1>{{current.name}}</h1><p>{{current.description}}</p></div></section>
|
||||
<nav class="campaign-tabs fieldbook-tabs" aria-label="Campaign sections"><button v-for="name in ['Session','Party','Journal','Campaign']" :key="name" :class="{active:tab===name}" :aria-current="tab===name?'page':undefined" @click="tab=name"><UiIcon :name="name==='Session'?'initiative':name==='Party'?'sheet':name==='Journal'?'notes':'gear'" />{{name}}</button></nav>
|
||||
<div v-if="tab==='Journal'" class="campaign-subnav"><button class="quiet" :class="{selected:journalTab==='Notes'}" @click="journalTab='Notes'">Notes & handouts</button><button class="quiet" :class="{selected:journalTab==='NPCs'}" @click="journalTab='NPCs'">{{isDm?'NPCs & secrets':'Known NPCs'}}</button></div>
|
||||
<div v-if="tab==='Journal'" class="campaign-subnav"><button class="quiet" :class="{selected:journalTab==='Notes'}" @click="journalTab='Notes'">Notes & handouts</button><button class="quiet" :class="{selected:journalTab==='NPCs'}" @click="journalTab='NPCs'">{{isDm?'NPCs & secrets':'Known NPCs'}}</button><button class="quiet" :class="{selected:journalTab==='Adventure'}" @click="journalTab='Adventure'">Quests & loot</button></div>
|
||||
<template v-if="tab==='Party'">
|
||||
<section class="panel"><div class="section-title"><h2><UiIcon name="sheet" />Your travelling companions</h2><button v-if="isDm" class="quiet" @click="tab='Campaign'">Invite players →</button></div><p class="hint">Shared combat statistics only. Personal story notes and inventory stay private.</p><p class="hint" v-if="!current.party.length">Assign a saved character below to join the party.</p><article class="party-entry" v-for="pc in current.party" :key="pc.owner+pc.id"><div class="section-title"><h3>{{pc.character.name}}</h3><span class="chip">Level {{pc.character.level}} {{pc.character.className}}</span></div><p class="party-owner">{{partyOwner(pc.owner)}}{{pc.owner===self?' · Your character':''}} · {{pc.allowDmEdit?'DM HP edits allowed':'Owner controls saved HP'}}</p><div class="party-stats"><span><UiIcon name="hp" />{{pc.character.hp.current}} / {{pc.character.hp.max}} <small>+ {{pc.character.hp.temp}} temp</small></span><span><UiIcon name="ac" />{{pc.character.ac}}</span><span><UiIcon name="speed" />{{pc.character.speed}} ft</span></div><div class="campaign-actions" v-if="isDm&&pc.allowDmEdit"><button :disabled="busy" @click="partyHp(pc,-1)">−1 HP</button><button :disabled="busy" @click="partyHp(pc,1)">+1 HP</button><span class="hint">Owner permits DM combat edits</span></div><div v-if="pc.owner===self" class="campaign-actions"><label class="inline-check"><input type="checkbox" :checked="pc.allowDmEdit" @change="action('assign',{character_id:pc.id,allow_dm_edit:($event.target as HTMLInputElement).checked})" :disabled="busy">Allow campaign DMs to edit HP</label><button class="quiet" @click="action('unassign',{character_id:pc.id})" :disabled="busy">Unassign</button></div></article></section>
|
||||
<details class="fieldbook-fold"><summary><UiIcon name="sheet" />Assign your character</summary><form class="panel" @submit.prevent="assign"><h2><UiIcon name="sheet" />Assign your PC</h2><label>Saved character<select v-model="selectedPc" required><option disabled value="">Choose your character</option><option v-for="pc in characters" :key="pc.id" :value="pc.id">{{pc.name}}</option></select></label><label class="inline-check"><input type="checkbox" v-model="delegate">Allow campaign DMs to change saved HP</label><p class="hint">Only combat stats are shared. You can revoke DM editing at any time.</p><button class="primary wide" :disabled="busy||!selectedPc">Assign to campaign</button></form></details>
|
||||
</template>
|
||||
<DmToolkit v-if="tab==='Journal'&&journalTab==='Adventure'" :campaign="current" :self="self" :busy="busy" :mutate="prepareAction" :claim="claimLoot" />
|
||||
<template v-if="tab==='Journal'&&journalTab==='Notes'">
|
||||
<section class="panel" v-for="n in current.notes" :key="n.id"><div class="section-title"><h2><UiIcon name="notes" />{{n.title}}</h2><span class="chip">{{n.visibility==='dm'?'DM only':n.visibility==='private'?'Private':'Party'}}</span></div><p class="campaign-prose">{{n.body}}</p><div class="campaign-actions" v-if="n.author===self"><button @click="editNote(n)">Edit</button><button class="quiet danger-text" @click="action('removeNote',{id:n.id})" :disabled="busy">Delete note</button></div></section>
|
||||
<form class="panel" @submit.prevent="saveNote"><h2><UiIcon name="notes" />{{note.id?'Edit note':'Write a chapter'}}</h2><label>Title<input v-model="note.title" maxlength="120" required></label><label>Who can read it?<select v-model="note.visibility"><option value="private">Only me</option><option value="party">The whole party</option><option v-if="isDm" value="dm">DMs only</option></select></label><label>Note<textarea v-model="note.body" rows="6" maxlength="12000"></textarea></label><button class="primary wide" :disabled="busy">Save note</button></form>
|
||||
|
||||
86
src/DmToolkit.vue
Normal file
86
src/DmToolkit.vue
Normal file
@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import {computed,ref,watch} from 'vue';
|
||||
import UiIcon from './UiIcon.vue';
|
||||
import type {Campaign,Quest,LootParcel,LootItem} from './campaign-types';
|
||||
const props=defineProps<{campaign:Campaign,self:string,busy:boolean,mutate:(name:string,input:unknown)=>Promise<boolean>,claim:(id:string)=>Promise<boolean>}>();
|
||||
const isDm=computed(()=>props.campaign.role==='dm');
|
||||
const quests=computed(()=>props.campaign.quests||[]),loot=computed(()=>props.campaign.loot||[]);
|
||||
const showingQuest=ref(false),showingLoot=ref(false),error=ref('');
|
||||
const newQuest=()=>({id:undefined as string|undefined,title:'',description:'',dm_notes:'',status:'open' as Quest['status'],visible:false});
|
||||
const quest=ref(newQuest());
|
||||
const blankItem=():LootItem=>({name:'',quantity:1,category:'Other',notes:''});
|
||||
type LootDraft={id?:string,title:string,description:string,recipient:string,items:LootItem[],currency:{gp:number,sp:number,cp:number}};
|
||||
const emptyLoot=():LootDraft=>({id:undefined,title:'',description:'',recipient:'',items:[],currency:{gp:0,sp:0,cp:0}});
|
||||
const draft=ref<LootDraft>(emptyLoot());
|
||||
const assigned=computed(()=>props.campaign.party);
|
||||
function recipientKey(owner:string,id:string){return owner+'|'+id}
|
||||
function characterName(l:LootParcel){const found=assigned.value.find(pc=>pc.owner===l.recipient?.owner&&pc.id===l.recipient?.character_id);return found?.character.name||'Unassigned character'}
|
||||
function editQuest(q?:Quest){quest.value=q?{id:q.id,title:q.title,description:q.description,dm_notes:q.dm_notes||'',status:q.status,visible:q.visible}:newQuest();error.value='';showingQuest.value=true}
|
||||
async function saveQuest(){if(await props.mutate('quest',quest.value)){showingQuest.value=false;quest.value=newQuest()}}
|
||||
async function toggleQuest(q:Quest){await props.mutate('quest',{...q,visible:!q.visible,dm_notes:q.dm_notes||''})}
|
||||
function editLoot(l?:LootParcel){
|
||||
draft.value=l?{id:l.id,title:l.title,description:l.description,recipient:l.recipient?recipientKey(l.recipient.owner,l.recipient.character_id):'',items:l.items.map(item=>({...item})),currency:{...l.currency}}:emptyLoot();
|
||||
showingLoot.value=true;error.value='';
|
||||
}
|
||||
function addItem(){if(draft.value.items.length<50)draft.value.items.push(blankItem())}
|
||||
async function saveLoot(){
|
||||
const itemList=draft.value.items.filter(item=>item.name.trim());
|
||||
if(itemList.length!==draft.value.items.length){error.value='Name each item, or remove its empty row.';return}
|
||||
const pc=assigned.value.find(p=>recipientKey(p.owner,p.id)===draft.value.recipient);
|
||||
if(draft.value.recipient&&!pc){error.value='Choose a currently assigned party character.';return}
|
||||
const input={id:draft.value.id,title:draft.value.title,description:draft.value.description,recipient:pc?{owner:pc.owner,character_id:pc.id}:null,items:itemList,currency:draft.value.currency};
|
||||
if(await props.mutate('loot',input)){showingLoot.value=false;draft.value=emptyLoot();error.value=''}
|
||||
}
|
||||
function canClaim(l:LootParcel){return !isDm.value&&l.status==='offered'&&l.recipient?.owner===props.self&&assigned.value.some(p=>p.owner===props.self&&p.id===l.recipient?.character_id)}
|
||||
watch(()=>props.campaign.id,()=>{showingQuest.value=false;showingLoot.value=false;error.value=''});
|
||||
</script>
|
||||
<template>
|
||||
<section class="dm-toolkit" aria-label="Quests and loot">
|
||||
<div class="folio-heading"><h2><UiIcon name="notes" />Quest journal</h2><button v-if="isDm" class="quiet" :disabled="busy" @click="editQuest()">+ Quest</button></div>
|
||||
<p class="hint">{{isDm?'Keep hidden plot threads, share discoveries and track outcomes. Only revealed descriptions are shown to players.':'Quests and objectives shared by your Dungeon Master.'}}</p>
|
||||
<p v-if="!quests.length" class="dm-toolkit-empty">{{isDm?'No quests yet. Begin with an objective or story hook.':'No quests have been revealed yet.'}}</p>
|
||||
<article v-for="q in quests" :key="q.id" class="dm-quest-entry">
|
||||
<div class="section-title"><h3>{{q.title}}</h3><span class="chip">{{q.status==='open'?'In progress':q.status==='complete'?'Completed':'Failed'}}</span></div>
|
||||
<p v-if="isDm" class="hint">{{q.visible?'Revealed to party':'Hidden from party'}}</p>
|
||||
<p v-if="q.description" class="campaign-prose">{{q.description}}</p>
|
||||
<details v-if="isDm&&q.dm_notes" class="scene-screen"><summary>Behind the screen</summary><p class="campaign-prose">{{q.dm_notes}}</p></details>
|
||||
<div v-if="isDm" class="campaign-actions"><button class="quiet" @click="toggleQuest(q)" :disabled="busy">{{q.visible?'Hide quest':'Reveal quest'}}</button><button class="quiet" @click="editQuest(q)" :disabled="busy">Edit</button><button class="quiet danger-text" @click="mutate('removeQuest',{id:q.id})" :disabled="busy">Delete</button></div>
|
||||
</article>
|
||||
<form v-if="isDm&&showingQuest" class="panel dm-tool-editor" @submit.prevent="saveQuest">
|
||||
<div class="section-title"><h2>{{quest.id?'Edit quest':'New quest'}}</h2><button type="button" class="quiet" @click="showingQuest=false">Cancel</button></div>
|
||||
<label>Quest title<input v-model="quest.title" maxlength="120" required placeholder="The missing caravan"></label>
|
||||
<label>Player-facing objective<textarea v-model="quest.description" maxlength="12000" rows="3" placeholder="What the party knows…"></textarea></label>
|
||||
<label>Behind the screen<textarea v-model="quest.dm_notes" maxlength="12000" rows="3" placeholder="Twists, secrets and alternative resolutions…"></textarea></label>
|
||||
<div class="form-grid"><label>Status<select v-model="quest.status"><option value="open">In progress</option><option value="complete">Completed</option><option value="failed">Failed</option></select></label><label class="inline-check dm-reveal"><input type="checkbox" v-model="quest.visible"> Reveal to the party</label></div>
|
||||
<button class="primary" :disabled="busy">Save quest</button>
|
||||
</form>
|
||||
<div class="folio-heading dm-loot-heading"><h2><UiIcon name="gear" />Treasure & rewards</h2><button v-if="isDm" class="quiet" @click="editLoot()" :disabled="busy">+ Parcel</button></div>
|
||||
<p class="hint">{{isDm?'Prepare treasure, assign an adventurer and explicitly offer it. Players must claim before their private inventory changes.':'Rewards offered to your own characters appear here. Claim one to add it to your sheet.'}}</p>
|
||||
<p v-if="!loot.length" class="dm-toolkit-empty">{{isDm?'No loot parcels prepared yet.':'No personal loot offered yet.'}}</p>
|
||||
<article v-for="l in loot" :key="l.id" class="dm-loot-entry">
|
||||
<div class="section-title"><h3>{{l.title}}</h3><span class="chip">{{l.status==='draft'?'DM draft':l.status==='offered'?'Awaiting claim':'Claimed'}}</span></div>
|
||||
<p v-if="l.description" class="campaign-prose">{{l.description}}</p>
|
||||
<p v-if="isDm" class="hint">Recipient: {{characterName(l)}}</p>
|
||||
<p v-if="l.currency.gp||l.currency.sp||l.currency.cp" class="dm-coins"><strong>{{l.currency.gp}} GP</strong><strong>{{l.currency.sp}} SP</strong><strong>{{l.currency.cp}} CP</strong></p>
|
||||
<div class="dm-loot-item" v-for="(item,i) in l.items" :key="i"><strong>{{item.name}}</strong><span>×{{item.quantity}} · {{item.category}}</span><p v-if="item.notes">{{item.notes}}</p></div>
|
||||
<p v-if="l.claimed_at" class="hint">Claimed {{l.claimed_at.slice(0,10)}}</p>
|
||||
<div v-if="isDm" class="campaign-actions">
|
||||
<button v-if="l.status==='draft'" class="quiet" @click="editLoot(l)" :disabled="busy">Edit draft</button>
|
||||
<button v-if="l.status==='draft'" class="primary" @click="mutate('lootStep',{id:l.id,action:'offer'})" :disabled="busy||!l.recipient">Offer to character →</button>
|
||||
<button v-if="l.status==='offered'" class="quiet" @click="mutate('lootStep',{id:l.id,action:'withdraw'})" :disabled="busy">Withdraw offer</button>
|
||||
</div>
|
||||
<div v-if="canClaim(l)" class="campaign-actions"><button class="primary" @click="claim(l.id)" :disabled="busy">Claim for {{characterName(l)}} →</button></div>
|
||||
</article>
|
||||
<form v-if="isDm&&showingLoot" class="panel dm-tool-editor" @submit.prevent="saveLoot">
|
||||
<div class="section-title"><h2>{{draft.id?'Edit parcel':'Prepare treasure'}}</h2><button type="button" class="quiet" @click="showingLoot=false">Cancel</button></div>
|
||||
<p v-if="error" class="notice danger" role="alert">{{error}}</p>
|
||||
<label>Parcel name<input v-model="draft.title" maxlength="120" required placeholder="Spoils of the goblin camp"></label>
|
||||
<label>Player-facing description<textarea v-model="draft.description" rows="2" maxlength="12000" placeholder="Where the treasure was found…"></textarea></label>
|
||||
<label>Recipient character<select v-model="draft.recipient"><option value="">Choose later (draft only)</option><option v-for="p in assigned" :key="recipientKey(p.owner,p.id)" :value="recipientKey(p.owner,p.id)">{{p.character.name}}</option></select></label>
|
||||
<fieldset class="folio-section"><legend>Coins</legend><div class="form-grid three"><label>Gold<input type="number" v-model.number="draft.currency.gp" min="0" max="9999999" step="1" required inputmode="numeric"></label><label>Silver<input type="number" v-model.number="draft.currency.sp" min="0" max="9999999" step="1" required inputmode="numeric"></label><label>Copper<input type="number" v-model.number="draft.currency.cp" min="0" max="9999999" step="1" required inputmode="numeric"></label></div></fieldset>
|
||||
<fieldset class="folio-section"><legend>Items</legend><article class="dm-loot-edit-item" v-for="(item,i) in draft.items" :key="i"><div class="form-grid"><label>Name<input v-model="item.name" maxlength="120" required></label><label>Quantity<input type="number" v-model.number="item.quantity" min="1" max="9999" required></label></div><label>Category<select v-model="item.category"><option>Weapon</option><option>Armor</option><option>Adventuring gear</option><option>Magic item</option><option>Other</option></select></label><label>Item notes<input v-model="item.notes" maxlength="12000"></label><button type="button" class="quiet danger-text" @click="draft.items.splice(i,1)">Remove item</button></article><button type="button" class="quiet" @click="addItem" :disabled="draft.items.length>=50">+ Add item</button></fieldset>
|
||||
<p class="hint">Saving creates a private draft. Offering later notifies the recipient through the campaign view; nothing enters their sheet until they claim.</p>
|
||||
<button class="primary" :disabled="busy">Save private loot draft</button>
|
||||
</form>
|
||||
</section>
|
||||
</template>
|
||||
@ -7,4 +7,9 @@ export type Participant={id:string,name:string,hp:number,maxHp:number,ac:number,
|
||||
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[]};
|
||||
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[],quests:Quest[],loot:LootParcel[]};
|
||||
|
||||
export type Quest={id:string,title:string,description:string,dm_notes?:string,status:'open'|'complete'|'failed',visible:boolean};
|
||||
export type LootItem={name:string,quantity:number,category:'Weapon'|'Armor'|'Adventuring gear'|'Magic item'|'Other',notes:string};
|
||||
export type LootRecipient={owner:string,character_id:string};
|
||||
export type LootParcel={id:string,title:string,description:string,recipient:LootRecipient|null,items:LootItem[],currency:{gp:number,sp:number,cp:number},status:'draft'|'offered'|'claimed',claimed_at?:string};
|
||||
|
||||
@ -219,3 +219,21 @@ input[type=checkbox]{accent-color:#3b603d}
|
||||
.slot-level small{font-size:8px}
|
||||
.spell-slots-count span{display:block;margin-top:4px}
|
||||
}
|
||||
|
||||
/* Quests and treasure use the same continuous parchment as the DM's Session fieldbook. */
|
||||
.dm-toolkit{padding-bottom:35px}.dm-toolkit .folio-heading{margin-top:18px}.dm-toolkit .folio-heading h2{font-size:27px}.dm-toolkit .folio-heading h2 .ui-icon{width:40px;height:40px}
|
||||
.dm-toolkit-empty{font-family:Georgia,serif;color:#74634b;font-size:17px;padding:15px 0}
|
||||
.dm-quest-entry,.dm-loot-entry{padding:18px 2px;border-bottom:1px solid #ad8d5180}
|
||||
.dm-quest-entry .section-title,.dm-loot-entry .section-title{flex-wrap:wrap;align-items:baseline}
|
||||
.dm-quest-entry h3,.dm-loot-entry h3{font-family:Georgia,serif;font-size:25px;font-weight:400;margin:0;overflow-wrap:anywhere}
|
||||
.dm-loot-heading{margin-top:38px!important}.dm-tool-editor{margin-top:22px;background:transparent!important;border:1px solid #9a774448!important;box-shadow:none!important}
|
||||
.dm-tool-editor .form-grid{margin:12px 0 18px}.dm-tool-editor .folio-section{margin:15px 0;padding:12px 8px}
|
||||
.dm-tool-editor .folio-section legend{font-family:Georgia,serif;font-size:20px;padding:0 8px}
|
||||
.dm-tool-editor .inline-check.dm-reveal{display:flex;align-items:center;gap:10px;padding:12px 0;min-height:44px}
|
||||
.dm-loot-edit-item{padding:12px 2px;border-bottom:1px solid #ad8d5180}.dm-loot-edit-item .form-grid{margin:0 0 8px}.dm-loot-edit-item label{margin:8px 0}
|
||||
.dm-loot-item{display:flex;flex-wrap:wrap;align-items:baseline;gap:5px 13px;padding:7px 0;border-top:1px solid #b99c6830}
|
||||
.dm-loot-item strong{font-family:Georgia,serif;font-size:19px;font-weight:400}.dm-loot-item span{color:#786344;font-size:12px}
|
||||
.dm-loot-item p{width:100%;margin:2px 0 6px;font-size:12px;white-space:pre-wrap}
|
||||
.dm-coins{display:flex;gap:16px;flex-wrap:wrap;margin:13px 0;font-family:Georgia,serif;font-size:19px;color:#70512a}
|
||||
.dm-toolkit .campaign-actions{flex-wrap:wrap}.campaign-subnav{flex-wrap:wrap}
|
||||
@media(max-width:700px){.dm-toolkit .folio-heading h2{font-size:22px}.dm-toolkit .folio-heading h2 .ui-icon{width:31px;height:31px}.dm-quest-entry h3,.dm-loot-entry h3{font-size:22px}.dm-tool-editor .form-grid.three{grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.dm-toolkit .campaign-actions button{min-height:44px}}
|
||||
|
||||
@ -7,7 +7,7 @@ test('private notes, DM secrets and hidden NPCs remain filtered after role chang
|
||||
test('delegated saved HP requires current DM role and owner permission, and preserves private data',async()=>{const {storage,campaigns,c,act}=await fixture(),pc=newCharacter();pc.notes='Private';pc.hp.temp=3;await storage.put('player',pc.id,pc,0);await act('player','assign',{character_id:pc.id});const input={owner:'player',character_id:pc.id,expected_revision:1,amount:-5};await assert.rejects(campaigns.partyHp('dm',c.id,input));await act('player','assign',{character_id:pc.id,allow_dm_edit:true});await assert.rejects(campaigns.partyHp('player',c.id,input));const changed=await campaigns.partyHp('dm',c.id,input);assert.equal(changed.revision,2);assert.deepEqual(changed.character.hp,{current:8,max:10,temp:0});assert.equal(changed.character.notes,undefined);assert.equal((await storage.get('player',pc.id)).character.notes,'Private');await assert.rejects(campaigns.partyHp('dm',c.id,input));await act('player','assign',{character_id:pc.id,allow_dm_edit:false});await assert.rejects(campaigns.partyHp('dm',c.id,{...input,expected_revision:2}));await act('dm','member',{sub:'player',role:'remove'});await assert.rejects(campaigns.view('player',c.id));assert.equal((await campaigns.view('dm',c.id)).party.length,0)});
|
||||
test('DM encounter snapshots sort initiative, wrap rounds and clamp HP without touching saved PCs',async()=>{const {storage,campaigns,c,act}=await fixture(),pc=newCharacter();await storage.put('player',pc.id,pc,0);await assert.rejects(act('player','session',{title:'No'}));await assert.rejects(act('player','npc',{name:'No',hp:1,maxHp:1,ac:1}));await assert.rejects(act('player','invite',{}));await act('dm','session',{title:'Session 1',summary:'Meet at the inn'});await act('dm','encounter',{name:'Ambush',participants:[{name:'PC',initiative:8,hp:10,maxHp:10,ac:10},{name:'Goblin',initiative:15,hp:5,maxHp:5,ac:12}]});let encounter=(await campaigns.view('player',c.id)).encounters[0];assert.equal(encounter.participants[0].name,'Goblin');await assert.rejects(act('player','encounterStep',{id:encounter.id,action:'next'}));await act('dm','encounterStep',{id:encounter.id,action:'next'});await act('dm','encounterStep',{id:encounter.id,action:'next'});await act('dm','encounterStep',{id:encounter.id,action:'damage',participant_id:encounter.participants[0].id,amount:50});encounter=(await campaigns.view('player',c.id)).encounters[0];assert.equal(encounter.round,2);assert.equal(encounter.turn,0);assert.equal(encounter.participants[0].hp,0);assert.equal((await storage.get('player',pc.id)).character.hp.current,10);const old=(await campaigns.row(c.id)).revision;await act('dm','encounterStep',{id:encounter.id,action:'finish'});await assert.rejects(campaigns.mutate('dm',c.id,old,'note',{title:'Stale',body:'',visibility:'party'}));assert.equal((await campaigns.view('dm',c.id)).notes.length,0)});
|
||||
test('HTTP campaign routes deny unsigned users and cross-origin writes',async()=>{const {campaigns}=await fixture(),app=express();app.use(express.json());app.use((req:any,res,next)=>{if(req.get('x-test-user'))req.identity={sub:req.get('x-test-user'),name:'Test'};next()});installCampaigns(app,{campaigns,origin:'https://dnd.example.test'});const server=app.listen(0,'127.0.0.1');await once(server,'listening');const base=`http://127.0.0.1:${(server.address() as any).port}`;try{assert.equal((await fetch(base+'/api/campaigns')).status,401);assert.equal((await fetch(base+'/api/campaigns',{method:'POST',headers:{'x-test-user':'dm','content-type':'application/json',origin:'https://evil.test'},body:JSON.stringify({name:'No'})})).status,403);assert.equal((await fetch(base+'/api/campaigns',{headers:{'x-test-user':'dm'}})).status,200)}finally{await new Promise<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,37);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,42);assert.equal((await read.callTool({name:'create_campaign',arguments:{name:'Denied'}})).isError,true);assert.equal((await stranger.callTool({name:'get_campaign',arguments:{campaign_id:c.id}})).isError,true);assert.equal((await player.callTool({name:'invite_campaign_member',arguments:{campaign_id:c.id,expected_revision:(await campaigns.row(c.id)).revision,input:{role:'dm'}}})).isError,true);assert.equal((await read.callTool({name:'get_campaign',arguments:{campaign_id:c.id}})).isError,undefined)}finally{await Promise.all([read.close(),player.close(),stranger.close()])}});
|
||||
|
||||
test('DM conditions are manual snapshots, bounded, revision guarded and removed explicitly',async()=>{
|
||||
const {storage,campaigns,c,act}=await fixture(),pc=newCharacter();await storage.put('player',pc.id,pc,0);
|
||||
|
||||
@ -5,4 +5,4 @@ test('official seeds preserve both editions, provenance and all SRD spells',asyn
|
||||
test('seeding persists counts, runs idempotently and preserves personal sheets',async()=>{const pc=newCharacter();pc.notes='Private';await storage.put('owner',pc.id,pc,0);const before=(await catalogue.pool.query('SELECT id FROM rules_catalogue')).rows.length;assert.equal(before,seed.entries.length);await catalogue.init();assert.equal((await catalogue.pool.query('SELECT id FROM rules_catalogue')).rows.length,before);assert.equal((await storage.get('owner',pc.id)).character.notes,'Private');assert.equal((await storage.get('owner',pc.id)).revision,1)});
|
||||
test('catalogue filters and paging never mix editions or fabricate mechanics',async()=>{const old=await catalogue.list({edition:'2014',kind:'spell',q:'Fire Bolt'}),revised=await catalogue.list({edition:'2024',kind:'spell',q:'Fire Bolt'});assert.equal(old.total,1);assert.equal(revised.total,1);assert.notEqual(old.entries[0].id,revised.entries[0].id);assert.equal(old.entries[0].description,undefined);const entry=await catalogue.get(revised.entries[0].id);assert.equal(entry.level,0);assert.match(entry.description,/Casting Time: Action/);assert.match(entry.description,/1d10 Fire damage/);assert.match((await catalogue.get(old.entries[0].id)).description,/Casting Time: 1 action/);const cantrips=await catalogue.list({edition:'2024',kind:'spell',level:0});assert.ok(cantrips.entries.every((e:any)=>e.level===0));const first=await catalogue.list({edition:'2014',kind:'spell'}),second=await catalogue.list({edition:'2014',kind:'spell',offset:40});assert.equal(first.entries.length,40);assert.ok(!second.entries.some((e:any)=>first.entries.some((f:any)=>f.id===e.id)));assert.equal((await catalogue.list({q:"' OR 1=1 --"})).total,0);await assert.rejects(catalogue.list({edition:'unknown'}));assert.equal(await catalogue.get('missing'),undefined);const staff=await catalogue.list({edition:'2024',kind:'item',category:'Weapon',q:'Quarterstaff'});assert.equal(staff.total,1);assert.equal((await catalogue.get(staff.entries[0].id)).mastery,'Topple')});
|
||||
test('catalogue HTTP requires a session and validates edition and missing entry',async()=>{const app=express();app.use((req:any,res,next)=>{if(req.get('x-test-user'))req.identity={sub:'reader'};next()});installCatalogue(app,{catalogue});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/catalogue')).status,401);assert.equal((await fetch(base+'/api/catalogue?edition=bad',{headers:{'x-test-user':'1'}})).status,400);const r=await fetch(base+'/api/catalogue?edition=2024&kind=spell&q=Fire%20Bolt',{headers:{'x-test-user':'1'}});assert.equal(r.status,200);assert.equal((await r.json()).total,1);assert.equal((await fetch(base+'/api/catalogue/missing',{headers:{'x-test-user':'1'}})).status,404)}finally{await new Promise<void>(r=>server.close(()=>r()))}});
|
||||
test('read-only MCP searches and reads official catalogue without modifying characters',async()=>{const server=characterMcp({storage,catalogue,sub:'reader',scopes:['characters:read']}),client=new Client({name:'catalogue-test',version:'1'}),[a,b]=InMemoryTransport.createLinkedPair();await server.connect(a);await client.connect(b);try{assert.equal((await client.listTools()).tools.length,19);const result:any=await client.callTool({name:'search_rules_catalogue',arguments:{edition:'2024',kind:'spell',q:'Fire Bolt'}});assert.equal(result.isError,undefined);const id=result.structuredContent.entries[0].id;const entry:any=await client.callTool({name:'get_rules_entry',arguments:{id}});assert.equal(entry.structuredContent.entry.name,'Fire Bolt');assert.equal((await storage.list('reader')).length,0)}finally{await client.close()}});
|
||||
test('read-only MCP searches and reads official catalogue without modifying characters',async()=>{const server=characterMcp({storage,catalogue,sub:'reader',scopes:['characters:read']}),client=new Client({name:'catalogue-test',version:'1'}),[a,b]=InMemoryTransport.createLinkedPair();await server.connect(a);await client.connect(b);try{assert.equal((await client.listTools()).tools.length,24);const result:any=await client.callTool({name:'search_rules_catalogue',arguments:{edition:'2024',kind:'spell',q:'Fire Bolt'}});assert.equal(result.isError,undefined);const id=result.structuredContent.entries[0].id;const entry:any=await client.callTool({name:'get_rules_entry',arguments:{id}});assert.equal(entry.structuredContent.entry.name,'Fire Bolt');assert.equal((await storage.list('reader')).length,0)}finally{await client.close()}});
|
||||
|
||||
105
tests/dm-tools.test.ts
Normal file
105
tests/dm-tools.test.ts
Normal file
@ -0,0 +1,105 @@
|
||||
import 'fake-indexeddb/auto';
|
||||
import {test} from 'node:test';import assert from 'node:assert/strict';import {newDb} from 'pg-mem';
|
||||
import {Storage} from '../server/storage.mjs';import {Campaigns} from '../server/campaigns.mjs';
|
||||
import {newCharacter,coinLimit} from '../src/model.ts';import {characterMcp} from '../server/mcp.mjs';
|
||||
import {Client} from '@modelcontextprotocol/sdk/client/index.js';import {InMemoryTransport} from '@modelcontextprotocol/sdk/inMemory.js';
|
||||
|
||||
async function fixture(){
|
||||
const pg=newDb().adapters.createPg(),storage=new Storage(new pg.Pool());await storage.init();
|
||||
const campaigns=new Campaigns(storage);await campaigns.init();
|
||||
const campaign=await campaigns.create('dm','DM',{name:'Fresh campaign'});
|
||||
const act=async(sub:string,action:string,input:unknown)=>campaigns.mutate(sub,campaign.id,(await campaigns.row(campaign.id)).revision,action,input);
|
||||
const invitation=await act('dm','invite',{role:'player'});await campaigns.join('player','Player',{code:invitation.invite_code});
|
||||
return {storage,campaigns,campaign,act};
|
||||
}
|
||||
async function partyFixture(){
|
||||
const f=await fixture(),pc=newCharacter();pc.name='Mira';await f.storage.put('player',pc.id,pc,0);
|
||||
await f.act('player','assign',{character_id:pc.id});
|
||||
return {...f,pc};
|
||||
}
|
||||
const coins=(gp:number)=>({gp,sp:0,cp:0});
|
||||
const reward=(pc:{id:string},items:any[]=[],gp=0)=>({title:'Spoils of the road',description:'Recovered after the battle',recipient:{owner:'player',character_id:pc.id},currency:coins(gp),items});
|
||||
const item={name:'Ornate dagger',quantity:2,category:'Weapon',notes:'A faded maker mark'};
|
||||
|
||||
test('hidden quests, DM notes and draft loot never leak to players',async()=>{
|
||||
const {campaigns,campaign,act,pc}=await partyFixture();
|
||||
await assert.rejects(act('player','quest',{title:'Forbidden',description:'No',dm_notes:''}));
|
||||
await act('dm','quest',{title:'Find the spy',description:'A missing messenger',dm_notes:'The mayor is the spy'});
|
||||
await act('dm','quest',{title:'Road patrol',description:'Protect the road',dm_notes:'Hidden ambush',visible:true});
|
||||
await act('dm','loot',reward(pc,[item],5));
|
||||
const dmView=await campaigns.view('dm',campaign.id),playerView=await campaigns.view('player',campaign.id);
|
||||
assert.equal(dmView.quests.length,2);assert.equal(dmView.loot.length,1);
|
||||
assert.deepEqual(playerView.quests.map((q:any)=>q.title),['Road patrol']);
|
||||
assert.equal(playerView.quests[0].dm_notes,undefined);
|
||||
assert.equal(playerView.loot.length,0);
|
||||
const hidden=dmView.quests.find((q:any)=>q.title==='Find the spy');
|
||||
await act('dm','quest',{...hidden,visible:true});
|
||||
const revealed=(await campaigns.view('player',campaign.id)).quests.find((q:any)=>q.title==='Find the spy');
|
||||
assert.equal(revealed.description,'A missing messenger');assert.equal(revealed.dm_notes,undefined);
|
||||
await act('dm','removeQuest',{id:hidden.id});
|
||||
assert.equal((await campaigns.view('player',campaign.id)).quests.length,1);
|
||||
});
|
||||
|
||||
test('offered loot claims atomically once and changes only the assigned owner sheet',async()=>{
|
||||
const {storage,campaigns,campaign,act,pc}=await partyFixture();
|
||||
const stranger=newCharacter();await storage.put('stranger',stranger.id,stranger,0);
|
||||
await act('dm','loot',reward(pc,[item],12));
|
||||
const offer=(await campaigns.view('dm',campaign.id)).loot[0];
|
||||
await assert.rejects(act('player','lootStep',{id:offer.id,action:'offer'}));
|
||||
await act('dm','lootStep',{id:offer.id,action:'offer'});
|
||||
assert.equal((await campaigns.view('player',campaign.id)).loot[0].status,'offered');
|
||||
await assert.rejects(act('dm','loot',{...reward(pc,[item],99),id:offer.id}));
|
||||
await assert.rejects(campaigns.claimLoot('stranger',campaign.id,{id:offer.id,expected_campaign_revision:(await campaigns.row(campaign.id)).revision,expected_character_revision:1}));
|
||||
const cRev=(await campaigns.row(campaign.id)).revision,sRev=(await storage.get('player',pc.id)).revision;
|
||||
const result=await campaigns.claimLoot('player',campaign.id,{id:offer.id,expected_campaign_revision:cRev,expected_character_revision:sRev});
|
||||
assert.equal(result.status,'claimed');
|
||||
const sheet=(await storage.get('player',pc.id)).character;
|
||||
assert.equal(sheet.currency.gp,12);
|
||||
assert.equal(sheet.inventoryItems[0].quantity,2);
|
||||
assert.equal(sheet.inventoryItems[0].name,'Ornate dagger');
|
||||
assert.equal(sheet.notes,pc.notes);
|
||||
assert.equal((await storage.get('stranger',stranger.id)).character.currency.gp,0);
|
||||
await assert.rejects(campaigns.claimLoot('player',campaign.id,{id:offer.id,expected_campaign_revision:(await campaigns.row(campaign.id)).revision,expected_character_revision:(await storage.get('player',pc.id)).revision}));
|
||||
assert.equal((await storage.get('player',pc.id)).character.currency.gp,12);
|
||||
await assert.rejects(act('dm','lootStep',{id:offer.id,action:'withdraw'}));
|
||||
});
|
||||
|
||||
test('stale revisions and failed rewards leave the parcel unclaimed and inventory untouched',async()=>{
|
||||
const {storage,campaigns,campaign,act,pc}=await partyFixture();
|
||||
const updated={...pc,currency:coins(coinLimit)};await storage.put('player',pc.id,updated,1);
|
||||
await act('dm','loot',reward(pc,[item],1));const offer=(await campaigns.view('dm',campaign.id)).loot[0];
|
||||
await act('dm','lootStep',{id:offer.id,action:'offer'});
|
||||
const currentRev=(await campaigns.row(campaign.id)).revision;
|
||||
await assert.rejects(campaigns.claimLoot('player',campaign.id,{id:offer.id,expected_campaign_revision:currentRev-1,expected_character_revision:2}));
|
||||
await assert.rejects(campaigns.claimLoot('player',campaign.id,{id:offer.id,expected_campaign_revision:currentRev,expected_character_revision:1}));
|
||||
await assert.rejects(campaigns.claimLoot('player',campaign.id,{id:offer.id,expected_campaign_revision:currentRev,expected_character_revision:2}));
|
||||
assert.equal((await campaigns.view('dm',campaign.id)).loot[0].status,'offered');
|
||||
const sheet=(await storage.get('player',pc.id)).character;
|
||||
assert.equal(sheet.currency.gp,coinLimit);assert.equal(sheet.inventoryItems.length,0);
|
||||
await act('dm','lootStep',{id:offer.id,action:'withdraw'});
|
||||
assert.equal((await campaigns.view('player',campaign.id)).loot.length,0);
|
||||
});
|
||||
|
||||
test('MCP tools use the same campaign permissions and claim authorization',async()=>{
|
||||
const {storage,campaigns,campaign,pc}=await partyFixture();
|
||||
async function connect(sub:string,scopes:string[]){
|
||||
const srv=characterMcp({storage,campaigns,sub,scopes}),client=new Client({name:'dm-tools-test',version:'1'});
|
||||
const [a,b]=InMemoryTransport.createLinkedPair();await srv.connect(a);await client.connect(b);return client;
|
||||
}
|
||||
const writer=await connect('dm',['characters:read','characters:write']),player=await connect('player',['characters:read','characters:write']),read=await connect('dm',['characters:read']);
|
||||
try{
|
||||
const revision=(await campaigns.row(campaign.id)).revision;
|
||||
assert.equal((await writer.listTools()).tools.length,42);
|
||||
assert.equal((await player.callTool({name:'save_campaign_quest',arguments:{campaign_id:campaign.id,expected_revision:revision,input:{title:'No',description:'',dm_notes:''}}})).isError,true);
|
||||
assert.equal((await read.callTool({name:'save_campaign_quest',arguments:{campaign_id:campaign.id,expected_revision:revision,input:{title:'No',description:'',dm_notes:''}}})).isError,true);
|
||||
const created:any=await writer.callTool({name:'save_campaign_loot',arguments:{campaign_id:campaign.id,expected_revision:revision,input:reward(pc,[],3)}});
|
||||
assert.equal(created.isError,undefined);
|
||||
const lootId=created.structuredContent.campaign.loot[0].id;
|
||||
const opened:any=await writer.callTool({name:'change_campaign_loot_offer',arguments:{campaign_id:campaign.id,expected_revision:created.structuredContent.campaign.revision,input:{id:lootId,action:'offer'}}});
|
||||
assert.equal(opened.isError,undefined);
|
||||
const claimed:any=await player.callTool({name:'claim_campaign_loot',arguments:{campaign_id:campaign.id,id:lootId,expected_campaign_revision:opened.structuredContent.campaign.revision,expected_character_revision:1}});
|
||||
assert.equal(claimed.isError,undefined);assert.equal((await storage.get('player',pc.id)).character.currency.gp,3);
|
||||
const second:any=await player.callTool({name:'claim_campaign_loot',arguments:{campaign_id:campaign.id,id:lootId,expected_campaign_revision:claimed.structuredContent.campaign_revision,expected_character_revision:2}});
|
||||
assert.equal(second.isError,true);
|
||||
}finally{await Promise.all([writer.close(),player.close(),read.close()])}
|
||||
});
|
||||
@ -8,7 +8,7 @@ async function storage(){const pg=newDb().adapters.createPg();const s=new Storag
|
||||
const hash=async(s:string)=>(await import('node:crypto')).createHash('sha256').update(s).digest('hex');
|
||||
test('persistent sheets isolate accounts and reject stale updates/deletions',async()=>{const s=await storage(),c=newCharacter();await s.put('alice',c.id,c,0);assert.equal((await s.list('bob')).length,0);await assert.rejects(s.put('bob',c.id,c,1),Conflict);const updated=await s.put('alice',c.id,{...c,name:'Updated'},1);assert.equal(updated.revision,2);await assert.rejects(s.put('alice',c.id,null,1,true),Conflict);assert.equal((await s.get('alice',c.id)).character.name,'Updated');const deleted=await s.put('alice',c.id,null,2,true);assert.equal(deleted.deleted,true);assert.equal((await s.list('alice')).length,1)});
|
||||
test('OAuth codes bind client, redirect, resource, scope and one-time use; refresh rotates; revocation rejects access',async()=>{const s=await storage(),p=new CharacterOAuth({storage:s,codec,origin});const client={client_id:'client',redirect_uris:['https://chatgpt.com/callback']};const payload={purpose:'mcp-code',clientId:'client',sub:'alice',challenge:'abc',redirectUri:client.redirect_uris[0],scopes:['characters:read'],sessionExpiry:Math.floor(Date.now()/1000)+3600,nonce:'unique'};const code=await codec.seal(payload,300);await s.code(await hash(code),{});assert.equal(await p.challengeForAuthorizationCode(client,code),'abc');await assert.rejects(p.challengeForAuthorizationCode({client_id:'other'},code));await assert.rejects(p.exchangeAuthorizationCode(client,code,undefined,'https://evil.test',p.resource));await assert.rejects(p.exchangeAuthorizationCode(client,code,undefined,payload.redirectUri,new URL('https://other.test/mcp')));const tokens=await p.exchangeAuthorizationCode(client,code,undefined,payload.redirectUri,p.resource);assert.equal((await p.verifyAccessToken(tokens.access_token)).extra.sub,'alice');await assert.rejects(p.exchangeAuthorizationCode(client,code,undefined,payload.redirectUri,p.resource));await assert.rejects(p.verifyAccessToken(await codec.seal({sub:'alice'},300)));await assert.rejects(p.exchangeRefreshToken(client,tokens.refresh_token,['characters:write'],p.resource));const renewed=await p.exchangeRefreshToken(client,tokens.refresh_token,undefined,p.resource);await assert.rejects(p.exchangeRefreshToken(client,tokens.refresh_token,undefined,p.resource));await p.revokeToken(client,{token:renewed.access_token});await assert.rejects(p.verifyAccessToken(tokens.access_token));});
|
||||
test('MCP SDK lists tools, performs account-scoped HP changes, rejects stale revisions and denies read-only writes',async()=>{const s=await storage(),c=newCharacter();c.hp.temp=5;await s.put('alice',c.id,c,0);async function connect(sub:string,scopes:string[]){const server=characterMcp({storage:s,sub,scopes}),client=new Client({name:'test',version:'1'});const [a,b]=InMemoryTransport.createLinkedPair();await server.connect(a);await client.connect(b);return client}const client=await connect('alice',['characters:read','characters:write']);assert.equal((await client.listTools()).tools.length,17);const hp=await client.callTool({name:'change_hit_points',arguments:{id:c.id,expected_revision:1,amount:-7}});assert.equal(hp.isError,undefined);assert.deepEqual((await s.get('alice',c.id)).character.hp,{current:8,max:10,temp:0});const stale=await client.callTool({name:'change_hit_points',arguments:{id:c.id,expected_revision:1,amount:-1}});assert.equal(stale.isError,true);const bob=await connect('bob',['characters:read']);const missing=await bob.callTool({name:'get_character',arguments:{id:c.id}});assert.equal(missing.isError,true);const readonly=await connect('alice',['characters:read']);assert.equal((await readonly.callTool({name:'create_character',arguments:{name:'Denied'}})).isError,true);await Promise.all([client.close(),bob.close(),readonly.close()])});
|
||||
test('MCP SDK lists tools, performs account-scoped HP changes, rejects stale revisions and denies read-only writes',async()=>{const s=await storage(),c=newCharacter();c.hp.temp=5;await s.put('alice',c.id,c,0);async function connect(sub:string,scopes:string[]){const server=characterMcp({storage:s,sub,scopes}),client=new Client({name:'test',version:'1'});const [a,b]=InMemoryTransport.createLinkedPair();await server.connect(a);await client.connect(b);return client}const client=await connect('alice',['characters:read','characters:write']);assert.equal((await client.listTools()).tools.length,22);const hp=await client.callTool({name:'change_hit_points',arguments:{id:c.id,expected_revision:1,amount:-7}});assert.equal(hp.isError,undefined);assert.deepEqual((await s.get('alice',c.id)).character.hp,{current:8,max:10,temp:0});const stale=await client.callTool({name:'change_hit_points',arguments:{id:c.id,expected_revision:1,amount:-1}});assert.equal(stale.isError,true);const bob=await connect('bob',['characters:read']);const missing=await bob.callTool({name:'get_character',arguments:{id:c.id}});assert.equal(missing.isError,true);const readonly=await connect('alice',['characters:read']);assert.equal((await readonly.callTool({name:'create_character',arguments:{name:'Denied'}})).isError,true);await Promise.all([client.close(),bob.close(),readonly.close()])});
|
||||
test('HTTP discovery, bearer denial, PKCE enforcement and API origin protection',async()=>{const s=await storage(),p=new CharacterOAuth({storage:s,codec,origin}),app=express();app.use(express.json());app.use(express.urlencoded({extended:false}));app.use((req,res,next)=>{req.identity={sub:'alice'};next()});const auth=p.install(app,{cookie:()=>{},readCookie:()=>''});installMcp(app,{storage:s,auth,origin});installCharacters(app,{storage:s,origin});const server=app.listen(0,'127.0.0.1');await once(server,'listening');const base=`http://127.0.0.1:${server.address().port}`;try{const denied=await fetch(base+'/mcp',{method:'POST',headers:{'content-type':'application/json'},body:'{}'});assert.equal(denied.status,401);assert.match(denied.headers.get('www-authenticate')||'',/oauth-protected-resource\/mcp/);const metadata=await(await fetch(base+'/.well-known/oauth-protected-resource/mcp')).json();assert.equal(metadata.resource,origin+'/mcp');const registered=await fetch(base+'/register',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({client_name:'Tests',redirect_uris:['https://chatgpt.com/callback'],token_endpoint_auth_method:'none'})});assert.equal(registered.status,201);const client=await registered.json();const authorize=await fetch(base+'/authorize?'+new URLSearchParams({client_id:client.client_id,redirect_uri:client.redirect_uris[0],response_type:'code',resource:origin+'/mcp',code_challenge:'a'.repeat(43),code_challenge_method:'S256'}),{redirect:'manual'});assert.equal(authorize.status,302);const badPkce=await fetch(base+'/token',{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded'},body:new URLSearchParams({client_id:client.client_id,grant_type:'authorization_code',code:'invalid',code_verifier:'wrong',redirect_uri:client.redirect_uris[0],resource:origin+'/mcp'})});assert.equal(badPkce.status,400);const c=newCharacter();const blocked=await fetch(base+'/api/characters/'+c.id,{method:'PUT',headers:{'content-type':'application/json','origin':'https://evil.test'},body:JSON.stringify({character:c,revision:0})});assert.equal(blocked.status,403)}finally{await new Promise<void>(resolve=>server.close(()=>resolve()))}});
|
||||
test('full consent → PKCE exchange → HTTP MCP call works and rejects wrong verifier',async()=>{
|
||||
const s=await storage(),p=new CharacterOAuth({storage:s,codec,origin}),app=express();app.use(express.json());app.use(express.urlencoded({extended:false}));app.use((req,res,next)=>{req.identity={sub:'alice',name:'Alice',exp:Math.floor(Date.now()/1000)+3600};next()});
|
||||
@ -24,6 +24,6 @@ test('full consent → PKCE exchange → HTTP MCP call works and rejects wrong v
|
||||
const tokenBody=(v:string)=>new URLSearchParams({client_id:c.client_id,grant_type:'authorization_code',code,code_verifier:v,redirect_uri:c.redirect_uris[0],resource:origin+'/mcp'});
|
||||
const wrong=await fetch(base+'/token',{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded'},body:tokenBody('x'.repeat(64))});assert.equal(wrong.status,400);
|
||||
const exchanged=await fetch(base+'/token',{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded'},body:tokenBody(verifier)});assert.equal(exchanged.status,200);const tokens=await exchanged.json();
|
||||
const {StreamableHTTPClientTransport}=await import('@modelcontextprotocol/sdk/client/streamableHttp.js');const client=new Client({name:'http-test',version:'1'});await client.connect(new StreamableHTTPClientTransport(new URL(base+'/mcp'),{requestInit:{headers:{Authorization:'Bearer '+tokens.access_token}}}));assert.equal((await client.listTools()).tools.length,17);const created=await client.callTool({name:'create_character',arguments:{name:'HTTP adventurer'}});assert.equal(created.isError,undefined);assert.equal((await s.list('alice'))[0].character.name,'HTTP adventurer');await client.close();
|
||||
const {StreamableHTTPClientTransport}=await import('@modelcontextprotocol/sdk/client/streamableHttp.js');const client=new Client({name:'http-test',version:'1'});await client.connect(new StreamableHTTPClientTransport(new URL(base+'/mcp'),{requestInit:{headers:{Authorization:'Bearer '+tokens.access_token}}}));assert.equal((await client.listTools()).tools.length,22);const created=await client.callTool({name:'create_character',arguments:{name:'HTTP adventurer'}});assert.equal(created.isError,undefined);assert.equal((await s.list('alice'))[0].character.name,'HTTP adventurer');await client.close();
|
||||
}finally{await new Promise<void>(resolve=>server.close(()=>resolve()))}
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user