Initial Foundry module scaffold

This commit is contained in:
ChatGPT 2026-06-04 16:07:28 +00:00
commit 927ea528b7
12 changed files with 517 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
.DS_Store
Thumbs.db
node_modules/
dist/
*.log

21
LICENSE Normal file
View File

@ -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.

65
README.md Normal file
View File

@ -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 <your-remote-url> 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 <your-remote-url>
git push -u origin main
```
## Status
Initial development scaffold. Not yet a production-ready automation module.

41
lang/de.json Normal file
View File

@ -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."
}

41
lang/en.json Normal file
View File

@ -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."
}

38
module.json Normal file
View File

@ -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"
}

54
scripts/constants.js Normal file
View File

@ -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: []
});

36
scripts/main.js Normal file
View File

@ -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;
});

39
scripts/settings.js Normal file
View File

@ -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
});
}

23
scripts/sockets.js Normal file
View File

@ -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);
}

View File

@ -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;
}

View File

@ -0,0 +1,75 @@
<div class="cr-config">
<header class="cr-header">
<h2>{{localize "CONFIGURABLE_REACTIONS.App.Title"}}</h2>
<button type="button" data-action="createReaction">
<i class="fa-solid fa-plus"></i> {{localize "CONFIGURABLE_REACTIONS.Reactions.Create"}}
</button>
</header>
<section class="cr-grid">
<aside class="cr-sidebar">
<div class="form-group">
<label>{{localize "CONFIGURABLE_REACTIONS.Reactions.Selected"}}</label>
<select name="selectedReactionId">
{{#each reactions}}
<option value="{{this.id}}" {{#if (eq this.id ../selectedReaction.id)}}selected{{/if}}>{{this.name}}</option>
{{/each}}
</select>
</div>
<div class="cr-button-column">
<button type="button" data-action="assignSelectedTokens" {{#unless selectedReaction}}disabled{{/unless}}>
<i class="fa-solid fa-crosshairs"></i> {{localize "CONFIGURABLE_REACTIONS.Assignments.AssignSelectedTokens"}}
</button>
<button type="button" data-action="addStatusAction" {{#unless selectedReaction}}disabled{{/unless}}>
<i class="fa-solid fa-circle-plus"></i> {{localize "CONFIGURABLE_REACTIONS.Actions.AddStatus"}}
</button>
<button type="button" data-action="addTeleportAction" {{#unless selectedReaction}}disabled{{/unless}}>
<i class="fa-solid fa-circle-plus"></i> {{localize "CONFIGURABLE_REACTIONS.Actions.AddTeleport"}}
</button>
<button type="button" data-action="deleteReaction" {{#unless selectedReaction}}disabled{{/unless}}>
<i class="fa-solid fa-trash"></i> {{localize "CONFIGURABLE_REACTIONS.Reactions.Delete"}}
</button>
</div>
<h3>{{localize "CONFIGURABLE_REACTIONS.Assignments.Title"}}</h3>
<ol class="cr-assignment-list">
{{#each assignments}}
<li>
<div>
<strong>{{this.name}}</strong>
<small>{{this.mode}} · {{this.reactionName}}</small>
</div>
<button type="button" data-action="removeAssignment" data-assignment-id="{{this.id}}" title="{{localize 'CONFIGURABLE_REACTIONS.Common.Remove'}}">
<i class="fa-solid fa-xmark"></i>
</button>
</li>
{{/each}}
</ol>
</aside>
<main class="cr-main">
{{#if selectedReaction}}
<div class="cr-reaction-summary">
<h3>{{selectedReaction.name}}</h3>
<p><strong>ID:</strong> <code>{{selectedReaction.id}}</code></p>
<p><strong>{{localize "CONFIGURABLE_REACTIONS.Trigger.Label"}}:</strong> {{selectedReaction.trigger.type}}</p>
<p><strong>{{localize "CONFIGURABLE_REACTIONS.Actions.Title"}}:</strong> {{selectedReaction.actions.length}}</p>
</div>
<div class="form-group stacked">
<label>{{localize "CONFIGURABLE_REACTIONS.Reactions.JsonEditor"}}</label>
<textarea name="reactionJson" spellcheck="false">{{selectedReactionJson}}</textarea>
</div>
<footer class="cr-footer">
<button type="button" data-action="saveReactionJson">
<i class="fa-solid fa-floppy-disk"></i> {{localize "CONFIGURABLE_REACTIONS.Common.Save"}}
</button>
</footer>
{{else}}
<p>{{localize "CONFIGURABLE_REACTIONS.Reactions.None"}}</p>
{{/if}}
</main>
</section>
</div>