// PublicSheet v1.1 // Maintain public read-only copies of character sheets /* global state, findObjs, getObj, globalconfig, log, playerIsGM, createObj, sendChat, on */ const PublicSheet = (() => { "use strict"; const version = '1.1', linkStyle = 'style="color:black;background:#fff;border:1px solid black;padding:2px;font-weight:bold"'; // Init const checkInstall = () => { // Initialise, cleanup, and log message if (!state.PublicSheet || state.PublicSheet.lastVersion === 1.0) { state.PublicSheet = { data: {}, globalconfigCache: { lastsaved: 0 }, lastVersion: version, namePattern: '(Public) NAME', unsynced: [], onlyShowControlled: false, controlledByAll: false, }; } else if (state.PublicSheet.lastVersion === '1.0') { state.PublicSheet.unsynced = []; state.PublicSheet.onlyShowControlled = false; state.PublicSheet.lastVersion = '1.1'; } else Object.entries(state.PublicSheet.data).forEach(([master, slave]) => { if (!getObj('character', master) || !getObj('character', slave)) delete state.PublicSheet.data[master]; }); log(`-=> PublicSheet v${version} <=-`); checkGlobalConfig(); }; const checkGlobalConfig = () => { // Deal with global config options const s = state.PublicSheet, g = globalconfig && globalconfig.publicsheet; if (g && g.lastsaved && g.lastsaved > s.globalconfigCache.lastsaved) { log(` > Updating PublicSheet from Global Config < [${(new Date(g.lastsaved * 1000))}]`); s.namePattern = g['Public name pattern'] || '(Public) NAME'; s.unsynced = (g['Non-synchronized attributes'] || '').split(',').map(x => x.trim()); s.onlyShowControlled = '1' === g['Only show characters controlled by a player']; s.controlledByAll = '1' === g['Public characters are controlled by all players']; s.globalconfigCache = globalconfig.publicsheet; } }; // Utility/Workhorse functions const getSlaveMaster = id => { // Return slave/master pair in state corresponding to ID (if applicable) const slaveInfo = Object.entries(state.PublicSheet.data).find(([_,s]) => s === id); if (slaveInfo) return { master: slaveInfo[0], slave: id, type: 'slave', }; else if (id in state.PublicSheet.data) return { master: id, slave: state.PublicSheet.data[id], type: 'master', }; else return { type: null }; }; const setupSlave = masterID => { // Initial creation of slave character const slave = createObj('character', {}); setSlaveProperties(masterID, slave.id); syncAttrs(masterID, slave.id); return slave.id; }; const syncAttrs = (masterID, slaveID) => { // Copy all attributes from master to slave // delete slave attrs if present findObjs({ _characterid: slaveID, _type: 'attribute', }).forEach(attr => attr.remove()); // create all slave attrs from master attrs findObjs({ _characterid: masterID, _type: 'attribute', }).forEach(attr => { createObj('attribute', { _characterid: slaveID, current: attr.get('current'), max: attr.get('max'), name: attr.get('name'), }); }); }; const setSlaveProperties = (masterID, slaveID) => { // Copy character properties (not attributes!) from master to slave const master = getObj('character', masterID), slave = getObj('character', slaveID); if (!master || !slave) return; const controllers = master.get('controlledby'), targetIDs = state.PublicSheet.controlledByAll ? 'all' : findObjs({ _type: 'player' }) .map(p => p.id) .filter(id => !controllers.includes(id)) .filter(id => !playerIsGM(id)) .join(','); master.get('bio', bio => { master.get('gmnotes', gmnotes => { slave.set('bio', bio === 'null' ? '' : bio); slave.set('gmnotes', gmnotes === 'null' ? '' : gmnotes); slave.set({ avatar: master.get('avatar'), controlledby: targetIDs, inplayerjournals: targetIDs, name: state.PublicSheet.namePattern.replace('NAME', master.get('name')), }); }); }); }; const setSlaveAttribute = (slaveID, masterAttr) => { // Utility function: sets slaveID's attribute corresponding to given master attribute if (!masterAttr) return; const slaveAttrs = findObjs({ _type: 'attribute', _characterid: slaveID, name: masterAttr.get('name'), }, {caseInsensitive: true}); // cleanup extra attributes slaveAttrs.slice(1).forEach(attr => attr.remove()); if (slaveAttrs.length) slaveAttrs[0].set({ current: masterAttr.get('current'), max: masterAttr.get('max'), }); else createObj('attribute', { _characterid: slaveID, current: masterAttr.get('current'), max: masterAttr.get('max'), name: masterAttr.get('name'), }); }; // Chat const htmlReplace = str => { const entities = { '<': 'lt', '>': 'gt', "'": '#39', '@': '#64', '{': '#123', '|': '#124', '}': '#125', '[': '#91', '"': 'quot', ']': '#93', '*': '#42', ',': '#44', }; return str.replace(/[<>'"@{|}[*\]]/g, c => ('&' + entities[c] + ';')); }; const getCharSelectorMenu = (command, caption) => { // Shows a button to playerid to choose a character to execute command on const charList = findObjs({ _type: 'character' }) .filter(x => !(getSlaveMaster(x.id).type)) .filter(x => !state.PublicSheet.onlyShowControlled || x.get('controlledby')) .map(x => [x.get('name'), x.id]) .sort((a,b) => { if (a[0].toLowerCase() < b[0].toLowerCase()) return -1; if (a[0].toLowerCase() > b[0].toLowerCase()) return 1; else return 0; }) .map(([name, id]) => `${htmlReplace(name)},${id}`) .join('|'); return `${caption}`; }; const getOverview = () => { // Generate list of active public characters & buttons const charRows = Object.keys(state.PublicSheet.data).map(master => { return `${getObj('character', master).get('name')}` + `Force Sync` + `Remove`; }).join(''); const output = `

Public characters

` + `${charRows}
` + `
${getCharSelectorMenu('add', 'Add character')}
`; return output; }; const sendMessage = (playerid, output) => { // sendChat wrapper to send nice boxed chat messages const message = output.map(m => `/w "${(getObj('player', playerid) || {get: () => 'GM'}).get('displayname')}" ` + `
${m}
`).join('\n'); sendChat('PublicSheet', message, null, {noarchive: true}); }; // Event handlers const handleCharChange = char => { // Force slave character properties to be the same as master's const {master, slave, type} = getSlaveMaster(char.id); if (type === 'master') setSlaveProperties(master, slave); }; const handleCharDelete = oldChar => { // Slave: delete slave from state // Master: delete corresponding slave and remove from state const {slave, master, type} = getSlaveMaster(oldChar.id); if (type === 'slave') delete state.PublicSheet.data[master]; else if (type === 'master') { getObj('character', slave).remove(); delete state.PublicSheet.data[master]; } }; const handleAttrChange = (attr, oldAttr) => { // Slave: reset to previous value // Master: propagate change to slave if (state.PublicSheet.unsynced.includes(attr.get('name').toLowerCase())) return; const {slave, type} = getSlaveMaster(attr.get('_characterid')); if (type === 'slave') attr.set({ current: oldAttr.current, max: oldAttr.max, name: oldAttr.name, }); else if (type === 'master') setSlaveAttribute(slave, attr); }; const handleAttrCreate = attr => { // Slave: cannot create any attributes on its own // Master: copy created attribute to slave const {slave, type} = getSlaveMaster(attr.get('_characterid')); if (type === 'slave') attr.remove(); else if (type === 'master') setSlaveAttribute(slave, attr); }; const handleAttrDelete = oldAttr => { // Slave: recreate attribute // Master: remove corresponding slave attributes const {master, slave, type} = getSlaveMaster(oldAttr.get('_characterid')); if (type === 'slave') { setTimeout(() => { if (getObj('character', slave)) { const masterAttr = findObjs({ _type: 'attribute', _characterid: master, name: oldAttr.get('name'), }, {caseInsensitive: true})[0]; setSlaveAttribute(slave, masterAttr); } }, 1000); } else if (type === 'master') { findObjs({ _type: 'attribute', _characterid: slave, name: oldAttr.get('name'), }, {caseInsensitive: true}).forEach(attr => attr.remove()); } }; const handleInput = msg => { // Handle chat messages if (msg.type !== 'api' || !('content' in msg) || msg.content.indexOf('!publicsheet') !== 0) return; const args = msg.content.split(' ').slice(1), id = args[1], {master, slave, type} = getSlaveMaster(id), output = []; if (playerIsGM(msg.playerid)) { switch(args.shift()) { case 'add': if (type === 'master') output.push(`Character ${getObj('character', master).get('name')} already has a public version.`); else if (type === 'slave') output.push(`Character ${getObj('character', slave).get('name')} is already the public version` + `of ${getObj('character', master).get('name')}.`); else if (getObj('character', id)) { state.PublicSheet.data[id] = setupSlave(id); output.push(`Public version of character ${getObj('character', id).get('name')} added.`); output.push(getOverview()); } else output.push(`No character with id ${id} found.`); break; case 'remove': if (type) { delete state.PublicSheet.data[master]; if (getObj('character', slave)) getObj('character', slave).remove(); output.push(`Public version of character ${getObj('character', master).get('name')} removed.`); output.push(getOverview()); } else if (getObj('character', id)) output.push(`It seems that the character ${getObj('character', id).get('name')} does not have a public version.`); else output.push(`No character with id ${id} found.`); break; case 'sync': if (type) { syncAttrs(master, slave); setSlaveProperties(master, slave); } else if (getObj('character', id)) output.push(`It seems that the character ${getObj('character', id).get('name')} does not have a public version.`); else output.push(`No character with id ${id} found.`); break; case 'config': switch(args.shift()) { case 'publicname': if (args[0]) { state.PublicSheet.namePattern = args.join(' '); output.push(`Public version of characters will now be named "${args.join(' ')}".`); Object.entries(state.PublicSheet.data).forEach(([master, slave]) => setSlaveProperties(master, slave)); } else output.push('Public name cannot be blank.'); break; case 'unsynced': state.PublicSheet.unsynced = args.join(' ').toLowerCase().split(',').map(x => x.trim()); output.push(`List of non-synced attributes is now "${args.join(' ').toLowerCase() || 'empty'}".`); break; case 'onlyshowcontrolled': state.PublicSheet.onlyShowControlled = ['true', '1', 'yes', 'on'].includes(args[0]); output.push(state.PublicSheet.onlyShowControlled ? 'Only player-controlled characters will be offered.' : 'All characters will be offered.'); break; case 'controlledbyall': state.PublicSheet.controlledByAll = ['true', '1', 'yes', 'on'].includes(args[0]); output.push(state.PublicSheet.controlledByAll ? 'Public characters are now controlled by all players.' : 'Public characters are now controlled only by players who do not control the original character.'); Object.entries(state.PublicSheet.data).forEach(([master, slave]) => setSlaveProperties(master, slave)); break; default: output.push(`Name pattern: ${state.PublicSheet.namePattern}.\nNon-synced attributes: ${state.PublicSheet.unsynced}`); } break; default: output.push(getOverview()); } } else output.push(`It seems that you are not a GM. Permission denied.`); sendMessage(msg.playerid, output); }; // register events const registerEventHandlers = () => { on('chat:message', handleInput); on('change:character', handleCharChange); on('destroy:character', handleCharDelete); on('add:attribute', handleAttrCreate); on('change:attribute', handleAttrChange); on('destroy:attribute', handleAttrDelete); }; return { checkInstall, registerEventHandlers }; })(); on('ready', () => { PublicSheet.checkInstall(); PublicSheet.registerEventHandlers(); });