Release DM fieldbook v0.6.0
This commit is contained in:
parent
ef1e2e07d9
commit
a67509b42e
@ -54,3 +54,6 @@ Authenticated API: GET /api/catalogue?edition=2024&kind=spell&q=Fire%20Bolt (nam
|
|||||||
|
|
||||||
## Inventory
|
## Inventory
|
||||||
Gear uses structured inventory entries with quantities, equipment and attunement markers, item notes and SRD provenance. Repeated catalogue selections increment the same entry; editions remain distinct. Existing inventory text remains in the inventory notebook. The additive optional `inventoryItems` field is preserved when older clients omit it, under the existing account and revision guards. Explicitly sending an empty array clears the collection. Equipment markers do not automatically change character stats.
|
Gear uses structured inventory entries with quantities, equipment and attunement markers, item notes and SRD provenance. Repeated catalogue selections increment the same entry; editions remain distinct. Existing inventory text remains in the inventory notebook. The additive optional `inventoryItems` field is preserved when older clients omit it, under the existing account and revision guards. Explicitly sending an empty array clears the collection. Equipment markers do not automatically change character stats.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "dnd-character-pwa",
|
"name": "dnd-character-pwa",
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "dnd-character-pwa",
|
"name": "dnd-character-pwa",
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.32.1",
|
"@modelcontextprotocol/sdk": "^1.32.1",
|
||||||
"dexie": "^4.2.0",
|
"dexie": "^4.2.0",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dnd-character-pwa",
|
"name": "dnd-character-pwa",
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@ -15,7 +15,7 @@ export const campaignInputs={
|
|||||||
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:z.array(z.object({name:title,initiative:z.number().int().min(-50).max(100),hp:z.number().int().min(0).max(9999),maxHp:z.number().int().min(1).max(9999),ac:z.number().int().min(0).max(9999)}).refine(x=>x.hp<=x.maxHp)).min(1).max(50)}),
|
||||||
encounterStep:z.object({id,action:z.enum(['next','finish','damage','heal']),participant_id:id.optional(),amount:z.number().int().min(1).max(9999).optional()}),
|
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)})
|
||||||
};
|
};
|
||||||
const hash=s=>createHash('sha256').update(s).digest('hex');
|
const hash=s=>createHash('sha256').update(s).digest('hex');
|
||||||
@ -49,8 +49,8 @@ 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)}
|
else if(action==='removeNote'){const n=d.notes.find(n=>n.id===p.id);if(n?.visibility==='dm')dm(d,sub);if(!n||n.author!==sub)throw new CampaignError('Only the author can delete this note');d.notes=d.notes.filter(n=>n.id!==p.id)}
|
||||||
else if(action==='npc'||action==='session'){dm(d,sub);const key=action==='npc'?'npcs':'sessions',items=d[key],old=p.id?items.find(n=>n.id===p.id):null;if(p.id&&!old)throw new CampaignError('Entry not found',404);if(!old&&items.length>=200)throw new CampaignError('Entry limit reached',400);const n={...p,id:p.id||randomUUID()};if(old)Object.assign(old,n);else items.push(n)}
|
else if(action==='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)}
|
||||||
else if(action==='encounter'){dm(d,sub);if(d.encounters.filter(e=>e.status==='active').length>=10)throw new CampaignError('Finish an active encounter first',400);d.encounters=d.encounters.filter(e=>e.status==='active').concat(d.encounters.filter(e=>e.status==='complete').slice(-49));d.encounters.push({id:randomUUID(),name:p.name,participants:p.participants.map(x=>({...x,id:randomUUID()})).sort((a,b)=>b.initiative-a.initiative),round:1,turn:0,status:'active'})}
|
else if(action==='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==='encounterStep'){dm(d,sub);const e=d.encounters.find(x=>x.id===p.id);if(!e||e.status!=='active')throw new CampaignError('Active encounter not found',404);if(p.action==='finish')e.status='complete';else if(p.action==='next'){e.turn=(e.turn+1)%e.participants.length;if(e.turn===0)e.round++}else{const participant=e.participants.find(x=>x.id===p.participant_id);if(!participant||!p.amount)throw new CampaignError('Participant and amount required',400);participant.hp=p.action==='damage'?Math.max(0,participant.hp-p.amount):Math.min(participant.maxHp,participant.hp+p.amount)}}
|
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};
|
||||||
}
|
}
|
||||||
@ -73,7 +73,7 @@ export function registerCampaignTools({tool,campaigns,sub,allowed,read,write}){
|
|||||||
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 and encounters. Private notes and DM secrets are filtered server-side.',{campaign_id},read,async a=>({campaign:await campaigns.view(sub,a.campaign_id)}));
|
||||||
tool('create_campaign','Create a campaign; you become its first DM.',campaignInputs.create.shape,write,async a=>{allowed();return {campaign:await campaigns.create(sub,'MCP adventurer',a)}});
|
tool('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, or finish an encounter.']]){
|
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.']]){
|
||||||
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)});
|
||||||
|
|||||||
@ -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.5.0',catalogue:catalogue.summary,revision:process.env.SOURCE_COMMIT||'unknown'}));
|
app.get('/health',(_,res)=>res.json({status:'ok',version:'0.6.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()});
|
||||||
|
|||||||
@ -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.5.0'});
|
const server=new McpServer({name:'arrowstack-character',version:'0.6.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.')};
|
||||||
|
|||||||
@ -5,35 +5,45 @@ type Member={sub:string,name:string,role:'dm'|'player'};
|
|||||||
type Npc={id:string,name:string,hp:number,maxHp:number,ac:number,initiative:number,notes?:string,visible:boolean};
|
type Npc={id:string,name:string,hp:number,maxHp:number,ac:number,initiative:number,notes?:string,visible:boolean};
|
||||||
type Note={id:string,title:string,body:string,visibility:'private'|'party'|'dm',author:string};
|
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 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};
|
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 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[]};
|
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('Party'),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'});
|
||||||
const npc=ref<{id?:string,name:string,hp:number,maxHp:number,ac:number,initiative:number,notes:string,visible:boolean}>({name:'',hp:10,maxHp:10,ac:10,initiative:0,notes:'',visible:false});
|
const npc=ref<{id?:string,name:string,hp:number,maxHp:number,ac:number,initiative:number,notes:string,visible:boolean}>({name:'',hp:10,maxHp:10,ac:10,initiative:0,notes:'',visible:false});
|
||||||
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 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 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';
|
||||||
|
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'}}
|
||||||
|
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 setSessionStatus(s:Session,status:Session['status']){await action('session',{...s,status})}
|
||||||
|
|
||||||
const online=ref(navigator.onLine);const connectivity=()=>{online.value=navigator.onLine};window.addEventListener('online',connectivity);window.addEventListener('offline',connectivity);onUnmounted(()=>{window.removeEventListener('online',connectivity);window.removeEventListener('offline',connectivity)});
|
const online=ref(navigator.onLine);const connectivity=()=>{online.value=navigator.onLine};window.addEventListener('online',connectivity);window.addEventListener('offline',connectivity);onUnmounted(()=>{window.removeEventListener('online',connectivity);window.removeEventListener('offline',connectivity)});
|
||||||
async function request(path:string,body?:unknown){if(!online.value)throw new Error('Campaigns need a connection. Your character sheets remain available offline.');const response=await fetch('/api/campaigns'+path,{method:body===undefined?'GET':'POST',headers:body===undefined?{}:{'Content-Type':'application/json'},body:body===undefined?undefined:JSON.stringify(body),cache:'no-store'});const data=await response.json();if(!response.ok)throw new Error(data.error||'Could not load campaign');return data}
|
async function request(path:string,body?:unknown){if(!online.value)throw new Error('Campaigns need a connection. Your character sheets remain available offline.');const response=await fetch('/api/campaigns'+path,{method:body===undefined?'GET':'POST',headers:body===undefined?{}:{'Content-Type':'application/json'},body:body===undefined?undefined:JSON.stringify(body),cache:'no-store'});const data=await response.json();if(!response.ok)throw new Error(data.error||'Could not load campaign');return data}
|
||||||
async function run(fn:()=>Promise<void>){if(busy.value)return;busy.value=true;error.value='';try{await fn()}catch(e){error.value=(e as Error).message}finally{busy.value=false}}
|
async function run(fn:()=>Promise<void>){if(busy.value)return;busy.value=true;error.value='';try{await fn()}catch(e){error.value=(e as Error).message}finally{busy.value=false}}
|
||||||
async function refreshList(){list.value=(await request('')).campaigns}
|
async function refreshList(){list.value=(await request('')).campaigns}
|
||||||
async function open(id:string){await run(async()=>{current.value=await request('/'+id);tab.value='Party';inviteCode.value='';notice.value='';await refreshList()})}
|
async function open(id:string){await run(async()=>{current.value=await request('/'+id);resetWorkspace();await refreshList()})}
|
||||||
async function reload(){if(current.value)await run(async()=>{current.value=await request('/'+current.value!.id);await refreshList()})}
|
async function reload(){if(current.value)await run(async()=>{current.value=await request('/'+current.value!.id);await refreshList()})}
|
||||||
async function create(){await run(async()=>{const result=await request('',{name:newName.value,description:description.value});current.value=await request('/'+result.id);newName.value='';description.value='';await refreshList()})}
|
async function create(){await run(async()=>{const result=await request('',{name:newName.value,description:description.value});current.value=await request('/'+result.id);newName.value='';description.value='';resetWorkspace();await refreshList()})}
|
||||||
async function join(){await run(async()=>{current.value=await request('/join',{code:joinCode.value.trim()});joinCode.value='';await refreshList()})}
|
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 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 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 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.'})}
|
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}}
|
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||''}}
|
function editNpc(n:Npc){npc.value={...n,notes:n.notes||''}}
|
||||||
function editSession(s:Session){session.value={...s}}
|
function editSession(s:Session){session.value={...s};showSession.value=true;tab.value='Session'}
|
||||||
async function saveNote(){await action('note',note.value);if(!error.value)note.value={title:'',body:'',visibility:'private'}}
|
async function saveNote(){await action('note',note.value);if(!error.value)note.value={title:'',body:'',visibility:'private'}}
|
||||||
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'}}
|
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(participants.value.length<50)participants.value.push({name,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=''}}
|
async function start(){await action('encounter',{name:encounterName.value,participants:participants.value});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()}));
|
||||||
@ -50,28 +60,40 @@ onMounted(()=>void run(async()=>{const identity=await fetch('/api/session').then
|
|||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="campaign-toolbar"><button class="quiet" @click="current=null;inviteCode=''">← All campaigns</button><button class="quiet" @click="reload" :disabled="busy">Refresh</button></div>
|
<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>
|
<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" aria-label="Campaign sections"><button v-for="name in ['Party','Journal','Sessions','Encounters','Members']" :key="name" :class="{active:tab===name}" :aria-current="tab===name?'page':undefined" @click="tab=name">{{name}}</button><button :class="{active:tab==='NPCs'}" @click="tab='NPCs'">{{isDm?'NPCs & secrets':'NPCs'}}</button></nav>
|
<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>
|
||||||
<template v-if="tab==='Party'">
|
<template v-if="tab==='Party'">
|
||||||
<section class="panel"><h2><UiIcon name="sheet" />Your travelling companions</h2><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><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>
|
<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>
|
||||||
<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 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>
|
</template>
|
||||||
<template v-if="tab==='Journal'">
|
<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>
|
<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>
|
<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>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="tab==='NPCs'">
|
<template v-if="tab==='Journal'&&journalTab==='NPCs'">
|
||||||
<section class="panel" v-for="n in current.npcs" :key="n.id"><div class="section-title"><h2><UiIcon name="con" />{{n.name}}</h2><span class="chip">{{n.visible?'Party visible':'DM only'}}</span></div><p>HP {{n.hp}} / {{n.maxHp}} · AC {{n.ac}} · Initiative {{n.initiative}}</p><p v-if="isDm" class="campaign-prose">{{n.notes}}</p><div v-if="isDm" class="campaign-actions"><button @click="editNpc(n)">Edit</button><button @click="action('removeNpc',{id:n.id})" :disabled="busy" class="quiet danger-text">Delete NPC</button></div></section>
|
<section class="panel" v-for="n in current.npcs" :key="n.id"><div class="section-title"><h2><UiIcon name="con" />{{n.name}}</h2><span class="chip">{{n.visible?'Party visible':'DM only'}}</span></div><p>HP {{n.hp}} / {{n.maxHp}} · AC {{n.ac}} · Initiative {{n.initiative}}</p><p v-if="isDm" class="campaign-prose">{{n.notes}}</p><div v-if="isDm" class="campaign-actions"><button @click="editNpc(n)">Edit</button><button @click="action('removeNpc',{id:n.id})" :disabled="busy" class="quiet danger-text">Delete NPC</button></div></section>
|
||||||
<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==='Sessions'">
|
<template v-if="tab==='Session'">
|
||||||
<section class="panel" v-for="s in current.sessions" :key="s.id"><div class="section-title"><h2><UiIcon name="long-rest" />{{s.title}}</h2><span class="chip">{{s.status}}</span></div><p class="hint">{{s.date}}</p><p class="campaign-prose">{{s.summary}}</p><button v-if="isDm" @click="editSession(s)">Edit session</button></section>
|
<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>
|
||||||
<form v-if="isDm" class="panel" @submit.prevent="saveSession"><h2><UiIcon name="long-rest" />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><p v-if="!current.sessions.length&&!isDm" class="hint">Your DM has not planned a session yet.</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>
|
||||||
|
<article class="encounter-entry" v-for="(p,index) in e.participants" :key="p.id" :class="{current:index===e.turn}"><div class="combatant-line"><span class="initiative-number" :aria-label="`Initiative ${p.initiative}`">{{p.initiative}}</span><div class="combatant-copy"><b>{{p.name}} <small v-if="index===e.turn">Taking a turn</small></b><span>AC {{p.ac}} · HP {{p.hp}} / {{p.maxHp}}</span></div><div v-if="isDm" class="campaign-actions"><button class="quiet" @click="step(e.id,'damage',p.id)" :disabled="busy" :aria-label="`Damage ${p.name} by ${hpAmount}`">− HP</button><button class="quiet" @click="step(e.id,'heal',p.id)" :disabled="busy" :aria-label="`Heal ${p.name} by ${hpAmount}`">+ HP</button></div></div>
|
||||||
|
<div class="condition-chips" v-if="p.conditions?.length"><template v-for="condition in p.conditions" :key="condition"><button v-if="isDm" class="quiet condition-chip" @click="setCondition(e,p,condition,true)" :disabled="busy" :aria-label="`Remove ${condition} from ${p.name}`">{{condition}} ×</button><span v-else class="condition-chip">{{condition}}</span></template></div>
|
||||||
|
<details v-if="isDm" class="combatant-options"><summary>Conditions</summary><label :for="'condition-'+p.id">Add a manual condition</label><div class="campaign-actions"><input :id="'condition-'+p.id" v-model="conditionDraft[p.id]" list="condition-choices" maxlength="60" placeholder="Prone, concentrating…" :disabled="busy"><button class="quiet" @click="setCondition(e,p,conditionDraft[p.id]||'')" :disabled="busy||!conditionDraft[p.id]?.trim()||((p.conditions||[]).length>=12)">Add</button></div></details>
|
||||||
|
</article><div class="encounter-controls" v-if="isDm"><label>Damage / healing amount<input type="number" v-model.number="hpAmount" min="1" max="9999" required></label><button class="primary" @click="step(e.id,'next')" :disabled="busy">Next turn →</button><button class="quiet" @click="step(e.id,'finish')" :disabled="busy">Finish encounter</button></div>
|
||||||
|
</section><datalist id="condition-choices"><option v-for="condition in conditionChoices" :key="condition" :value="condition" /></datalist>
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
<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 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>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="tab==='Encounters'">
|
<template v-if="tab==='Campaign'">
|
||||||
<section class="panel" v-for="e in current.encounters" :key="e.id"><div class="section-title"><h2><UiIcon name="actions" />{{e.name}}</h2><span class="chip">{{e.status}} · Round {{e.round}}</span></div><p class="hint">Encounter HP is a snapshot; it does not change saved character sheets.</p><article class="encounter-entry" v-for="(p,index) in e.participants" :key="p.id" :class="{current:e.status==='active'&&index===e.turn}"><div><b>{{e.status==='active'&&index===e.turn?'→ ':''}}{{p.name}}</b><small>Initiative {{p.initiative}} · AC {{p.ac}} · HP {{p.hp}} / {{p.maxHp}}</small></div><div v-if="isDm&&e.status==='active'" class="campaign-actions"><button @click="step(e.id,'damage',p.id)" :disabled="busy">Damage</button><button @click="step(e.id,'heal',p.id)" :disabled="busy">Heal</button></div></article><div class="campaign-actions" v-if="isDm&&e.status==='active'"><label>HP amount<input type="number" v-model.number="hpAmount" min="1" max="9999"></label><button class="primary" @click="step(e.id,'next')" :disabled="busy">Next turn</button><button @click="step(e.id,'finish')" :disabled="busy">Finish encounter</button></div></section>
|
<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>
|
||||||
<form v-if="isDm" class="panel" @submit.prevent="start"><h2><UiIcon name="initiative" />Start an encounter</h2><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>
|
|
||||||
</template>
|
|
||||||
<template v-if="tab==='Members'">
|
|
||||||
<section class="panel"><h2><UiIcon name="sheet" />Your table</h2><article class="member-entry" v-for="m in current.members" :key="m.sub"><span><b>{{m.name}}</b><small>{{m.role==='dm'?'Dungeon Master':'Player'}}{{m.sub===self?' · You':''}}</small></span><div v-if="isDm" class="campaign-actions"><button @click="action('member',{sub:m.sub,role:m.role==='dm'?'player':'dm'})" :disabled="busy">Make {{m.role==='dm'?'player':'DM'}}</button><button class="quiet danger-text" @click="action('member',{sub:m.sub,role:'remove'})" :disabled="busy">Remove</button></div></article></section>
|
<section class="panel"><h2><UiIcon name="sheet" />Your table</h2><article class="member-entry" v-for="m in current.members" :key="m.sub"><span><b>{{m.name}}</b><small>{{m.role==='dm'?'Dungeon Master':'Player'}}{{m.sub===self?' · You':''}}</small></span><div v-if="isDm" class="campaign-actions"><button @click="action('member',{sub:m.sub,role:m.role==='dm'?'player':'dm'})" :disabled="busy">Make {{m.role==='dm'?'player':'DM'}}</button><button class="quiet danger-text" @click="action('member',{sub:m.sub,role:'remove'})" :disabled="busy">Remove</button></div></article></section>
|
||||||
<form v-if="isDm" class="panel" @submit.prevent="action('invite',{role:inviteRole})"><h2><UiIcon name="gear" />Invite a companion</h2><label>Campaign role<select v-model="inviteRole"><option value="player">Player</option><option value="dm">Co-DM</option></select></label><p class="hint">An invitation works once and expires after seven days. Share it only with the intended person.</p><button class="primary wide" :disabled="busy">Create invitation</button><template v-if="inviteCode"><code class="invite-code">{{inviteCode}}</code><button type="button" class="wide" @click="copyInvite">Copy invitation</button></template></form>
|
<form v-if="isDm" class="panel" @submit.prevent="action('invite',{role:inviteRole})"><h2><UiIcon name="gear" />Invite a companion</h2><label>Campaign role<select v-model="inviteRole"><option value="player">Player</option><option value="dm">Co-DM</option></select></label><p class="hint">An invitation works once and expires after seven days. Share it only with the intended person.</p><button class="primary wide" :disabled="busy">Create invitation</button><template v-if="inviteCode"><code class="invite-code">{{inviteCode}}</code><button type="button" class="wide" @click="copyInvite">Copy invitation</button></template></form>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -109,3 +109,22 @@ input[type=checkbox]{accent-color:#3b603d}
|
|||||||
.folio-heading-tools{display:flex;align-items:center;gap:4px}.folio-heading-tools .quiet{min-height:44px;white-space:nowrap}.folio-empty>.quiet{display:block;margin:10px auto 0}.rules-picker{max-width:680px}.rules-result{display:flex;align-items:center;justify-content:space-between;gap:16px;width:100%;text-align:left;border:0;border-bottom:1px solid #ad8d51;border-radius:0;background:transparent;box-shadow:none;padding:15px 0}.rules-result:hover{background:#ad8d5118}.rules-result b{font-family:Georgia,serif;font-size:21px;font-weight:400;overflow-wrap:anywhere}.rules-result small{display:block;color:#6e6549;font-size:12px;margin-top:5px}.catalogue-pages{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:12px;margin:22px 0}.catalogue-pages button{padding:9px 12px}.rules-entry-name{margin-top:20px;font-size:29px}.rules-description{white-space:pre-wrap;overflow-wrap:anywhere;font-size:14px;line-height:1.7}.rules-source{display:block;margin:22px 0;color:#284e3d;font-size:13px}.rules-attribution{margin-top:26px;border-top:1px solid #ad8d51;padding-top:14px;font-size:11px;line-height:1.6;overflow-wrap:anywhere}.rules-attribution summary{cursor:pointer;min-height:44px;padding:12px 0}.rules-attribution summary:focus-visible{outline:3px solid #b78a3e}.rules-attribution p{margin:10px 0}@media(max-width:420px){.folio-heading-tools{gap:0}.folio-heading-tools .quiet{font-size:11px;padding:8px 4px}.folio-heading h2{font-size:25px}.folio-heading h2 .ui-icon{width:33px;height:33px}.rules-picker{padding:20px}.catalogue-pages button{font-size:12px}}
|
.folio-heading-tools{display:flex;align-items:center;gap:4px}.folio-heading-tools .quiet{min-height:44px;white-space:nowrap}.folio-empty>.quiet{display:block;margin:10px auto 0}.rules-picker{max-width:680px}.rules-result{display:flex;align-items:center;justify-content:space-between;gap:16px;width:100%;text-align:left;border:0;border-bottom:1px solid #ad8d51;border-radius:0;background:transparent;box-shadow:none;padding:15px 0}.rules-result:hover{background:#ad8d5118}.rules-result b{font-family:Georgia,serif;font-size:21px;font-weight:400;overflow-wrap:anywhere}.rules-result small{display:block;color:#6e6549;font-size:12px;margin-top:5px}.catalogue-pages{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:12px;margin:22px 0}.catalogue-pages button{padding:9px 12px}.rules-entry-name{margin-top:20px;font-size:29px}.rules-description{white-space:pre-wrap;overflow-wrap:anywhere;font-size:14px;line-height:1.7}.rules-source{display:block;margin:22px 0;color:#284e3d;font-size:13px}.rules-attribution{margin-top:26px;border-top:1px solid #ad8d51;padding-top:14px;font-size:11px;line-height:1.6;overflow-wrap:anywhere}.rules-attribution summary{cursor:pointer;min-height:44px;padding:12px 0}.rules-attribution summary:focus-visible{outline:3px solid #b78a3e}.rules-attribution p{margin:10px 0}@media(max-width:420px){.folio-heading-tools{gap:0}.folio-heading-tools .quiet{font-size:11px;padding:8px 4px}.folio-heading h2{font-size:25px}.folio-heading h2 .ui-icon{width:33px;height:33px}.rules-picker{padding:20px}.catalogue-pages button{font-size:12px}}
|
||||||
|
|
||||||
.inventory-quantity{display:flex;align-items:center;gap:3px;flex-shrink:0}.inventory-quantity button{min-width:36px;min-height:44px;font-size:20px;padding:6px}.inventory-quantity span{min-width:24px;font-size:15px;text-align:center;font-weight:600}.inventory-flags{display:flex;flex-wrap:wrap;gap:14px;margin:14px 0}.inventory-flags label{display:flex;align-items:center;gap:8px;min-height:44px;margin:0}.inventory-source{font-size:11px;color:#6e6549;white-space:pre-wrap;overflow-wrap:anywhere;line-height:1.6}.inventory-notebook{border-top:1px solid #ad8d51;padding:16px 0;margin-top:26px}.inventory-notebook summary{display:flex;align-items:center;gap:10px;cursor:pointer;min-height:44px;font-family:Georgia,serif;font-size:21px}.inventory-notebook summary:after{content:'+';margin-left:auto;font-family:system-ui;font-size:19px}.inventory-notebook[open] summary:after{content:'−'}.inventory-notebook summary:focus-visible{outline:3px solid #b78a3e}.inventory-notebook summary .ui-icon{width:30px;height:30px}.inventory-notebook textarea{background:#fff3d366;border-radius:3px}.inventory-entry .folio-action-copy{align-self:center}@media(max-width:420px){.inventory-entry .folio-action-icon{display:none}.inventory-entry .folio-options{margin-left:0}.inventory-quantity{gap:0}.inventory-quantity button{min-width:32px;padding:5px}.inventory-entry .folio-action-main{gap:8px}.inventory-entry h3{font-size:21px}}
|
.inventory-quantity{display:flex;align-items:center;gap:3px;flex-shrink:0}.inventory-quantity button{min-width:36px;min-height:44px;font-size:20px;padding:6px}.inventory-quantity span{min-width:24px;font-size:15px;text-align:center;font-weight:600}.inventory-flags{display:flex;flex-wrap:wrap;gap:14px;margin:14px 0}.inventory-flags label{display:flex;align-items:center;gap:8px;min-height:44px;margin:0}.inventory-source{font-size:11px;color:#6e6549;white-space:pre-wrap;overflow-wrap:anywhere;line-height:1.6}.inventory-notebook{border-top:1px solid #ad8d51;padding:16px 0;margin-top:26px}.inventory-notebook summary{display:flex;align-items:center;gap:10px;cursor:pointer;min-height:44px;font-family:Georgia,serif;font-size:21px}.inventory-notebook summary:after{content:'+';margin-left:auto;font-family:system-ui;font-size:19px}.inventory-notebook[open] summary:after{content:'−'}.inventory-notebook summary:focus-visible{outline:3px solid #b78a3e}.inventory-notebook summary .ui-icon{width:30px;height:30px}.inventory-notebook textarea{background:#fff3d366;border-radius:3px}.inventory-entry .folio-action-copy{align-self:center}@media(max-width:420px){.inventory-entry .folio-action-icon{display:none}.inventory-entry .folio-options{margin-left:0}.inventory-quantity{gap:0}.inventory-quantity button{min-width:32px;padding:5px}.inventory-entry .folio-action-main{gap:8px}.inventory-entry h3{font-size:21px}}
|
||||||
|
|
||||||
|
/* A campaign fieldbook: ink divisions and compact table controls. */
|
||||||
|
.campaign-workspace .panel{background:transparent;border:0;border-radius:0;box-shadow:none;padding:20px 0;margin:0 0 22px;border-bottom:1px solid #ad8d5180}
|
||||||
|
.campaign-workspace .campaign-welcome{text-align:center;padding:30px 0}.campaign-welcome>.ui-icon{width:72px;height:72px;margin-bottom:16px}
|
||||||
|
.campaign-workspace .campaign-card{width:100%;background:transparent;border:0;border-bottom:1px solid #ad8d5180;border-radius:0;box-shadow:none;margin:0;padding:18px 0;text-align:left}.campaign-card b{font-family:Georgia,serif;font-size:24px;font-weight:400}
|
||||||
|
.campaign-workspace .hero{margin-bottom:18px}.campaign-workspace .hero h1{overflow-wrap:anywhere}.campaign-toolbar{margin-bottom:20px}.campaign-toolbar .quiet{padding-left:0}
|
||||||
|
.campaign-tabs.fieldbook-tabs{margin:0 0 28px;padding:0;gap:0;overflow:visible;border-block:1px solid #ad8d5180}
|
||||||
|
.fieldbook-tabs button{flex:1;min-width:0;min-height:64px;padding:8px 4px;border:0;border-radius:0;background:transparent;box-shadow:none;display:flex;align-items:center;justify-content:center;gap:8px;color:#6d6246;position:relative}
|
||||||
|
.fieldbook-tabs button .ui-icon{width:30px;height:30px}.fieldbook-tabs button.active{background:transparent;color:#253c32;border:0}.fieldbook-tabs button.active:after{content:'';position:absolute;bottom:0;left:25%;right:25%;height:3px;background:#37583f;border-radius:2px}
|
||||||
|
.campaign-subnav{display:flex;gap:12px;margin:0 0 20px;border-bottom:1px solid #ad8d5140}.campaign-subnav .selected{color:#253c32;text-decoration:underline;text-underline-offset:8px}
|
||||||
|
.campaign-workspace .folio-heading h2{display:flex;align-items:center;gap:10px}.campaign-workspace .folio-heading h2 .ui-icon{width:36px;height:36px}
|
||||||
|
.fieldbook-fold{border-bottom:1px solid #ad8d5180;margin:16px 0 26px;padding:0 0 12px}.fieldbook-fold>summary{min-height:52px;display:flex;align-items:center;gap:10px;cursor:pointer;font-family:Georgia,serif;font-size:23px}.fieldbook-fold>summary:after{content:'+';margin-left:auto;font-family:system-ui;font-size:19px}.fieldbook-fold[open]>summary:after{content:'−'}.fieldbook-fold>summary .ui-icon{width:32px;height:32px}.fieldbook-fold .panel{border:0;margin-bottom:0}.fieldbook-fold>summary:focus-visible,.combatant-options>summary:focus-visible,.past-encounter>summary:focus-visible{outline:3px solid #b78a3e;outline-offset:3px}
|
||||||
|
.party-owner{font-size:12px;color:#6d6246;margin:0 0 15px}.campaign-workspace .party-stats{flex-wrap:wrap;gap:12px 20px}.campaign-workspace .party-entry .section-title{margin-bottom:8px}
|
||||||
|
.encounter-turn{font-family:Georgia,serif;font-size:27px;margin:12px 0}.encounter-turn small{font-family:system-ui;font-size:12px;color:#6d6246;white-space:nowrap;margin-left:10px}
|
||||||
|
.campaign-workspace .encounter-entry{display:block}.campaign-workspace .encounter-entry.current{border:0;border-left:3px solid #547047;border-radius:0;background:#dce0b466;padding:14px 12px;margin:0}.combatant-line{display:flex;align-items:center;gap:12px}.initiative-number{font-family:Georgia,serif;font-size:26px;min-width:28px;text-align:center;color:#6d6246}.combatant-copy{flex:1;min-width:0}.combatant-copy b{font-family:Georgia,serif;font-size:23px;font-weight:400;overflow-wrap:anywhere}.combatant-copy b small{font-family:system-ui;font-size:10px;color:#37583f}.combatant-copy>span{display:block;font-size:12px;color:#6d6246;margin-top:6px}.combatant-line .campaign-actions{flex-shrink:0;gap:0}
|
||||||
|
.condition-chips{display:flex;flex-wrap:wrap;gap:6px;margin:12px 0 0 40px}.condition-chip,.quiet.condition-chip{font-size:11px;background:#f2dfae;border:1px solid #a68b51;border-radius:4px;box-shadow:none;padding:8px 10px;min-height:32px;color:#53492e}.quiet.condition-chip{min-height:44px}.combatant-options{margin:8px 0 0 40px}.combatant-options>summary{min-height:44px;font-size:12px;color:#6d6246;cursor:pointer;padding:10px 0}.combatant-options label{font-size:12px}.combatant-options .campaign-actions{display:flex;flex-wrap:nowrap}.combatant-options input{min-width:0;flex:1}.encounter-controls{display:flex;align-items:flex-end;flex-wrap:wrap;gap:12px;margin:24px 0 10px}.encounter-controls label{flex:1;max-width:230px;margin:0;font-size:12px}
|
||||||
|
.agenda-entry{border-bottom:1px solid #ad8d5180;padding:16px 0}.agenda-entry h3{font-family:Georgia,serif;font-size:25px;font-weight:400;margin:0}.session-agenda{margin:26px 0}.campaign-workspace .campaign-prose{overflow-wrap:anywhere}.past-encounter{padding:14px 0;border-bottom:1px solid #ad8d5140}.past-encounter summary{min-height:44px;cursor:pointer;font-family:Georgia,serif;font-size:22px}.past-encounter p{font-size:13px}.session-quick-note textarea{background:#fff3d366}
|
||||||
|
@media(max-width:700px){.fieldbook-tabs button{flex-direction:column;gap:4px;font-size:11px;min-height:68px}.fieldbook-tabs button .ui-icon{width:28px;height:28px}.campaign-workspace .hero h1{font-size:34px}.campaign-workspace .section-title h2{font-size:24px}.campaign-workspace .folio-heading h2{font-size:27px}.campaign-workspace .campaign-emblem{width:50px;height:58px}}
|
||||||
|
@media(max-width:420px){.combatant-line{flex-wrap:wrap;gap:8px}.combatant-line .campaign-actions{width:100%;margin-left:36px}.combatant-line .quiet{padding:6px 12px 6px 0}.combatant-copy b{font-size:22px}.combatant-options,.condition-chips{margin-left:36px}.encounter-controls label{flex-basis:100%;max-width:none}.campaign-workspace .section-title h2{font-size:22px}.campaign-workspace .folio-heading h2{font-size:25px}}
|
||||||
|
|||||||
@ -8,3 +8,28 @@ test('delegated saved HP requires current DM role and owner permission, and pres
|
|||||||
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,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('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);
|
||||||
|
await act('dm','encounter',{name:'Mountain pass',participants:[{name:pc.name,initiative:12,hp:10,maxHp:10,ac:12}]});
|
||||||
|
const e=(await campaigns.view('dm',c.id)).encounters[0],p=e.participants[0];
|
||||||
|
assert.deepEqual(p.conditions,[]);
|
||||||
|
const change={id:e.id,action:'conditions',participant_id:p.id,conditions:['Prone','Concentrating','Prone']};
|
||||||
|
await assert.rejects(act('player','encounterStep',change));await assert.rejects(campaigns.mutate('outsider',c.id,(await campaigns.row(c.id)).revision,'encounterStep',change));
|
||||||
|
await act('dm','encounterStep',change);let updated=(await campaigns.view('player',c.id)).encounters[0].participants[0];
|
||||||
|
assert.deepEqual(updated.conditions,['Prone','Concentrating']);assert.equal(updated.hp,10);assert.equal((await storage.get('player',pc.id)).character.hp.current,10);
|
||||||
|
const revision=(await campaigns.row(c.id)).revision;await act('dm','encounterStep',{...change,conditions:[]});
|
||||||
|
await assert.rejects(campaigns.mutate('dm',c.id,revision,'encounterStep',change));
|
||||||
|
await assert.rejects(act('dm','encounterStep',{...change,conditions:Array(13).fill('Prone')}));await assert.rejects(act('dm','encounterStep',{...change,conditions:[' ']}));await assert.rejects(act('dm','encounterStep',{...change,conditions:undefined}));
|
||||||
|
assert.deepEqual((await campaigns.view('dm',c.id)).encounters[0].participants[0].conditions,[]);
|
||||||
|
await act('dm','encounterStep',{id:e.id,action:'finish'});await assert.rejects(act('dm','encounterStep',change));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('MCP changes encounter conditions for DMs and denies player and read-only callers',async()=>{
|
||||||
|
const {storage,campaigns,c,act}=await fixture();await act('dm','encounter',{name:'Ridge',participants:[{name:'Guard',initiative:1,hp:10,maxHp:10,ac:12}]});
|
||||||
|
const e=(await campaigns.view('dm',c.id)).encounters[0];
|
||||||
|
async function connect(sub:string,scopes:string[]){const server=characterMcp({storage,campaigns,sub,scopes}),client=new Client({name:'conditions-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']),player=await connect('player',['characters:read','characters:write']),read=await connect('dm',['characters:read']);
|
||||||
|
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()])}
|
||||||
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user