- Percentile roll cards: outcome banners (crit/success/failure) with icons and margin-of-success text instead of color-only inline styles. - Damage and lethality cards: Apply Damage / Apply Lethality buttons that hit targeted or selected tokens, auto-subtracting equipped armor protection reduced by the weapon's armor-piercing value. - Sanity damage cards: apply buttons for success/failure loss with GM whispers for temporary insanity (5+), breaking point crossings, and 0 SAN. - Ammo: weapon ammo is now numeric with a magazine capacity; attack rolls expend a round (ammoTracking setting, default on), gear tab shows an ammo column with a reload control, compendium weapons carry capacities. - CSS: drop live Google Fonts imports (offline reliability); DG2 styles with dark-theme support. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
203 lines
5.9 KiB
JavaScript
203 lines
5.9 KiB
JavaScript
import DGUtils from "./utility-functions.js";
|
|
|
|
/**
|
|
* DG2 chat-card apply actions.
|
|
*
|
|
* Resolves the tokens that damage/sanity loss should be applied to:
|
|
* the user's targets first, then their controlled tokens.
|
|
*
|
|
* @returns {Token[]}
|
|
*/
|
|
function resolveTargetTokens() {
|
|
const targeted = Array.from(game.user.targets);
|
|
if (targeted.length) return targeted;
|
|
return canvas?.tokens?.controlled ?? [];
|
|
}
|
|
|
|
/**
|
|
* Whisper an application summary to the GM (and the applying user).
|
|
*
|
|
* @param {string} content - HTML content of the whisper.
|
|
*/
|
|
async function whisperGM(content) {
|
|
const gmIds = game.users.filter((u) => u.isGM).map((u) => u.id);
|
|
const whisper = [...new Set([...gmIds, game.user.id])];
|
|
await ChatMessage.create({
|
|
content,
|
|
whisper,
|
|
speaker: { alias: "Delta Green 2" },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Guard helper - can the current user update this actor?
|
|
*
|
|
* @param {Actor} actor
|
|
* @returns {boolean}
|
|
*/
|
|
function canModify(actor) {
|
|
if (actor.canUserModify(game.user, "update")) return true;
|
|
ui.notifications.warn(
|
|
`${game.user.name} does not have permission to modify ${actor.name}.`,
|
|
);
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Apply HP damage to targeted/selected tokens, subtracting armor
|
|
* protection (reduced by the weapon's armor-piercing value).
|
|
*
|
|
* @param {Object} options
|
|
* @param {number} options.damage - the rolled damage total.
|
|
* @param {number} [options.ap] - armor piercing value of the source weapon.
|
|
*/
|
|
export async function applyDamageToTargets({ damage, ap = 0 }) {
|
|
const tokens = resolveTargetTokens();
|
|
if (!tokens.length) {
|
|
ui.notifications.warn(
|
|
DGUtils.localizeWithFallback(
|
|
"DG2.Apply.NoTarget",
|
|
"Target or select a token first.",
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
for (const token of tokens) {
|
|
const { actor } = token;
|
|
if (!actor?.system?.health || !canModify(actor)) continue;
|
|
|
|
const protection = Math.max(
|
|
0,
|
|
(actor.system.health.protection ?? 0) - Math.max(0, ap),
|
|
);
|
|
const applied = Math.max(0, damage - protection);
|
|
const oldHP = actor.system.health.value;
|
|
const newHP = Math.max(0, oldHP - applied);
|
|
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await actor.update({ "system.health.value": newHP });
|
|
|
|
const armorNote = protection
|
|
? ` (${damage} − ${protection} armor${ap ? `, AP ${ap}` : ""})`
|
|
: "";
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await whisperGM(
|
|
`<b>${actor.name}</b> takes <b>${applied}</b> damage${armorNote}. HP ${oldHP} → ${newHP}.${
|
|
newHP === 0 ? " <b>DOWN.</b>" : ""
|
|
}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Apply a successful lethality roll: target drops to 0 HP.
|
|
*/
|
|
export async function applyLethalityToTargets() {
|
|
const tokens = resolveTargetTokens();
|
|
if (!tokens.length) {
|
|
ui.notifications.warn(
|
|
DGUtils.localizeWithFallback(
|
|
"DG2.Apply.NoTarget",
|
|
"Target or select a token first.",
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
for (const token of tokens) {
|
|
const { actor } = token;
|
|
if (!actor?.system?.health || !canModify(actor)) continue;
|
|
const oldHP = actor.system.health.value;
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await actor.update({ "system.health.value": 0 });
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await whisperGM(
|
|
`<b>${actor.name}</b> suffers a <b>LETHAL</b> hit. HP ${oldHP} → 0.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Apply sanity loss. Applies to targeted/selected tokens if any,
|
|
* otherwise falls back to the actor who made the roll (the usual case).
|
|
* Alerts the GM if the loss crosses the actor's breaking point, or if
|
|
* the loss in one hit is 5+ (temporary insanity per the Agent's Handbook).
|
|
*
|
|
* @param {Object} options
|
|
* @param {number} options.san - the sanity loss to apply.
|
|
* @param {Actor} [options.fallbackActor] - the rolling actor.
|
|
*/
|
|
export async function applySanToTargets({ san, fallbackActor }) {
|
|
const tokens = resolveTargetTokens();
|
|
let actors = tokens.map((t) => t.actor).filter((a) => a?.system?.sanity);
|
|
if (!actors.length && fallbackActor?.system?.sanity) {
|
|
actors = [fallbackActor];
|
|
}
|
|
if (!actors.length) {
|
|
ui.notifications.warn(
|
|
DGUtils.localizeWithFallback(
|
|
"DG2.Apply.NoSanTarget",
|
|
"No valid target with a Sanity score.",
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
for (const actor of actors) {
|
|
if (!canModify(actor)) continue;
|
|
const oldSan = actor.system.sanity.value;
|
|
const newSan = Math.max(0, oldSan - san);
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await actor.update({ "system.sanity.value": newSan });
|
|
|
|
let note = `<b>${actor.name}</b> loses <b>${san}</b> SAN. ${oldSan} → ${newSan}.`;
|
|
if (san >= 5) {
|
|
note += ` <b>TEMPORARY INSANITY</b> (5+ SAN from one source).`;
|
|
}
|
|
const breakingPoint = actor.system.sanity.currentBreakingPoint;
|
|
if (
|
|
typeof breakingPoint === "number" &&
|
|
oldSan > breakingPoint &&
|
|
newSan <= breakingPoint
|
|
) {
|
|
note += ` <b>BREAKING POINT REACHED</b> — the agent gains a disorder and should reset their breaking point.`;
|
|
}
|
|
if (newSan === 0) {
|
|
note += ` <b>PERMANENT INSANITY.</b>`;
|
|
}
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await whisperGM(note);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Router for DG2 chat-card buttons. Returns true if the action was handled.
|
|
*
|
|
* @param {HTMLElement} btn - the clicked button.
|
|
* @param {ChatMessage} message - the chat message the button lives in.
|
|
* @returns {Promise<boolean>}
|
|
*/
|
|
export async function handleDG2ChatAction(btn, message) {
|
|
const { action } = btn.dataset;
|
|
switch (action) {
|
|
case "dg2-apply-damage":
|
|
await applyDamageToTargets({
|
|
damage: parseInt(btn.dataset.damage) || 0,
|
|
ap: parseInt(btn.dataset.ap) || 0,
|
|
});
|
|
return true;
|
|
case "dg2-apply-lethality":
|
|
await applyLethalityToTargets();
|
|
return true;
|
|
case "dg2-apply-san":
|
|
await applySanToTargets({
|
|
san: parseInt(btn.dataset.san) || 0,
|
|
fallbackActor: message?.speakerActor,
|
|
});
|
|
return true;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|