commit 927ea528b7dea102bf9699540caee01875c99b5c Author: ChatGPT Date: Thu Jun 4 16:07:28 2026 +0000 Initial Foundry module scaffold diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c53d4cf --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.DS_Store +Thumbs.db +node_modules/ +dist/ +*.log diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b0f8d71 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Florian Zumpe + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..383c7e0 --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# Configurable Reactions + +**Configurable Reactions** is a Foundry VTT 14 module for configurable automatic reactions assigned to actors or concrete token instances. + +The module is designed as a generic reaction engine: + +```text +Trigger -> Conditions -> Consumption -> Actions +``` + +## Current scope + +This repository is an initial module skeleton. It includes: + +- World-level reaction storage +- World-level assignment storage +- A GM configuration app +- Assignment of a configured reaction to selected tokens +- Linked-token handling: linked tokens assign the reaction to the Actor +- Unlinked-token handling: unlinked tokens assign the reaction to the TokenDocument +- Managed flags and optional managed ActiveEffects +- A reaction engine skeleton +- A damage-received trigger skeleton for dnd5e +- Action handlers for: + - applying statuses + - teleporting a token + - opening/using an inventory item placeholder + - casting a spell placeholder + +## Important design rules + +For teleport actions: + +- `askOwner === true`: the owner should choose the target, then the GM validates it. +- `askOwner === false`: the GM chooses a random valid target. +- The target must always be reachable. +- The target must never be blocked by movement walls. +- The target must never be occupied by another token. +- The target must remain inside scene bounds. +- If no valid random target is found within `maxAttempts`, the teleport fails. +- `consumeOnFailure` is configurable. + +## Installation during development + +Clone or copy this repository into Foundry's `Data/modules` directory: + +```bash +cd /path/to/FoundryVTT/Data/modules +git clone configurable-reactions +``` + +Restart Foundry and enable **Configurable Reactions** in the world. + +## Development workflow + +This repository is initialized as a Git repository. You can set a remote URL afterwards: + +```bash +git remote add origin +git push -u origin main +``` + +## Status + +Initial development scaffold. Not yet a production-ready automation module. diff --git a/lang/de.json b/lang/de.json new file mode 100644 index 0000000..8180bbb --- /dev/null +++ b/lang/de.json @@ -0,0 +1,41 @@ +{ + "CONFIGURABLE_REACTIONS.App.Title": "Configurable Reactions", + "CONFIGURABLE_REACTIONS.Controls.OpenConfig": "Configurable Reactions öffnen", + "CONFIGURABLE_REACTIONS.Settings.Reactions.Name": "Reaktionen", + "CONFIGURABLE_REACTIONS.Settings.Reactions.Hint": "Konfigurierte automatische Reaktionen.", + "CONFIGURABLE_REACTIONS.Settings.Assignments.Name": "Zuweisungen", + "CONFIGURABLE_REACTIONS.Settings.Assignments.Hint": "Zuweisungen von Reaktionen an Actors oder Tokens.", + "CONFIGURABLE_REACTIONS.Settings.ShowFailedTeleportMessages.Name": "Fehlgeschlagene Teleports im Chat anzeigen", + "CONFIGURABLE_REACTIONS.Settings.ShowFailedTeleportMessages.Hint": "Erzeugt eine Chat-Nachricht, wenn kein gültiges Teleportziel gefunden wurde.", + "CONFIGURABLE_REACTIONS.Settings.CreateManagedEffects.Name": "Verwaltete ActiveEffects bei Zuweisung erzeugen", + "CONFIGURABLE_REACTIONS.Settings.CreateManagedEffects.Hint": "Erzeugt auf verknüpften Actors einen sichtbaren ActiveEffect, wenn eine Reaktion zugewiesen wird.", + "CONFIGURABLE_REACTIONS.Reactions.Create": "Reaktion erstellen", + "CONFIGURABLE_REACTIONS.Reactions.NewReaction": "Neue Reaktion", + "CONFIGURABLE_REACTIONS.Reactions.Selected": "Ausgewählte Reaktion", + "CONFIGURABLE_REACTIONS.Reactions.JsonEditor": "Reaktion als JSON bearbeiten", + "CONFIGURABLE_REACTIONS.Reactions.Delete": "Reaktion löschen", + "CONFIGURABLE_REACTIONS.Reactions.DeleteConfirm": "Diese Reaktion und ihre Zuweisungen wirklich löschen?", + "CONFIGURABLE_REACTIONS.Reactions.None": "Noch keine Reaktion vorhanden.", + "CONFIGURABLE_REACTIONS.Assignments.Title": "Zuweisungen", + "CONFIGURABLE_REACTIONS.Assignments.AssignSelectedTokens": "Ausgewählten Tokens zuweisen", + "CONFIGURABLE_REACTIONS.Assignments.AssignedResult": "Reaktion zugewiesen: {assigned}. Übersprungen: {skipped}.", + "CONFIGURABLE_REACTIONS.Actions.Title": "Aktionen", + "CONFIGURABLE_REACTIONS.Actions.AddStatus": "Status-Aktion hinzufügen", + "CONFIGURABLE_REACTIONS.Actions.AddTeleport": "Teleport-Aktion hinzufügen", + "CONFIGURABLE_REACTIONS.Trigger.Label": "Auslöser", + "CONFIGURABLE_REACTIONS.Common.Save": "Speichern", + "CONFIGURABLE_REACTIONS.Common.Remove": "Entfernen", + "CONFIGURABLE_REACTIONS.Common.Choose": "Wählen", + "CONFIGURABLE_REACTIONS.Common.Cancel": "Abbrechen", + "CONFIGURABLE_REACTIONS.Errors.GmOnly": "Nur der GM kann diese Aktion ausführen.", + "CONFIGURABLE_REACTIONS.Errors.NoReactionSelected": "Keine Reaktion ausgewählt.", + "CONFIGURABLE_REACTIONS.Errors.NoTokensSelected": "Bitte wähle zuerst mindestens einen Token aus.", + "CONFIGURABLE_REACTIONS.Errors.ReactionNotFound": "Die ausgewählte Reaktion wurde nicht gefunden.", + "CONFIGURABLE_REACTIONS.Errors.InvalidJson": "Das JSON ist ungültig.", + "CONFIGURABLE_REACTIONS.Effects.ManagedReaction": "Automatische Reaktion: {name}", + "CONFIGURABLE_REACTIONS.Teleport.Title": "Reaktiver Teleport", + "CONFIGURABLE_REACTIONS.Teleport.ChooseTarget": "Wähle ein Ziel innerhalb von {radius} ft.", + "CONFIGURABLE_REACTIONS.Teleport.SwitchScene": "Bitte wechsle zur betroffenen Szene.", + "CONFIGURABLE_REACTIONS.Teleport.InvalidTarget": "Das gewählte Teleportziel ist ungültig.", + "CONFIGURABLE_REACTIONS.Teleport.NoValidTarget": "Kein gültiges Teleportziel für {name} innerhalb von {radius} ft gefunden." +} diff --git a/lang/en.json b/lang/en.json new file mode 100644 index 0000000..746bce9 --- /dev/null +++ b/lang/en.json @@ -0,0 +1,41 @@ +{ + "CONFIGURABLE_REACTIONS.App.Title": "Configurable Reactions", + "CONFIGURABLE_REACTIONS.Controls.OpenConfig": "Open Configurable Reactions", + "CONFIGURABLE_REACTIONS.Settings.Reactions.Name": "Reactions", + "CONFIGURABLE_REACTIONS.Settings.Reactions.Hint": "Configured automatic reactions.", + "CONFIGURABLE_REACTIONS.Settings.Assignments.Name": "Assignments", + "CONFIGURABLE_REACTIONS.Settings.Assignments.Hint": "Assignments of reactions to actors or tokens.", + "CONFIGURABLE_REACTIONS.Settings.ShowFailedTeleportMessages.Name": "Show failed teleports in chat", + "CONFIGURABLE_REACTIONS.Settings.ShowFailedTeleportMessages.Hint": "Creates a chat message when no valid teleport target was found.", + "CONFIGURABLE_REACTIONS.Settings.CreateManagedEffects.Name": "Create managed ActiveEffects on assignment", + "CONFIGURABLE_REACTIONS.Settings.CreateManagedEffects.Hint": "Creates a visible ActiveEffect on linked actors when a reaction is assigned.", + "CONFIGURABLE_REACTIONS.Reactions.Create": "Create reaction", + "CONFIGURABLE_REACTIONS.Reactions.NewReaction": "New reaction", + "CONFIGURABLE_REACTIONS.Reactions.Selected": "Selected reaction", + "CONFIGURABLE_REACTIONS.Reactions.JsonEditor": "Edit reaction as JSON", + "CONFIGURABLE_REACTIONS.Reactions.Delete": "Delete reaction", + "CONFIGURABLE_REACTIONS.Reactions.DeleteConfirm": "Really delete this reaction and its assignments?", + "CONFIGURABLE_REACTIONS.Reactions.None": "No reaction exists yet.", + "CONFIGURABLE_REACTIONS.Assignments.Title": "Assignments", + "CONFIGURABLE_REACTIONS.Assignments.AssignSelectedTokens": "Assign selected tokens", + "CONFIGURABLE_REACTIONS.Assignments.AssignedResult": "Reaction assigned: {assigned}. Skipped: {skipped}.", + "CONFIGURABLE_REACTIONS.Actions.Title": "Actions", + "CONFIGURABLE_REACTIONS.Actions.AddStatus": "Add status action", + "CONFIGURABLE_REACTIONS.Actions.AddTeleport": "Add teleport action", + "CONFIGURABLE_REACTIONS.Trigger.Label": "Trigger", + "CONFIGURABLE_REACTIONS.Common.Save": "Save", + "CONFIGURABLE_REACTIONS.Common.Remove": "Remove", + "CONFIGURABLE_REACTIONS.Common.Choose": "Choose", + "CONFIGURABLE_REACTIONS.Common.Cancel": "Cancel", + "CONFIGURABLE_REACTIONS.Errors.GmOnly": "Only the GM can perform this action.", + "CONFIGURABLE_REACTIONS.Errors.NoReactionSelected": "No reaction selected.", + "CONFIGURABLE_REACTIONS.Errors.NoTokensSelected": "Please select at least one token first.", + "CONFIGURABLE_REACTIONS.Errors.ReactionNotFound": "The selected reaction was not found.", + "CONFIGURABLE_REACTIONS.Errors.InvalidJson": "The JSON is invalid.", + "CONFIGURABLE_REACTIONS.Effects.ManagedReaction": "Automatic Reaction: {name}", + "CONFIGURABLE_REACTIONS.Teleport.Title": "Reactive Teleport", + "CONFIGURABLE_REACTIONS.Teleport.ChooseTarget": "Choose a target within {radius} ft.", + "CONFIGURABLE_REACTIONS.Teleport.SwitchScene": "Please switch to the affected scene.", + "CONFIGURABLE_REACTIONS.Teleport.InvalidTarget": "The selected teleport target is invalid.", + "CONFIGURABLE_REACTIONS.Teleport.NoValidTarget": "No valid teleport target for {name} within {radius} ft found." +} diff --git a/module.json b/module.json new file mode 100644 index 0000000..63b7045 --- /dev/null +++ b/module.json @@ -0,0 +1,38 @@ +{ + "id": "configurable-reactions", + "title": "Configurable Reactions", + "description": "A Foundry VTT 14 module for configurable token and actor reactions triggered by damage, targeting, spell usage, feature usage and HP thresholds.", + "version": "0.1.0", + "compatibility": { + "minimum": "14", + "verified": "14" + }, + "authors": [ + { + "name": "Florian Zumpe" + } + ], + "esmodules": [ + "scripts/main.js" + ], + "styles": [ + "styles/configurable-reactions.css" + ], + "languages": [ + { + "lang": "de", + "name": "Deutsch", + "path": "lang/de.json" + }, + { + "lang": "en", + "name": "English", + "path": "lang/en.json" + } + ], + "socket": true, + "url": "", + "manifest": "", + "download": "", + "license": "MIT" +} diff --git a/scripts/constants.js b/scripts/constants.js new file mode 100644 index 0000000..9ae6a52 --- /dev/null +++ b/scripts/constants.js @@ -0,0 +1,54 @@ +export const MODULE_ID = "configurable-reactions"; +export const MODULE_TITLE = "Configurable Reactions"; + +export const SETTINGS = Object.freeze({ + REACTIONS: "reactions", + ASSIGNMENTS: "assignments", + SHOW_FAILED_TELEPORT_MESSAGES: "showFailedTeleportMessages", + CREATE_MANAGED_EFFECTS_ON_ASSIGNMENT: "createManagedEffectsOnAssignment" +}); + +export const TRIGGER_TYPES = Object.freeze({ + DAMAGE_RECEIVED: "damageReceived", + TARGET_SELECTED: "targetSelected", + SPELL_CAST_START: "spellCastStart", + SPELL_CAST_COMPLETE: "spellCastComplete", + FEATURE_USED: "featureUsed" +}); + +export const ACTION_TYPES = Object.freeze({ + APPLY_STATUS: "applyStatus", + TELEPORT: "teleport", + CAST_SPELL_FROM_TOKEN: "castSpellFromToken", + USE_INVENTORY_ITEM: "useInventoryItem" +}); + +export const DEFAULT_REACTION = Object.freeze({ + name: "Neue Reaktion", + enabled: true, + trigger: { + type: TRIGGER_TYPES.DAMAGE_RECEIVED + }, + conditions: { + damage: { + enabled: true, + amountMode: "damageOnly", + types: [], + typeMode: "any", + minAmount: 1 + }, + hpAfterDamage: { + enabled: false, + operator: "lte", + mode: "percent", + value: 50 + } + }, + consumption: { + enabled: false, + mode: "none", + maxUses: 1, + consumeOnFailure: false + }, + actions: [] +}); diff --git a/scripts/main.js b/scripts/main.js new file mode 100644 index 0000000..803665d --- /dev/null +++ b/scripts/main.js @@ -0,0 +1,36 @@ +import { MODULE_ID } from "./constants.js"; +import { registerSettings } from "./settings.js"; +import { registerSocket } from "./sockets.js"; +import { ConfigurableReactionsConfigApp } from "./apps/reaction-config-app.js"; +import { registerDnd5eDamageTrigger } from "./triggers/damage-received.js"; +import { registerTargetSelectedTrigger } from "./triggers/target-selected.js"; + +Hooks.once("init", () => { + registerSettings(); + console.log(`${MODULE_ID} | Settings registered`); +}); + +Hooks.once("ready", () => { + registerSocket(); + registerDnd5eDamageTrigger(); + registerTargetSelectedTrigger(); + console.log(`${MODULE_ID} | Ready`); +}); + +Hooks.on("getSceneControlButtons", controls => { + if (!game.user.isGM) return; + + const tokenControls = controls.tokens ?? controls.find?.(c => c.name === "token"); + if (!tokenControls) return; + + const tool = { + name: "configurable-reactions", + title: game.i18n.localize("CONFIGURABLE_REACTIONS.Controls.OpenConfig"), + icon: "fa-solid fa-bolt", + button: true, + onChange: () => new ConfigurableReactionsConfigApp().render(true) + }; + + if (Array.isArray(tokenControls.tools)) tokenControls.tools.push(tool); + else if (tokenControls.tools instanceof Object) tokenControls.tools[tool.name] = tool; +}); diff --git a/scripts/settings.js b/scripts/settings.js new file mode 100644 index 0000000..dfbd65a --- /dev/null +++ b/scripts/settings.js @@ -0,0 +1,39 @@ +import { MODULE_ID, SETTINGS } from "./constants.js"; + +export function registerSettings() { + game.settings.register(MODULE_ID, SETTINGS.REACTIONS, { + name: game.i18n.localize("CONFIGURABLE_REACTIONS.Settings.Reactions.Name"), + hint: game.i18n.localize("CONFIGURABLE_REACTIONS.Settings.Reactions.Hint"), + scope: "world", + config: false, + type: Array, + default: [] + }); + + game.settings.register(MODULE_ID, SETTINGS.ASSIGNMENTS, { + name: game.i18n.localize("CONFIGURABLE_REACTIONS.Settings.Assignments.Name"), + hint: game.i18n.localize("CONFIGURABLE_REACTIONS.Settings.Assignments.Hint"), + scope: "world", + config: false, + type: Array, + default: [] + }); + + game.settings.register(MODULE_ID, SETTINGS.SHOW_FAILED_TELEPORT_MESSAGES, { + name: game.i18n.localize("CONFIGURABLE_REACTIONS.Settings.ShowFailedTeleportMessages.Name"), + hint: game.i18n.localize("CONFIGURABLE_REACTIONS.Settings.ShowFailedTeleportMessages.Hint"), + scope: "world", + config: true, + type: Boolean, + default: true + }); + + game.settings.register(MODULE_ID, SETTINGS.CREATE_MANAGED_EFFECTS_ON_ASSIGNMENT, { + name: game.i18n.localize("CONFIGURABLE_REACTIONS.Settings.CreateManagedEffects.Name"), + hint: game.i18n.localize("CONFIGURABLE_REACTIONS.Settings.CreateManagedEffects.Hint"), + scope: "world", + config: true, + type: Boolean, + default: true + }); +} diff --git a/scripts/sockets.js b/scripts/sockets.js new file mode 100644 index 0000000..bd0946e --- /dev/null +++ b/scripts/sockets.js @@ -0,0 +1,23 @@ +import { MODULE_ID } from "./constants.js"; +import { handleTeleportTargetRequest, handleTeleportTargetChosen } from "./actions/teleport-action.js"; + +export function registerSocket() { + game.socket.on(`module.${MODULE_ID}`, async message => { + if (!message?.type) return; + + switch (message.type) { + case "requestTeleportTarget": + return handleTeleportTargetRequest(message); + + case "teleportTargetChosen": + return handleTeleportTargetChosen(message); + + default: + console.warn(`${MODULE_ID} | Unknown socket message`, message); + } + }); +} + +export function emitSocket(message) { + game.socket.emit(`module.${MODULE_ID}`, message); +} diff --git a/styles/configurable-reactions.css b/styles/configurable-reactions.css new file mode 100644 index 0000000..9ffc540 --- /dev/null +++ b/styles/configurable-reactions.css @@ -0,0 +1,79 @@ +.configurable-reactions .cr-config { + display: flex; + flex-direction: column; + gap: 0.75rem; + height: 100%; +} + +.configurable-reactions .cr-header, +.configurable-reactions .cr-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.configurable-reactions .cr-grid { + display: grid; + grid-template-columns: 280px 1fr; + gap: 1rem; + min-height: 0; + height: 100%; +} + +.configurable-reactions .cr-sidebar, +.configurable-reactions .cr-main { + min-height: 0; + overflow: auto; +} + +.configurable-reactions .cr-button-column { + display: flex; + flex-direction: column; + gap: 0.35rem; + margin: 0.75rem 0; +} + +.configurable-reactions .cr-assignment-list { + display: flex; + flex-direction: column; + gap: 0.35rem; + padding: 0; + margin: 0; + list-style: none; +} + +.configurable-reactions .cr-assignment-list li { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + border: 1px solid var(--color-border-light-tertiary, #888); + border-radius: 4px; + padding: 0.35rem; +} + +.configurable-reactions .cr-assignment-list small { + display: block; + opacity: 0.8; +} + +.configurable-reactions .cr-main textarea[name="reactionJson"] { + width: 100%; + min-height: 430px; + font-family: var(--font-monospace, monospace); + resize: vertical; +} + +.configurable-reactions .cr-reaction-summary { + border: 1px solid var(--color-border-light-tertiary, #888); + border-radius: 4px; + padding: 0.5rem; + margin-bottom: 0.75rem; +} + +.configurable-reactions .form-group.stacked { + display: flex; + flex-direction: column; + align-items: stretch; +} diff --git a/templates/reaction-config.hbs b/templates/reaction-config.hbs new file mode 100644 index 0000000..e6fb638 --- /dev/null +++ b/templates/reaction-config.hbs @@ -0,0 +1,75 @@ +
+
+

{{localize "CONFIGURABLE_REACTIONS.App.Title"}}

+ +
+ +
+ + +
+ {{#if selectedReaction}} +
+

{{selectedReaction.name}}

+

ID: {{selectedReaction.id}}

+

{{localize "CONFIGURABLE_REACTIONS.Trigger.Label"}}: {{selectedReaction.trigger.type}}

+

{{localize "CONFIGURABLE_REACTIONS.Actions.Title"}}: {{selectedReaction.actions.length}}

+
+ +
+ + +
+ +
+ +
+ {{else}} +

{{localize "CONFIGURABLE_REACTIONS.Reactions.None"}}

+ {{/if}} +
+
+