Ringworld 0.5.0: v13/v14 compatibility, Ringworld '84 theme, combat automation

- Foundry v13/v14 compatibility (verified on 14.364)
- Complete "Ringworld '84" retheme: CSS custom properties, warm
  space-black + bronze + ring-arc amber palette, uniform across sheets,
  dialogs, chat cards, hit-location picker, and impulse HUD
- Fix HP damage evaporating on re-prepare: maximums always derived,
  current values source-authoritative, with a one-time world migration
- Ammunition tracking: per-attack consumption, reload button, ammo
  badge; empty weapons block attacks
- Energy weapons auto-link a carried power pack instead of failing
- Special Success rolls damage dice twice; fumbles flag GM adjudication
  (both toggleable world settings)
- Defender Dodge prompt before damage (special hits need special dodge)
- Damage Mod and derived rolls always recompute; shown read-only
- Creature sheet rebuilt to full parity: characteristics, derived
  rolls, body-schema hit grid with AP totals, weapons/armor/skills
- Seeded "Ringworld GM Reference" journal (outcomes, impulses, attack
  workflow, wounds, 1984 quirks, quick start)
- Shared characteristic-check dialog between explorer and creature

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 23:41:33 +01:00
co-authored by Claude Fable 5
parent 162bbde1fe
commit 6b63a95dc2
24 changed files with 2630 additions and 1394 deletions
+11 -9
View File
@@ -1,4 +1,4 @@
import { recomputeHitPointMaximums, rollSkillForActor, rollStandaloneHitLocation, useWeaponWorkflow } from "../mechanics";
import { recomputeHitPointMaximums, reloadWeapon, rollSkillForActor, rollStandaloneHitLocation, useWeaponWorkflow } from "../mechanics";
import { normalizeSkillName, RINGWORLD_BASE_SKILLS } from "../data-base-skills";
import { randomizeExplorerActor, rollCharacteristicsForActor } from "../character-tools";
import { skillIconPath } from "../skill-icons";
@@ -12,10 +12,6 @@ function num(value: unknown, fallback = 0): number {
return Number.isFinite(n) ? n : fallback;
}
function hasValue(value: unknown): boolean {
return value !== null && value !== undefined && String(value).trim() !== "";
}
function clampCharacteristic(value: number): number {
return Math.max(1, Math.min(99, Math.floor(value)));
}
@@ -146,15 +142,15 @@ export class RingworldActor extends Actor {
profile.physiologicalAge = num(profile.physiologicalAge, profile.chronologicalAge);
system.profile = profile;
// Damage mod and derived rolls are always recomputed from characteristics so
// they can never go stale after a characteristic edit.
system.combat = system.combat ?? {};
if (!hasValue(system.combat.damageMod)) {
system.combat.damageMod = computeDamageMod(num(characteristics.str, 10), num(characteristics.mas, 10));
}
system.combat.damageMod = computeDamageMod(num(characteristics.str, 10), num(characteristics.mas, 10));
system.derivedRolls = system.derivedRolls ?? {};
const derivedRollDefaults = computeDerivedRolls(characteristics);
for (const key of Object.keys(derivedRollDefaults) as DerivedCheckKey[]) {
system.derivedRolls[key] = num(system.derivedRolls[key], derivedRollDefaults[key]);
system.derivedRolls[key] = derivedRollDefaults[key];
}
system.skillRootMax = system.skillRootMax ?? {};
@@ -228,6 +224,12 @@ export class RingworldActor extends Actor {
await useWeaponWorkflow(this, weapon);
}
async reloadWeaponById(weaponId: string): Promise<void> {
const weapon = this.items.get(weaponId);
if (!weapon || weapon.type !== "weapon") return;
await reloadWeapon(this, weapon);
}
async ensureBaseSkills(): Promise<number> {
if (this.type !== "explorer" && this.type !== "creature") return 0;
+11
View File
@@ -63,6 +63,17 @@ export async function postAttackCard(data: {
energySourceLabel: string;
energyCost: number;
energyRemaining: number;
ammoUsed?: boolean;
ammoCost?: number;
ammoRemaining?: number;
dodge?: {
attempted: boolean;
roll: number;
target: number;
success: boolean;
special: boolean;
negated: boolean;
} | null;
notes: string;
}): Promise<void> {
const payload = {
+125
View File
@@ -0,0 +1,125 @@
import { SYSTEM_ID } from "./constants";
const JOURNAL_NAME = "Ringworld GM Reference";
interface ReferencePage {
name: string;
content: string;
}
function buildPages(): ReferencePage[] {
return [
{
name: "Percentile Outcomes",
content: `
<p>Every check is a d100 roll against a target percentage. The system classifies results automatically:</p>
<ul>
<li><strong>Success</strong> &mdash; roll &le; target.</li>
<li><strong>Special Success</strong> &mdash; roll &le; one fifth of the target (minimum 1). Attacks roll their damage dice twice when <em>Automate Special Success Damage</em> is enabled.</li>
<li><strong>Failure</strong> &mdash; roll &gt; target.</li>
<li><strong>Fumble</strong> &mdash; roll of 96&ndash;100 when the target is below 50, or a natural 100 otherwise. The GM adjudicates a mishap: dropped or damaged weapon, hit an ally, drained power pack.</li>
</ul>
<p>Characteristic checks use Characteristic &times; Multiplier (usually &times;5): click any characteristic button on a sheet to pick the multiplier.</p>
`
},
{
name: "Combat Sequence (Impulses)",
content: `
<p>Ringworld does not use simple initiative. Combat rounds are split into <strong>impulses</strong> and each combatant acts on a rhythm set by their <strong>Action Ranking (AR)</strong>, which is derived from DEX (lower is faster) and worsened by encumbrance.</p>
<ol>
<li>Add combatants to the Combat Tracker as normal. The Ringworld impulse panel replaces the core tracker list.</li>
<li>Each combatant schedules an action: a <strong>major action</strong> (attack, full move, complex task) completes after AR impulses; a <strong>minor action</strong> (drop prone, shout, draw) completes after 1 impulse.</li>
<li>Advance the clock with <em>Next Completion</em> or <em>+1</em>. When a combatant's action completes, resolve it, then let them schedule the next one.</li>
</ol>
<p>The floating impulse HUD can be dragged anywhere on screen and mirrors the tracker for quick play.</p>
`
},
{
name: "Attack Workflow",
content: `
<p>Target a token, then press the weapon's <strong>Use</strong> button on the attacker's sheet. The system walks through the 1984 rules in order:</p>
<ol>
<li><strong>Aim state</strong> (ranged only): unaimed fires at quarter skill, partially aimed at half, fully aimed at full skill. Cover, movement, range and situational modifiers are applied before the aim multiplier.</li>
<li><strong>Ammunition and energy</strong> are spent. Projectile weapons with an ammo capacity are blocked when empty &mdash; use the <em>Reload</em> button on the weapon row. Energy weapons draw from the actor pool or a linked power pack.</li>
<li><strong>Dodge</strong>: on a hit, the defender is offered a Dodge roll (DEX &times;5). A successful dodge evades a normal hit; only a <em>special</em> dodge evades a <em>special</em> hit. Disable with the <em>Prompt Defender Dodge</em> setting.</li>
<li><strong>Hit location</strong> is rolled on a d20 (or picked manually). Species use their own body schema &mdash; puppeteers, dolphins, trinocs, bandersnatchi and kzinti map differently.</li>
<li><strong>Damage</strong> is rolled, stacked armor at that location is subtracted, and the result reduces <em>both</em> the location HP and the general HP.</li>
</ol>
`
},
{
name: "Wounds & Conditions",
content: `
<p>When a hit location reaches 0 HP or less, conditions are applied automatically:</p>
<ul>
<li><strong>Head or Chest at 0</strong> &mdash; unconscious and prone.</li>
<li><strong>Abdomen at 0</strong> &mdash; prone, both legs unusable.</li>
<li><strong>Arm at 0</strong> &mdash; that arm is unusable.</li>
<li><strong>Leg at 0</strong> &mdash; that leg is unusable, and the character falls prone.</li>
<li><strong>General HP at 0</strong> &mdash; dying.</li>
<li><strong>Head, chest or abdomen at negative full value</strong> &mdash; dying; a head at negative full value is death outright.</li>
</ul>
<p>Limbs cannot go below their negative maximum. Use <em>Reset Conditions</em> on a sheet after treatment or recovery. Prone, unconscious and dead sync to token status icons.</p>
`
},
{
name: "Running the 1984 Rules",
content: `
<p>Quirks of the original boxed set that the system encodes for you:</p>
<ul>
<li><strong>General HP = CON + MAS.</strong> MAS (mass) replaces the familiar SIZ. Location HP scale from general HP by body schema.</li>
<li><strong>Damage modifier</strong> comes from STR + MAS and is recomputed automatically when characteristics change.</li>
<li><strong>Skill pools:</strong> Education points = (EDU &minus; 10) &times; 20, Pursuit points = (Age &minus; EDU &minus; 6) &times; 20, Special points = INT &times; 10. The character builder on the Skills tab tracks all three.</li>
<li><strong>Skill category caps:</strong> each category is capped by a characteristic pair (e.g. Agility by STR + DEX). The builder enforces the caps.</li>
<li><strong>Gravity matters:</strong> carried weight is halved in light gravity and doubled in heavy gravity before comparing to the STR limit; over-limit explorers slow to Move 2 and suffer an Action Ranking penalty.</li>
<li><strong>Energy economy:</strong> most Ringworld tech runs on power packs (R20/R50/R100). Weapons can draw from a linked pack &mdash; keep spares, and remember fumbles can drain them.</li>
</ul>
`
},
{
name: "GM Quick Start",
content: `
<p>To get a table playing quickly:</p>
<ol>
<li><strong>Seed the catalog</strong> (Items sidebar button) &mdash; species, 55 base skills, pursuits, weapons, armor and gear from the boxed set appear in organised folders. This runs automatically for new worlds.</li>
<li><strong>Create Random Explorer</strong> (Actors sidebar button) &mdash; rolls characteristics by species and gravity, adds base skills and a starter kit.</li>
<li>For bespoke characters, unlock the sheet tools (padlock, top right of the sheet) and use the <strong>Character Builder</strong> on the Skills tab: pick pursuits, then allocate the three point pools.</li>
<li>Creatures get the same automation &mdash; hit locations, dodge, armor stacking &mdash; via the creature sheet, with a body schema selector for non-humanoids.</li>
<li>Check <strong>System Settings</strong> for the automation toggles (special success damage, defender dodge prompts).</li>
</ol>
`
}
];
}
export async function seedGmReference(): Promise<boolean> {
if (!game.user?.isGM) return false;
const journals = (game as any).journal;
const existing = journals?.find((entry: any) => entry.getFlag?.(SYSTEM_ID, "gmReference") === true)
?? journals?.getName?.(JOURNAL_NAME);
if (existing) return false;
const pages = buildPages().map((page, index) => ({
name: page.name,
type: "text",
sort: (index + 1) * 100000,
text: {
format: 1,
content: page.content.trim()
}
}));
await (getDocumentClass("JournalEntry") as any).create({
name: JOURNAL_NAME,
pages,
flags: {
[SYSTEM_ID]: {
gmReference: true
}
},
ownership: { default: 0 }
});
return true;
}
+279 -26
View File
@@ -222,6 +222,44 @@ async function promptAttackModifiers(attacker: any, weapon: any, baseSkillTarget
});
}
async function consumeWeaponAmmo(actor: any, weapon: any): Promise<{
used: boolean;
remaining: number;
cost: number;
insufficient: boolean;
}> {
const cost = Math.max(0, num(weapon.system.ammoUsedPerAttack));
if (cost <= 0) {
return { used: false, remaining: 0, cost: 0, insufficient: false };
}
const current = num(weapon.system.ammo?.value);
const max = num(weapon.system.ammo?.max);
if (max <= 0 && current <= 0) {
// Ammo tracking not configured for this weapon; treat as untracked.
return { used: false, remaining: 0, cost, insufficient: false };
}
if (current < cost) {
ui.notifications?.warn(`${weapon.name} is out of ammunition. Reload before attacking.`);
return { used: true, remaining: current, cost, insufficient: true };
}
const next = current - cost;
await actor.updateEmbeddedDocuments("Item", [{ _id: weapon.id, "system.ammo.value": next }]);
return { used: true, remaining: next, cost, insufficient: false };
}
export async function reloadWeapon(actor: any, weapon: any): Promise<void> {
const max = num(weapon.system.ammo?.max);
if (max <= 0) {
ui.notifications?.info(`${weapon.name} has no ammunition capacity configured (set Ammo Max on the weapon).`);
return;
}
await actor.updateEmbeddedDocuments("Item", [{ _id: weapon.id, "system.ammo.value": max }]);
ui.notifications?.info(`${weapon.name} reloaded (${max}/${max}).`);
}
async function consumeWeaponEnergy(actor: any, weapon: any): Promise<{
sourceLabel: string;
remaining: number;
@@ -237,10 +275,25 @@ async function consumeWeaponEnergy(actor: any, weapon: any): Promise<{
if (source === "powerPack") {
const packId = String(weapon.system.powerPackId || "");
const pack = packId ? actor.items.get(packId) : null;
let 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.");
// Auto-link the best carried power pack rather than failing the attack.
pack = actor.items.find((item: any) =>
item.type === "gear"
&& item.system.isPowerPack
&& !item.system.stowed
&& num(item.system.energy?.value) >= cost
) ?? null;
if (pack) {
await actor.updateEmbeddedDocuments("Item", [{ _id: weapon.id, "system.powerPackId": pack.id }]);
ui.notifications?.info(`${weapon.name} linked to ${pack.name}.`);
}
}
if (!pack || pack.type !== "gear" || !pack.system.isPowerPack) {
ui.notifications?.warn("Weapon is configured for a power pack but no valid power pack is carried.");
return { sourceLabel: "Power Pack", remaining: 0, cost, insufficient: true };
}
@@ -376,6 +429,147 @@ async function applyLocationDamage(targetActor: any, slug: HitLocationSlug, rawD
};
}
export async function promptCharacteristicCheck(actor: any, characterKey: string, label: string): Promise<void> {
const validKeys = new Set(["str", "mas", "con", "int", "pow", "dex", "app", "edu", "age"]);
if (!validKeys.has(characterKey)) return;
const base = Math.max(0, num(actor.system?.characteristics?.[characterKey], 0));
const content = `
<form class="rw-stat-roll-form">
<div class="form-group">
<label>Characteristic</label>
<input type="text" value="${foundry.utils.escapeHTML(label)}" disabled />
</div>
<div class="form-group">
<label>Base Value</label>
<input type="number" value="${base}" disabled />
</div>
<div class="form-group">
<label>Multiplier</label>
<select name="multiplier">
<option value="1">x1</option>
<option value="2">x2</option>
<option value="3">x3</option>
<option value="4">x4</option>
<option value="5" selected>x5</option>
<option value="6">x6</option>
<option value="7">x7</option>
</select>
</div>
<p class="hint">Roll d100 against Characteristic x Multiplier.</p>
</form>
`;
const multiplier = await new Promise<number | null>((resolve) => {
let settled = false;
const done = (value: number | null) => {
if (settled) return;
settled = true;
resolve(value);
};
new Dialog({
title: `${label} Check`,
content,
buttons: {
roll: {
label: "Roll",
callback: (dlgHtml: JQuery) => {
const selected = Number((dlgHtml[0]?.querySelector("select[name='multiplier']") as HTMLSelectElement | null)?.value || 1);
const safeMultiplier = Math.max(1, Math.min(7, Math.floor(selected || 1)));
done(safeMultiplier);
}
},
cancel: {
label: "Cancel",
callback: () => done(null)
}
},
default: "roll",
close: () => done(null)
}, {
classes: ["ringworld-attack-dialog"]
}).render(true);
});
if (!multiplier) return;
const target = clampPercentTarget(base * multiplier);
const result = await rollPercentile(target);
await postSkillRollCard({
actor,
actorName: actor.name,
actorImg: actor.img,
skillName: `${label} x${multiplier}`,
roll: result.roll,
target: result.target,
outcomeLabel: outcomeLabel(result.outcome),
isSuccess: result.isSuccess,
isSpecial: result.isSpecial,
isFumble: result.isFumble
});
}
function getWorldSetting(key: string, fallback: boolean): boolean {
try {
return Boolean(game.settings.get(SYSTEM_ID, key));
} catch (_err) {
return fallback;
}
}
async function promptDefenderDodge(targetActor: any, attackWasSpecial: boolean): Promise<{
attempted: boolean;
roll: number;
target: number;
success: boolean;
special: boolean;
negated: boolean;
} | null> {
const dodgeTarget = num(targetActor.system?.derivedRolls?.dodge, 0);
const safeName = foundry.utils.escapeHTML(String(targetActor.name || "Target"));
const attempt = await new Promise<boolean>((resolve) => {
let settled = false;
const done = (value: boolean) => {
if (settled) return;
settled = true;
resolve(value);
};
new Dialog({
title: `${targetActor.name} - Dodge Attempt`,
content: `
<div class="rw-stat-roll-form">
<p><strong>${safeName}</strong> has been hit${attackWasSpecial ? " by a special success" : ""}.</p>
<p class="hint">Dodge ${dodgeTarget}%. A successful dodge evades a normal hit; only a special dodge evades a special hit.</p>
</div>
`,
buttons: {
dodge: { label: `Dodge (${dodgeTarget}%)`, callback: () => done(true) },
none: { label: "No Dodge", callback: () => done(false) }
},
default: "dodge",
close: () => done(false)
}, {
classes: ["ringworld-attack-dialog"],
width: 420
}).render(true);
});
if (!attempt) return null;
const result = await rollPercentile(dodgeTarget);
const negated = result.isSuccess && (!attackWasSpecial || result.isSpecial);
return {
attempted: true,
roll: result.roll,
target: result.target,
success: result.isSuccess,
special: result.isSpecial,
negated
};
}
export async function useWeaponWorkflow(attacker: any, weapon: any): Promise<void> {
const targetActor = getSingleTargetActor();
const hasTarget = Boolean(targetActor);
@@ -385,6 +579,9 @@ export async function useWeaponWorkflow(attacker: any, weapon: any): Promise<voi
const modifierResult = await promptAttackModifiers(attacker, weapon, linkedSkill.target);
if (!modifierResult) return;
const ammo = await consumeWeaponAmmo(attacker, weapon);
if (ammo.insufficient) return;
const energy = await consumeWeaponEnergy(attacker, weapon);
if (energy.insufficient) return;
@@ -418,7 +615,55 @@ export async function useWeaponWorkflow(attacker: any, weapon: any): Promise<voi
energySourceLabel: energy.sourceLabel,
energyCost: energy.cost,
energyRemaining: energy.remaining,
notes: `Attack failed before damage was rolled. ${modifierResult.summary}`
ammoUsed: ammo.used,
ammoCost: ammo.cost,
ammoRemaining: ammo.remaining,
dodge: null,
notes: attack.isFumble
? `Fumble! The GM should adjudicate a mishap (dropped or damaged weapon, hit an ally, drained power). ${modifierResult.summary}`
: `Attack failed before damage was rolled. ${modifierResult.summary}`
});
return;
}
// Give the defender a dodge attempt before damage is applied.
let dodge: Awaited<ReturnType<typeof promptDefenderDodge>> = null;
if (targetActor && getWorldSetting("promptDefenderDodge", true)) {
dodge = await promptDefenderDodge(targetActor, attack.isSpecial);
}
if (dodge?.negated) {
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: false,
hitLocationLabel: "-",
hitLocationRoll: null,
rawDamage: 0,
armor: 0,
finalDamage: 0,
remainingLocationHp: 0,
remainingGeneralHp: num(targetActor.system.generalHp?.value, 0),
energySourceLabel: energy.sourceLabel,
energyCost: energy.cost,
energyRemaining: energy.remaining,
ammoUsed: ammo.used,
ammoCost: ammo.cost,
ammoRemaining: ammo.remaining,
dodge,
notes: `${targetName} dodged the attack. ${modifierResult.summary}`
});
return;
}
@@ -427,8 +672,14 @@ export async function useWeaponWorkflow(attacker: any, weapon: any): Promise<voi
if (!hitSelection) return;
const damageFormula = String(weapon.system.damageFormula || "0").trim() || "0";
const automateSpecial = getWorldSetting("automateSpecialDamage", true);
const rollSpecialDamage = attack.isSpecial && automateSpecial;
const damageRoll = await evaluateRollWithDice(damageFormula);
const rawDamage = Math.max(0, num(damageRoll.total));
let rawDamage = Math.max(0, num(damageRoll.total));
if (rollSpecialDamage) {
const bonusRoll = await evaluateRollWithDice(damageFormula);
rawDamage += Math.max(0, num(bonusRoll.total));
}
const application = targetActor
? await applyLocationDamage(targetActor, hitSelection.slug, rawDamage)
@@ -465,9 +716,17 @@ export async function useWeaponWorkflow(attacker: any, weapon: any): Promise<voi
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}`
ammoUsed: ammo.used,
ammoCost: ammo.cost,
ammoRemaining: ammo.remaining,
dodge,
notes: [
rollSpecialDamage ? "Special success: damage dice rolled twice." : "",
targetActor
? "Location and general HP were both reduced by final damage, per Ringworld rules."
: "No target selected: roll resolved with no damage application.",
modifierResult.summary
].filter(Boolean).join(" ")
});
}
@@ -475,24 +734,19 @@ 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.
// Maximums are always derived; current values are source-authoritative so damage
// survives re-preparation. A null source value means "never set": start at max.
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));
const source = (actor._source ?? actor.toObject?.(false) ?? {}).system ?? {};
actor.system.generalHp = actor.system.generalHp ?? { value: generalMax, max: generalMax };
const sourceGeneralValue = source.generalHp?.value;
actor.system.generalHp = actor.system.generalHp ?? {};
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)
actor.system.generalHp.value = (sourceGeneralValue === null || sourceGeneralValue === undefined)
? 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;
: Math.min(num(sourceGeneralValue, generalMax), generalMax);
actor.system.hitLocations = actor.system.hitLocations ?? {};
actor.system.hitLocationSchema = actor.system.hitLocationSchema ?? "auto";
@@ -502,20 +756,19 @@ export function recomputeHitPointMaximums(actor: any): void {
const slug = entry.slug;
const loc = actor.system.hitLocations[slug] ?? {
label: slug,
hp: { value: 1, max: 1 },
hp: {},
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 sourceLocValue = source.hitLocations?.[slug]?.hp?.value;
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 = loc.hp ?? {};
loc.hp.max = max;
loc.hp.value = (isPlaceholder || shouldBackfillToFull) ? max : Math.min(previousValue, max);
loc.hp.value = (sourceLocValue === null || sourceLocValue === undefined)
? max
: Math.min(num(sourceLocValue, max), max);
actor.system.hitLocations[slug] = loc;
}
}
+95 -11
View File
@@ -1,13 +1,27 @@
import { getActorHitLocationSchema } from "../../hit-location-schemas";
import { getActorHitLocationSchema, listHitLocationSchemas } from "../../hit-location-schemas";
import { promptCharacteristicCheck } from "../../mechanics";
const BaseActorSheet = (foundry as any).appv1.sheets.ActorSheet;
function num(value: unknown, fallback = 0): number {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
function itemArmorAtLocation(item: any, slug: string): 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);
}
export class RingworldCreatureSheet extends BaseActorSheet {
static get defaultOptions(): any {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["ringworld", "sheet", "actor", "creature"],
width: 760,
height: 700
width: 860,
height: 780
});
}
@@ -18,18 +32,33 @@ export class RingworldCreatureSheet extends BaseActorSheet {
async getData(options?: any): Promise<any> {
const data = await super.getData(options);
const actor = this.actor as any;
const schema = getActorHitLocationSchema(actor);
return {
...data,
system: actor.system,
skills: actor.items.filter((item: any) => item.type === "skill"),
skills: actor.items
.filter((item: any) => item.type === "skill")
.sort((a: any, b: any) => a.name.localeCompare(b.name)),
weapons: actor.items.filter((item: any) => item.type === "weapon"),
hitLocations: getActorHitLocationSchema(actor).entries.map((entry) => ({
slug: entry.slug,
rollRange: `${entry.min}-${entry.max}`,
label: entry.label,
...(actor.system.hitLocations?.[entry.slug] ?? {})
}))
armors: actor.items.filter((item: any) => item.type === "armor"),
hitSchemaId: schema.id,
hitSchemas: listHitLocationSchemas().map((entry: any) => ({ id: entry.id, label: entry.label })),
hitLocations: schema.entries.map((entry) => {
const loc = actor.system.hitLocations?.[entry.slug] ?? {};
const baseArmor = num(loc.armor);
const itemArmor = actor.items
.filter((item: any) => item.type === "armor")
.reduce((sum: number, item: any) => sum + itemArmorAtLocation(item, entry.slug), 0);
return {
slug: entry.slug,
rollRange: `${entry.min}-${entry.max}`,
label: entry.label,
armor: baseArmor,
apTotal: baseArmor + itemArmor,
hp: loc.hp ?? { value: 1, max: 1 }
};
})
};
}
@@ -43,31 +72,86 @@ export class RingworldCreatureSheet extends BaseActorSheet {
});
html.find(".item-edit").on("click", (event) => {
event.preventDefault();
const li = (event.currentTarget as HTMLElement).closest(".item");
const item = li ? this.actor.items.get(li.dataset.itemId ?? "") : null;
item?.sheet?.render(true);
});
html.find(".item-delete").on("click", async (event) => {
event.preventDefault();
const li = (event.currentTarget as HTMLElement).closest(".item");
if (!li?.dataset.itemId) return;
await this.actor.deleteEmbeddedDocuments("Item", [li.dataset.itemId]);
});
html.find(".item-roll-skill").on("click", async (event) => {
event.preventDefault();
const li = (event.currentTarget as HTMLElement).closest(".item");
if (!li?.dataset.itemId) return;
await (this.actor as any).rollSkillById(li.dataset.itemId);
});
html.find(".item-use-weapon").on("click", async (event) => {
event.preventDefault();
const li = (event.currentTarget as HTMLElement).closest(".item");
if (!li?.dataset.itemId) return;
await (this.actor as any).useWeaponById(li.dataset.itemId);
});
html.find(".roll-hit-location").on("click", async () => {
html.find(".item-reload-weapon").on("click", async (event) => {
event.preventDefault();
const li = (event.currentTarget as HTMLElement).closest(".item");
if (!li?.dataset.itemId) return;
await (this.actor as any).reloadWeaponById(li.dataset.itemId);
});
html.find(".roll-hit-location").on("click", async (event) => {
event.preventDefault();
await (this.actor as any).rollHitLocation();
});
html.find(".roll-characteristic").on("click", async (event) => {
event.preventDefault();
const button = event.currentTarget as HTMLElement;
const key = String(button.dataset.char || "").trim().toLowerCase();
const label = String(button.dataset.label || key.toUpperCase());
if (!key) return;
await promptCharacteristicCheck(this.actor, key, label);
});
html.find(".roll-derived").on("click", async (event) => {
event.preventDefault();
const key = String((event.currentTarget as HTMLElement).dataset.check || "");
if (!key) return;
await (this.actor as any).rollDerivedCheck(key);
});
html.find(".reset-conditions").on("click", async (event) => {
event.preventDefault();
await this.actor.update({
"system.conditions.prone": false,
"system.conditions.unconscious": false,
"system.conditions.dead": false,
"system.conditions.dying": false,
"system.conditions.unusableLimbs.rArm": false,
"system.conditions.unusableLimbs.lArm": false,
"system.conditions.unusableLimbs.rLeg": false,
"system.conditions.unusableLimbs.lLeg": false
});
});
html.find(".rw-portrait-img").on("click", (event) => {
event.preventDefault();
const FilePickerImpl = (foundry as any).applications?.apps?.FilePicker?.implementation ?? (globalThis as any).FilePicker;
const picker = new FilePickerImpl({
type: "image",
current: (this.actor as any).img,
callback: async (path: string) => {
await this.actor.update({ img: path });
}
});
picker.render(true);
});
}
}
+9 -81
View File
@@ -10,6 +10,7 @@ import { getActorHitLocationSchema, listHitLocationSchemas } from "../../hit-loc
import { PURSUIT_CATALOG } from "../../world-catalog";
import { outcomeLabel, rollPercentile } from "../../utils/percentile";
import { postSkillRollCard } from "../../chat";
import { promptCharacteristicCheck } from "../../mechanics";
const BaseActorSheet = (foundry as any).appv1.sheets.ActorSheet;
@@ -777,86 +778,6 @@ export class RingworldExplorerSheet extends BaseActorSheet {
await this.syncSkillsFromBuilderState(nextState);
}
private async rollCharacteristicCheck(characterKey: string, label: string): Promise<void> {
const validKeys = new Set(["str", "mas", "con", "int", "pow", "dex", "app", "edu", "age"]);
if (!validKeys.has(characterKey)) return;
const base = Math.max(0, toInt((this.actor as any).system?.characteristics?.[characterKey], 0));
const content = `
<form class="rw-stat-roll-form">
<div class="form-group">
<label>Characteristic</label>
<input type="text" value="${foundry.utils.escapeHTML(label)}" disabled />
</div>
<div class="form-group">
<label>Base Value</label>
<input type="number" value="${base}" disabled />
</div>
<div class="form-group">
<label>Multiplier</label>
<select name="multiplier">
<option value="1">x1</option>
<option value="2">x2</option>
<option value="3">x3</option>
<option value="4">x4</option>
<option value="5">x5</option>
<option value="6">x6</option>
<option value="7">x7</option>
</select>
</div>
<p class="hint">Roll d100 against Characteristic x Multiplier.</p>
</form>
`;
const multiplier = await new Promise<number | null>((resolve) => {
let settled = false;
const done = (value: number | null) => {
if (settled) return;
settled = true;
resolve(value);
};
new Dialog({
title: `${label} Check`,
content,
buttons: {
roll: {
label: "Roll",
callback: (dlgHtml: JQuery) => {
const selected = Number((dlgHtml[0]?.querySelector("select[name='multiplier']") as HTMLSelectElement | null)?.value || 1);
const safeMultiplier = Math.max(1, Math.min(7, Math.floor(selected || 1)));
done(safeMultiplier);
}
},
cancel: {
label: "Cancel",
callback: () => done(null)
}
},
default: "roll",
close: () => done(null)
}).render(true);
});
if (!multiplier) return;
const target = clampPercent(base * multiplier);
const result = await rollPercentile(target);
await postSkillRollCard({
actor: this.actor,
actorName: this.actor.name,
actorImg: this.actor.img,
skillName: `${label} x${multiplier}`,
skillImg: DEFAULT_SKILL_ICON,
roll: result.roll,
target: result.target,
outcomeLabel: outcomeLabel(result.outcome),
isSuccess: result.isSuccess,
isSpecial: result.isSpecial,
isFumble: result.isFumble
});
}
activateListeners(html: JQuery): void {
super.activateListeners(html);
@@ -873,7 +794,7 @@ export class RingworldExplorerSheet extends BaseActorSheet {
const key = String(button.dataset.char || "").trim().toLowerCase();
const label = String(button.dataset.label || key.toUpperCase());
if (!key) return;
await this.rollCharacteristicCheck(key, label);
await promptCharacteristicCheck(this.actor, key, label);
});
html.find(".item-create").on("click", async (event) => {
@@ -922,6 +843,13 @@ export class RingworldExplorerSheet extends BaseActorSheet {
await (this.actor as any).useWeaponById(li.dataset.itemId);
});
html.find(".item-reload-weapon").on("click", async (event) => {
event.preventDefault();
const li = (event.currentTarget as HTMLElement).closest(".item");
if (!li?.dataset.itemId) return;
await (this.actor as any).reloadWeaponById(li.dataset.itemId);
});
html.find(".item-toggle-equipped").on("click", async (event) => {
event.preventDefault();
const li = (event.currentTarget as HTMLElement).closest(".item");
+6 -2
View File
@@ -1,6 +1,7 @@
import { SYSTEM_ID } from "./constants";
import { normalizeSkillName, RINGWORLD_BASE_SKILLS } from "./data-base-skills";
import { skillIconPath } from "./skill-icons";
import { seedGmReference } from "./gm-reference";
export type GravityType = "light" | "normal" | "heavy";
@@ -78,7 +79,7 @@ interface CatalogSeed {
img: string;
}
export const RINGWORLD_CATALOG_VERSION = 9;
export const RINGWORLD_CATALOG_VERSION = 10;
const ICONS = {
speciesDefault: "systems/ringworld/assets/icons/species.svg",
@@ -962,8 +963,11 @@ export async function seedWorldCatalog(options?: { notify?: boolean }): Promise<
await Item.updateDocuments(updatePayload as any[]);
}
const addedReference = await seedGmReference();
if (options?.notify) {
ui.notifications?.info(`Ringworld catalog synced: +${createPayload.length} new, ${updatePayload.length} updated.`);
const referenceNote = addedReference ? " GM Reference journal added." : "";
ui.notifications?.info(`Ringworld catalog synced: +${createPayload.length} new, ${updatePayload.length} updated.${referenceNote}`);
}
return { added: createPayload.length, updated: updatePayload.length };