Ok, this is very untested, but you can give it a go. It should link a repeating attribute to a token bar. The token must represent a character sheet (obviously). Command line looks like this: !attrlink --bar 3 --attr repeating_resource_$0_resource_left --bar must be 1, 2 or 3 --attr must be a repeating attribute. You can use either the index '$0', or the actual row ID '-qwertyuiop987654321', but otherwise it needs to be an exact match of the attribute name on the sheet --remove will instead remove all observers & attributes that have been created for the attribute supplied in --attr The script just creates a static attribute, then links that to the token bar. It then sets up an observer so each time the repeating target updates, the static middle-man attribute also updates. If you're lucky it might even work.... /* globals on, sendChat, findObjs, getObj, log, state, createObj */ const attributeLink = (() => { //eslint-disable-line no-unused-vars const scriptName = 'attributeLink', scriptVersion = '0.1.0'; const setTokenBarLink = ({ tokenId, tokenBar, targetAttribute }) => { const token = getObj('graphic', tokenId); token.set({ [`bar${tokenBar}_link`]: targetAttribute.id }); // console.info(targetAttribute.id, token.get(`bar${tokenBar}_link`)); } const removeObserver = ({ sourceAttribute, characterId }) => { if (!state[scriptName][characterId]) return postChat(`No observers found for id "${characterId}"`); state[scriptName][characterId] = state[scriptName][characterId].filter(observer => { if (observer.sourceId === sourceAttribute.id) { const attributeCleanup = getObj('attribute', observer.targetId); if (attributeCleanup) attributeCleanup.remove(); return false; } return true; }); } const setObserver = ({ sourceAttribute, targetAttribute, characterId }) => { if (!sourceAttribute || !targetAttribute || !characterId) return null; state[scriptName][characterId] = Array.isArray(state[scriptName][characterId]) ? state[scriptName][characterId] : []; const existingObserver = state[scriptName][characterId].filter(o => o.sourceId === sourceAttribute.id && o.targetId === targetAttribute.id); if (existingObserver.length) { postChat(`Observer already exists for that attribute, skipping setObserver.`); return true; } state[scriptName][characterId].push({ sourceId: sourceAttribute.id, targetId: targetAttribute.id, }); // console.info(state[scriptName]); return true; } const createLinkedAttribute = ({ sourceAttribute, characterId }) => { if (!sourceAttribute) return; const staticAttributeName = `attrlink_${sourceAttribute.get('name')}`; const existing = findObjs({ type: 'attribute', characterid: characterId }).find(attribute => attribute.get('name') === staticAttributeName); // console.warn(existing); if (existing) { existing.set({ current: sourceAttribute.get('current') || '', max: sourceAttribute.get('max') || '', }); // console.info(existing); return existing; } const newAttribute = createObj('attribute', { name: staticAttributeName, current: sourceAttribute.get('current') || '', max: sourceAttribute.get('max') || '', characterid: characterId }); // console.warn(newAttribute); return newAttribute; } const resolveRepeatingIndex = (attributeName, characterId, allAttributes) => { const repeatingSectionName = (attributeName.match(/(repeating_\w+?)_/i)||[])[1], reporderAttributeName = `_reporder_${repeatingSectionName}`, rxRepeatingSectionName = new RegExp(`${repeatingSectionName}`, 'i'), rxRowId = /^repeating_[^_]+_(-[0-z]{19})_/i; // console.log(reporderAttributeName, repeatingSectionName, rxRepeatingSectionName); const reporderAttribute = allAttributes.find(attribute => attribute.get('name') === reporderAttributeName), reorderedRowIds = reporderAttribute ? reporderAttribute.get('current').split(/\s*,\s*/g) : []; const allRepeatingRowIds = allAttributes.reduce((output, attribute) => { if (rxRepeatingSectionName.test(attribute.get('name'))) { const rowId = (attribute.get('name').match(rxRowId)||[])[1]; if (rowId && !output.includes(rowId)) return [ ...output, rowId ]; } return output; }, []); // console.info(allRepeatingRowIds, reorderedRowIds); const finalRowOrder = [ ...reorderedRowIds, ...allRepeatingRowIds.filter(v => !reorderedRowIds.includes(v)) ]; // console.warn(finalRowOrder); const attributeIndex = (attributeName.match(/_\$(\d+)_/)||[])[1]; // console.log(attributeIndex); const targetRowId = (finalRowOrder.length && attributeIndex != null) ? finalRowOrder[parseInt(attributeIndex)] : null; return targetRowId ? attributeName.replace(`$${attributeIndex}`, targetRowId) : null; } const findAttribute = (attributeString, characterId) => { if (!/^repeating/.test(attributeString)) return postChat(`This script only works on repeating attributes.`); const allAttributes = findObjs({ type: 'attribute', characterid: characterId }); attributeString = (/_\$\d+_/.test(attributeString)) ? resolveRepeatingIndex(attributeString, characterId, allAttributes) : attributeString; const targetAttribute = allAttributes.find(attribute => attribute.get('name') === attributeString); // console.info(targetAttribute); return targetAttribute; } const getSelectedTokenAndCharacter = (msg) => { if (!msg.selected) return {}; const tokenId = msg.selected[0]._id, token = tokenId ? getObj('graphic', tokenId) : null, characterId = token ? token.get('represents') : null; return { tokenId, characterId } } const handleInput = (msg) => { if (msg.type === 'api' && /^!attr(ibute)?link\s/i.test(msg.content)) { const { tokenId, characterId } = getSelectedTokenAndCharacter(msg); if (!tokenId || !characterId) return postChat(`No selected token or couldn't find linked character sheet.`); const commands = msg.content.trim().split(/\s*--\s*/g); commands.shift(); const scriptParameters = { tokenBar: null, sourceAttribute: null, remove: false, characterId, tokenId } commands.forEach(command => { const args = command.replace(/^[\S]+\s*/, ''); if (/^bar/i.test(command)) { const barNumber = parseInt(args.replace(/\D/g, '')); if (barNumber > 0 && barNumber < 4) scriptParameters.tokenBar = barNumber; else return postChat(`${barNumber} is not a valid token bar.`); } else if (/^attr/i.test(command)) { const targetAttribute = findAttribute(args.trim(), characterId); if (!targetAttribute) return postChat(`Could not find attribute from "${args}"`); scriptParameters.sourceAttribute = targetAttribute; } else if (/^rem(ove)?/i.test(command)) scriptParameters.remove = true; }); if (scriptParameters.tokenBar && scriptParameters.sourceAttribute) { if (scriptParameters.remove) { removeObserver(scriptParameters); } else { scriptParameters.targetAttribute = createLinkedAttribute(scriptParameters); if (!scriptParameters.targetAttribute) return postChat(`Could not create linked attribute.`); const observerResult = setObserver(scriptParameters); if (!observerResult) return postChat(`Could not set up observer for static attribute.`); setTokenBarLink(scriptParameters); } } } } const checkAttributes = (event, previous) => { // console.info(event, previous); const charId = event.get('characterid'); // console.log(charId); if (!state[scriptName][charId]) return; else { // console.info(event.id); const validObservers = state[scriptName][charId].filter(observer => observer.sourceId === event.id); // console.info(validObservers); validObservers.forEach(observer => { console.info(`Triggered`, observer); const targetAttribute = getObj('attribute', observer.targetId); const changedValueType = `${event.get('max')}` === `${previous.max}` ? 'current' : 'max'; if (targetAttribute) targetAttribute.setWithWorker(changedValueType, event.get(changedValueType)); else { sendChat(scriptName, 'Cleaning up observer for non existent attribute.'); removeObserver({ sourceAttribute: { id: event.id }, characterId: charId }); } }) } } const postChat = (chatText, whisper = 'gm') => { const whisperText = whisper ? `/w "${whisper}" ` : ``; sendChat(scriptName, `${whisperText}${chatText}`, null, { noarchive: true }); } on('ready', () => { if (!state[scriptName]) state[scriptName] = { version: scriptVersion }; on('chat:message', handleInput); on('change:attribute', checkAttributes); log(`${scriptName} - v${scriptVersion}`); }); })();