//if (typeof MarkStart != 'undefined') {MarkStart('MassCombat');} /* Mass Combat * * A companion script for Kyburn's Mass Combat rule set: * https://docs.google.com/document/d/1-56AC-p57x-vr_BzszksjC55kTFK4d67XOzcIb1pWCY/edit?usp=sharing * * by Michael Greene (Volt Cruelerz) * */ on('ready', () => { const mcname = 'MassCombat'; const v = 0.9; const cache = {}; let debugLog = false; // Debug Log const dlog = (str) => { if (debugLog) { log(str); } }; // Initialize the state const ConfigureState = () => { if (!state.MassCombat) { state.MassCombat = { SavedInitiative: [] }; } if (!state.MassCombat.Version || state.MassCombat.Version < 0.8) { log('-=> Updating Mass Combat to version 0.8'); state.MassCombat.Version = 0.8; state.MassCombat.OperationHistory = []; state.MassCombat.OperationHistorySize = 20; state.MassCombat.OpId = 0; } else if (state.MassCombat.Version < 0.9) { state.MassCombat.Version = 0.9; } }; ConfigureState(); const getCharByAny = (nameOrId) => { let character = null; // Try to directly load the character ID character = getObj('character', nameOrId); if (character) { return character; } // Try to load indirectly from the token ID const token = getObj('graphic', nameOrId); if (token) { character = getObj('character', token.get('represents')); if (character) { return character; } } // Try loading through char name const list = findObjs({ _type: 'character', name: nameOrId, }); if (list.length === 1) { return list[0]; } // Default to null return null; }; const getAttrs = (char, attrName) => { const attr = filterObjs((obj) => { if (obj.get('type') === 'attribute' && obj.get('characterid') === char.id && obj.get('name') == attrName) { return obj; } }); if (!attr || attr.length === 0) { dlog('No Attr: ' + char + ': ' + attrName); return null; } return attr; } const getAttr = (char, attrName) => { let attr = getAttrs(char, attrName); if (!attr) { return null; } return attr[0]; } const getAttrsFromSub = (char, substringName) => { const attrs = filterObjs((obj) => { if (obj.get('type') === 'attribute' && obj.get('characterid') === char.id && obj.get('name').indexOf(substringName) !== -1) { return obj; } }); if (!attrs || attrs.length === 0) { dlog('No Substr Attr: ' + char + ': ' + substringName); return null; } return attrs; } const getAttrFromSub = (char, substringName) => { return getAttrsFromSub(char, substringName)[0]; } const setAttr = (charId, attrName, val) => { const attr = findObjs({ type: 'attribute', characterid: charId, name: attrName, })[0]; if (typeof attr === 'undefined' || attr == null) { const attr = createObj('attribute', { name: attrName, characterid: charId, current: val+'' }); } else { attr.setWithWorker({ current: val+'', }); } }; // Pulls the interior message out of carets (^) const Decaret = (quotedString) => { const startQuote = quotedString.indexOf('^'); const endQuote = quotedString.lastIndexOf('^'); if (startQuote >= endQuote) { if (!quietMode) { sendChat(drname, `**ERROR:** You must have a string within carets in the phrase ${string}`); } return null; } return quotedString.substring(startQuote + 1, endQuote); }; const sizeCostArray = [0.01, 0.03, 0.1, 0.3, 1.0, 3.0]; const creatureSize = { "Tiny": 0, "Small": 1, "Medium": 2, "Large": 3, "Huge": 4, "Gargantuan": 5 }; const getCreatureSize = (npcType) => { if (npcType.toLowerCase().includes('tiny')) { return creatureSize.Tiny; } else if (npcType.toLowerCase().includes('small')) { return creatureSize.Medium; } else if (npcType.toLowerCase().includes('medium')) { return creatureSize.Medium; } else if (npcType.toLowerCase().includes('large')) { return creatureSize.Large; } else if (npcType.toLowerCase().includes('huge')) { return creatureSize.Huge; } else if (npcType.toLowerCase().includes('gargantuan')) { return creatureSize.Gargantuan; } else { return -1; } }; class Hero { constructor(name, hp, mhp) { this.Name = name; this.HP = hp; this.MHP = mhp; } } class Status { constructor(type, value) { this.Type = type; this.Value = value; } } const GetStatuses = (token) => { const rawStatus = token.get('statusmarkers'); const oldStatusArray = rawStatus.split(','); const newStatusArray = []; let previousType = false; let prevStatus = null; oldStatusArray.forEach((entry) => { const statusFields = entry.split('@'); const type = statusFields[0]; const value = statusFields.length > 1 ? statusFields[1] : true; let newStatus = null; // If we've already gotten one of this kind, we're seeing a duplicate, so add to previous if (type === previousType) { newStatus = prevStatus; prevStatus.Value = prevStatus.Value + value; } else { newStatus = new Status(type, value); newStatusArray.push(newStatus); } previousType = type; prevStatus = newStatus; }); return newStatusArray; }; const GetStatusValue = (token, type) => { const statuses = GetStatuses(token); for(let i = 0; i < statuses.length; i++) { const curStatus = statuses[i]; if (type === curStatus.Type) { const intVal = parseInt(curStatus.Value); if (isNaN(intVal)) { return curStatus.Value; } else { return intVal; } } } return false; }; const StringifyStatus = (status) => { if (status.Value === true) { return status.Type; } else { const strVal = status.Value + ""; const digitArray = strVal.split(''); const valArray = []; for (let i = 0; i < digitArray.length; i++) { let char = digitArray[i]; valArray.push(status.Type + '@' + char); } return valArray.join(','); } }; const UpdateStatusValue = (token, type, value) => { let alreadyExists = false; const statuses = GetStatuses(token); for(let i = 0; i < statuses.length; i++) { const curStatus = statuses[i]; if (type === curStatus.Type) { curStatus.Value = value; alreadyExists = true; break; } } if (!alreadyExists) { statuses.push(new Status(type, value)); } const statusStrings = []; for (let i = 0; i < statuses.length; i++) { const curStatus = statuses[i]; statusStrings.push(StringifyStatus(curStatus)); } const statusString = statusStrings.join(','); token.set('statusmarkers', statusString); return value; }; const StatusIcons = { Routed: "status_chained-heart", Guard: "status_sentry-gun", Defend: "status_bolt-shield", Disorganized: "status_rolling-bomb", Recovering: "status_half-heart", Dead: "status_dead" }; const StripStatus = (icon) => { const prefix = "status_"; return icon.substr(prefix.length); } const LeftAlignDiv = { Open: `
`, Close: '
' }; const brTag = '
'; const HTag = (str, tier, startLine = false, stopLine = false) => { let startTag = startLine ? brTag : ''; let stopTag = stopLine ? brTag : ''; return startTag + '' + str + '' + stopTag; }; const Bold = (str, startLine = false, stopLine = false) => { let startTag = startLine ? brTag : ''; let stopTag = stopLine ? brTag : ''; return startTag + '' + str + '' + stopTag; }; const ListItem = (str, startLine = false, stopLine = false) => { let startTag = startLine ? brTag : ''; let stopTag = stopLine ? brTag : ''; return startTag + '
  • ' + str + '
  • ' + stopTag; }; const printBattleRating = (infExp, infCount, infTroops, cavExp, cavCount, cavTroops, arcExp, arcCount, arcTroops, magExp, magCount, magTroops, sctExp, sctCount, sctTroops, heroList) => { let totalExp = infExp + cavExp + arcExp + magExp + sctExp; totalExp = totalExp.toExponential(3); let armyOverview = `&{template:desc} {{desc=` + `

    Army Summary

    ` + `

    Battle Rating

    ` + `Total BR: ${totalExp}` + (infCount + cavCount + arcCount + magCount + sctCount > 0 ? `

    Force Details

    ` : '') + (infCount > 0 ? `
    Infantry: ${infCount} formation` + (infCount > 1 ? 's' : '') + `

    ${infTroops} infantrymen

    ` : '') + (cavCount > 0 ? `
    Cavalry: ${cavCount} formation` + (cavCount > 1 ? 's' : '') + `

    ${cavTroops} cavalrymen

    ` : '') + (arcCount > 0 ? `
    Archers: ${arcCount} formation` + (arcCount > 1 ? 's' : '') + `

    ${arcTroops} archers

    ` : '') + (magCount > 0 ? `
    Mages: ${magCount} formation` + (magCount > 1 ? 's' : '') + `

    ${magTroops} mages

    ` : '') + (sctCount > 0 ? `
    Scouts: ${sctCount} formation` + (sctCount > 1 ? 's' : '') + `

    ${sctTroops} scouts

    ` : '') + (heroList.length > 0 ? `

    Hero Details

    ` : ''); heroList.forEach((hero) => { armyOverview += `

    ${hero.Name}: ${hero.HP}/${hero.MHP}

    `; }); armyOverview += '}}'; sendChat(mcname, armyOverview); }; const sendChatToFormation = (formName, title, text) => { const msg = `/w "${formName}" &{template:desc} {{desc=

    ${title}


    ${LeftAlignDiv.Open}${text}${LeftAlignDiv.Close}}}`; dlog(msg); sendChat(mcname, msg); }; const sendChatToSource = (inMsg, outMsg) => { sendChat(mcname, `/w "${inMsg.who.replace(' (GM)', '')}" ${outMsg}`); }; class Operation { constructor(name, formName, tokenId, barChanges, iconChanges) { this.Name = name; this.FormName = formName; this.TokenId = tokenId; this.BarChanges = barChanges; this.IconChanges = iconChanges; this.Timestamp = new Date().toUTCString(); this.Id = state.MassCombat.OpId++; } } class Diff { constructor(type, oldVal, newVal) { this.Type = type; this.Old = oldVal; this.New = newVal; } } const AttrEnum = { HP: 'bar1_value', HPM: 'bar1_max', FP: 'bar2_value', FPM: 'bar2_max', CP: 'bar3_value', CPM: 'bar3_max' }; const DiffDict = {}; const InitDiffDict = () => { DiffDict[AttrEnum.HP] = 'Hit Points'; DiffDict[AttrEnum.HPM] = 'Hit Point Max'; DiffDict[AttrEnum.CP] = 'Chaos Points'; DiffDict[AttrEnum.CPM] = 'Chaos Point Max'; DiffDict[AttrEnum.FP] = 'Fatality Points'; DiffDict[AttrEnum.FPM] = 'Fatality Point Max'; DiffDict[StatusIcons.Routed] = 'Routed'; DiffDict[StatusIcons.Guard] = 'Guarding'; DiffDict[StatusIcons.Defend] = 'Defending'; DiffDict[StatusIcons.Disorganized] = 'Disorganization'; DiffDict[StatusIcons.Recovering] = 'Recovering'; DiffDict[StatusIcons.Dead] = 'Dead'; }; InitDiffDict(); const addOperation = (operation) => { // Add the new one state.MassCombat.OperationHistory.push(operation); // If limit exceeded, remove the oldest if (state.MassCombat.OperationHistory.length > state.MassCombat.OperationHistorySize) { state.MassCombat.OperationHistory.splice(0, 1); } }; const stringifyDiff = (diff) => { const type = DiffDict[diff.Type]; const oldVal = diff.Old; const newVal = diff.New; // Don't print things that didn't happen to change if (oldVal === newVal) { return ''; } return ListItem(`${Bold(type)}: ${oldVal} to ${newVal}`); }; const rgbToHex = (rgb) => { var hex = Number(rgb).toString(16); if (hex.length < 2) { hex = "0" + hex; } return hex; }; const getAuraColor = (hp, maxHP) => { let half = maxHP*0.5; let perc = hp/half; let gVal = Math.floor(perc*255); let gHex = rgbToHex(gVal).toLowerCase(); return '#ff' + gHex + '00'; }; const setTokenAura = (formToken, oldHP, newHP, maxHP) => { if (oldHP === newHP) { return; } let color = 'transparent'; let showAura = false; let isDead = false; let size = 0.0; maxHP = maxHP > -1 ? maxHP : (formToken.get(AttrEnum.HPM) || 0); if (newHP < 1) { isDead = true; } else if (newHP * 2 <= maxHP) { color = getAuraColor(newHP, maxHP); size = 12.6; showAura = true; } let params = { 'aura1_radius': size, 'aura1_color': color, 'showplayers_aura1': showAura, 'status_dead': isDead }; dlog("Aura Params: " + JSON.stringify(params)); formToken.set(params); }; const revertOp = (op) => { const formToken = getObj('graphic', op.TokenId); log('Got Token'); const barReverts = []; const iconReverts = []; let oldHP = -1; let newHP = -1; let maxHP = -1; log('Reverting Op ' + op.Name + ' with ' + op.BarChanges.length + ' bar changes and ' + op.IconChanges.length + ' icon changes.'); // Revert changes to the bars for (let i = 0; i < op.BarChanges.length; i++) { const diff = op.BarChanges[i]; const live = formToken.get(diff.Type); if (diff.Type === AttrEnum.HP) { oldHP = parseInt(formToken.get(AttrEnum.HP)) || 0; newHP = diff.Old; } else if (diff.Type === AttrEnum.HPM) { maxHP = parseInt(formToken.get(AttrEnum.HPM)) || 0 ; } barReverts.push(new Diff(diff.Type, live, diff.Old)); dlog('Set ' + op.TokenId + '\' ' + diff.Type + ' from ' + live + ' to ' + diff.Old); formToken.set(diff.Type, diff.Old); } // Revert changes to icons for (let i = 0; i < op.IconChanges.length; i++) { const diff = op.IconChanges[i]; const strippedType = StripStatus(diff.Type); const live = GetStatusValue(formToken, strippedType); iconReverts.push(new Diff(diff.Type, live, diff.Old)); dlog('Set ' + op.TokenId + '\' ' + diff.Type + ' from ' + live + ' to ' + diff.Old); UpdateStatusValue(formToken, strippedType, diff.Old); } // Record the revert as its own revertible operation addOperation(new Operation(`Revert ${op.Id}`, op.FormName, op.TokenId, barReverts, iconReverts)); // Update Auras if (oldHP !== -1) { setTokenAura(formToken, oldHP, newHP, maxHP); } // Print sendChatToFormation(op.FormName, 'Reverted', 'Successfully reverted ' + op.Name); }; class MoraleFactor { constructor(name, value) { this.Name = name; this.Value = value; } } class Morale { constructor(commanderCha, factors) { this.CommanderCha = commanderCha; this.Factors = factors; } } const MoraleAttrName = 'mc_morale'; const GetMorale = (char, cr) => { let attr = getAttr(char, MoraleAttrName); if (!attr) { dlog('Morale Attr does not exist yet'); const commanderCha = (cr/2).toFixed(0); const factors = [ new MoraleFactor('Rest', 0), new MoraleFactor('Food & Drink', 0), new MoraleFactor('Equipment', 0), new MoraleFactor('Compensation', 0), new MoraleFactor('Personal Stake', 0), new MoraleFactor('Prototype Might', 0), new MoraleFactor('Commander Prep', 0), new MoraleFactor('Treatment', 0), new MoraleFactor('Camaraderie', 0), new MoraleFactor('Terrain', 0), new MoraleFactor('Weather', 0) ]; const morale = new Morale(commanderCha, factors); const moraleStr = JSON.stringify(morale); dlog('New Morale String: ' + moraleStr); setAttr(char.id, MoraleAttrName, moraleStr); return morale; } const attrCurrent = attr.get('current'); dlog('Load existing morale attr.' + attrCurrent); return JSON.parse(attrCurrent); }; const GetMoraleRating = (factors) => { // Sum factors let morale = 0; for (let i = 0; i < factors.length; i++) { morale += factors[i].Value; } // Get string let desc = ''; if (morale <= -10) { desc = 'Openly Rebellious'; } else if (morale <= -8) { desc = 'Mutinous'; } else if (morale <= -4) { desc = 'Disgruntled'; } else if (morale <= -2) { desc = 'Shaky'; } else if (morale < 2) { desc = 'Average'; } else if (morale < 4) { desc = 'Motivated'; } else if (morale < 8) { desc = 'Stalwart'; } else if (morale < 10) { desc = 'Fanatical'; } else { desc = 'Unbreakable'; } dlog('Get Morale Rating: ' + morale + ' = ' + desc); return { Value: morale, Description: desc }; }; const PrintMorale = (name, morale) => { const rating = GetMoraleRating(morale.Factors); const desc = rating.Description; const value = rating.Value; let moraleMsg = `${HTag(name, 4)}` + `${Bold('Commander CHA')}: ${morale.CommanderCha} [+/-](!mc -setMorale ?{Please enter new Commander Charisma modifier} ^CommanderCha^)
    ` + `${Bold('Morale Rating')}: ${value} (${desc})
    `; // Begin formulating deltas for the roll string let deltas = morale.CommanderCha > 0 ? '+' + morale.CommanderCha : morale.CommanderCha; deltas += '[Commander Charisma]'; if (morale.Expand) { moraleMsg += `${HTag('Morale Factors', 4)}`; } for (let i = 0; i < morale.Factors.length; i++) { const factor = morale.Factors[i]; if (morale.Expand) { moraleMsg += `${factor.Value} |` + `[-](!mc -setMorale ${factor.Value-1} ^${factor.Name}^)` + `[+](!mc -setMorale ${factor.Value+1} ^${factor.Name}^)` + ` ${factor.Name}
    `; } if (factor.Value !== 0) { deltas += factor.Value > 0 ? '+' + factor.Value : factor.Value; deltas += `[${factor.Name}]`; } } if (morale.Expand) { moraleMsg += '[Hide](!mc -setMorale 0 ^Expand^)'; } else { moraleMsg += '
    [Expand](!mc -setMorale 1 ^Expand^)'; } moraleMsg += `
    ${HTag('Leadership Check', 4)}[[d20${deltas}]]`; dlog('Sending Morale to ' + name + ': ' + moraleMsg); sendChatToFormation(name, 'Morale', moraleMsg); return moraleMsg; }; on('chat:message', (msg) => { if (msg.type !== 'api') return; if (msg.content.startsWith('!mc') !== true) return; let tokens = msg.content.split(' '); if (tokens.length < 2) return; let key = tokens[1]; // BR sums let infExp = 0; let infCount = 0; let infTroops = 0; let cavExp = 0; let cavCount = 0; let cavTroops = 0; let arcExp = 0; let arcCount = 0; let arcTroops = 0; let magExp = 0; let magCount = 0; let magTroops = 0; let sctExp = 0; let sctCount = 0; let sctTroops = 0; // Upkeep sums let purchaseCost = 0; let upkeepCost = 0; // List of hero names let heroList = []; // Perform operations without needed selection if (key === '-overview' || key === '-o' || key === '-help') { let menuString = `&{template:desc} {{desc=` + LeftAlignDiv.Open + HTag('Battle', 3) + HTag('Damage', 4) + `[Direct](!mc -damage ?{Damage Type|Battle|Chaos|Fatality} ?{Amount})` + `[Scaled](!mc -scaledDamage ?{Damage Type|Battle|Chaos|Fatality} ?{Amount} ?{Scale})` + HTag('Disorganize', 4) + `[Set](!mc -disorganize ?{Please type the new disorganization scale. Type 'false' to remove existing disorganization})` + `[Pop](!mc -popDisorganize ?{Are you sure you wish to pop disorganization|yes|no})` + HTag('Recovery', 4) + `[Start](!mc -startRecover)` + `[Finish](!mc -recover)` + HTag('Restore', 4) + `[Heal](!mc -heal ?{Points of healing})` + `[Long Rest](!mc -longrest ?{Sure you want to long rest|yes|no})` + HTag('Stance', 4) + `[Guard](!mc -guard)` + `[Defend](!mc -defend)` + HTag('Route', 4) + `[Set](!mc -route ?{Route Degree|Not Routed,-1|0 Failures,0|1 Failure,1|2 Failures,2|3 Failures,3})` + `[Tick](!mc -routeDamage)` + HTag('Stats', 3) + `[AC](!mc -ac)` + `[Speed](!mc -speed)` + `[Morale](!mc -morale)` + HTag('Initiative', 3) + `[Save](!mc -saveInitiative ?{Are you sure you wish to SAVE initiative|yes|no})` + `[Load](!mc -loadInitiative ?{Are you sure you wish to LOAD initiative|yes|no})` + HTag('Other', 3) + `[Upkeep](!mc -upkeep)` + `[BR](!mc -battleRating)` + `[History](!mc -history)` + LeftAlignDiv.Close + `}}`; sendChatToSource(msg, menuString); return; } else if (key === '-saveInitiative') { if (tokens.length < 3) return; if (tokens[2] !== 'yes') return; let turnorder = []; let existingOrder = Campaign().get('turnorder'); if (existingOrder != '') { turnorder = JSON.parse(existingOrder); } else { sendChat(mcname, 'ERROR: There is no initiative order to save.'); return; } state.MassCombat.SavedInitiative = turnorder; let saveMessage = `&{template:desc} {{desc=Turn Order Saved.}}`; sendChat(mcname, saveMessage); return; } else if (key === '-loadInitiative') { if (tokens.length < 3) return; if (tokens[2] !== 'yes') return; Campaign().set('turnorder', JSON.stringify(state.MassCombat.SavedInitiative)); state.MassCombat.SavedInitiative = []; let saveMessage = `&{template:desc} {{desc=Turn Order Loaded.}}`; sendChat(mcname, saveMessage); return; } else if (key === '-history') { let text = ''; for (let i = 0; i < state.MassCombat.OperationHistory.length; i++) { const op = state.MassCombat.OperationHistory[i]; let opStr = HTag(`[${op.Id}] ${op.Name}`, 4); opStr += Bold('Target') + ': ' + op.FormName + brTag; opStr += Bold('Time') + ': ' + op.Timestamp + brTag; opStr += ''; opStr += `[Revert](!mc -revert ${op.Id})`; // Don't paste an empty op that did nothing if (postLeng > preLeng) { text += opStr; } } const historyStr = `&{template:desc} {{desc=

    History


    ${LeftAlignDiv.Open}${text}${LeftAlignDiv.Close}}}`; sendChatToSource(msg, historyStr); return; } else if (key === '-clearHistory') { if (tokens.length < 3) return; if (tokens[2] !== 'yes') return; state.MassCombat.OperationHistory = []; sendChat(mcname, 'Cleared Op History'); return; } else if (key === '-revert') { if (tokens.length < 3) return; const id = parseInt(tokens[2]); if(isNaN(id)) return; for (let i = 0; i < state.MassCombat.OperationHistory.length; i++) { const op = state.MassCombat.OperationHistory[i]; if (op.Id === id) { revertOp(op); return; } } } // Iterate through selected tokens if (!msg.selected) return; let processed = 0; // Wait Duration Warning Messages let wait = 0; if (msg.selected.length > 0) { if (key === '-battleRating'){ sendChat(mcname, 'Calculating Battle Rating. This may take a few moments...'); wait = 100; } else if (key === '-upkeep'){ sendChat(mcname, 'Calculating Upkeep. This may take a few moments...'); wait = 100; } } else { sendChatToSource(msg, 'You must select a token for the command you selected.'); } setTimeout(() => { msg.selected.forEach((selection) => { _.defer(() => { processed++; // Load token data. Unfortunately, we can't use the cache for this because you could have mook tokens with different hp bars let formToken = getObj('graphic', selection._id); let formationType = formToken.get('represents'); let formName = formToken.get('name'); let hp = parseInt(formToken.get(AttrEnum.HP)) || 0; let hpm = parseInt(formToken.get(AttrEnum.HPM)) || 0 ; let fp = parseInt(formToken.get(AttrEnum.FP)) || 0; let cp = parseInt(formToken.get(AttrEnum.CP)) || 0 ; dlog(`Operation ${key} on ${formName} with ${hp}/${fp}/${cp}/${hpm}`); // Load charsheet data. Use a cache for this let cacheEntry = cache[formationType]; if (!cacheEntry) { buildNewEntry = true; let char = getCharByAny(formationType); // Discount accidental selections of unowned tokens or graphics if (!char) { return; } // Filter NPCs vs PCs const npcAttr = getAttr(char, 'npc'); let isNPC = false; if (npcAttr && (isNPC = npcAttr.get('current'))) { let traits = getAttrsFromSub(char, 'npctrait'); // Discount non-formation NPCs if (!traits) { heroList.push(new Hero(formName, hp, hpm)); return; } // Get formation values let formationTraitArray = traits.filter(trait => trait.get('current').includes('Formation of')); if(formationTraitArray.length === 0) { cacheEntry = { char: char, isNPC: isNPC, isHero: true, npcType: getAttr(char, 'npc_type').get('current'), cr: parseInt(getAttr(char, 'npc_challenge').get('current')), xp: parseInt(getAttr(char, 'npc_xp').get('current')), traits: traits, formationTraitArray: formationTraitArray }; } else { let formDetails = formationTraitArray[0].get('current'); cacheEntry = { char: char, isNPC: isNPC, isHero: false, npcType: getAttr(char, 'npc_type').get('current'), cr: parseInt(getAttr(char, 'npc_challenge').get('current')), xp: parseInt(getAttr(char, 'npc_xp').get('current')), ac: parseInt(getAttr(char, 'npc_ac').get('current')), speed: parseInt(getAttr(char, 'npc_speed').get('current')), traits: traits, formationTraitArray: formationTraitArray, formDetails: formDetails }; } } else { cacheEntry = { char: char, isNPC: isNPC, isHero: true }; } cache[formationType] = cacheEntry; } if (cacheEntry.isHero) { dlog(`Selected ${formName} is not an NPC`); heroList.push(new Hero(formName, hp, hpm)); if (processed === msg.selected.length) { if (key === '-battleRating') { printBattleRating(infExp, infCount, infTroops, cavExp, cavCount, cavTroops, arcExp, arcCount, arcTroops, magExp, magCount, magTroops, sctExp, sctCount, sctTroops, heroList); } else if (key === '-upkeep') { sendChat(mcname, `&{template:desc} {{desc=

    Army Cost


    Procurement Cost: ${purchaseCost}
    Upkeep: ${upkeepCost}gp
    (plus mounts and gear for troops if relevant)}}`); } } return; } let npcType = cacheEntry.npcType; let cr = cacheEntry.cr; let xp = cacheEntry.xp; let formDetails = cacheEntry.formDetails; let formTokens = formDetails.split(' '); let formType = formTokens[0]; let protoCount = parseInt(formTokens[3]); let recruitSource = formTokens[4]; let sourceCreature = formTokens[4]; if (formTokens.length > 5) { let sourceCreatureIndex = formDetails.indexOf(formTokens[5]); dlog('Source Index: ' + sourceCreatureIndex); sourceCreature = formDetails.substr(sourceCreatureIndex); } dlog(`${npcType} NPC of ${protoCount} CR ${cr} ${sourceCreature} recruited via ${recruitSource}`); if (key === '-damage' || key === '-scaledDamage') { if (tokens.length < 4) return; let type = tokens[2]; let amount = parseInt(tokens[3]); if (key === '-scaledDamage') { if (tokens.length < 5) return; let damageScale = parseInt(tokens[4]); amount *= damageScale; } let chaosBurn = hp-amount < 0 ? hp-amount : 0; let fatalZone = hp-amount >= 0 ? amount : amount - hp; dlog(`Received ${amount} ${type} damage. ChaosBurn: ${chaosBurn}, FatalZone: ${fatalZone}`); let remHP = Math.max(0, hp-amount); let iconChanges = []; if (remHP < 1) { iconChanges.push(new Diff(StatusIcons.Dead, hp < 1, true)); } setTokenAura(formToken, hp, remHP, hpm); if (type === 'Battle') { const newFP = fp + 0.25 * fatalZone + chaosBurn; const newCP = Math.max(0, cp + amount / 2 + chaosBurn); formToken.set(AttrEnum.HP, remHP); formToken.set(AttrEnum.CP, newCP); formToken.set(AttrEnum.FP, newFP); const operation = new Operation('Battle Damage', formName, selection._id, [ new Diff(AttrEnum.HP, hp, remHP), new Diff(AttrEnum.CP, cp, newCP), new Diff(AttrEnum.FP, fp, newFP) ], iconChanges); addOperation(operation); } else if (type === 'Chaos') { const newCP = Math.max(0, cp + amount + chaosBurn); formToken.set(AttrEnum.HP, remHP); formToken.set(AttrEnum.CP, newCP); const operation = new Operation('Chaos Damage', formName, selection._id, [ new Diff(AttrEnum.HP, hp, remHP), new Diff(AttrEnum.CP, cp, newCP) ], iconChanges); addOperation(operation); } else if (type === 'Fatality') { const newFP = fp + fatalZone + chaosBurn; const newCP = Math.max(0, cp + chaosBurn); formToken.set(AttrEnum.HP, remHP); formToken.set(AttrEnum.FP, newFP); formToken.set(AttrEnum.CP, newCP); const operation = new Operation('Fatality Damage', formName, selection._id, [ new Diff(AttrEnum.HP, hp, remHP), new Diff(AttrEnum.CP, cp, newCP), new Diff(AttrEnum.FP, fp, newFP) ], iconChanges); addOperation(operation); } else { sendChat(mcname, 'Invalid Damage Type.'); return; } sendChatToFormation(formName, 'Damage Received', `Victim: ${formDetails}
    Damage: ${amount} ${type}`); } else if (key === '-routeDamage') { let remHP = Math.max(0, hp-hpm*.1); let iconChanges = []; if (remHP < 1) { iconChanges.push(new Diff(StatusIcons.Dead, hp < 1, true)); } setTokenAura(formToken, hp, remHP, hpm); const newCP = cp + hpm*.1; formToken.set(AttrEnum.HP, remHP); formToken.set(AttrEnum.CP, newCP); const operation = new Operation('Route Tick', formName, selection._id, [ new Diff(AttrEnum.HP, hp, remHP), new Diff(AttrEnum.CP, cp, newCP) ], iconChanges); addOperation(operation); sendChatToFormation(formName, 'Routed Tick', `Victim: ${formDetails}
    Damage: ${hpm*.1}`); } else if (key === '-startRecover') { formToken.set(StatusIcons.Recovering, true); addOperation(new Operation('Start Recover', formName, selection._id, [], [new Diff(StatusIcons.Recovering, false, true)])); } else if (key === '-recover') { formToken.set(StatusIcons.Recovering, false); const newHP = Math.min(hpm, hp+cp); formToken.set(AttrEnum.HP, newHP); formToken.set(AttrEnum.CP, 0); const operation = new Operation('Finish Recovery', formName, selection._id, [ new Diff(AttrEnum.HP, hp, newHP), new Diff(AttrEnum.CP, cp, 0) ], [ new Diff(StatusIcons.Recovering, true, false) ]); addOperation(operation); setTokenAura(formToken, hp, newHP, hpm); sendChatToFormation(formName, 'Recovery', `Benefactor: ${formDetails}
    Regerated: ${cp}`); } else if (key === '-disorganize') { if (tokens.length < 3) return; let type = StripStatus(StatusIcons.Disorganized); const oldVal = GetStatusValue(formToken, type); let disorganizeScale = parseInt(tokens[2]); formToken.set(StatusIcons.Disorganized, "" + disorganizeScale); UpdateStatusValue(formToken, type, disorganizeScale); addOperation(new Operation('Disorganize', formName, selection._id, [], [new Diff(StatusIcons.Disorganized, oldVal, disorganizeScale)])); } else if (key === '-popDisorganize') { if (tokens.length < 3) return; if (tokens[2] !== 'yes') return; let type = StripStatus(StatusIcons.Disorganized); const popScale = GetStatusValue(formToken, type); dlog('Disorganization Scale of Token: ' + popScale); if (popScale !== true) { let amount = hpm * 0.05 * popScale; dlog(`Popping Disorganized ${formName}, dealing ${amount} direct damage due to scale ${popScale}`); let remHP = Math.max(0, hp-amount); setTokenAura(formToken, hp, remHP, hpm); const newFP = fp + amount; formToken.set(AttrEnum.HP, remHP); formToken.set(AttrEnum.FP, newFP); formToken.set(StatusIcons.Disorganized, false); const operation = new Operation('Pop Disorganized', formName, selection._id, [ new Diff(AttrEnum.HP, hp, remHP), new Diff(AttrEnum.FP, fp, newFP) ], [ new Diff(StatusIcons.Disorganized, popScale, false) ]); addOperation(operation); sendChatToFormation(formName, 'Disorganization Popped', `Victim: ${formDetails}
    Damage: ${amount} fatality`); } else { sendChat(mcname, 'That token was not marked as disorganized! Unable to pop.'); } } else if (key === '-longrest') { if (tokens.length < 3) return; if (tokens[2] !== 'yes') return; let newMax = hpm - fp; let reduxPerc = 1 - newMax/hpm; reduxPerc = +reduxPerc.toFixed(2); formToken.set(AttrEnum.HP, newMax); formToken.set(AttrEnum.HPM, newMax); formToken.set(AttrEnum.FP, 0); formToken.set(AttrEnum.FPM, newMax); formToken.set(AttrEnum.CP, 0); formToken.set(AttrEnum.CPM, newMax); setTokenAura(formToken, hp, newMax, newMax); let reduxScalar = 1-reduxPerc; addOperation(new Operation('Long Rest', formName, selection._id, [ new Diff(AttrEnum.HP, hp, newMax), new Diff(AttrEnum.HPM, hpm, newMax), new Diff(AttrEnum.CP, cp, 0), new Diff(AttrEnum.CPM, hpm, newMax), new Diff(AttrEnum.FP, fp, 0), new Diff(AttrEnum.FPM, hpm, newMax) ], [])); sendChatToFormation(formName, 'Long Rest', `${formName} has had CP converted to HP.
    Requires manual reduction in damage by ${100*reduxPerc}%
    (multiply by ${reduxScalar.toFixed(2)})`); } else if (key === '-upkeep') { let upkeep = 0; let buyPrice = 0; let buyString = '0'; let formType = ''; if (npcType.toLowerCase().includes('undead')) { upkeep = cr * protoCount * 0.1; buyPrice = cr * protoCount * 20; buyString = `Creation: ${buyPrice}gp
    `; } else if (npcType.toLowerCase().includes('construct')) { upkeep = cr * protoCount * 0.2; buyPrice = cr * protoCount * 50; buyString = `Creation: ${buyPrice}gp
    `; } else if (npcType.toLowerCase().includes('plant')) { upkeep = cr * protoCount * 0.2; buyPrice = cr * protoCount; buyString = `Creation: ${buyPrice}gp
    `; } else { let sizeMod = sizeCostArray[getCreatureSize(npcType)]; let recruitMod = 1; buyString = ''; formType = 'Mercenary '; buyPrice = cr * protoCount; if (recruitSource === 'Levied') { recruitMod = 0.5; buyString = `Procurement: ${buyPrice}gp
    `; formType = 'Levied '; } dlog(`Size Mod: ${sizeMod} CR: ${cr} Count: ${protoCount} Recruit Mod: ${recruitMod}`); upkeep = (sizeMod + cr) * protoCount * recruitMod; } upkeep = +upkeep.toFixed(2); purchaseCost += buyPrice; upkeepCost += upkeep; if (msg.selected.length === 1) { sendChat(mcname, `&{template:desc} {{desc=

    ${formType}${formName} Cost


    ${buyString}Upkeep: ${upkeep}gp
    (plus mounts and gear for ${protoCount} ${sourceCreature} if relevant)}}`); return; } if (processed === msg.selected.length) { sendChat(mcname, `&{template:desc} {{desc=

    Army Cost


    Procurement Cost: ${purchaseCost}
    Upkeep: ${upkeepCost}gp
    (plus mounts and gear for troops if relevant)}}`); } } else if (key === '-battleRating') { dlog('Calc Battle Rating'); switch (formType.toLowerCase()) { case 'infantry': infExp += protoCount * xp; infCount++; infTroops += protoCount; break; case 'cavalry': cavExp += protoCount * xp; cavCount++; cavTroops += protoCount; break; case 'archers': case 'archer': arcExp += protoCount * xp; arcCount++; arcTroops += protoCount; break; case 'mage': case 'mages': magExp += protoCount * xp; magCount++; magTroops += protoCount; break; case 'scout': case 'scouts': sctExp += protoCount * xp; sctCount++; sctTroops += protoCount; break; default: dlog('Invalid Formation Type: ' + formType); sendChat(mcname, 'Invalid Formation Type: ' + formDetails); return; } if (processed === msg.selected.length) { _.defer(() => { printBattleRating(infExp, infCount, infTroops, cavExp, cavCount, cavTroops, arcExp, arcCount, arcTroops, magExp, magCount, magTroops, sctExp, sctCount, sctTroops, heroList); }); } } else if (key === '-defend') { const isDefending = formToken.get(StatusIcons.Defend); formToken.set(StatusIcons.Defend, !isDefending); addOperation(new Operation('Defending', formName, selection._id, [], [new Diff(StatusIcons.Defend, isDefending, !isDefending)])); } else if (key === '-guard') { const isGuarding = formToken.get(StatusIcons.Guard); formToken.set(StatusIcons.Guard, !isGuarding); addOperation(new Operation('Guarding', formName, selection._id, [], [new Diff(StatusIcons.Guard, isGuarding, !isGuarding)])); } else if (key === '-route') { formToken.set(StatusIcons.Guard, false); formToken.set(StatusIcons.Defend, false); if (tokens.length < 3) return; let newRouteValue = parseInt(tokens[2]); if (newRouteValue === -1) { formToken.set(StatusIcons.Routed, false); } else if (newRouteValue === 0) { formToken.set(StatusIcons.Routed, true); } else if (newRouteValue === 1) { formToken.set(StatusIcons.Routed, "1"); } else if (newRouteValue === 2) { formToken.set(StatusIcons.Routed, "2"); } else { formToken.set(StatusIcons.Routed, false); formToken.set(StatusIcons.Dead, true); } } else if (key === '-heal') { if (tokens.length < 3) return; let healVal = parseInt(tokens[2]); let missingVitality = hpm - fp - cp - hp; let realHeal = Math.min(healVal, missingVitality); dlog(`Attempting Heal of ${healVal}, which was reduced to ${realHeal}`); const newHP = hp + realHeal; formToken.set(AttrEnum.HP, newHP); const capString = realHeal < healVal ? `, capped at ${realHeal}` : ``; const operation = new Operation('Heal', formName, selection._id, [ new Diff(AttrEnum.HP, hp, newHP) ], []); addOperation(operation); setTokenAura(formToken, hp, newHP, hpm); sendChatToFormation(formName, 'Healing Received', `Recipient: ${formDetails}
    Healing: ${healVal}${capString}`); } else if (key === '-ac') { sendChatToSource(msg, `${formName}'s AC: ${cacheEntry.ac}`); } else if (key === '-speed') { sendChatToSource(msg, `${formName}'s Speed: ${cacheEntry.speed}`); } else if (key === '-morale') { const char = getCharByAny(formationType); let morale = cacheEntry.Morale ? cacheEntry.Morale : GetMorale(char, cr); cacheEntry.Morale = morale; PrintMorale(formName, morale); } else if (key === '-setMorale') { if (msg.selected.length > 1) { sendChatToSource(msg, 'Error: set morale does not support multiselect.'); return; } if (tokens.length < 4) return; let newVal = parseInt(tokens[2]); let factorName = Decaret(msg.content); dlog('Updating Factor ' + factorName + ' to ' + newVal); // Load existing const char = getCharByAny(formationType); let morale = cacheEntry.Morale ? cacheEntry.Morale : GetMorale(char, cr); // Mutate if (factorName === 'CommanderCha') { morale.CommanderCha = newVal; } else if (factorName === 'Expand') { morale.Expand = newVal === 1; } else { for (let i = 0; i < morale.Factors.length; i++) { const factor = morale.Factors[i]; if (factor.Name === factorName) { factor.Value = newVal; break; } } } // Save const moraleStr = JSON.stringify(morale); setAttr(formationType, MoraleAttrName, moraleStr); cacheEntry.Morale = morale; // Print PrintMorale(formName, morale); } else { dlog('Unrecognized Input'); sendChatToSource(msg, 'Unrecognized input.'); } }); }); }, wait); }); log(`-=> ${mcname} v${v} online. <=-`); }); //if (typeof MarkStop != 'undefined') {MarkStop('MassCombat');}