Files
Ringworld/src/module/mechanics.ts
T

526 lines
19 KiB
TypeScript

import { outcomeLabel, rollPercentile } from "./utils/percentile";
import { chooseOrRollHitLocation, rollHitLocation } from "./utils/hit-locations";
import { LEG_LOCATIONS, SYSTEM_ID, isLimb, type HitLocationSlug } from "./constants";
import { postAttackCard, postSkillRollCard } from "./chat";
import { evaluateRollWithDice } from "./utils/dice";
import { getActorHitLocationSchema } from "./hit-location-schemas";
function num(value: unknown, fallback = 0): number {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
function getSingleTargetActor(): any | null {
const targets = Array.from(game.user?.targets ?? []);
if (!targets.length) return null;
return (targets[0] as any)?.actor ?? null;
}
export async function rollStandaloneHitLocation(actor?: any): Promise<void> {
const result = await rollHitLocation(actor);
const speaker = actor ? ChatMessage.getSpeaker({ actor }) : ChatMessage.getSpeaker();
const content = `
<div class="ringworld-chat-card">
<h3>Hit Location</h3>
<p><strong>Roll:</strong> ${result.roll}</p>
<p><strong>Location:</strong> ${result.location.label}</p>
</div>
`;
await ChatMessage.create({
user: game.user?.id,
speaker,
content
});
}
export async function rollSkillForActor(actor: any, skillName: string, target: number): Promise<void> {
const result = await rollPercentile(target);
const skillItem = actor.items.find(
(item: any) => item.type === "skill" && item.name.toLowerCase() === String(skillName).toLowerCase()
);
await postSkillRollCard({
actor,
actorName: actor.name,
actorImg: actor.img,
skillName,
skillImg: skillItem?.img,
roll: result.roll,
target: result.target,
outcomeLabel: outcomeLabel(result.outcome),
isSuccess: result.isSuccess,
isSpecial: result.isSpecial,
isFumble: result.isFumble
});
}
function resolveLinkedSkill(actor: any, weapon: any): { name: string; target: number; img?: string } {
const skillId = weapon.system.linkedSkillId;
const byId = skillId ? actor.items.get(skillId) : null;
if (byId?.type === "skill") {
return { name: byId.name, target: num(byId.system.value), img: byId.img };
}
const linkedSkillName = String(weapon.system.linkedSkill || "").trim();
if (linkedSkillName) {
const byName = actor.items.find(
(item: any) => item.type === "skill" && item.name.toLowerCase() === linkedSkillName.toLowerCase()
);
if (byName) {
return { name: byName.name, target: num(byName.system.value), img: byName.img };
}
}
return {
name: linkedSkillName || "Unlinked Skill",
target: 0
};
}
type AimMode = "unaimed" | "partial" | "full";
interface AttackRollModifierResult {
finalTarget: number;
summary: string;
aimMode: AimMode;
aimMultiplier: number;
flatModifierTotal: number;
}
function clampPercentTarget(value: number): number {
return Math.max(0, Math.min(99, Math.floor(value)));
}
async function promptAttackModifiers(attacker: any, weapon: any, baseSkillTarget: number): Promise<AttackRollModifierResult | null> {
const attackType = String(weapon.system?.attackType || "ranged");
const isRanged = attackType === "ranged";
const defaultAim: AimMode = "full";
const safeActorName = foundry.utils.escapeHTML(String(attacker?.name || "Attacker"));
const safeWeaponName = foundry.utils.escapeHTML(String(weapon?.name || "Weapon"));
const safeActorImg = foundry.utils.escapeHTML(String(attacker?.img || "icons/svg/mystery-man.svg"));
const safeWeaponImg = foundry.utils.escapeHTML(String(weapon?.img || "icons/svg/sword.svg"));
const content = `
<form class="ringworld-attack-modifiers">
<header class="rw-attack-dialog-head">
<img class="rw-attack-dialog-avatar" src="${safeActorImg}" alt="${safeActorName}" />
<img class="rw-attack-dialog-weapon" src="${safeWeaponImg}" alt="${safeWeaponName}" />
<div class="rw-attack-dialog-title-block">
<h3>${safeWeaponName}</h3>
<p>${safeActorName} Attack Setup</p>
</div>
</header>
<div class="form-group">
<label>Base Skill</label>
<input type="number" name="baseSkill" value="${baseSkillTarget}" disabled />
</div>
<div class="form-group">
<label>Aim State</label>
<select name="aimMode" ${isRanged ? "" : "disabled"}>
<option value="unaimed" ${defaultAim === "unaimed" ? "selected" : ""}>Unaimed (Quarter Skill)</option>
<option value="partial" ${defaultAim === "partial" ? "selected" : ""}>Partially Aimed (Half Skill)</option>
<option value="full" ${defaultAim === "full" ? "selected" : ""}>Fully Aimed (Full Skill)</option>
</select>
</div>
<div class="form-group">
<label>Cover Modifier</label>
<input type="number" name="coverMod" value="0" />
</div>
<div class="form-group">
<label>Movement Modifier</label>
<input type="number" name="movementMod" value="0" />
</div>
<div class="form-group">
<label>Range Modifier</label>
<input type="number" name="rangeMod" value="0" />
</div>
<div class="form-group">
<label>Situational Modifier</label>
<input type="number" name="situationalMod" value="0" />
</div>
<div class="form-group">
<label>Custom Modifier Label</label>
<input type="text" name="customLabel" value="" />
</div>
<div class="form-group">
<label>Custom Modifier Value</label>
<input type="number" name="customMod" value="0" />
</div>
<p class="notes">Ringworld rule support: unaimed ranged attacks use quarter skill, partial aim uses half skill, full aim uses full skill.</p>
</form>
`;
return new Promise((resolve) => {
let settled = false;
const done = (value: AttackRollModifierResult | null) => {
if (settled) return;
settled = true;
resolve(value);
};
new Dialog({
title: `${weapon.name} - Attack Modifiers`,
content,
buttons: {
roll: {
label: "Roll Attack",
callback: (dlgHtml: JQuery) => {
const root = dlgHtml[0] as HTMLElement | undefined;
const query = (name: string) => (root?.querySelector(`[name="${name}"]`) as HTMLInputElement | HTMLSelectElement | null);
const aimModeRaw = String(query("aimMode")?.value || defaultAim) as AimMode;
const aimMode: AimMode = (aimModeRaw === "unaimed" || aimModeRaw === "partial" || aimModeRaw === "full")
? aimModeRaw
: defaultAim;
const aimMultiplier = isRanged ? ({ unaimed: 0.25, partial: 0.5, full: 1 } as const)[aimMode] : 1;
const coverMod = num(query("coverMod")?.value, 0);
const movementMod = num(query("movementMod")?.value, 0);
const rangeMod = num(query("rangeMod")?.value, 0);
const situationalMod = num(query("situationalMod")?.value, 0);
const customMod = num(query("customMod")?.value, 0);
const customLabel = String(query("customLabel")?.value || "").trim();
const flatModifierTotal = coverMod + movementMod + rangeMod + situationalMod + customMod;
const preAimTarget = baseSkillTarget + flatModifierTotal;
const adjustedByAim = Math.floor(preAimTarget * aimMultiplier);
const finalTarget = clampPercentTarget(adjustedByAim);
const parts: string[] = [`Base ${baseSkillTarget}`];
if (coverMod) parts.push(`Cover ${coverMod >= 0 ? "+" : ""}${coverMod}`);
if (movementMod) parts.push(`Movement ${movementMod >= 0 ? "+" : ""}${movementMod}`);
if (rangeMod) parts.push(`Range ${rangeMod >= 0 ? "+" : ""}${rangeMod}`);
if (situationalMod) parts.push(`Situational ${situationalMod >= 0 ? "+" : ""}${situationalMod}`);
if (customMod) parts.push(`${customLabel || "Custom"} ${customMod >= 0 ? "+" : ""}${customMod}`);
parts.push(`Pre-Aim ${preAimTarget}`);
if (isRanged) {
const aimLabel = aimMode === "unaimed" ? "Quarter" : aimMode === "partial" ? "Half" : "Full";
parts.push(`Aim ${aimLabel} x${aimMultiplier}`);
}
parts.push(`Final ${finalTarget}`);
done({
finalTarget,
summary: parts.join(" | "),
aimMode,
aimMultiplier,
flatModifierTotal
});
}
},
cancel: {
label: "Cancel",
callback: () => done(null)
}
},
default: "roll",
close: () => done(null)
}, {
classes: ["ringworld-attack-dialog"],
width: 510,
resizable: false
}).render(true);
});
}
async function consumeWeaponEnergy(actor: any, weapon: any): Promise<{
sourceLabel: string;
remaining: number;
cost: number;
insufficient: boolean;
}> {
const cost = Math.max(0, num(weapon.system.energyUsedPerAttack));
if (cost <= 0) {
return { sourceLabel: "None", remaining: 0, cost: 0, insufficient: false };
}
const source = String(weapon.system.powerSource || "actor");
if (source === "powerPack") {
const packId = String(weapon.system.powerPackId || "");
const pack = packId ? actor.items.get(packId) : null;
if (!pack || pack.type !== "gear" || !pack.system.isPowerPack) {
ui.notifications?.warn("Weapon is configured for a power pack but no valid power pack is linked.");
return { sourceLabel: "Power Pack", remaining: 0, cost, insufficient: true };
}
const current = num(pack.system.energy?.value);
if (current < cost) {
ui.notifications?.warn(`${pack.name} does not have enough energy.`);
return { sourceLabel: pack.name, remaining: current, cost, insufficient: true };
}
const next = current - cost;
await actor.updateEmbeddedDocuments("Item", [{ _id: pack.id, "system.energy.value": next }]);
return { sourceLabel: pack.name, remaining: next, cost, insufficient: false };
}
const current = num(actor.system.resources?.energy?.value);
if (current < cost) {
ui.notifications?.warn(`${actor.name} does not have enough actor energy.`);
return { sourceLabel: "Actor Energy", remaining: current, cost, insufficient: true };
}
const next = current - cost;
await actor.update({ "system.resources.energy.value": next });
return { sourceLabel: "Actor Energy", remaining: next, cost, insufficient: false };
}
function getItemArmorAtLocation(item: any, slug: HitLocationSlug): number {
if (item.type !== "armor" || !item.system.equipped || item.system.stowed) return 0;
if (!item.system.coverage?.[slug]) return 0;
const specific = num(item.system.perLocation?.[slug]);
if (specific > 0) return specific;
return num(item.system.value);
}
function getTotalArmorAtLocation(targetActor: any, slug: HitLocationSlug): number {
const locArmor = num(targetActor.system.hitLocations?.[slug]?.armor);
const stackedItemsArmor = targetActor.items
.filter((item: any) => item.type === "armor")
.reduce((sum: number, item: any) => sum + getItemArmorAtLocation(item, slug), 0);
return locArmor + stackedItemsArmor;
}
function buildConditionUpdates(targetActor: any, slug: HitLocationSlug, locationValue: number, locationMax: number, generalHp: number) {
const current = foundry.utils.deepClone(targetActor.system.conditions ?? {});
current.unusableLimbs = current.unusableLimbs ?? { rArm: false, lArm: false, rLeg: false, lLeg: false };
if (locationValue <= 0) {
if (slug === "head" || slug === "chest") {
current.unconscious = true;
current.prone = true;
}
if (slug === "abdomen") {
current.prone = true;
current.unusableLimbs.rLeg = true;
current.unusableLimbs.lLeg = true;
}
if (slug === "rArm" || slug === "lArm") {
current.unusableLimbs[slug] = true;
}
if (LEG_LOCATIONS.includes(slug)) {
current.unusableLimbs[slug] = true;
current.prone = true;
}
}
if (generalHp <= 0) {
current.dying = true;
}
if ((slug === "head" || slug === "chest" || slug === "abdomen") && locationValue <= -locationMax) {
current.dying = true;
}
if (slug === "head" && locationValue <= -locationMax) {
current.dead = true;
current.unconscious = true;
}
return current;
}
async function syncCoreStatusEffects(targetActor: any, conditions: any): Promise<void> {
const toggler = (targetActor as any).toggleStatusEffect;
if (typeof toggler !== "function") return;
try {
await toggler.call(targetActor, "prone", { active: !!conditions.prone });
await toggler.call(targetActor, "unconscious", { active: !!conditions.unconscious });
await toggler.call(targetActor, "dead", { active: !!conditions.dead });
} catch (_err) {
// Some worlds lock actor-level status manipulation; system conditions still update.
}
}
async function applyLocationDamage(targetActor: any, slug: HitLocationSlug, rawDamage: number): Promise<{
armor: number;
finalDamage: number;
locationValue: number;
generalHpValue: number;
}> {
const location = targetActor.system.hitLocations?.[slug];
const currentLoc = num(location?.hp?.value);
const locMax = Math.max(1, num(location?.hp?.max, 1));
const armor = getTotalArmorAtLocation(targetActor, slug);
const finalDamage = Math.max(0, rawDamage - armor);
const unclampedLoc = currentLoc - finalDamage;
const nextLoc = isLimb(slug) ? Math.max(unclampedLoc, -locMax) : unclampedLoc;
const currentGeneral = num(targetActor.system.generalHp?.value);
const nextGeneral = currentGeneral - finalDamage;
const conditions = buildConditionUpdates(targetActor, slug, nextLoc, locMax, nextGeneral);
const updates: Record<string, unknown> = {
[`system.hitLocations.${slug}.hp.value`]: nextLoc,
"system.generalHp.value": nextGeneral,
"system.conditions": conditions
};
await targetActor.update(updates);
await syncCoreStatusEffects(targetActor, conditions);
return {
armor,
finalDamage,
locationValue: nextLoc,
generalHpValue: nextGeneral
};
}
export async function useWeaponWorkflow(attacker: any, weapon: any): Promise<void> {
const targetActor = getSingleTargetActor();
const hasTarget = Boolean(targetActor);
const targetName = targetActor?.name ?? "No Target";
const linkedSkill = resolveLinkedSkill(attacker, weapon);
const modifierResult = await promptAttackModifiers(attacker, weapon, linkedSkill.target);
if (!modifierResult) return;
const energy = await consumeWeaponEnergy(attacker, weapon);
if (energy.insufficient) return;
const attack = await rollPercentile(modifierResult.finalTarget);
if (!attack.isSuccess) {
const remainingGeneralHp = targetActor ? num(targetActor.system.generalHp?.value, 0) : 0;
await postAttackCard({
actor: attacker,
actorName: attacker.name,
actorImg: attacker.img,
targetName,
hasTarget,
weaponName: weapon.name,
weaponImg: weapon.img,
skillName: linkedSkill.name,
skillImg: linkedSkill.img,
attackRoll: attack.roll,
attackTarget: attack.target,
attackOutcomeLabel: outcomeLabel(attack.outcome),
isSuccess: false,
isSpecial: attack.isSpecial,
isFumble: attack.isFumble,
hitLocationLabel: "-",
hitLocationRoll: null,
rawDamage: 0,
armor: 0,
finalDamage: 0,
remainingLocationHp: 0,
remainingGeneralHp,
energySourceLabel: energy.sourceLabel,
energyCost: energy.cost,
energyRemaining: energy.remaining,
notes: `Attack failed before damage was rolled. ${modifierResult.summary}`
});
return;
}
const hitSelection = await chooseOrRollHitLocation(targetActor ?? attacker);
if (!hitSelection) return;
const damageFormula = String(weapon.system.damageFormula || "0").trim() || "0";
const damageRoll = await evaluateRollWithDice(damageFormula);
const rawDamage = Math.max(0, num(damageRoll.total));
const application = targetActor
? await applyLocationDamage(targetActor, hitSelection.slug, rawDamage)
: {
armor: 0,
finalDamage: rawDamage,
locationValue: 0,
generalHpValue: 0
};
await postAttackCard({
actor: attacker,
actorName: attacker.name,
actorImg: attacker.img,
targetName,
hasTarget,
weaponName: weapon.name,
weaponImg: weapon.img,
skillName: linkedSkill.name,
skillImg: linkedSkill.img,
attackRoll: attack.roll,
attackTarget: attack.target,
attackOutcomeLabel: outcomeLabel(attack.outcome),
isSuccess: attack.isSuccess,
isSpecial: attack.isSpecial,
isFumble: attack.isFumble,
hitLocationLabel: hitSelection.label,
hitLocationRoll: hitSelection.rolled,
rawDamage,
armor: application.armor,
finalDamage: application.finalDamage,
remainingLocationHp: application.locationValue,
remainingGeneralHp: application.generalHpValue,
energySourceLabel: energy.sourceLabel,
energyCost: energy.cost,
energyRemaining: energy.remaining,
notes: targetActor
? `Location and general HP were both reduced by final damage, per Ringworld rules. ${modifierResult.summary}`
: `No target selected: roll resolved with no damage application. ${modifierResult.summary}`
});
}
export function recomputeHitPointMaximums(actor: any): void {
if (actor.type !== "explorer" && actor.type !== "creature") return;
// Ringworld general HP is CON + MAS. Location HP then scales from that general HP.
const con = num(actor.system?.characteristics?.con, 10);
const mas = num(actor.system?.characteristics?.mas, 10);
const existingMax = num(actor.system.generalHp?.max, 0);
const existingValue = num(actor.system.generalHp?.value, 0);
const generalMax = Math.max(1, Math.floor(con + mas));
actor.system.generalHp = actor.system.generalHp ?? { value: generalMax, max: generalMax };
actor.system.generalHp.max = generalMax;
const hadExisting = existingMax > 0;
const wasFull = hadExisting && existingValue >= existingMax;
const looksPlaceholder = existingMax <= 1 && existingValue <= 1;
const preservedDamage = hadExisting ? Math.max(0, existingMax - existingValue) : 0;
const recomputedValue = (!hadExisting || wasFull || looksPlaceholder)
? generalMax
: Math.max(0, generalMax - preservedDamage);
actor.system.generalHp.value = Math.min(Math.max(0, recomputedValue), generalMax);
const generalAtFull = num(actor.system.generalHp.value, generalMax) >= generalMax;
actor.system.hitLocations = actor.system.hitLocations ?? {};
actor.system.hitLocationSchema = actor.system.hitLocationSchema ?? "auto";
const schema = getActorHitLocationSchema(actor);
for (const entry of schema.entries) {
const slug = entry.slug;
const loc = actor.system.hitLocations[slug] ?? {
label: slug,
hp: { value: 1, max: 1 },
armor: 0
};
loc.label = entry.label;
const hpFactor = Math.max(0.05, num((entry as any).hpFactor, 0.3));
const max = Math.max(1, Math.ceil(generalMax * hpFactor));
const previousMax = num(loc.hp?.max, 1);
const previousValue = num(loc.hp?.value, previousMax);
const isPlaceholder = previousMax <= 1 && previousValue <= 1;
const shouldBackfillToFull = generalAtFull && previousValue <= 1 && max > 1;
loc.hp.max = max;
loc.hp.value = (isPlaceholder || shouldBackfillToFull) ? max : Math.min(previousValue, max);
actor.system.hitLocations[slug] = loc;
}
}
export function getTemplatePath(path: string): string {
return `systems/${SYSTEM_ID}/templates/${path}`;
}