var FamiliarArcanaResolve = FamiliarArcanaResolve || (function () { 'use strict'; var version = '1.2'; function esc(value) { return String(value || '').replace(/[&<>"']/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; }); } function replaceInlineRolls(content, msg) { if (!msg.inlinerolls) return content; return String(content || '').replace(/\$\[\[(\d+)\]\]/g, function (match, index) { var roll = msg.inlinerolls[parseInt(index, 10)]; return roll && roll.results ? roll.results.total : match; }); } function parseArgs(content) { var args = {}; var re = /--([a-zA-Z0-9_-]+)(?:\s+("[^"]*"|'[^']*'|[^\s]+))?/g; var match; while ((match = re.exec(content)) !== null) { var key = match[1].toLowerCase(); var value = match[2] || '1'; if ((value.charAt(0) === '"' && value.charAt(value.length - 1) === '"') || (value.charAt(0) === "'" && value.charAt(value.length - 1) === "'")) { value = value.substring(1, value.length - 1); } args[key] = value; } return args; } function numberFrom(value, fallback) { var match = String(value || '').match(/[-+]?\d+(\.\d+)?/); if (!match) return fallback; var number = parseFloat(match[0]); return isNaN(number) ? fallback : number; } function findAttr(characterId, names) { for (var i = 0; i < names.length; i++) { var attr = findObjs({ type: 'attribute', characterid: characterId, name: names[i] })[0]; if (attr) return attr; } return null; } function getAttrText(characterId, names) { var attr = findAttr(characterId, names); return attr ? String(attr.get('current') || '') : ''; } function getAttrNumber(characterId, names, fallback) { return numberFrom(getAttrText(characterId, names), fallback); } function resolveRollValue(value, callback) { value = String(value || '').trim(); if (/^[-+]?\d+(\.\d+)?$/.test(value)) { callback(parseFloat(value)); return; } if (value.indexOf('[[') === 0 && value.lastIndexOf(']]') === value.length - 2) { value = value.slice(2, -2).trim(); } sendChat('Familiar Arcana', '[[' + value + ']]', function (ops) { var total = null; if (ops && ops[0] && ops[0].inlinerolls && ops[0].inlinerolls[0]) { total = ops[0].inlinerolls[0].results.total; } callback(total); }); } function selectedTokens(msg) { if (!msg.selected || !msg.selected.length) return []; var tokens = []; msg.selected.forEach(function (selected) { var token = getObj('graphic', selected._id); if (token) tokens.push(token); }); return tokens; } function getCharacterForToken(token) { var characterId = token.get('represents'); return characterId ? getObj('character', characterId) : null; } function getPb(characterId) { return getAttrNumber(characterId, [ 'npc_pb', 'pb', 'npc_proficiency_bonus', 'proficiency_bonus' ], 0); } function getAbilityMod(characterId, ability) { var map = { strength: ['npc_strength_mod', 'npc_str_mod', 'strength_mod', 'str_mod'], dexterity: ['npc_dexterity_mod', 'npc_dex_mod', 'dexterity_mod', 'dex_mod'], constitution: ['npc_constitution_mod', 'npc_con_mod', 'constitution_mod', 'con_mod'], intelligence: ['npc_intelligence_mod', 'npc_int_mod', 'intelligence_mod', 'int_mod'], wisdom: ['npc_wisdom_mod', 'npc_wis_mod', 'wisdom_mod', 'wis_mod'], charisma: ['npc_charisma_mod', 'npc_cha_mod', 'charisma_mod', 'cha_mod'] }; return getAttrNumber(characterId, map[ability] || [], 0); } function getDefense(characterId, defenseName) { var key = String(defenseName || 'AC').toLowerCase(); var pb = getPb(characterId); if (key === 'ac' || key === 'armor' || key === 'armorclass') { return { label: 'AC', value: getAttrNumber(characterId, ['npc_ac', 'npc_acbase', 'ac'], 10) }; } if (key === 'reflex' || key === 'ref') { return { label: 'Reflex', value: 10 + pb + Math.max( getAbilityMod(characterId, 'strength'), getAbilityMod(characterId, 'dexterity') ) }; } if (key === 'cognitive' || key === 'cog') { return { label: 'Cognitive', value: 10 + pb + Math.max( getAbilityMod(characterId, 'intelligence'), getAbilityMod(characterId, 'wisdom') ) }; } if (key === 'will' || key === 'willpower') { return { label: 'Will', value: 10 + pb + Math.max( getAbilityMod(characterId, 'constitution'), getAbilityMod(characterId, 'charisma') ) }; } return { label: 'AC', value: getAttrNumber(characterId, ['npc_ac', 'npc_acbase', 'ac'], 10) }; } function normalizeDamageType(value) { return String(value || '') .toLowerCase() .replace(/damage/g, '') .replace(/[^a-z]/g, ''); } function traitText(characterId, kind) { var names = { resistance: ['npc_resistances', 'npc_resistance', 'resistances', 'resistance', 'damage_resistances'], immunity: ['npc_immunities', 'npc_immunity', 'immunities', 'immunity', 'damage_immunities'], vulnerability: ['npc_vulnerabilities', 'npc_vulnerability', 'vulnerabilities', 'vulnerability', 'damage_vulnerabilities'] }; var parts = []; names[kind].forEach(function (name) { var text = getAttrText(characterId, [name]); if (text) parts.push(text); }); return parts.join(' ').toLowerCase(); } function traitMatches(text, damageType, magical) { var type = normalizeDamageType(damageType); var clean = normalizeDamageType(text); if (!type || !clean) return false; if (clean.indexOf('nonmagical') !== -1 && magical) return false; if (clean.indexOf(type) !== -1) return true; if (clean.indexOf('all') !== -1) return true; if ((type === 'acid' || type === 'cold' || type === 'fire' || type === 'lightning' || type === 'thunder') && clean.indexOf('elemental') !== -1) { return true; } return false; } function applyDamageTraits(characterId, damage, damageType, magical) { var result = { damage: Math.max(0, Math.floor(damage)), immune: false, resistant: false, vulnerable: false }; if (result.damage <= 0) return result; if (traitMatches(traitText(characterId, 'immunity'), damageType, magical)) { result.damage = 0; result.immune = true; return result; } if (traitMatches(traitText(characterId, 'resistance'), damageType, magical)) { result.damage = Math.floor(result.damage / 2); result.resistant = true; } if (traitMatches(traitText(characterId, 'vulnerability'), damageType, magical)) { result.damage = result.damage * 2; result.vulnerable = true; } return result; } function getOutcome(attackTotal, defenseValue, baseDamage) { if (attackTotal >= defenseValue) { return { label: 'Full Hit', damage: Math.floor(baseDamage) }; } if (attackTotal >= defenseValue / 2) { return { label: 'Partial Hit', damage: Math.floor(baseDamage / 2) }; } return { label: 'Miss', damage: 0 }; } function getTempAttr(characterId) { return findAttr(characterId, ['hp_temp', 'temp_hp', 'temporary_hp', 'temphp']); } function getHpSource(character, token, hpBar) { var barValue = token.get('bar' + hpBar + '_value'); if (String(barValue || '').trim() !== '') { return { type: 'bar', current: numberFrom(barValue, 0) }; } var hpAttr = findAttr(character.id, ['hp', 'npc_hp', 'hit_points']); if (hpAttr) { return { type: 'attr', attr: hpAttr, current: numberFrom(hpAttr.get('current'), 0) }; } return null; } function applyDamage(character, token, hpBar, damage) { var result = { oldHp: null, newHp: null, tempUsed: 0, hpDamage: 0, changed: false, missingHp: false }; damage = Math.max(0, Math.floor(damage)); if (damage <= 0) { var unchanged = getHpSource(character, token, hpBar); if (unchanged) { result.oldHp = unchanged.current; result.newHp = unchanged.current; } return result; } var tempAttr = getTempAttr(character.id); var tempHp = tempAttr ? numberFrom(tempAttr.get('current'), 0) : 0; if (tempHp > 0) { result.tempUsed = Math.min(tempHp, damage); damage -= result.tempUsed; tempAttr.setWithWorker({ current: tempHp - result.tempUsed }); } var hp = getHpSource(character, token, hpBar); if (!hp) { result.missingHp = true; return result; } result.oldHp = hp.current; result.hpDamage = damage; result.newHp = Math.max(0, hp.current - damage); if (hp.type === 'bar') { token.set('bar' + hpBar + '_value', result.newHp); } else { hp.attr.setWithWorker({ current: result.newHp }); } result.changed = true; return result; } function buildResolveLine(tokenName, defense, outcome) { return ( '
' + '' + esc(tokenName) + ': ' + esc(outcome.label) + ' against ' + esc(defense.label) + ' ' + defense.value + '.' + '
' ); } function buildDamageLine(tokenName, defense, outcome, traits, hpResult, damageType) { var notes = []; var hpText = ''; if (traits.immune) notes.push('Immune'); if (traits.resistant) notes.push('Resistant'); if (traits.vulnerable) notes.push('Vulnerable'); if (hpResult.missingHp) { hpText = ' HP not found.'; } else if (hpResult.oldHp !== null) { hpText = ' HP ' + hpResult.oldHp + ' to ' + hpResult.newHp + '.'; } if (hpResult.tempUsed > 0) { hpText += ' Temp HP absorbed ' + hpResult.tempUsed + '.'; } return ( '
' + '' + esc(tokenName) + ': ' + esc(outcome.label) + ' against ' + esc(defense.label) + ' ' + defense.value + '. Damage ' + traits.damage + (damageType ? ' ' + esc(damageType) : '') + '.' + (notes.length ? ' ' + esc(notes.join(', ')) + '.' : '') + hpText + '
' ); } function runResolve(msg, applyHpDamage) { var content = replaceInlineRolls(msg.content, msg); var args = parseArgs(content); var tokens = selectedTokens(msg); if (!tokens.length) { sendChat('Familiar Arcana', '/w gm Select one or more target tokens first.'); return; } var attackExpression = args.attack || args.atk || args.roll; var damageExpression = args.damage || args.dmg; var defenseName = args.defense || args.def || 'AC'; var damageType = args.type || args.dtype || ''; var hpBar = parseInt(args.hpbar || args.bar || '1', 10); var magical = String(args.magical || '').toLowerCase() === 'yes' || String(args.magical || '').toLowerCase() === 'true'; if (!attackExpression) { sendChat('Familiar Arcana', '/w gm Use: !fa-resolve --attack 18 --defense Reflex'); return; } if (applyHpDamage && !damageExpression) { sendChat('Familiar Arcana', '/w gm Use: !fa-damage --attack 18 --defense Reflex --damage [[2d6]] --type Fire'); return; } if (isNaN(hpBar) || hpBar < 1 || hpBar > 3) hpBar = 1; resolveRollValue(attackExpression, function (attackTotal) { if (attackTotal === null) { sendChat('Familiar Arcana', '/w gm Could not read the attack number.'); return; } function finish(baseDamage) { var lines = []; tokens.forEach(function (token) { var character = getCharacterForToken(token); var tokenName = token.get('name') || 'Selected Token'; if (!character) { lines.push('
' + esc(tokenName) + ': no represented character.
'); return; } var defense = getDefense(character.id, defenseName); var outcome = getOutcome(attackTotal, defense.value, baseDamage || 0); if (!applyHpDamage) { lines.push(buildResolveLine(tokenName, defense, outcome)); return; } var traits = applyDamageTraits(character.id, outcome.damage, damageType, magical); var hpResult = applyDamage(character, token, hpBar, traits.damage); lines.push(buildDamageLine(tokenName, defense, outcome, traits, hpResult, damageType)); }); sendChat( 'Familiar Arcana', '/w gm
' + '
' + (applyHpDamage ? 'FA Damage' : 'FA Resolve') + '
' + '
Attack ' + attackTotal + ' vs ' + esc(defenseName) + (applyHpDamage ? '. Base damage ' + baseDamage + (damageType ? ' ' + esc(damageType) : '') + '.' : '.') + '
' + lines.join('') + '
' ); } if (!applyHpDamage) { finish(0); return; } resolveRollValue(damageExpression, function (baseDamage) { if (baseDamage === null) { sendChat('Familiar Arcana', '/w gm Could not read the damage roll.'); return; } finish(baseDamage); }); }); } function handleInput(msg) { if (msg.type !== 'api') return; if (msg.content.indexOf('!fa-resolve') === 0) { runResolve(msg, false); return; } if (msg.content.indexOf('!fa-damage') === 0) { runResolve(msg, true); return; } } on('chat:message', handleInput); on('ready', function () { log('Familiar Arcana Resolve API v' + version + ' ready.'); }); return {}; }());