Skip to content

Commit 3d2a330

Browse files
committed
EzExhaust2024 - v1.0
initial commit request
1 parent 99f7854 commit 3d2a330

File tree

3 files changed

+320
-0
lines changed

3 files changed

+320
-0
lines changed

EzExhaust2024/1.0/EzExhaust2024.js

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
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 &lt;character name&gt; &lt;#|clear&gt;</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+
})();

EzExhaust2024/Readme.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Ez Exhaust 2024
2+
Automates and tracks **5E 2024 exhaustion effects** for GMs and players in Roll20.
3+
4+
## 1. What does this do?
5+
Provides automated exhaustion handling across six levels.
6+
- Applies the **sleepy icon** with the current exhaustion level.
7+
- Applies **-2 × exhaustion level** to all ability checks and saving throws.
8+
- **Whispers** the player controllers their current movement speed reduction.
9+
- Automatically sets **0 HP** and applies the **death icon** at exhaustion level 6.
10+
11+
## 2. What are some other features?
12+
- Exhaustion level is **capped at 6** (any value above is clamped).
13+
- Reducing exhaustion from 6 cleanly **removes death effects**.
14+
- Exhaustion can be **fully cleared** with a command.
15+
- **Fuzzy search** is used for player names, allowing quick targeting.
16+
17+
## 3. What are all the commands?
18+
!exh — Displays basic exhaustion info
19+
!exh playername # — Applies exhaustion level (# = 1–6)
20+
!exh playername clear — Clears exhaustion effects
21+
!exh config — Displays configuration options
22+
23+
## 4. Is this configurable for 2014 rules?
24+
Yes. The code is commented — a few variable changes can adapt it for **5E 2014** exhaustion rules.

EzExhaust2024/script.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"name": "Ez Exhaust 2024",
3+
"script": "EzExhaust2024.js",
4+
"version": "1.0",
5+
"previousversions": [],
6+
"description": "Provides GMs and Players with a simple method for applying, and managing Exhastion within the 5E 2024 ruleset.",
7+
"authors": "Kahooty.",
8+
"roll20userid": "14337689",
9+
"useroptions": [],
10+
"dependencies": [],
11+
"modifies": {},
12+
"conflicts": []
13+
}

0 commit comments

Comments
 (0)