Add reaction engine and action skeletons

This commit is contained in:
ChatGPT
2026-06-04 16:07:28 +00:00
parent 927ea528b7
commit a243776070
15 changed files with 642 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
import { executeApplyStatusAction } from "../actions/apply-status-action.js";
import { executeTeleportAction } from "../actions/teleport-action.js";
import { executeCastSpellAction } from "../actions/cast-spell-action.js";
import { executeUseInventoryItemAction } from "../actions/use-inventory-item-action.js";
const ACTION_HANDLERS = Object.freeze({
applyStatus: executeApplyStatusAction,
teleport: executeTeleportAction,
castSpellFromToken: executeCastSpellAction,
useInventoryItem: executeUseInventoryItemAction
});
export async function executeReactionActions(reaction, context) {
let anySuccess = false;
let anyFailureConsumes = false;
for (const action of reaction.actions ?? []) {
if (!action?.enabled) continue;
const handler = ACTION_HANDLERS[action.type];
if (!handler) continue;
const result = await handler(action, {
...context,
reaction,
reactionOrigin: `Module.configurable-reactions.Reaction.${reaction.id}`
});
if (result.success) anySuccess = true;
else if (shouldConsumeOnFailure(reaction, action)) anyFailureConsumes = true;
if (result.stopProcessing) break;
}
return {
success: anySuccess,
shouldConsume: anySuccess || anyFailureConsumes
};
}
function shouldConsumeOnFailure(reaction, action) {
if (typeof action.consumeOnFailure === "boolean") return action.consumeOnFailure;
return reaction.consumption?.consumeOnFailure === true;
}
+19
View File
@@ -0,0 +1,19 @@
import { MODULE_ID, SETTINGS } from "../constants.js";
export async function getAssignedReactionsForContext(context) {
const reactions = game.settings.get(MODULE_ID, SETTINGS.REACTIONS) ?? [];
const assignments = game.settings.get(MODULE_ID, SETTINGS.ASSIGNMENTS) ?? [];
const actorUuid = context.targetActor?.uuid ?? context.actor?.uuid ?? null;
const tokenUuid = context.targetTokenDocument?.uuid ?? context.tokenDocument?.uuid ?? null;
const matching = assignments.filter(assignment => {
if (assignment.mode === "actor" && assignment.actorUuid === actorUuid) return true;
if (assignment.mode === "token" && assignment.tokenUuid === tokenUuid) return true;
return false;
});
return matching
.map(assignment => reactions.find(reaction => reaction.id === assignment.reactionId))
.filter(Boolean);
}
+21
View File
@@ -0,0 +1,21 @@
import { checkDamageCondition } from "../conditions/damage-condition.js";
import { checkHpAfterDamageCondition } from "../conditions/hp-threshold-condition.js";
const CONDITION_HANDLERS = Object.freeze({
damage: checkDamageCondition,
hpAfterDamage: checkHpAfterDamageCondition
});
export async function checkConditions(reaction, context) {
for (const [conditionType, config] of Object.entries(reaction.conditions ?? {})) {
if (!config?.enabled) continue;
const handler = CONDITION_HANDLERS[conditionType];
if (!handler) return false;
const passed = await handler(config, context);
if (!passed) return false;
}
return true;
}
+54
View File
@@ -0,0 +1,54 @@
import { MODULE_ID } from "../constants.js";
export async function checkConsumptionAvailable(reaction, context) {
const consumption = reaction.consumption;
if (!consumption?.enabled || consumption.mode === "none") return true;
const document = getUsageDocument(context);
if (!document) return true;
const usage = document.getFlag(MODULE_ID, "usage") ?? {};
const reactionUsage = usage[reaction.id] ?? {};
if (consumption.mode === "perCombat") {
if (!game.combat?.id) return true;
return reactionUsage.combatId !== game.combat.id || (reactionUsage.uses ?? 0) < consumption.maxUses;
}
if (consumption.mode === "perRound") {
if (!game.combat?.id) return true;
return reactionUsage.combatId !== game.combat.id || reactionUsage.round !== game.combat.round || (reactionUsage.uses ?? 0) < consumption.maxUses;
}
if (consumption.mode === "perTurn") {
if (!game.combat?.id) return true;
return reactionUsage.combatId !== game.combat.id || reactionUsage.round !== game.combat.round || reactionUsage.turn !== game.combat.turn || (reactionUsage.uses ?? 0) < consumption.maxUses;
}
return true;
}
export async function consumeReactionUse(reaction, context) {
const consumption = reaction.consumption;
if (!consumption?.enabled || consumption.mode === "none") return;
const document = getUsageDocument(context);
if (!document) return;
const usage = foundry.utils.deepClone(document.getFlag(MODULE_ID, "usage") ?? {});
const current = usage[reaction.id] ?? {};
usage[reaction.id] = {
combatId: game.combat?.id ?? null,
round: game.combat?.round ?? null,
turn: game.combat?.turn ?? null,
uses: (current.uses ?? 0) + 1,
lastUsedAt: Date.now()
};
await document.setFlag(MODULE_ID, "usage", usage);
}
function getUsageDocument(context) {
return context.targetTokenDocument ?? context.tokenDocument ?? context.targetActor ?? context.actor ?? null;
}
+25
View File
@@ -0,0 +1,25 @@
import { MODULE_ID } from "../constants.js";
import { getAssignedReactionsForContext } from "./assignment-resolver.js";
import { checkConditions } from "./condition-runner.js";
import { checkConsumptionAvailable, consumeReactionUse } from "./consumption-manager.js";
import { executeReactionActions } from "./action-runner.js";
export async function handleTrigger(triggerType, context) {
if (!game.user.isGM) return;
const assignedReactions = await getAssignedReactionsForContext(context);
for (const reaction of assignedReactions) {
try {
if (!reaction?.enabled) continue;
if (reaction.trigger?.type !== triggerType) continue;
if (!await checkConsumptionAvailable(reaction, context)) continue;
if (!await checkConditions(reaction, context)) continue;
const result = await executeReactionActions(reaction, context);
if (result.shouldConsume) await consumeReactionUse(reaction, context);
} catch (error) {
console.error(`${MODULE_ID} | Failed to handle reaction`, reaction, error);
}
}
}