/* G-Tome / Wizzy Party Exporter 0.3.0. Install as one Roll20 Mod script. * Reads selected character sheets; writes only its own GM-only export handout. * Never calls setSheetItem, setComputed, performAction or external services. */ (() => { 'use strict'; const VERSION='0.3.0', MAX_FIELDS=200, MAX_CHARACTERS=12, PART_SIZE=400000, MAX_EXPORT=12000000; let running=false; const esc=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); const tell=message=>sendChat('Wizzy Export',`/w gm ${message}`,null,{noarchive:true}); const clean=value=>{try{return JSON.parse(JSON.stringify(value));}catch{return String(value);}}; const fieldKey=name=>String(name).toLowerCase().replace(/[^a-z0-9]/g,''); const importFields=new Set('toolProficiencies tools skillProficiencies proficientSkills feats featNames inventory equipment items level characterLevel totalLevel currency coins goldPieces gp gold str strength dex dexterity con constitution int intelligence wis wisdom cha charisma ac armorClass hp hitPoints class className'.split(' ').map(fieldKey)); const irrelevant=name=>/^(spell|cantrip|roll|attack|action|biography|bio$|gmnotes|token)/i.test(name); function compactField(name,value){ const k=fieldKey(name); if(['inventory','equipment','items'].includes(k)){ const item=v=>v&&typeof v==='object'&&typeof v.name==='string'?{name:v.name,quantity:v.quantity??1,...(v.id?{id:v.id}:{})}:clean(v); if(Array.isArray(value))return value.map(item); if(value&&typeof value==='object')return Object.fromEntries(Object.entries(value).map(([id,v])=>[id,item(v)])); } if(['toolproficiencies','tools','skillproficiencies','proficientskills','feats','featnames'].includes(k)&&Array.isArray(value))return value.map(v=>v&&typeof v==='object'&&(v.name||v.label)?Object.fromEntries(['name','label','proficient','proficiency'].filter(key=>v[key]!==undefined).map(key=>[key,clean(v[key])])):clean(v)); return clean(value); } function summaryNames(summary) { if(typeof summary==='string') {try{return summaryNames(JSON.parse(summary));}catch{return summary.split(/[\n,]+/).map(s=>s.trim()).filter(s=>/^[a-zA-Z][\w.:-]*$/.test(s));}} if(Array.isArray(summary))return summary.flatMap(x=>typeof x==='string'?[x]:x&&typeof x==='object'?[x.name||x.property||x.key].filter(Boolean):[]); if(summary&&typeof summary==='object') { if(summary.properties)return summaryNames(summary.properties); if(summary.computed)return summaryNames(summary.computed); return Object.keys(summary); } return []; } async function boundedRead(id,name,playerId) { let timer;try{return await Promise.race([Promise.resolve(getSheetItem(id,name,'current',{playerId})),new Promise((_,reject)=>{timer=setTimeout(()=>reject(Error('Timed out reading this field.')),3500);})]);}finally{clearTimeout(timer);} } function selectedCharacters(msg,ids) { const selected=ids?.length?ids:(msg.selected||[]).map(s=>getObj('graphic',s._id)?.get('represents')).filter(Boolean); const unique=[...new Set(selected)];if(!unique.length)throw Error('Select the party’s character tokens first, then run !wizzy-export.'); if(unique.length>MAX_CHARACTERS)throw Error(`Export at most ${MAX_CHARACTERS} characters at once.`); return unique.map(id=>getObj('character',id)||(()=>{throw Error(`Character not found: ${id}`);})()); } function utf8Bytes(text){ let size=0;for(let i=0;i=0xd800&&c<=0xdbff&&text.charCodeAt(i+1)>=0xdc00&&text.charCodeAt(i+1)<=0xdfff){size+=4;i++;}else size+=3;}return size; } function writeExport(payload) { const raw=JSON.stringify(payload);if(raw.length>MAX_EXPORT||(raw.length>MAX_EXPORT/3&&utf8Bytes(raw)>MAX_EXPORT))throw Error('The compact export exceeds 12 MB. Use --inspect to review the available sheet fields.'); const saved=state.WizzyPartyExporter,roster=payload.format==='wizzy-roll20-roster',slot=roster?'rosterHandoutId':'handoutId'; const count=Math.max(1,Math.ceil(raw.length/PART_SIZE)); const ids=[saved[slot],...(roster?[]:saved.partHandoutIds||[])],handouts=[]; for(let i=0;ih.id),...(saved.partHandoutIds||[]).slice(count-1)]; for(let i=0;iNext export part

`:''; handouts[i].set('notes',`

${count===1?'G-Tome export.':`Part ${i+1} of ${count}. G-Tome combines these parts into one JSON file.`}

${esc(JSON.stringify(part))}
${following}`); } const bridge=payload.bridgeNonce?` title="g-satchel:${esc(payload.bridgeNonce)}"`:''; tell(`${roster?'Roster':'Export'} ready: ${roster?'Open G-Tome Roster':'Open Wizzy Party Export'}.${count>1?` ${count} parts.`:''} ${payload.bridgeNonce?'G-Tome will collect this GM-only handout.':'Open G-Tome to download the party export.'}`); } async function run(msg) { if(msg.type!=='api'||!/^!wizzy-export(?:\s|$)/.test(msg.content))return; if(!playerIsGM(msg.playerid)){sendChat('Wizzy Export',`/w "${String(msg.who||'').replace(/"/g,'')}" Only the GM can export characters.`,null,{noarchive:true});return;} if(running){tell('An export is already running.');return;} running=true; try { const campaign=Campaign(); if(campaign.sandboxVersion!=='1.5'||typeof getSheetItem!=='function')throw Error('This exporter requires Mod Script Sandbox v1.5. Check the campaign’s Mod Scripts settings, restart the sandbox, and try again.'); state.WizzyPartyExporter ||= {campaignId:`wizzy-${Date.now()}-${randomInteger(1000000000)}`}; const names=[...new Set(summaryNames(campaign.computedSummary))].filter(n=>typeof n==='string'&&n.length<160&&!/^__|prototype|constructor/.test(n)); const readNames=names.filter(n=>!irrelevant(n)).sort((a,b)=>Number(importFields.has(fieldKey(b)))-Number(importFields.has(fieldKey(a)))).slice(0,MAX_FIELDS); const payload={format:'wizzy-roll20',version:1,exporterVersion:VERSION,exportedAt:new Date().toISOString(),campaignId:state.WizzyPartyExporter.campaignId,sheetName:campaign.sheetName||'D&D 2024',sandboxVersion:campaign.sandboxVersion,transport:'parts-v1',computedSummary:names.slice(0,2000),characters:[],warnings:[]}; const bridgeMatch=msg.content.match(/--bridge\s+([a-f0-9-]{20,64})(?:\s|$)/i); if(msg.content.includes('--bridge')){if(!bridgeMatch)throw Error('Invalid G-Tome export request.');payload.bridgeNonce=bridgeMatch[1];} if(/--roster(?:\s|$)/.test(msg.content)){ payload.format='wizzy-roll20-roster'; delete payload.computedSummary; payload.characters=findObjs({_type:'character'}).filter(c=>!c.get('archived')).map(c=>({roll20Id:c.id,name:String(c.get('name')||'Unnamed character'),playerControlled:String(c.get('controlledby')||'').split(',').some(id=>id==='all'||id&&!playerIsGM(id))})).sort((a,b)=>Number(b.playerControlled)-Number(a.playerControlled)||a.name.localeCompare(b.name)); writeExport(payload);return; } if(msg.content.includes('--inspect')) {payload.warnings.push('Schema inspection only; no characters were exported.');writeExport(payload);return;} const idMatch=msg.content.match(/--ids\s+([^\s]+)/),characters=selectedCharacters(msg,idMatch?idMatch[1].split(','):null); tell(`Reading ${characters.length} selected character${characters.length===1?'':'s'}…`); for(const c of characters) { const result={roll20Id:c.id,name:c.get('name'),fields:{},errors:[],attributes:[]}; // Legacy/custom attributes may supplement computed Beacon fields. Never export bio or GM notes. result.attributes=findObjs({_type:'attribute',_characterid:c.id}).filter(a=>importFields.has(fieldKey(a.get('name')))).map(a=>({name:a.get('name'),current:compactField(a.get('name'),a.get('current')),max:clean(a.get('max'))})); for(let start=0;start{try{const value=await boundedRead(c.id,name,msg.playerid);if(value!==undefined)result.fields[name]=compactField(name,value);}catch(e){result.errors.push({field:name,message:String(e.message||e).slice(0,200)});}})); if(!Object.keys(result.fields).length)result.errors.push({field:'computedSummary',message:'No readable computed fields were discovered. Import the identity, then send the inspection export to refine the field mapping.'}); payload.characters.push(result); } if(!names.length)payload.warnings.push('Roll20 did not publish a recognized computed-property list. Use !wizzy-export --inspect to examine the schema.'); writeExport(payload); }catch(e){tell(`Export stopped: ${esc(e.message||e)}`);}finally{running=false;} } on('ready',()=>{on('chat:message',msg=>{run(msg).catch(e=>{running=false;tell(`Export stopped: ${esc(e.message||e)}`);});});log(`Wizzy Party Exporter ${VERSION} ready. Select PC tokens and use !wizzy-export.`);}); })();