| 
 | 1 | +// EzExhaust2024 (D&D 5e 2024) — v1.0  | 
 | 2 | +// Created by Kahooty, licensed under MIT  | 
 | 3 | +//  | 
 | 4 | +// Commands:  | 
 | 5 | +//   !exh <character name> <#|clear>  | 
 | 6 | +//   !exh config  | 
 | 7 | + | 
 | 8 | +(() => {  | 
 | 9 | +  'use strict';  | 
 | 10 | + | 
 | 11 | +  const SCRIPT = { NAME: 'EzExhaust2024', VERSION: '1.0', STATEKEY: 'EzExhaust2024' };  | 
 | 12 | + | 
 | 13 | +  // rules per 2024  | 
 | 14 | +  const MAX_EXHAUSTION = 6;  | 
 | 15 | +  const PENALTY_PER_LEVEL = 2;  | 
 | 16 | + | 
 | 17 | +  // config (only cosmetic/sheet fields)  | 
 | 18 | +  const DEFAULTS = Object.freeze({  | 
 | 19 | +    tokenMarker: 'sleepy',          // marker for levels 1–5  | 
 | 20 | +    showMarkerNumber: true,  | 
 | 21 | +    adjustSpellDC: true,  | 
 | 22 | +    sheetAttr: {  | 
 | 23 | +      globalAbilityChecks: 'global_skill_mod',  | 
 | 24 | +      globalSaves:         'global_save_mod',  | 
 | 25 | +      globalAttacks:       'global_attack_mod',  | 
 | 26 | +      globalSpellDC:       'global_spell_dc_mod'  | 
 | 27 | +    }  | 
 | 28 | +  });  | 
 | 29 | + | 
 | 30 | +  const ensureState = () => {  | 
 | 31 | +    state[SCRIPT.STATEKEY] = state[SCRIPT.STATEKEY] || {};  | 
 | 32 | +    const S = state[SCRIPT.STATEKEY];  | 
 | 33 | +    if (!S.config) S.config = JSON.parse(JSON.stringify(DEFAULTS));  | 
 | 34 | +    // rules are fixed (not user-configurable)  | 
 | 35 | +    delete S.config.maxExhaustion;  | 
 | 36 | +    delete S.config.perLevelPenalty;  | 
 | 37 | +    return S;  | 
 | 38 | +  };  | 
 | 39 | +  const getConfig = () => ensureState().config;  | 
 | 40 | + | 
 | 41 | +  const say = (msg, who = 'gm') => sendChat(SCRIPT.NAME, (who === 'gm' ? `/w gm ${msg}` : msg));  | 
 | 42 | +  const clamp = (n, min, max) => Math.max(min, Math.min(max, n));  | 
 | 43 | + | 
 | 44 | +  const getOrCreateAttr = (charId, name) => {  | 
 | 45 | +    if (!name) return null;  | 
 | 46 | +    let a = findObjs({ _type: 'attribute', _characterid: charId, name })[0];  | 
 | 47 | +    if (!a) a = createObj('attribute', { _characterid: charId, name, current: '' });  | 
 | 48 | +    return a;  | 
 | 49 | +  };  | 
 | 50 | +  const setAttrToSignedNumber = (attr, n) => {  | 
 | 51 | +    if (!attr) return;  | 
 | 52 | +    const str = n === 0 ? '' : (n > 0 ? `+${n}` : `${n}`);  | 
 | 53 | +    attr.set({ current: str });  | 
 | 54 | +  };  | 
 | 55 | + | 
 | 56 | +  // token markers  | 
 | 57 | +  const getAllTokenMarkers = () => {  | 
 | 58 | +    try { return JSON.parse(Campaign().get('token_markers') || '[]'); } catch { return []; }  | 
 | 59 | +  };  | 
 | 60 | +  const resolveMarkerTag = (key) => {  | 
 | 61 | +    const all = getAllTokenMarkers();  | 
 | 62 | +    let m = all.find(x => x.tag === key) || all.find(x => (x.name||'').toLowerCase() === String(key).toLowerCase());  | 
 | 63 | +    return m ? m.tag : key;  | 
 | 64 | +  };  | 
 | 65 | +  const removeMarkerByTag = (tok, tag) => {  | 
 | 66 | +    const list = (tok.get('statusmarkers') || '').split(',').filter(Boolean);  | 
 | 67 | +    const cleaned = list.filter(m => !(m === tag || m.startsWith(`${tag}@`)));  | 
 | 68 | +    tok.set('statusmarkers', cleaned.join(','));  | 
 | 69 | +  };  | 
 | 70 | +  const addMarkerWithLevel = (tok, tag, level, showNumber) => {  | 
 | 71 | +    const entry = (showNumber && level > 0) ? `${tag}@${level}` : tag;  | 
 | 72 | +    const list = (tok.get('statusmarkers') || '').split(',').filter(Boolean);  | 
 | 73 | +    list.push(entry);  | 
 | 74 | +    tok.set('statusmarkers', list.join(','));  | 
 | 75 | +  };  | 
 | 76 | + | 
 | 77 | +  // fuzzy character find  | 
 | 78 | +  const levenshtein = (a, b) => {  | 
 | 79 | +    a = (a||'').toLowerCase(); b = (b||'').toLowerCase();  | 
 | 80 | +    const dp = Array(b.length + 1).fill(0).map((_, i) => [i]);  | 
 | 81 | +    for (let j = 0; j <= a.length; j++) dp[0][j] = j;  | 
 | 82 | +    for (let i = 1; i <= b.length; i++) {  | 
 | 83 | +      for (let j = 1; j <= a.length; j++) {  | 
 | 84 | +        dp[i][j] = Math.min(  | 
 | 85 | +          dp[i-1][j] + 1,  | 
 | 86 | +          dp[i][j-1] + 1,  | 
 | 87 | +          dp[i-1][j-1] + (a[j-1] === b[i-1] ? 0 : 1)  | 
 | 88 | +        );  | 
 | 89 | +      }  | 
 | 90 | +    }  | 
 | 91 | +    return dp[b.length][a.length];  | 
 | 92 | +  };  | 
 | 93 | +  const findCharacterFuzzy = (name) => {  | 
 | 94 | +    const exact = findObjs({ _type: 'character', name })[0];  | 
 | 95 | +    if (exact) return exact;  | 
 | 96 | +    const chars = findObjs({ _type: 'character' });  | 
 | 97 | +    const lc = name.toLowerCase();  | 
 | 98 | + | 
 | 99 | +    let pool = chars.filter(c => (c.get('name')||'').toLowerCase() === lc || (c.get('name')||'').toLowerCase().startsWith(lc));  | 
 | 100 | +    if (pool.length === 1) return pool[0];  | 
 | 101 | +    pool = chars.filter(c => (c.get('name')||'').toLowerCase().includes(lc));  | 
 | 102 | +    if (pool.length === 1) return pool[0];  | 
 | 103 | +    if (pool.length === 0) pool = chars;  | 
 | 104 | + | 
 | 105 | +    let best = null, bestD = Infinity;  | 
 | 106 | +    pool.forEach(c => {  | 
 | 107 | +      const d = levenshtein(name, c.get('name')||'');  | 
 | 108 | +      if (d < bestD) { bestD = d; best = c; }  | 
 | 109 | +    });  | 
 | 110 | +    return best;  | 
 | 111 | +  };  | 
 | 112 | + | 
 | 113 | +  // controllers + whispers  | 
 | 114 | +  const getControllingPlayers = (charObj) => {  | 
 | 115 | +    const raw = (charObj.get('controlledby') || '').trim();  | 
 | 116 | +    if (raw === 'all' || raw === '') {  | 
 | 117 | +      // 'all' => everybody can control; '' => no explicit controllers (GM implicitly)  | 
 | 118 | +      return findObjs({ _type: 'player' }) || [];  | 
 | 119 | +    }  | 
 | 120 | +    const ids = raw.split(',').map(s => s.trim()).filter(Boolean);  | 
 | 121 | +    const players = ids.map(id => getObj('player', id)).filter(Boolean);  | 
 | 122 | +    return players;  | 
 | 123 | +  };  | 
 | 124 | +  const whisperToPlayer = (player, html) => {  | 
 | 125 | +    const name = player.get('displayname') || player.get('_displayname') || 'Player';  | 
 | 126 | +    sendChat(SCRIPT.NAME, `/w "${name}" ${html}`);  | 
 | 127 | +  };  | 
 | 128 | +  const whisperToControllers = (charObj, html) => {  | 
 | 129 | +    const players = getControllingPlayers(charObj);  | 
 | 130 | +    const sent = new Set();  | 
 | 131 | +    players.forEach(p => {  | 
 | 132 | +      const pid = p.id;  | 
 | 133 | +      if (!sent.has(pid)) { sent.add(pid); whisperToPlayer(p, html); }  | 
 | 134 | +    });  | 
 | 135 | +  };  | 
 | 136 | +  const speedPenaltyByLevel = (level) => {  | 
 | 137 | +    // Levels 1–5: -5 ft per level; otherwise 0 for 0 or 6+  | 
 | 138 | +    if (level >= 1 && level <= 5) return -5 * level;  | 
 | 139 | +    return 0;  | 
 | 140 | +  };  | 
 | 141 | +  const whisperSpeedPenalty = (charObj, level) => {  | 
 | 142 | +    const pen = speedPenaltyByLevel(level);  | 
 | 143 | +    if (pen === 0) return;  | 
 | 144 | +    const cname = _.escape(charObj.get('name') || 'Unknown');  | 
 | 145 | +    const html = `<div style="font-family:monospace">  | 
 | 146 | +<b>${cname}</b> — Exhaustion <b>${level}</b><br>  | 
 | 147 | +Movement Speed Penalty: <b>${pen} ft</b>  | 
 | 148 | +</div>`;  | 
 | 149 | +    whisperToControllers(charObj, html);  | 
 | 150 | +  };  | 
 | 151 | + | 
 | 152 | +  // level 6: death helpers  | 
 | 153 | +  const enactDeath = (charId) => {  | 
 | 154 | +    const hpAttr = findObjs({ _type: 'attribute', _characterid: charId, name: 'hp' })[0];  | 
 | 155 | +    if (hpAttr) hpAttr.set({ current: 0 });  | 
 | 156 | +    const toks = findObjs({ _type: 'graphic', _subtype: 'token', represents: charId });  | 
 | 157 | +    toks.forEach(tok => tok.set('bar1_value', 0));  | 
 | 158 | +  };  | 
 | 159 | + | 
 | 160 | +  const clearAllExhaustionMarkers = (charId, cfg) => {  | 
 | 161 | +    const sleepy   = resolveMarkerTag(cfg.tokenMarker);  | 
 | 162 | +    const pummeled = resolveMarkerTag('pummeled');  | 
 | 163 | +    const dead     = resolveMarkerTag('dead');  | 
 | 164 | +    findObjs({ _type: 'graphic', _subtype: 'token', represents: charId }).forEach(tok => {  | 
 | 165 | +      removeMarkerByTag(tok, sleepy);  | 
 | 166 | +      removeMarkerByTag(tok, pummeled);  | 
 | 167 | +      removeMarkerByTag(tok, dead); // ensure death icon drops when lowering from 6  | 
 | 168 | +    });  | 
 | 169 | +  };  | 
 | 170 | + | 
 | 171 | +  const applyExhaustion = (charObj, rawLevel, cfg) => {  | 
 | 172 | +    const level = clamp(parseInt(rawLevel, 10) || 0, 0, MAX_EXHAUSTION);  | 
 | 173 | +    const S = cfg.sheetAttr;  | 
 | 174 | + | 
 | 175 | +    // clean before setting fresh state  | 
 | 176 | +    clearAllExhaustionMarkers(charObj.id, cfg);  | 
 | 177 | + | 
 | 178 | +    if (level === MAX_EXHAUSTION) {  | 
 | 179 | +      // clear mods (character is dead at 6; no ongoing penalties needed)  | 
 | 180 | +      setAttrToSignedNumber(getOrCreateAttr(charObj.id, S.globalAbilityChecks), 0);  | 
 | 181 | +      setAttrToSignedNumber(getOrCreateAttr(charObj.id, S.globalSaves),         0);  | 
 | 182 | +      setAttrToSignedNumber(getOrCreateAttr(charObj.id, S.globalAttacks),       0);  | 
 | 183 | +      if (cfg.adjustSpellDC && S.globalSpellDC) {  | 
 | 184 | +        setAttrToSignedNumber(getOrCreateAttr(charObj.id, S.globalSpellDC), 0);  | 
 | 185 | +      }  | 
 | 186 | + | 
 | 187 | +      enactDeath(charObj.id);  | 
 | 188 | + | 
 | 189 | +      // set pummeled@6 and 'dead' icon  | 
 | 190 | +      const pummeled = resolveMarkerTag('pummeled');  | 
 | 191 | +      const dead     = resolveMarkerTag('dead');  | 
 | 192 | +      findObjs({ _type: 'graphic', _subtype: 'token', represents: charObj.id }).forEach(tok => {  | 
 | 193 | +        addMarkerWithLevel(tok, pummeled, 6, true);  | 
 | 194 | +        addMarkerWithLevel(tok, dead, 0, false);  | 
 | 195 | +      });  | 
 | 196 | + | 
 | 197 | +      // no speed whisper at level 6  | 
 | 198 | +      return level;  | 
 | 199 | +    }  | 
 | 200 | + | 
 | 201 | +    // levels 0–5: penalty = –2 × level (exactly)  | 
 | 202 | +    const penalty = -(PENALTY_PER_LEVEL * level);  | 
 | 203 | +    setAttrToSignedNumber(getOrCreateAttr(charObj.id, S.globalAbilityChecks), penalty);  | 
 | 204 | +    setAttrToSignedNumber(getOrCreateAttr(charObj.id, S.globalSaves),         penalty);  | 
 | 205 | +    setAttrToSignedNumber(getOrCreateAttr(charObj.id, S.globalAttacks),       penalty);  | 
 | 206 | +    if (cfg.adjustSpellDC && S.globalSpellDC) {  | 
 | 207 | +      setAttrToSignedNumber(getOrCreateAttr(charObj.id, S.globalSpellDC), penalty);  | 
 | 208 | +    }  | 
 | 209 | + | 
 | 210 | +    // sleepy@level  | 
 | 211 | +    const sleepy = resolveMarkerTag(cfg.tokenMarker);  | 
 | 212 | +    findObjs({ _type: 'graphic', _subtype: 'token', represents: charObj.id }).forEach(tok => {  | 
 | 213 | +      if (level > 0) addMarkerWithLevel(tok, sleepy, level, cfg.showMarkerNumber);  | 
 | 214 | +    });  | 
 | 215 | + | 
 | 216 | +    // whisper movement penalty to controllers for levels 1–5  | 
 | 217 | +    whisperSpeedPenalty(charObj, level);  | 
 | 218 | + | 
 | 219 | +    return level;  | 
 | 220 | +  };  | 
 | 221 | + | 
 | 222 | +  const renderConfig = (cfg) =>  | 
 | 223 | +    `<div style="font-family:monospace">  | 
 | 224 | +<b>tokenMarker</b>: ${_.escape(cfg.tokenMarker)}<br>  | 
 | 225 | +<b>showMarkerNumber</b>: ${cfg.showMarkerNumber}<br>  | 
 | 226 | +<b>adjustSpellDC</b>: ${cfg.adjustSpellDC}<br>  | 
 | 227 | +<b>sheetAttr.globalAbilityChecks</b>: ${_.escape(cfg.sheetAttr.globalAbilityChecks)}<br>  | 
 | 228 | +<b>sheetAttr.globalSaves</b>: ${_.escape(cfg.sheetAttr.globalSaves)}<br>  | 
 | 229 | +<b>sheetAttr.globalAttacks</b>: ${_.escape(cfg.sheetAttr.globalAttacks)}<br>  | 
 | 230 | +<b>sheetAttr.globalSpellDC</b>: ${_.escape(cfg.sheetAttr.globalSpellDC)}<br>  | 
 | 231 | +<hr>  | 
 | 232 | +<b>Rules (fixed)</b>: max exhaustion = 6; penalty = -2×level; level 6 = death + pummeled + dead marker.  | 
 | 233 | +</div>`;  | 
 | 234 | + | 
 | 235 | +  const handleChat = (msg) => {  | 
 | 236 | +    if (msg.type !== 'api') return;  | 
 | 237 | +    const content = msg.content.trim();  | 
 | 238 | +    if (!content.startsWith('!exh')) return;  | 
 | 239 | + | 
 | 240 | +    const parts = content.split(/\s+/);  | 
 | 241 | +    parts.shift();  | 
 | 242 | + | 
 | 243 | +    // config view only (read-only for fixed rules)  | 
 | 244 | +    if (parts[0] && parts[0].toLowerCase() === 'config') {  | 
 | 245 | +      const cfg = getConfig();  | 
 | 246 | +      say(`<div><b>${SCRIPT.NAME} v${SCRIPT.VERSION} config</b></div>${renderConfig(cfg)}`);  | 
 | 247 | +      return;  | 
 | 248 | +    }  | 
 | 249 | + | 
 | 250 | +    // usage  | 
 | 251 | +    if (parts.length < 2) {  | 
 | 252 | +      say('Usage: <code>!exh <character name> <#|clear></code><br>Example: <code>!exh Tallus 2</code> or <code>!exh Tallus clear</code>', msg.who);  | 
 | 253 | +      return;  | 
 | 254 | +    }  | 
 | 255 | + | 
 | 256 | +    const name = parts.shift();  | 
 | 257 | +    const op   = parts.shift();  | 
 | 258 | +    const cfg  = getConfig();  | 
 | 259 | + | 
 | 260 | +    const charObj = findCharacterFuzzy(name);  | 
 | 261 | +    if (!charObj) { say(`No character found resembling <b>${_.escape(name)}</b>.`); return; }  | 
 | 262 | + | 
 | 263 | +    if (op.toLowerCase && op.toLowerCase() === 'clear') {  | 
 | 264 | +      applyExhaustion(charObj, 0, cfg);  | 
 | 265 | +      say(`Exhaustion cleared for <b>${_.escape(charObj.get('name'))}</b>.`);  | 
 | 266 | +      return;  | 
 | 267 | +    }  | 
 | 268 | + | 
 | 269 | +    if (!/^\d{1,2}$/.test(op)) {  | 
 | 270 | +      say('Level must be an integer (0–6) or <code>clear</code>.');  | 
 | 271 | +      return;  | 
 | 272 | +    }  | 
 | 273 | + | 
 | 274 | +    const clamped = applyExhaustion(charObj, parseInt(op, 10), cfg);  | 
 | 275 | +    say(`Exhaustion set to <b>${clamped}</b> for <b>${_.escape(charObj.get('name'))}</b>.${clamped === 6 ? ' (Death applied.)' : ''}`);  | 
 | 276 | +  };  | 
 | 277 | + | 
 | 278 | +  on('ready', () => {  | 
 | 279 | +    ensureState();  | 
 | 280 | +    on('chat:message', handleChat);  | 
 | 281 | +    log(`${SCRIPT.NAME} v${SCRIPT.VERSION} ready — Command: !exh`);  | 
 | 282 | +  });  | 
 | 283 | +})();  | 
0 commit comments