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
+53
View File
@@ -0,0 +1,53 @@
import { MODULE_ID } from "../constants.js";
export async function executeApplyStatusAction(action, context) {
const actor = context.targetActor ?? context.actor;
if (!actor) return { success: false, consumed: false, reason: "NO_ACTOR" };
const statuses = action.statuses ?? [];
if (!statuses.length) return { success: false, consumed: false, reason: "NO_STATUSES" };
const effects = statuses.map(statusId => {
const statusConfig = CONFIG.statusEffects.find(status => status.id === statusId);
return {
name: statusConfig?.name ? game.i18n.localize(statusConfig.name) : statusId,
icon: statusConfig?.img ?? "icons/svg/aura.svg",
statuses: [statusId],
origin: context.reactionOrigin,
duration: buildEffectDuration(action.duration),
changes: [],
flags: {
[MODULE_ID]: {
managed: true,
reactionId: context.reaction.id,
actionId: action.id
}
}
};
});
await actor.createEmbeddedDocuments("ActiveEffect", effects);
return { success: true, consumed: true };
}
function buildEffectDuration(duration) {
if (!duration || duration.type === "unlimited") return {};
if (duration.type === "rounds") {
return {
rounds: Number(duration.value) || 1,
startRound: game.combat?.round,
startTurn: game.combat?.turn
};
}
if (duration.type === "minutes") {
return {
seconds: (Number(duration.value) || 1) * 60,
startTime: game.time.worldTime
};
}
return {};
}
+28
View File
@@ -0,0 +1,28 @@
export async function executeCastSpellAction(action, context) {
const token = resolveCasterToken(action, context);
const actor = token?.actor;
if (!actor) return { success: false, consumed: false, reason: "NO_CASTER" };
const item = await resolveItem(actor, action.spellSelection);
if (!item) return { success: false, consumed: false, reason: "NO_SPELL_ITEM" };
item.sheet?.render(true);
ui.notifications.info(`${item.name} wurde für ${actor.name} geöffnet. Vollautomatische Zauberauslösung folgt in einer späteren Version.`);
return { success: true, consumed: true };
}
function resolveCasterToken(action, context) {
switch (action.casterMode ?? "affectedToken") {
case "sourceToken": return context.sourceToken ?? null;
case "assignedToken": return context.assignedToken ?? context.targetToken ?? null;
case "affectedToken":
default: return context.targetToken ?? context.tokenDocument?.object ?? null;
}
}
async function resolveItem(actor, selection = {}) {
if (selection.mode === "itemUuid" && selection.itemUuid) return fromUuid(selection.itemUuid);
if (selection.mode === "itemName" && selection.itemName) return actor.items.find(item => item.name === selection.itemName);
return null;
}
+206
View File
@@ -0,0 +1,206 @@
import { MODULE_ID, SETTINGS } from "../constants.js";
import { emitSocket } from "../sockets.js";
import { getPrimaryOwner, isTokenSpaceOccupied } from "../utils/token-utils.js";
import { isWithinTeleportRadius, randomPointInCircle, sceneDistanceToPixels } from "../utils/distance-utils.js";
export async function executeTeleportAction(action, context) {
const tokenDocument = context.targetTokenDocument ?? context.tokenDocument;
if (!tokenDocument) return { success: false, consumed: false, reason: "NO_TOKEN" };
if (action.askOwner === true) {
return requestOwnerTeleportTarget({ tokenDocument, action, context });
}
return executeRandomTeleport({ tokenDocument, action });
}
async function requestOwnerTeleportTarget({ tokenDocument, action }) {
const actor = tokenDocument.actor;
const owner = getPrimaryOwner(actor);
if (!owner) {
return executeRandomTeleport({ tokenDocument, action });
}
emitSocket({
type: "requestTeleportTarget",
userId: owner.id,
sceneId: tokenDocument.parent.id,
tokenId: tokenDocument.id,
radius: Number(action.radius) || 30,
snapToGrid: action.snapToGrid !== false
});
return { success: true, consumed: true, pending: true };
}
export async function handleTeleportTargetRequest(message) {
if (message.userId !== game.user.id) return;
const scene = game.scenes.get(message.sceneId);
if (!scene || canvas.scene?.id !== scene.id) {
ui.notifications.warn(game.i18n.localize("CONFIGURABLE_REACTIONS.Teleport.SwitchScene"));
return;
}
const token = canvas.tokens.get(message.tokenId);
if (!token) return;
const confirmed = await foundry.applications.api.DialogV2.confirm({
window: { title: game.i18n.localize("CONFIGURABLE_REACTIONS.Teleport.Title") },
content: `<p>${game.i18n.format("CONFIGURABLE_REACTIONS.Teleport.ChooseTarget", { radius: message.radius })}</p>`,
yes: { label: game.i18n.localize("CONFIGURABLE_REACTIONS.Common.Choose") },
no: { label: game.i18n.localize("CONFIGURABLE_REACTIONS.Common.Cancel") }
});
if (!confirmed) return;
const point = await waitForCanvasClick();
emitSocket({
type: "teleportTargetChosen",
sceneId: message.sceneId,
tokenId: message.tokenId,
userId: game.user.id,
x: point.x,
y: point.y,
radius: message.radius,
snapToGrid: message.snapToGrid !== false
});
}
export async function handleTeleportTargetChosen(message) {
if (!game.user.isGM) return;
const scene = game.scenes.get(message.sceneId);
const tokenDocument = scene?.tokens.get(message.tokenId);
const token = tokenDocument?.object;
if (!tokenDocument || !token) return;
const user = game.users.get(message.userId);
if (!tokenDocument.actor?.testUserPermission(user, "OWNER")) return;
let position = centerToTopLeft(token, { x: message.x, y: message.y });
if (message.snapToGrid !== false) position = snapPosition(token, position);
const radiusPx = sceneDistanceToPixels(Number(message.radius) || 30);
if (!isTeleportCandidateValid(token, position, radiusPx)) {
ui.notifications.warn(game.i18n.localize("CONFIGURABLE_REACTIONS.Teleport.InvalidTarget"));
return;
}
await tokenDocument.update({ x: position.x, y: position.y });
}
export async function executeRandomTeleport({ tokenDocument, action }) {
const token = tokenDocument.object;
if (!token) return { success: false, consumed: false, reason: "NO_TOKEN_OBJECT" };
const target = findRandomReachableTeleportTarget(token, {
radius: Number(action.radius) || 30,
maxAttempts: Number(action.maxAttempts) || 80,
snapToGrid: action.snapToGrid !== false
});
if (!target) {
ui.notifications.warn(game.i18n.format("CONFIGURABLE_REACTIONS.Teleport.NoValidTarget", {
name: token.name,
radius: Number(action.radius) || 30
}));
await createTeleportFailureMessage(token, action);
return {
success: false,
consumed: action.consumeOnFailure === true,
reason: "NO_VALID_TELEPORT_TARGET"
};
}
await tokenDocument.update({ x: target.x, y: target.y });
return { success: true, consumed: true };
}
function findRandomReachableTeleportTarget(token, options) {
const radiusPx = sceneDistanceToPixels(options.radius);
const originCenter = token.center;
for (let attempt = 0; attempt < options.maxAttempts; attempt++) {
const candidateCenter = randomPointInCircle(originCenter, radiusPx);
let targetPosition = centerToTopLeft(token, candidateCenter);
if (options.snapToGrid) targetPosition = snapPosition(token, targetPosition);
if (isTeleportCandidateValid(token, targetPosition, radiusPx)) return targetPosition;
}
return null;
}
function isTeleportCandidateValid(token, targetPosition, radiusPx) {
const candidateCenter = token.getCenterPoint(targetPosition);
if (!isWithinTeleportRadius(token.center, candidateCenter, radiusPx)) return false;
if (!isWithinSceneBounds(token, targetPosition)) return false;
if (movementBlockedByWalls(token, candidateCenter)) return false;
if (isTokenSpaceOccupied(token, targetPosition)) return false;
return true;
}
function movementBlockedByWalls(token, destinationCenter) {
const origin = token.document.getMovementOrigin?.() ?? token.center;
const destination = {
x: destinationCenter.x,
y: destinationCenter.y,
elevation: token.document.elevation
};
return token.checkCollision(destination, {
origin,
type: "move",
mode: "any"
}) === true;
}
function isWithinSceneBounds(token, position) {
const dimensions = canvas.scene.dimensions;
return position.x >= 0 &&
position.y >= 0 &&
position.x + token.w <= dimensions.width &&
position.y + token.h <= dimensions.height;
}
function centerToTopLeft(token, center) {
return {
x: center.x - token.w / 2,
y: center.y - token.h / 2
};
}
function snapPosition(token, position) {
if (typeof token.getSnappedPosition === "function") return token.getSnappedPosition(position);
const snapped = canvas.grid.getSnappedPoint(position);
return { x: snapped.x, y: snapped.y };
}
function waitForCanvasClick() {
return new Promise(resolve => {
const handler = event => {
canvas.stage.off("pointerdown", handler);
const position = event.data.getLocalPosition(canvas.stage);
resolve({ x: position.x, y: position.y });
};
canvas.stage.on("pointerdown", handler);
});
}
async function createTeleportFailureMessage(token, action) {
if (!game.settings.get(MODULE_ID, SETTINGS.SHOW_FAILED_TELEPORT_MESSAGES)) return;
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ token }),
content: `<p><strong>${token.name}</strong> findet kein erreichbares freies Teleportziel innerhalb von ${Number(action.radius) || 30} ft.</p>`
});
}
@@ -0,0 +1,36 @@
export async function executeUseInventoryItemAction(action, context) {
const token = resolveUserToken(action, context);
const actor = token?.actor;
if (!actor) return { success: false, consumed: false, reason: "NO_USER" };
const item = await resolveItem(actor, action.itemSelection);
if (!item) return { success: false, consumed: false, reason: "NO_ITEM" };
if (action.activationMode === "openSheet" || !item.use) {
item.sheet?.render(true);
return { success: true, consumed: true };
}
if (action.activationMode === "autoUse" && typeof item.use === "function") {
await item.use();
return { success: true, consumed: true };
}
item.sheet?.render(true);
return { success: true, consumed: true };
}
function resolveUserToken(action, context) {
switch (action.userMode ?? "affectedToken") {
case "sourceToken": return context.sourceToken ?? null;
case "assignedToken": return context.assignedToken ?? context.targetToken ?? null;
case "affectedToken":
default: return context.targetToken ?? context.tokenDocument?.object ?? null;
}
}
async function resolveItem(actor, selection = {}) {
if (selection.mode === "itemUuid" && selection.itemUuid) return fromUuid(selection.itemUuid);
if (selection.mode === "itemName" && selection.itemName) return actor.items.find(item => item.name === selection.itemName);
return null;
}