526 lines
19 KiB
TypeScript
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}`;
|
|
}
|