Initial Ringworld system publish with companion content module and starter world data
This commit is contained in:
@@ -0,0 +1,335 @@
|
||||
import { recomputeHitPointMaximums, 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";
|
||||
|
||||
type DerivedCheckKey = "health" | "reasoning" | "luck" | "dodge";
|
||||
type SpeciesModifierKey = "str" | "mas" | "con" | "int" | "pow" | "dex" | "app" | "edu" | "age";
|
||||
const SPECIES_MODIFIER_KEYS: SpeciesModifierKey[] = ["str", "mas", "con", "int", "pow", "dex", "app", "edu", "age"];
|
||||
|
||||
function num(value: unknown, fallback = 0): number {
|
||||
const n = Number(value);
|
||||
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)));
|
||||
}
|
||||
|
||||
function computeDamageMod(str: number, mas: number): string {
|
||||
const total = str + mas;
|
||||
if (total <= 12) return "-1d6";
|
||||
if (total <= 24) return "-1d4";
|
||||
if (total <= 32) return "0";
|
||||
if (total <= 40) return "+1d4";
|
||||
if (total <= 56) return "+1d6";
|
||||
if (total <= 72) return "+2d6";
|
||||
return "+3d6";
|
||||
}
|
||||
|
||||
function computeDerivedRolls(characteristics: any): Record<DerivedCheckKey, number> {
|
||||
const con = num(characteristics?.con, 10);
|
||||
const int = num(characteristics?.int, 10);
|
||||
const pow = num(characteristics?.pow, 10);
|
||||
const dex = num(characteristics?.dex, 10);
|
||||
|
||||
return {
|
||||
health: Math.max(0, Math.min(99, con * 5)),
|
||||
reasoning: Math.max(0, Math.min(99, int * 5)),
|
||||
luck: Math.max(0, Math.min(99, pow * 5)),
|
||||
dodge: Math.max(0, Math.min(99, dex * 5))
|
||||
};
|
||||
}
|
||||
|
||||
function computeRootMaxes(characteristics: any): Record<string, number> {
|
||||
const str = num(characteristics?.str, 10);
|
||||
const dex = num(characteristics?.dex, 10);
|
||||
const app = num(characteristics?.app, 10);
|
||||
const edu = num(characteristics?.edu, 10);
|
||||
const int = num(characteristics?.int, 10);
|
||||
const pow = num(characteristics?.pow, 10);
|
||||
const con = num(characteristics?.con, 10);
|
||||
|
||||
return {
|
||||
agility: Math.max(0, str + dex),
|
||||
communication: Math.max(0, int + app),
|
||||
knowledge: Math.max(0, int + edu),
|
||||
perception: Math.max(0, pow + con),
|
||||
technical: Math.max(0, dex + int)
|
||||
};
|
||||
}
|
||||
|
||||
function computeActionRanking(dex: number): number {
|
||||
if (dex <= 4) return 7;
|
||||
if (dex <= 8) return 6;
|
||||
if (dex <= 12) return 5;
|
||||
if (dex <= 16) return 4;
|
||||
if (dex <= 20) return 3;
|
||||
if (dex <= 24) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function gravityLoadMultiplier(gravity: string): number {
|
||||
if (gravity === "light") return 0.5;
|
||||
if (gravity === "heavy") return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function contributesToEncumbrance(item: any): boolean {
|
||||
if (item.type !== "weapon" && item.type !== "armor" && item.type !== "gear") return false;
|
||||
return item.system?.stowed !== true;
|
||||
}
|
||||
|
||||
function itemWeightKg(item: any): number {
|
||||
return Math.max(0, num(item.system?.weight, 0));
|
||||
}
|
||||
|
||||
function computeEncumbrance(system: any, items: any[]): {
|
||||
rawLoadKg: number;
|
||||
effectiveLoadKg: number;
|
||||
limitKg: number;
|
||||
overLimitKg: number;
|
||||
actionRankingPenalty: number;
|
||||
overLimit: boolean;
|
||||
currentMove: number;
|
||||
currentRun: number;
|
||||
gravityMultiplier: number;
|
||||
} {
|
||||
const gravity = String(system?.profile?.gravity || "normal");
|
||||
const gravityMultiplier = gravityLoadMultiplier(gravity);
|
||||
const equippedLoadKg = items
|
||||
.filter((item) => item.type === "weapon" || item.type === "armor" || item.type === "gear")
|
||||
.filter(contributesToEncumbrance)
|
||||
.reduce((sum, item) => sum + itemWeightKg(item), 0);
|
||||
|
||||
const rawLoadKg = Math.max(0, equippedLoadKg);
|
||||
const effectiveLoadKg = rawLoadKg * gravityMultiplier;
|
||||
const limitKg = Math.max(0, num(system?.characteristics?.str, 0));
|
||||
const overLimitKg = Math.max(0, effectiveLoadKg - limitKg);
|
||||
const actionRankingPenalty = overLimitKg > 0 ? Math.ceil(overLimitKg / 10) : 0;
|
||||
const overLimit = overLimitKg > 0;
|
||||
|
||||
const baseMove = Math.max(1, num(system?.derived?.move, 3));
|
||||
const baseRun = Math.max(1, num(system?.derived?.run, baseMove * 2));
|
||||
const currentMove = overLimit ? 2 : baseMove;
|
||||
const currentRun = overLimit ? 4 : baseRun;
|
||||
|
||||
return {
|
||||
rawLoadKg,
|
||||
effectiveLoadKg,
|
||||
limitKg,
|
||||
overLimitKg,
|
||||
actionRankingPenalty,
|
||||
overLimit,
|
||||
currentMove,
|
||||
currentRun,
|
||||
gravityMultiplier
|
||||
};
|
||||
}
|
||||
|
||||
export class RingworldActor extends Actor {
|
||||
prepareDerivedData(): void {
|
||||
super.prepareDerivedData();
|
||||
recomputeHitPointMaximums(this as any);
|
||||
|
||||
if (this.type !== "explorer" && this.type !== "creature") return;
|
||||
|
||||
const system = (this as any).system ?? {};
|
||||
const characteristics = system.characteristics ?? {};
|
||||
const profile = system.profile ?? {};
|
||||
|
||||
profile.chronologicalAge = num(profile.chronologicalAge, num(characteristics.age, 25));
|
||||
profile.physiologicalAge = num(profile.physiologicalAge, profile.chronologicalAge);
|
||||
system.profile = profile;
|
||||
|
||||
system.combat = system.combat ?? {};
|
||||
if (!hasValue(system.combat.damageMod)) {
|
||||
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.skillRootMax = system.skillRootMax ?? {};
|
||||
const rootMaxDefaults = computeRootMaxes(characteristics);
|
||||
for (const [key, value] of Object.entries(rootMaxDefaults)) {
|
||||
system.skillRootMax[key] = value;
|
||||
}
|
||||
|
||||
system.derived = system.derived ?? {};
|
||||
system.derived.actionRanking = computeActionRanking(num(characteristics.dex, 10));
|
||||
system.derived.move = num(system.derived.move, 3);
|
||||
system.derived.run = num(system.derived.run, Math.max(1, num(system.derived.move, 3) * 2));
|
||||
|
||||
system.encumbrance = system.encumbrance ?? {};
|
||||
const encumbrance = computeEncumbrance(system, this.items as any[]);
|
||||
system.encumbrance.rawLoadKg = encumbrance.rawLoadKg;
|
||||
system.encumbrance.effectiveLoadKg = encumbrance.effectiveLoadKg;
|
||||
system.encumbrance.limitKg = encumbrance.limitKg;
|
||||
system.encumbrance.overLimitKg = encumbrance.overLimitKg;
|
||||
system.encumbrance.actionRankingPenalty = encumbrance.actionRankingPenalty;
|
||||
system.encumbrance.overLimit = encumbrance.overLimit;
|
||||
system.encumbrance.gravityMultiplier = encumbrance.gravityMultiplier;
|
||||
system.derived.currentMove = encumbrance.currentMove;
|
||||
system.derived.currentRun = encumbrance.currentRun;
|
||||
|
||||
system.finance = system.finance ?? { money: 0, inBank: 0 };
|
||||
system.finance.money = num(system.finance.money, 0);
|
||||
system.finance.inBank = num(system.finance.inBank, 0);
|
||||
|
||||
system.notes = system.notes ?? { defects: "", habits: "" };
|
||||
if (typeof system.sheetToolsUnlocked !== "boolean") {
|
||||
system.sheetToolsUnlocked = false;
|
||||
}
|
||||
system.characterBuilder = system.characterBuilder ?? {
|
||||
pursuits: [],
|
||||
allocations: {},
|
||||
baseBySkillId: {}
|
||||
};
|
||||
system.unconsciousLevel = num(system.unconsciousLevel, 0);
|
||||
}
|
||||
|
||||
get skills(): any[] {
|
||||
return this.items.filter((item) => item.type === "skill");
|
||||
}
|
||||
|
||||
get weapons(): any[] {
|
||||
return this.items.filter((item) => item.type === "weapon");
|
||||
}
|
||||
|
||||
get armors(): any[] {
|
||||
return this.items.filter((item) => item.type === "armor");
|
||||
}
|
||||
|
||||
get gears(): any[] {
|
||||
return this.items.filter((item) => item.type === "gear");
|
||||
}
|
||||
|
||||
async rollSkillById(skillId: string): Promise<void> {
|
||||
const skill = this.items.get(skillId);
|
||||
if (!skill || skill.type !== "skill") return;
|
||||
await rollSkillForActor(this, skill.name, Number((skill as any).system.value ?? 0));
|
||||
}
|
||||
|
||||
async rollHitLocation(): Promise<void> {
|
||||
await rollStandaloneHitLocation(this);
|
||||
}
|
||||
|
||||
async useWeaponById(weaponId: string): Promise<void> {
|
||||
const weapon = this.items.get(weaponId);
|
||||
if (!weapon || weapon.type !== "weapon") return;
|
||||
await useWeaponWorkflow(this, weapon);
|
||||
}
|
||||
|
||||
async ensureBaseSkills(): Promise<number> {
|
||||
if (this.type !== "explorer" && this.type !== "creature") return 0;
|
||||
|
||||
const existing = new Set(
|
||||
this.items
|
||||
.filter((item) => item.type === "skill")
|
||||
.map((item) => normalizeSkillName(item.name))
|
||||
);
|
||||
|
||||
const toCreate = RINGWORLD_BASE_SKILLS
|
||||
.filter((skill) => !existing.has(normalizeSkillName(skill.name)))
|
||||
.map((skill) => ({
|
||||
name: skill.name,
|
||||
type: "skill",
|
||||
img: skillIconPath(skill.name),
|
||||
system: {
|
||||
value: skill.base,
|
||||
category: skill.category,
|
||||
specialization: skill.specialization ?? ""
|
||||
}
|
||||
}));
|
||||
|
||||
if (!toCreate.length) return 0;
|
||||
await this.createEmbeddedDocuments("Item", toCreate as any[]);
|
||||
return toCreate.length;
|
||||
}
|
||||
|
||||
async rollCharacteristics(): Promise<void> {
|
||||
if (this.type !== "explorer") return;
|
||||
await rollCharacteristicsForActor(this as any);
|
||||
}
|
||||
|
||||
async randomizeCharacter(): Promise<void> {
|
||||
if (this.type !== "explorer") return;
|
||||
await randomizeExplorerActor(this as any);
|
||||
}
|
||||
|
||||
async applySpeciesItem(speciesData: any): Promise<void> {
|
||||
if (this.type !== "explorer" && this.type !== "creature") return;
|
||||
|
||||
const speciesName = String(speciesData?.name || "").trim();
|
||||
const speciesSystem = speciesData?.system ?? {};
|
||||
const incomingMods = speciesSystem?.modifiers ?? {};
|
||||
const appliedState = (this as any).system?.speciesApplied ?? {};
|
||||
const previousMods = appliedState?.modifiers ?? {};
|
||||
|
||||
const currentCharacteristics = (this as any).system?.characteristics ?? {};
|
||||
const currentProfile = (this as any).system?.profile ?? {};
|
||||
const update: Record<string, unknown> = {};
|
||||
const normalizedMods: Record<SpeciesModifierKey, number> = {
|
||||
str: 0,
|
||||
mas: 0,
|
||||
con: 0,
|
||||
int: 0,
|
||||
pow: 0,
|
||||
dex: 0,
|
||||
app: 0,
|
||||
edu: 0,
|
||||
age: 0
|
||||
};
|
||||
|
||||
for (const key of SPECIES_MODIFIER_KEYS) {
|
||||
const prev = num(previousMods?.[key], 0);
|
||||
const next = num(incomingMods?.[key], 0);
|
||||
normalizedMods[key] = next;
|
||||
|
||||
const current = num(currentCharacteristics?.[key], key === "age" ? 25 : 10);
|
||||
const base = current - prev;
|
||||
update[`system.characteristics.${key}`] = clampCharacteristic(base + next);
|
||||
}
|
||||
|
||||
const agePrev = num(previousMods?.age, 0);
|
||||
const ageNext = normalizedMods.age;
|
||||
const chronoCurrent = num(currentProfile?.chronologicalAge, num(currentCharacteristics?.age, 25));
|
||||
const physCurrent = num(currentProfile?.physiologicalAge, chronoCurrent);
|
||||
update["system.profile.chronologicalAge"] = clampCharacteristic((chronoCurrent - agePrev) + ageNext);
|
||||
update["system.profile.physiologicalAge"] = clampCharacteristic((physCurrent - agePrev) + ageNext);
|
||||
|
||||
if (speciesName) update["system.profile.species"] = speciesName;
|
||||
if (speciesSystem?.gravity) update["system.profile.gravity"] = String(speciesSystem.gravity);
|
||||
if (speciesSystem?.homeworld !== undefined) update["system.profile.homeworld"] = String(speciesSystem.homeworld || "");
|
||||
|
||||
update["system.speciesApplied"] = {
|
||||
name: speciesName,
|
||||
modifiers: normalizedMods
|
||||
};
|
||||
|
||||
await this.update(update);
|
||||
}
|
||||
|
||||
async rollDerivedCheck(key: DerivedCheckKey): Promise<void> {
|
||||
if (this.type !== "explorer" && this.type !== "creature") return;
|
||||
|
||||
const labels: Record<DerivedCheckKey, string> = {
|
||||
health: "Health Roll",
|
||||
reasoning: "Reasoning Roll",
|
||||
luck: "Luck Roll",
|
||||
dodge: "Dodge Roll"
|
||||
};
|
||||
|
||||
const defaults = computeDerivedRolls((this as any).system?.characteristics ?? {});
|
||||
const target = num((this as any).system?.derivedRolls?.[key], defaults[key]);
|
||||
await rollSkillForActor(this, labels[key], target);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { speciesCatalogForGeneration, getPlayableSpeciesFromWorld } from "./world-catalog";
|
||||
import { evaluateRollWithDice } from "./utils/dice";
|
||||
|
||||
type GravityType = "light" | "normal" | "heavy";
|
||||
|
||||
interface CharacteristicSet {
|
||||
str: number;
|
||||
mas: number;
|
||||
con: number;
|
||||
int: number;
|
||||
pow: number;
|
||||
dex: number;
|
||||
app: number;
|
||||
edu: number;
|
||||
age: number;
|
||||
}
|
||||
|
||||
function clampScore(value: number): number {
|
||||
return Math.max(1, Math.min(99, Math.floor(value)));
|
||||
}
|
||||
|
||||
async function rollTotal(formula: string): Promise<number> {
|
||||
const roll = await evaluateRollWithDice(formula);
|
||||
return Number(roll.total ?? 0);
|
||||
}
|
||||
|
||||
async function rollEdu(): Promise<number> {
|
||||
let edu = 10;
|
||||
while (true) {
|
||||
const die = await rollTotal("1d6");
|
||||
edu += die;
|
||||
if (die <= 4) break;
|
||||
}
|
||||
return clampScore(edu);
|
||||
}
|
||||
|
||||
async function rollByGravity(gravity: GravityType): Promise<CharacteristicSet> {
|
||||
const gravityFormulas = {
|
||||
light: { str: "2d6+3", mas: "2d6+3", con: "2d6+6", dex: "2d6+9" },
|
||||
normal: { str: "2d6+6", mas: "2d6+6", con: "2d6+6", dex: "2d6+6" },
|
||||
heavy: { str: "2d6+9", mas: "2d6+9", con: "2d6+3", dex: "2d6+6" }
|
||||
}[gravity];
|
||||
|
||||
return {
|
||||
str: await rollTotal(gravityFormulas.str),
|
||||
mas: await rollTotal(gravityFormulas.mas),
|
||||
con: await rollTotal(gravityFormulas.con),
|
||||
int: await rollTotal("2d6+6"),
|
||||
pow: await rollTotal("2d6+6"),
|
||||
dex: await rollTotal(gravityFormulas.dex),
|
||||
app: await rollTotal("2d6+6"),
|
||||
edu: await rollEdu(),
|
||||
age: await rollTotal("1d20+16")
|
||||
};
|
||||
}
|
||||
|
||||
function randomPick<T>(arr: T[]): T {
|
||||
return arr[Math.floor(Math.random() * arr.length)];
|
||||
}
|
||||
|
||||
function randomExplorerName(speciesName: string): string {
|
||||
const starts = ["Ar", "Bel", "Cor", "Den", "Ela", "Far", "Gal", "Hel", "Ira", "Jun", "Kor", "Lys", "Mor", "Nyr", "Or", "Pra", "Qua", "Rin", "Sol", "Tor", "Uri", "Val", "Wen", "Xel", "Yor", "Zen"];
|
||||
const ends = ["an", "el", "is", "or", "us", "a", "en", "ion", "eth", "al", "ir", "os"];
|
||||
return `${randomPick(starts)}${randomPick(ends)} (${speciesName})`;
|
||||
}
|
||||
|
||||
function applySpeciesModifiers(base: CharacteristicSet, speciesSystem: any): CharacteristicSet {
|
||||
const mods = speciesSystem?.modifiers ?? {};
|
||||
return {
|
||||
str: clampScore(base.str + Number(mods.str || 0)),
|
||||
mas: clampScore(base.mas + Number(mods.mas || 0)),
|
||||
con: clampScore(base.con + Number(mods.con || 0)),
|
||||
int: clampScore(base.int + Number(mods.int || 0)),
|
||||
pow: clampScore(base.pow + Number(mods.pow || 0)),
|
||||
dex: clampScore(base.dex + Number(mods.dex || 0)),
|
||||
app: clampScore(base.app + Number(mods.app || 0)),
|
||||
edu: clampScore(base.edu + Number(mods.edu || 0)),
|
||||
age: clampScore(base.age + Number(mods.age || 0))
|
||||
};
|
||||
}
|
||||
|
||||
export async function rollCharacteristicsForActor(actor: any): Promise<void> {
|
||||
const gravity = String(actor.system?.profile?.gravity || "normal") as GravityType;
|
||||
const gravityType: GravityType = gravity === "light" || gravity === "heavy" ? gravity : "normal";
|
||||
|
||||
const currentSpeciesName = String(actor.system?.profile?.species || "");
|
||||
const worldSpecies = getPlayableSpeciesFromWorld();
|
||||
const speciesItem = worldSpecies.find((item: any) => item.name === currentSpeciesName);
|
||||
const builtIn = speciesCatalogForGeneration().find((entry) => entry.name === currentSpeciesName);
|
||||
const speciesSystem = speciesItem?.system ?? builtIn ?? null;
|
||||
|
||||
const rolled = await rollByGravity(gravityType);
|
||||
const finalValues = applySpeciesModifiers(rolled, speciesSystem);
|
||||
|
||||
await actor.update({
|
||||
"system.characteristics.str": finalValues.str,
|
||||
"system.characteristics.mas": finalValues.mas,
|
||||
"system.characteristics.con": finalValues.con,
|
||||
"system.characteristics.int": finalValues.int,
|
||||
"system.characteristics.pow": finalValues.pow,
|
||||
"system.characteristics.dex": finalValues.dex,
|
||||
"system.characteristics.app": finalValues.app,
|
||||
"system.characteristics.edu": finalValues.edu,
|
||||
"system.characteristics.age": finalValues.age,
|
||||
"system.profile.chronologicalAge": finalValues.age,
|
||||
"system.profile.physiologicalAge": finalValues.age
|
||||
});
|
||||
|
||||
const ownLanguage = actor.items.find((item: any) => item.type === "skill" && item.name.toLowerCase() === "own language");
|
||||
if (ownLanguage) {
|
||||
await actor.updateEmbeddedDocuments("Item", [{
|
||||
_id: ownLanguage.id,
|
||||
"system.value": Math.min(99, finalValues.int * 5)
|
||||
}]);
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyStarterKit(actor: any): Promise<void> {
|
||||
const starterNames = [
|
||||
["weapon", "Laser Pistol"],
|
||||
["weapon", "Variable Sword"],
|
||||
["armor", "Soft Combat Vest"],
|
||||
["gear", "Power Pack R20"],
|
||||
["gear", "Medkit"],
|
||||
["gear", "Survey Scanner"]
|
||||
];
|
||||
|
||||
const worldItems = game.items;
|
||||
const actorExisting = new Set(actor.items.map((item: any) => `${item.type}:${item.name.toLowerCase()}`));
|
||||
const createPayload: any[] = [];
|
||||
|
||||
for (const [type, name] of starterNames) {
|
||||
if (actorExisting.has(`${type}:${String(name).toLowerCase()}`)) continue;
|
||||
const worldItem = worldItems.find((item: any) => item.type === type && item.name === name);
|
||||
if (!worldItem) continue;
|
||||
createPayload.push(worldItem.toObject());
|
||||
}
|
||||
|
||||
if (createPayload.length) {
|
||||
await actor.createEmbeddedDocuments("Item", createPayload);
|
||||
}
|
||||
}
|
||||
|
||||
export async function randomizeExplorerActor(actor: any): Promise<void> {
|
||||
const worldSpecies = getPlayableSpeciesFromWorld();
|
||||
const builtInSpecies = speciesCatalogForGeneration();
|
||||
|
||||
const pickedWorldSpecies = worldSpecies.length ? randomPick(worldSpecies) : null;
|
||||
const pickedBuiltInSpecies = !pickedWorldSpecies ? randomPick(builtInSpecies) : null;
|
||||
|
||||
const speciesChoice = pickedWorldSpecies?.system ?? pickedBuiltInSpecies;
|
||||
const speciesName = pickedWorldSpecies?.name ?? pickedBuiltInSpecies?.name ?? "Human";
|
||||
|
||||
const gravity = String(speciesChoice.gravity || "normal") as GravityType;
|
||||
const homeworld = String(speciesChoice.homeworld || "Ringworld");
|
||||
|
||||
await actor.update({
|
||||
name: randomExplorerName(speciesName),
|
||||
"system.profile.species": speciesName,
|
||||
"system.profile.gravity": gravity,
|
||||
"system.profile.homeworld": homeworld
|
||||
});
|
||||
|
||||
await rollCharacteristicsForActor(actor);
|
||||
await actor.ensureBaseSkills?.();
|
||||
await applyStarterKit(actor);
|
||||
}
|
||||
|
||||
export async function createRandomExplorer(): Promise<any> {
|
||||
const ActorClass = getDocumentClass("Actor");
|
||||
const actor = await ActorClass.create({
|
||||
name: "Random Explorer",
|
||||
type: "explorer"
|
||||
});
|
||||
|
||||
await randomizeExplorerActor(actor);
|
||||
return actor;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { SYSTEM_ID } from "./constants";
|
||||
import { DEFAULT_SKILL_ICON } from "./skill-icons";
|
||||
|
||||
interface CreateChatOptions {
|
||||
actor?: any;
|
||||
content: string;
|
||||
}
|
||||
|
||||
async function createChatMessage(options: CreateChatOptions): Promise<void> {
|
||||
const speaker = options.actor ? ChatMessage.getSpeaker({ actor: options.actor }) : ChatMessage.getSpeaker();
|
||||
await ChatMessage.create({
|
||||
user: game.user?.id,
|
||||
speaker,
|
||||
content: options.content
|
||||
});
|
||||
}
|
||||
|
||||
export async function postSkillRollCard(data: {
|
||||
actor: any;
|
||||
actorName: string;
|
||||
actorImg?: string;
|
||||
skillName: string;
|
||||
skillImg?: string;
|
||||
roll: number;
|
||||
target: number;
|
||||
outcomeLabel: string;
|
||||
isSuccess: boolean;
|
||||
isSpecial: boolean;
|
||||
isFumble: boolean;
|
||||
}): Promise<void> {
|
||||
const payload = {
|
||||
...data,
|
||||
actorImg: data.actorImg || data.actor?.img || "icons/svg/mystery-man.svg",
|
||||
skillImg: data.skillImg || DEFAULT_SKILL_ICON
|
||||
};
|
||||
const content = await renderTemplate(`systems/${SYSTEM_ID}/templates/chat/skill-roll-card.hbs`, payload);
|
||||
await createChatMessage({ actor: data.actor, content });
|
||||
}
|
||||
|
||||
export async function postAttackCard(data: {
|
||||
actor: any;
|
||||
actorName: string;
|
||||
actorImg?: string;
|
||||
targetName: string;
|
||||
hasTarget?: boolean;
|
||||
weaponName: string;
|
||||
weaponImg?: string;
|
||||
skillName: string;
|
||||
skillImg?: string;
|
||||
attackRoll: number;
|
||||
attackTarget: number;
|
||||
attackOutcomeLabel: string;
|
||||
isSuccess: boolean;
|
||||
isSpecial: boolean;
|
||||
isFumble: boolean;
|
||||
hitLocationLabel: string;
|
||||
hitLocationRoll: number | null;
|
||||
rawDamage: number;
|
||||
armor: number;
|
||||
finalDamage: number;
|
||||
remainingLocationHp: number;
|
||||
remainingGeneralHp: number;
|
||||
energySourceLabel: string;
|
||||
energyCost: number;
|
||||
energyRemaining: number;
|
||||
notes: string;
|
||||
}): Promise<void> {
|
||||
const payload = {
|
||||
...data,
|
||||
actorImg: data.actorImg || data.actor?.img || "icons/svg/mystery-man.svg",
|
||||
weaponImg: data.weaponImg || "icons/svg/sword.svg",
|
||||
skillImg: data.skillImg || DEFAULT_SKILL_ICON,
|
||||
hasTarget: data.hasTarget ?? true
|
||||
};
|
||||
const content = await renderTemplate(`systems/${SYSTEM_ID}/templates/chat/attack-card.hbs`, payload);
|
||||
await createChatMessage({ actor: data.actor, content });
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
export type HitLocationSlug = "rLeg" | "lLeg" | "abdomen" | "chest" | "rArm" | "lArm" | "head";
|
||||
|
||||
export interface HitLocationEntry {
|
||||
slug: HitLocationSlug;
|
||||
label: string;
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
export const SYSTEM_ID = "ringworld";
|
||||
|
||||
export const HIT_LOCATION_TABLE: HitLocationEntry[] = [
|
||||
{ slug: "rLeg", label: "Right Leg", min: 1, max: 3 },
|
||||
{ slug: "lLeg", label: "Left Leg", min: 4, max: 6 },
|
||||
{ slug: "abdomen", label: "Abdomen", min: 7, max: 10 },
|
||||
{ slug: "chest", label: "Chest", min: 11, max: 15 },
|
||||
{ slug: "rArm", label: "Right Arm", min: 16, max: 17 },
|
||||
{ slug: "lArm", label: "Left Arm", min: 18, max: 19 },
|
||||
{ slug: "head", label: "Head", min: 20, max: 20 }
|
||||
];
|
||||
|
||||
export const HIT_LOCATION_ORDER: HitLocationSlug[] = [
|
||||
"rLeg",
|
||||
"lLeg",
|
||||
"abdomen",
|
||||
"chest",
|
||||
"rArm",
|
||||
"lArm",
|
||||
"head"
|
||||
];
|
||||
|
||||
export const LIMB_LOCATIONS: HitLocationSlug[] = ["rArm", "lArm", "rLeg", "lLeg"];
|
||||
export const LEG_LOCATIONS: HitLocationSlug[] = ["rLeg", "lLeg"];
|
||||
|
||||
export function isLimb(slug: HitLocationSlug): boolean {
|
||||
return LIMB_LOCATIONS.includes(slug);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
export interface BaseSkillDef {
|
||||
name: string;
|
||||
category: "Agility" | "Communication" | "Knowledge" | "Perception" | "Technical";
|
||||
base: number;
|
||||
specialization?: string;
|
||||
}
|
||||
|
||||
export const BASE_SKILL_CATEGORIES: Array<BaseSkillDef["category"]> = [
|
||||
"Agility",
|
||||
"Communication",
|
||||
"Knowledge",
|
||||
"Perception",
|
||||
"Technical"
|
||||
];
|
||||
|
||||
export const RINGWORLD_BASE_SKILLS: BaseSkillDef[] = [
|
||||
{ name: "Archaic Melee Weapon", category: "Agility", base: 5 },
|
||||
{ name: "Archaic Ranged Weapons", category: "Agility", base: 3 },
|
||||
{ name: "Athletics", category: "Agility", base: 15 },
|
||||
{ name: "Hide", category: "Agility", base: 10 },
|
||||
{ name: "Sneak", category: "Agility", base: 5 },
|
||||
{ name: "Unarmed Combat", category: "Agility", base: 0 },
|
||||
{ name: "V. Sword, F. Laser", category: "Agility", base: 15 },
|
||||
|
||||
{ name: "Bargain", category: "Communication", base: 10 },
|
||||
{ name: "Debate", category: "Communication", base: 5 },
|
||||
{ name: "Fast Talk", category: "Communication", base: 10 },
|
||||
{ name: "Fine Arts", category: "Communication", base: 5 },
|
||||
{ name: "Musicianship", category: "Communication", base: 5 },
|
||||
{ name: "Orate", category: "Communication", base: 5 },
|
||||
{ name: "Own Language", category: "Communication", base: 0, specialization: "INTx5 base" },
|
||||
{ name: "Perform", category: "Communication", base: 5 },
|
||||
{ name: "Psychology", category: "Communication", base: 0 },
|
||||
|
||||
{ name: "Anthropology", category: "Knowledge", base: 0 },
|
||||
{ name: "Astronomy", category: "Knowledge", base: 0 },
|
||||
{ name: "Biology", category: "Knowledge", base: 0 },
|
||||
{ name: "Botany", category: "Knowledge", base: 0 },
|
||||
{ name: "Chemistry", category: "Knowledge", base: 0 },
|
||||
{ name: "Computers", category: "Knowledge", base: 0 },
|
||||
{ name: "Emergency Treatment", category: "Knowledge", base: 1 },
|
||||
{ name: "Engineering", category: "Knowledge", base: 0 },
|
||||
{ name: "Farming", category: "Knowledge", base: 0 },
|
||||
{ name: "History", category: "Knowledge", base: 5 },
|
||||
{ name: "Law", category: "Knowledge", base: 0 },
|
||||
{ name: "Mathematics", category: "Knowledge", base: 0 },
|
||||
{ name: "Physics", category: "Knowledge", base: 0 },
|
||||
{ name: "Planetology", category: "Knowledge", base: 0 },
|
||||
{ name: "Second Languages", category: "Knowledge", base: 0 },
|
||||
{ name: "Strategy", category: "Knowledge", base: 0 },
|
||||
{ name: "Theology", category: "Knowledge", base: 0 },
|
||||
{ name: "Zoology", category: "Knowledge", base: 0 },
|
||||
|
||||
{ name: "Handgun, Energy", category: "Perception", base: 5 },
|
||||
{ name: "Handgun, Projectile", category: "Perception", base: 3 },
|
||||
{ name: "Heavy Weapon, Energy", category: "Perception", base: 5 },
|
||||
{ name: "Heavy Weapon, Projectile", category: "Perception", base: 3 },
|
||||
{ name: "Listen", category: "Perception", base: 5 },
|
||||
{ name: "Observe", category: "Perception", base: 5 },
|
||||
{ name: "Scent", category: "Perception", base: 0 },
|
||||
{ name: "Search", category: "Perception", base: 5 },
|
||||
{ name: "Track", category: "Perception", base: 5 },
|
||||
|
||||
{ name: "Aquatic Vehicle", category: "Technical", base: 0 },
|
||||
{ name: "Atmospheric Craft", category: "Technical", base: 0 },
|
||||
{ name: "Ground Vehicle", category: "Technical", base: 0 },
|
||||
{ name: "Hyperdrive", category: "Technical", base: 0 },
|
||||
{ name: "Personal Flyer", category: "Technical", base: 15 },
|
||||
{ name: "Reaction Drive", category: "Technical", base: 0 },
|
||||
{ name: "Reactionless Drive", category: "Technical", base: 0 },
|
||||
{ name: "Repair", category: "Technical", base: 0 },
|
||||
{ name: "Ringworld", category: "Technical", base: 0 },
|
||||
{ name: "Weapons System", category: "Technical", base: 0 }
|
||||
];
|
||||
|
||||
export function normalizeSkillName(name: string): string {
|
||||
return String(name || "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function baseSkillNameSet(): Set<string> {
|
||||
return new Set(RINGWORLD_BASE_SKILLS.map((skill) => normalizeSkillName(skill.name)));
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { HIT_LOCATION_ORDER, HIT_LOCATION_TABLE, type HitLocationEntry, type HitLocationSlug } from "./constants";
|
||||
|
||||
export type HitLocationSchemaId = "humanoid" | "dolphin" | "puppeteer" | "trinoc" | "bandersnatchi" | "kzinti";
|
||||
|
||||
export interface HitLocationSchemaEntry extends HitLocationEntry {
|
||||
hpFactor: number;
|
||||
}
|
||||
|
||||
export interface HitLocationSchema {
|
||||
id: HitLocationSchemaId;
|
||||
label: string;
|
||||
bodyOrder: HitLocationSlug[];
|
||||
entries: HitLocationSchemaEntry[];
|
||||
}
|
||||
|
||||
function normalize(value: unknown): string {
|
||||
return String(value ?? "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "");
|
||||
}
|
||||
|
||||
function withFactors(entries: HitLocationEntry[], hpFactors?: Partial<Record<HitLocationSlug, number>>): HitLocationSchemaEntry[] {
|
||||
return entries.map((entry) => ({
|
||||
...entry,
|
||||
hpFactor: hpFactors?.[entry.slug] ?? (entry.slug === "chest" ? 0.35 : (entry.slug === "rArm" || entry.slug === "lArm") ? 0.25 : 0.3)
|
||||
}));
|
||||
}
|
||||
|
||||
const HUMANOID_SCHEMA: HitLocationSchema = {
|
||||
id: "humanoid",
|
||||
label: "Humanoid",
|
||||
bodyOrder: ["head", "lArm", "chest", "rArm", "abdomen", "lLeg", "rLeg"],
|
||||
entries: withFactors(HIT_LOCATION_TABLE)
|
||||
};
|
||||
|
||||
const DOLPHIN_SCHEMA: HitLocationSchema = {
|
||||
id: "dolphin",
|
||||
label: "Dolphin",
|
||||
bodyOrder: ["head", "lArm", "chest", "rArm", "abdomen", "lLeg", "rLeg"],
|
||||
entries: withFactors([
|
||||
{ slug: "rLeg", label: "Right Fluke", min: 1, max: 3 },
|
||||
{ slug: "lLeg", label: "Left Fluke", min: 4, max: 6 },
|
||||
{ slug: "abdomen", label: "Lower Body", min: 7, max: 9 },
|
||||
{ slug: "chest", label: "Main Body", min: 10, max: 15 },
|
||||
{ slug: "rArm", label: "Right Fin", min: 16, max: 17 },
|
||||
{ slug: "lArm", label: "Left Fin", min: 18, max: 19 },
|
||||
{ slug: "head", label: "Head", min: 20, max: 20 }
|
||||
], {
|
||||
head: 0.3,
|
||||
rArm: 0.2,
|
||||
lArm: 0.2,
|
||||
chest: 0.4,
|
||||
abdomen: 0.35,
|
||||
rLeg: 0.25,
|
||||
lLeg: 0.25
|
||||
})
|
||||
};
|
||||
|
||||
const PUPPETEER_SCHEMA: HitLocationSchema = {
|
||||
id: "puppeteer",
|
||||
label: "Puppeteer",
|
||||
bodyOrder: ["head", "lArm", "chest", "rArm", "abdomen", "lLeg", "rLeg"],
|
||||
entries: withFactors([
|
||||
{ slug: "rLeg", label: "Right Rear Leg", min: 1, max: 3 },
|
||||
{ slug: "lLeg", label: "Left Rear Leg", min: 4, max: 6 },
|
||||
{ slug: "rArm", label: "Right Foreleg", min: 7, max: 9 },
|
||||
{ slug: "lArm", label: "Left Foreleg", min: 10, max: 12 },
|
||||
{ slug: "chest", label: "Fore Body", min: 13, max: 15 },
|
||||
{ slug: "abdomen", label: "Hind Body", min: 16, max: 18 },
|
||||
{ slug: "head", label: "Twin Heads", min: 19, max: 20 }
|
||||
], {
|
||||
head: 0.32,
|
||||
chest: 0.35,
|
||||
abdomen: 0.35,
|
||||
rArm: 0.28,
|
||||
lArm: 0.28,
|
||||
rLeg: 0.28,
|
||||
lLeg: 0.28
|
||||
})
|
||||
};
|
||||
|
||||
const TRINOC_SCHEMA: HitLocationSchema = {
|
||||
id: "trinoc",
|
||||
label: "Trinoc",
|
||||
bodyOrder: ["head", "lArm", "chest", "rArm", "abdomen", "lLeg", "rLeg"],
|
||||
entries: withFactors([
|
||||
{ slug: "rArm", label: "Right Wing/Arm", min: 1, max: 3 },
|
||||
{ slug: "lArm", label: "Left Wing/Arm", min: 4, max: 6 },
|
||||
{ slug: "chest", label: "Upper Torso", min: 7, max: 11 },
|
||||
{ slug: "abdomen", label: "Lower Torso", min: 12, max: 14 },
|
||||
{ slug: "rLeg", label: "Right Leg", min: 15, max: 17 },
|
||||
{ slug: "lLeg", label: "Left Leg", min: 18, max: 19 },
|
||||
{ slug: "head", label: "Head", min: 20, max: 20 }
|
||||
], {
|
||||
head: 0.3,
|
||||
rArm: 0.27,
|
||||
lArm: 0.27,
|
||||
chest: 0.36,
|
||||
abdomen: 0.3,
|
||||
rLeg: 0.27,
|
||||
lLeg: 0.27
|
||||
})
|
||||
};
|
||||
|
||||
const BANDERSNATCHI_SCHEMA: HitLocationSchema = {
|
||||
id: "bandersnatchi",
|
||||
label: "Bandersnatchi",
|
||||
bodyOrder: ["head", "lArm", "chest", "rArm", "abdomen", "lLeg", "rLeg"],
|
||||
entries: withFactors([
|
||||
{ slug: "rLeg", label: "Right Rear Mass", min: 1, max: 2 },
|
||||
{ slug: "lLeg", label: "Left Rear Mass", min: 3, max: 4 },
|
||||
{ slug: "abdomen", label: "Lower Body Mass", min: 5, max: 11 },
|
||||
{ slug: "chest", label: "Upper Body Mass", min: 12, max: 17 },
|
||||
{ slug: "rArm", label: "Right Manipulator", min: 18, max: 18 },
|
||||
{ slug: "lArm", label: "Left Manipulator", min: 19, max: 19 },
|
||||
{ slug: "head", label: "Head Cluster", min: 20, max: 20 }
|
||||
], {
|
||||
head: 0.28,
|
||||
chest: 0.42,
|
||||
abdomen: 0.42,
|
||||
rArm: 0.2,
|
||||
lArm: 0.2,
|
||||
rLeg: 0.24,
|
||||
lLeg: 0.24
|
||||
})
|
||||
};
|
||||
|
||||
const KZINTI_SCHEMA: HitLocationSchema = {
|
||||
id: "kzinti",
|
||||
label: "Kzinti",
|
||||
bodyOrder: ["head", "lArm", "chest", "rArm", "abdomen", "lLeg", "rLeg"],
|
||||
entries: withFactors([
|
||||
{ slug: "rLeg", label: "Right Hind Leg", min: 1, max: 3 },
|
||||
{ slug: "lLeg", label: "Left Hind Leg", min: 4, max: 6 },
|
||||
{ slug: "abdomen", label: "Abdomen", min: 7, max: 10 },
|
||||
{ slug: "chest", label: "Chest", min: 11, max: 15 },
|
||||
{ slug: "rArm", label: "Right Foreleg", min: 16, max: 17 },
|
||||
{ slug: "lArm", label: "Left Foreleg", min: 18, max: 19 },
|
||||
{ slug: "head", label: "Head", min: 20, max: 20 }
|
||||
])
|
||||
};
|
||||
|
||||
export const HIT_LOCATION_SCHEMAS: Record<HitLocationSchemaId, HitLocationSchema> = {
|
||||
humanoid: HUMANOID_SCHEMA,
|
||||
dolphin: DOLPHIN_SCHEMA,
|
||||
puppeteer: PUPPETEER_SCHEMA,
|
||||
trinoc: TRINOC_SCHEMA,
|
||||
bandersnatchi: BANDERSNATCHI_SCHEMA,
|
||||
kzinti: KZINTI_SCHEMA
|
||||
};
|
||||
|
||||
export function listHitLocationSchemas(): HitLocationSchema[] {
|
||||
return Object.values(HIT_LOCATION_SCHEMAS);
|
||||
}
|
||||
|
||||
export function inferHitLocationSchemaId(speciesValue: unknown): HitLocationSchemaId {
|
||||
const species = normalize(speciesValue);
|
||||
if (!species) return "humanoid";
|
||||
if (species.includes("dolphin")) return "dolphin";
|
||||
if (species.includes("puppeteer")) return "puppeteer";
|
||||
if (species.includes("trinoc")) return "trinoc";
|
||||
if (species.includes("bandersnatch")) return "bandersnatchi";
|
||||
if (species.includes("kzinti")) return "kzinti";
|
||||
return "humanoid";
|
||||
}
|
||||
|
||||
export function getHitLocationSchemaById(schemaId: unknown): HitLocationSchema {
|
||||
const key = normalize(schemaId) as HitLocationSchemaId;
|
||||
return HIT_LOCATION_SCHEMAS[key] ?? HUMANOID_SCHEMA;
|
||||
}
|
||||
|
||||
export function getActorHitLocationSchema(actor: any): HitLocationSchema {
|
||||
const mode = String(actor?.system?.hitLocationSchema || "auto").trim().toLowerCase();
|
||||
if (mode && mode !== "auto") {
|
||||
return getHitLocationSchemaById(mode);
|
||||
}
|
||||
const species = actor?.system?.profile?.species ?? "";
|
||||
return getHitLocationSchemaById(inferHitLocationSchemaId(species));
|
||||
}
|
||||
|
||||
export function getSchemaLocationEntry(schema: HitLocationSchema, slug: HitLocationSlug): HitLocationSchemaEntry {
|
||||
return schema.entries.find((entry) => entry.slug === slug) ?? withFactors(HIT_LOCATION_TABLE).find((entry) => entry.slug === slug)!;
|
||||
}
|
||||
|
||||
export function getSchemaOrder(schema: HitLocationSchema): HitLocationSlug[] {
|
||||
const set = new Set(schema.entries.map((entry) => entry.slug));
|
||||
const ordered = schema.bodyOrder.filter((slug) => set.has(slug));
|
||||
if (ordered.length === schema.entries.length) return ordered;
|
||||
return [...HIT_LOCATION_ORDER];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,756 @@
|
||||
import { SYSTEM_ID } from "./constants";
|
||||
|
||||
type MajorActionKind = "draw" | "aim" | "melee" | "stand" | "perception" | "unarmed";
|
||||
type MinorActionKind = "fire" | "fall" | "rise" | "minorCustom";
|
||||
type ActionKind = MajorActionKind | MinorActionKind;
|
||||
|
||||
interface ActionEvent {
|
||||
id: string;
|
||||
actorId: string | null;
|
||||
combatantId: string;
|
||||
kind: ActionKind;
|
||||
major: boolean;
|
||||
startImpulse: number;
|
||||
completeImpulse: number;
|
||||
itemId?: string;
|
||||
skillId?: string;
|
||||
targetTokenId?: string;
|
||||
}
|
||||
|
||||
interface ImpulseState {
|
||||
currentImpulse: number;
|
||||
queue: ActionEvent[];
|
||||
}
|
||||
|
||||
const IMPULSE_FLAG = "impulseState";
|
||||
const IMPULSE_HUD_POS_KEY = `${SYSTEM_ID}.impulseHudPos`;
|
||||
const MAJOR_ACTION_OPTIONS: { value: MajorActionKind; label: string }[] = [
|
||||
{ value: "draw", label: "Draw / Stow Weapon" },
|
||||
{ value: "aim", label: "Aim Ranged Weapon" },
|
||||
{ value: "melee", label: "Melee Attack" },
|
||||
{ value: "stand", label: "Stand / Recover" },
|
||||
{ value: "perception", label: "Perception Skill" },
|
||||
{ value: "unarmed", label: "Unarmed Combat" }
|
||||
];
|
||||
const MINOR_ACTION_OPTIONS: { value: MinorActionKind; label: string }[] = [
|
||||
{ value: "fire", label: "Fire Ranged Weapon" },
|
||||
{ value: "fall", label: "Fall" },
|
||||
{ value: "rise", label: "Rise (Kneeling to Stand/Run)" },
|
||||
{ value: "minorCustom", label: "Other Minor Action" }
|
||||
];
|
||||
|
||||
function num(value: unknown, fallback = 0): number {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
function esc(value: unknown): string {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll("\"", """);
|
||||
}
|
||||
|
||||
export function actionRankingFromDex(dex: number): number {
|
||||
if (dex <= 4) return 7;
|
||||
if (dex <= 8) return 6;
|
||||
if (dex <= 12) return 5;
|
||||
if (dex <= 16) return 4;
|
||||
if (dex <= 20) return 3;
|
||||
if (dex <= 24) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function actionKindLabel(kind: ActionKind): string {
|
||||
switch (kind) {
|
||||
case "draw":
|
||||
return "Draw / Stow";
|
||||
case "aim":
|
||||
return "Aim";
|
||||
case "melee":
|
||||
return "Melee Attack";
|
||||
case "stand":
|
||||
return "Stand / Recover";
|
||||
case "perception":
|
||||
return "Perception";
|
||||
case "unarmed":
|
||||
return "Unarmed Combat";
|
||||
case "fire":
|
||||
return "Fire Ranged Weapon";
|
||||
case "fall":
|
||||
return "Fall";
|
||||
case "rise":
|
||||
return "Rise";
|
||||
case "minorCustom":
|
||||
return "Minor Action";
|
||||
default:
|
||||
return String(kind);
|
||||
}
|
||||
}
|
||||
|
||||
function getDexForCombatant(combatant: any): number {
|
||||
return Math.max(1, num(combatant?.actor?.system?.characteristics?.dex, 10));
|
||||
}
|
||||
|
||||
function getCombatantActionRanking(combatant: any): number {
|
||||
const fromFlag = num(combatant?.flags?.[SYSTEM_ID]?.actionRanking, 0);
|
||||
if (fromFlag > 0) return fromFlag;
|
||||
return actionRankingFromDex(getDexForCombatant(combatant));
|
||||
}
|
||||
|
||||
function getEncumbranceArPenalty(combatant: any): number {
|
||||
const fromActor = num(combatant?.actor?.system?.encumbrance?.actionRankingPenalty, 0);
|
||||
return Math.max(0, Math.trunc(fromActor));
|
||||
}
|
||||
|
||||
async function syncCombatantActionRanking(combatant: any): Promise<number> {
|
||||
const ranking = actionRankingFromDex(getDexForCombatant(combatant));
|
||||
const existing = num(combatant?.flags?.[SYSTEM_ID]?.actionRanking, 0);
|
||||
if (existing !== ranking) {
|
||||
await combatant.update({ [`flags.${SYSTEM_ID}.actionRanking`]: ranking });
|
||||
}
|
||||
return ranking;
|
||||
}
|
||||
|
||||
function getImpulseState(combat: any): ImpulseState {
|
||||
const raw = foundry.utils.deepClone(combat.getFlag(SYSTEM_ID, IMPULSE_FLAG) ?? {});
|
||||
const queue = Array.isArray(raw.queue) ? raw.queue : [];
|
||||
return {
|
||||
currentImpulse: Math.max(1, num(raw.currentImpulse, 1)),
|
||||
queue
|
||||
};
|
||||
}
|
||||
|
||||
async function setImpulseState(combat: any, state: ImpulseState): Promise<void> {
|
||||
await combat.setFlag(SYSTEM_ID, IMPULSE_FLAG, {
|
||||
currentImpulse: Math.max(1, num(state.currentImpulse, 1)),
|
||||
queue: Array.isArray(state.queue) ? state.queue : []
|
||||
});
|
||||
}
|
||||
|
||||
function sortQueueByCompletion(queue: ActionEvent[], combat: any): ActionEvent[] {
|
||||
return [...queue].sort((a, b) => {
|
||||
if (a.completeImpulse !== b.completeImpulse) return a.completeImpulse - b.completeImpulse;
|
||||
const aDex = getDexForCombatant(combat.combatants.get(a.combatantId));
|
||||
const bDex = getDexForCombatant(combat.combatants.get(b.combatantId));
|
||||
return bDex - aDex;
|
||||
});
|
||||
}
|
||||
|
||||
function pruneQueueToCombatants(queue: ActionEvent[], combat: any): ActionEvent[] {
|
||||
const validIds = new Set(combat.combatants.map((c: any) => c.id));
|
||||
return queue.filter((entry) => validIds.has(entry.combatantId));
|
||||
}
|
||||
|
||||
function loadHudPosition(): { left: number; top: number } | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(IMPULSE_HUD_POS_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
const left = num(parsed?.left, NaN);
|
||||
const top = num(parsed?.top, NaN);
|
||||
if (!Number.isFinite(left) || !Number.isFinite(top)) return null;
|
||||
return { left, top };
|
||||
} catch (_err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveHudPosition(position: { left: number; top: number }): void {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
IMPULSE_HUD_POS_KEY,
|
||||
JSON.stringify({ left: Math.round(position.left), top: Math.round(position.top) })
|
||||
);
|
||||
} catch (_err) {
|
||||
// Ignore storage issues.
|
||||
}
|
||||
}
|
||||
|
||||
function clearHudPosition(): void {
|
||||
try {
|
||||
localStorage.removeItem(IMPULSE_HUD_POS_KEY);
|
||||
} catch (_err) {
|
||||
// Ignore storage issues.
|
||||
}
|
||||
}
|
||||
|
||||
function clampHudPosition(position: { left: number; top: number }, element: HTMLElement): { left: number; top: number } {
|
||||
const margin = 8;
|
||||
const width = element.offsetWidth || 300;
|
||||
const height = element.offsetHeight || 220;
|
||||
const maxLeft = Math.max(margin, window.innerWidth - width - margin);
|
||||
const maxTop = Math.max(margin, window.innerHeight - height - margin);
|
||||
return {
|
||||
left: Math.min(Math.max(margin, position.left), maxLeft),
|
||||
top: Math.min(Math.max(margin, position.top), maxTop)
|
||||
};
|
||||
}
|
||||
|
||||
function applyHudAbsolutePosition(hud: JQuery, position: { left: number; top: number }): void {
|
||||
const el = hud.get(0) as HTMLElement | undefined;
|
||||
if (!el) return;
|
||||
const clamped = clampHudPosition(position, el);
|
||||
hud.css({
|
||||
left: `${clamped.left}px`,
|
||||
top: `${clamped.top}px`,
|
||||
right: "auto",
|
||||
bottom: "auto"
|
||||
});
|
||||
}
|
||||
|
||||
function makeHudDraggable(hud: JQuery): void {
|
||||
const el = hud.get(0) as HTMLElement | undefined;
|
||||
if (!el) return;
|
||||
|
||||
const handle = hud.find(".rw-imp-mini-head");
|
||||
if (!handle.length) return;
|
||||
|
||||
const saved = loadHudPosition();
|
||||
if (saved) {
|
||||
applyHudAbsolutePosition(hud, saved);
|
||||
}
|
||||
|
||||
let dragging = false;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let startLeft = 0;
|
||||
let startTop = 0;
|
||||
|
||||
handle.on("mousedown", (event) => {
|
||||
const mouseEvent = event as JQuery.MouseDownEvent;
|
||||
if (mouseEvent.button !== 0) return;
|
||||
if ((mouseEvent.target as HTMLElement)?.closest("button")) return;
|
||||
mouseEvent.preventDefault();
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
startX = mouseEvent.clientX;
|
||||
startY = mouseEvent.clientY;
|
||||
startLeft = rect.left;
|
||||
startTop = rect.top;
|
||||
dragging = true;
|
||||
|
||||
hud.addClass("dragging");
|
||||
hud.css({ left: `${startLeft}px`, top: `${startTop}px`, right: "auto", bottom: "auto" });
|
||||
|
||||
$(document).on("mousemove.ringworldImpulseHudDrag", (moveEvent) => {
|
||||
if (!dragging) return;
|
||||
const evt = moveEvent as JQuery.MouseMoveEvent;
|
||||
const deltaX = evt.clientX - startX;
|
||||
const deltaY = evt.clientY - startY;
|
||||
applyHudAbsolutePosition(hud, { left: startLeft + deltaX, top: startTop + deltaY });
|
||||
});
|
||||
|
||||
$(document).one("mouseup.ringworldImpulseHudDrag", () => {
|
||||
dragging = false;
|
||||
hud.removeClass("dragging");
|
||||
$(document).off("mousemove.ringworldImpulseHudDrag");
|
||||
const latestRect = el.getBoundingClientRect();
|
||||
saveHudPosition({ left: latestRect.left, top: latestRect.top });
|
||||
});
|
||||
});
|
||||
|
||||
handle.on("dblclick", () => {
|
||||
clearHudPosition();
|
||||
hud.css({ left: "", top: "", right: "", bottom: "" });
|
||||
});
|
||||
}
|
||||
|
||||
function getQueueWithNames(combat: any, queue: ActionEvent[]): Array<{
|
||||
combatantId: string;
|
||||
name: string;
|
||||
label: string;
|
||||
completeImpulse: number;
|
||||
dex: number;
|
||||
}> {
|
||||
return sortQueueByCompletion(pruneQueueToCombatants(queue, combat), combat).map((entry) => {
|
||||
const combatant = combat.combatants.get(entry.combatantId);
|
||||
return {
|
||||
combatantId: entry.combatantId,
|
||||
name: String(combatant?.name || "Unknown"),
|
||||
label: actionKindLabel(entry.kind),
|
||||
completeImpulse: entry.completeImpulse,
|
||||
dex: getDexForCombatant(combatant)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function renderQueueRowsMarkup(rows: ReturnType<typeof getQueueWithNames>, limit = 8): string {
|
||||
if (!rows.length) return `<div class="rw-imp-mini-empty">No actions queued.</div>`;
|
||||
return rows.slice(0, limit).map((row) => `
|
||||
<div class="rw-imp-mini-row" data-combatant-id="${esc(row.combatantId)}">
|
||||
<span class="rw-imp-mini-name">${esc(row.name)}</span>
|
||||
<span class="rw-imp-mini-action">${esc(row.label)} @ ${row.completeImpulse}</span>
|
||||
</div>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function attachOverlayControls(root: JQuery, combat: any): void {
|
||||
const disabled = game.user?.isGM ? false : true;
|
||||
root.find(".rw-mini-next").prop("disabled", disabled).on("click", async (event) => {
|
||||
event.preventDefault();
|
||||
await advanceImpulse(combat, "next");
|
||||
});
|
||||
root.find(".rw-mini-step").prop("disabled", disabled).on("click", async (event) => {
|
||||
event.preventDefault();
|
||||
await advanceImpulse(combat, "step");
|
||||
});
|
||||
root.find(".rw-mini-reset").prop("disabled", disabled).on("click", async (event) => {
|
||||
event.preventDefault();
|
||||
await resetImpulseSequence(combat);
|
||||
});
|
||||
}
|
||||
|
||||
function renderChatImpulseMarker(combat: any | null): void {
|
||||
const chatEl = (ui as any)?.chat?.element as JQuery | undefined;
|
||||
if (!chatEl?.length) return;
|
||||
chatEl.find(".ringworld-impulse-chat-marker").remove();
|
||||
if (!combat) return;
|
||||
|
||||
const state = getImpulseState(combat);
|
||||
const rows = getQueueWithNames(combat, state.queue);
|
||||
const marker = $(`
|
||||
<section class="ringworld-impulse-chat-marker">
|
||||
<div class="rw-imp-mini-head">
|
||||
<strong>Impulse ${state.currentImpulse}</strong>
|
||||
<span>Queued ${rows.length}</span>
|
||||
</div>
|
||||
<div class="rw-imp-mini-list">${renderQueueRowsMarkup(rows, 5)}</div>
|
||||
<div class="rw-imp-mini-controls">
|
||||
<button type="button" class="rw-mini-next">Next</button>
|
||||
<button type="button" class="rw-mini-step">+1</button>
|
||||
<button type="button" class="rw-mini-reset">Reset</button>
|
||||
</div>
|
||||
</section>
|
||||
`);
|
||||
attachOverlayControls(marker, combat);
|
||||
chatEl.prepend(marker);
|
||||
}
|
||||
|
||||
function renderFloatingImpulseHud(combat: any | null): void {
|
||||
const existing = $("#ringworld-impulse-hud");
|
||||
existing.remove();
|
||||
if (!combat) return;
|
||||
|
||||
const state = getImpulseState(combat);
|
||||
const rows = getQueueWithNames(combat, state.queue);
|
||||
const hud = $(`
|
||||
<section id="ringworld-impulse-hud" class="ringworld-impulse-hud">
|
||||
<div class="rw-imp-mini-head">
|
||||
<strong>Impulse ${state.currentImpulse}</strong>
|
||||
<span>Queued ${rows.length}</span>
|
||||
</div>
|
||||
<div class="rw-imp-mini-list">${renderQueueRowsMarkup(rows, 8)}</div>
|
||||
<div class="rw-imp-mini-controls">
|
||||
<button type="button" class="rw-mini-next">Next Completion</button>
|
||||
<button type="button" class="rw-mini-step">+1 Impulse</button>
|
||||
<button type="button" class="rw-mini-reset">Break / Reset</button>
|
||||
</div>
|
||||
</section>
|
||||
`);
|
||||
attachOverlayControls(hud, combat);
|
||||
$("body").append(hud);
|
||||
makeHudDraggable(hud);
|
||||
}
|
||||
|
||||
function refreshImpulseOverlays(combat: any | null): void {
|
||||
renderChatImpulseMarker(combat);
|
||||
renderFloatingImpulseHud(combat);
|
||||
}
|
||||
|
||||
async function ensureGmControl(): Promise<boolean> {
|
||||
if (game.user?.isGM) return true;
|
||||
ui.notifications?.warn("Only the GM can modify Ringworld impulse sequencing.");
|
||||
return false;
|
||||
}
|
||||
|
||||
async function scheduleAction(
|
||||
combat: any,
|
||||
combatantId: string,
|
||||
kind: ActionKind,
|
||||
major: boolean
|
||||
): Promise<void> {
|
||||
if (!(await ensureGmControl())) return;
|
||||
|
||||
const combatant = combat.combatants.get(combatantId);
|
||||
if (!combatant) return;
|
||||
|
||||
const state = getImpulseState(combat);
|
||||
const currentImpulse = state.currentImpulse;
|
||||
const actionRanking = await syncCombatantActionRanking(combatant);
|
||||
const encPenalty = getEncumbranceArPenalty(combatant);
|
||||
const impulseMod = num(combatant.flags?.[SYSTEM_ID]?.impulseModifier, 0);
|
||||
const effectiveAr = Math.max(1, actionRanking + encPenalty);
|
||||
const baseDuration = major ? effectiveAr : 1;
|
||||
const duration = Math.max(1, baseDuration + impulseMod);
|
||||
const completeImpulse = currentImpulse + duration;
|
||||
|
||||
const event: ActionEvent = {
|
||||
id: foundry.utils.randomID(),
|
||||
actorId: combatant.actor?.id ?? null,
|
||||
combatantId,
|
||||
kind,
|
||||
major,
|
||||
startImpulse: currentImpulse,
|
||||
completeImpulse
|
||||
};
|
||||
|
||||
const queueWithoutCombatant = pruneQueueToCombatants(
|
||||
state.queue.filter((entry) => entry.combatantId !== combatantId),
|
||||
combat
|
||||
);
|
||||
queueWithoutCombatant.push(event);
|
||||
|
||||
await setImpulseState(combat, {
|
||||
currentImpulse,
|
||||
queue: sortQueueByCompletion(queueWithoutCombatant, combat)
|
||||
});
|
||||
|
||||
await combatant.update({
|
||||
[`flags.${SYSTEM_ID}.pendingEventId`]: event.id,
|
||||
[`flags.${SYSTEM_ID}.pendingKind`]: kind,
|
||||
[`flags.${SYSTEM_ID}.actionRanking`]: actionRanking,
|
||||
[`flags.${SYSTEM_ID}.effectiveActionRanking`]: effectiveAr
|
||||
});
|
||||
|
||||
const timingLabel = major
|
||||
? `AR ${actionRanking} + Enc ${encPenalty}${impulseMod ? ` ${impulseMod > 0 ? "+" : ""} Imp ${impulseMod}` : ""}`
|
||||
: `Minor 1${impulseMod ? ` ${impulseMod > 0 ? "+" : ""} Imp ${impulseMod}` : ""}`;
|
||||
ui.notifications?.info(
|
||||
`${combatant.name}: ${actionKindLabel(kind)} completes on impulse ${completeImpulse} (${timingLabel} => ${duration}).`
|
||||
);
|
||||
refreshImpulseOverlays(combat);
|
||||
}
|
||||
|
||||
async function cancelAction(combat: any, combatantId: string): Promise<void> {
|
||||
if (!(await ensureGmControl())) return;
|
||||
|
||||
const combatant = combat.combatants.get(combatantId);
|
||||
if (!combatant) return;
|
||||
|
||||
const state = getImpulseState(combat);
|
||||
const nextQueue = pruneQueueToCombatants(
|
||||
state.queue.filter((entry) => entry.combatantId !== combatantId),
|
||||
combat
|
||||
);
|
||||
|
||||
await setImpulseState(combat, {
|
||||
currentImpulse: state.currentImpulse,
|
||||
queue: sortQueueByCompletion(nextQueue, combat)
|
||||
});
|
||||
|
||||
await combatant.update({
|
||||
[`flags.${SYSTEM_ID}.pendingEventId`]: null,
|
||||
[`flags.${SYSTEM_ID}.pendingKind`]: "",
|
||||
[`flags.${SYSTEM_ID}.aimState`]: {
|
||||
active: false,
|
||||
targetId: null,
|
||||
sinceImpulse: null
|
||||
}
|
||||
});
|
||||
refreshImpulseOverlays(combat);
|
||||
}
|
||||
|
||||
async function resolveActionEvent(combat: any, event: ActionEvent, impulse: number): Promise<void> {
|
||||
const combatant = combat.combatants.get(event.combatantId);
|
||||
if (!combatant) return;
|
||||
|
||||
const updates: Record<string, unknown> = {
|
||||
[`flags.${SYSTEM_ID}.pendingEventId`]: null,
|
||||
[`flags.${SYSTEM_ID}.pendingKind`]: ""
|
||||
};
|
||||
|
||||
if (event.kind === "aim") {
|
||||
updates[`flags.${SYSTEM_ID}.aimState`] = {
|
||||
active: true,
|
||||
targetId: event.targetTokenId ?? null,
|
||||
sinceImpulse: impulse
|
||||
};
|
||||
}
|
||||
|
||||
await combatant.update(updates);
|
||||
|
||||
const content = `
|
||||
<div class="ringworld-chat-card">
|
||||
<h3>Impulse ${impulse} Completion</h3>
|
||||
<p><strong>${esc(combatant.name)}</strong> completes <strong>${esc(actionKindLabel(event.kind))}</strong>.</p>
|
||||
<p><small>DEX ${getDexForCombatant(combatant)} | AR ${getCombatantActionRanking(combatant)}</small></p>
|
||||
</div>
|
||||
`;
|
||||
await ChatMessage.create({
|
||||
user: game.user?.id,
|
||||
speaker: ChatMessage.getSpeaker({ actor: combatant.actor ?? undefined }),
|
||||
content
|
||||
});
|
||||
}
|
||||
|
||||
async function advanceImpulse(combat: any, mode: "next" | "step"): Promise<void> {
|
||||
if (!(await ensureGmControl())) return;
|
||||
|
||||
const state = getImpulseState(combat);
|
||||
const queue = sortQueueByCompletion(pruneQueueToCombatants(state.queue, combat), combat);
|
||||
const current = state.currentImpulse;
|
||||
|
||||
let targetImpulse = current + 1;
|
||||
if (mode === "next") {
|
||||
const next = queue
|
||||
.map((event) => event.completeImpulse)
|
||||
.filter((impulse) => impulse >= current)
|
||||
.sort((a, b) => a - b)[0];
|
||||
if (Number.isFinite(next)) {
|
||||
targetImpulse = next;
|
||||
}
|
||||
}
|
||||
|
||||
const completing = queue.filter((event) => event.completeImpulse === targetImpulse);
|
||||
const remaining = queue.filter((event) => event.completeImpulse !== targetImpulse);
|
||||
|
||||
await setImpulseState(combat, {
|
||||
currentImpulse: targetImpulse,
|
||||
queue: remaining
|
||||
});
|
||||
|
||||
const ordered = sortQueueByCompletion(completing, combat);
|
||||
for (const event of ordered) {
|
||||
await resolveActionEvent(combat, event, targetImpulse);
|
||||
}
|
||||
|
||||
if (!ordered.length) {
|
||||
ui.notifications?.info(`Impulse advanced to ${targetImpulse}.`);
|
||||
}
|
||||
refreshImpulseOverlays(combat);
|
||||
}
|
||||
|
||||
async function resetImpulseSequence(combat: any): Promise<void> {
|
||||
if (!(await ensureGmControl())) return;
|
||||
|
||||
await setImpulseState(combat, { currentImpulse: 1, queue: [] });
|
||||
|
||||
for (const combatant of combat.combatants) {
|
||||
await combatant.update({
|
||||
[`flags.${SYSTEM_ID}.pendingEventId`]: null,
|
||||
[`flags.${SYSTEM_ID}.pendingKind`]: "",
|
||||
[`flags.${SYSTEM_ID}.aimState`]: {
|
||||
active: false,
|
||||
targetId: null,
|
||||
sinceImpulse: null
|
||||
},
|
||||
[`flags.${SYSTEM_ID}.actionRanking`]: actionRankingFromDex(getDexForCombatant(combatant)),
|
||||
[`flags.${SYSTEM_ID}.effectiveActionRanking`]: null,
|
||||
[`flags.${SYSTEM_ID}.impulseModifier`]: 0
|
||||
});
|
||||
}
|
||||
refreshImpulseOverlays(combat);
|
||||
}
|
||||
|
||||
function renderImpulsePanel(app: any, htmlLike: JQuery | HTMLElement): void {
|
||||
const html = "find" in (htmlLike as any) ? (htmlLike as JQuery) : $(htmlLike);
|
||||
if (!html?.length) return;
|
||||
html.addClass("ringworld-impulse-mode ringworld-impulse-only");
|
||||
|
||||
const combat = app?.viewed ?? game.combat;
|
||||
if (!combat) return;
|
||||
|
||||
const state = getImpulseState(combat);
|
||||
const queue = sortQueueByCompletion(pruneQueueToCombatants(state.queue, combat), combat);
|
||||
if (queue.length !== state.queue.length) {
|
||||
void setImpulseState(combat, { currentImpulse: state.currentImpulse, queue });
|
||||
}
|
||||
|
||||
html
|
||||
.find(
|
||||
".token-initiative, .combatant-initiative, .initiative, [data-action='rollInitiative'], [data-control='rollInitiative'], .combatant .fa-dice-d20"
|
||||
)
|
||||
.addClass("rw-hide-initiative");
|
||||
|
||||
html.find(".ringworld-impulse-panel").remove();
|
||||
|
||||
const disabled = game.user?.isGM ? "" : "disabled";
|
||||
const rows = combat.combatants.map((combatant: any) => {
|
||||
const ar = getCombatantActionRanking(combatant);
|
||||
const encPenalty = getEncumbranceArPenalty(combatant);
|
||||
const impMod = num(combatant.flags?.[SYSTEM_ID]?.impulseModifier, 0);
|
||||
const effAr = Math.max(1, ar + encPenalty);
|
||||
const dex = getDexForCombatant(combatant);
|
||||
const pending = queue.find((event) => event.combatantId === combatant.id);
|
||||
const pendingLabel = pending
|
||||
? `${actionKindLabel(pending.kind)} @ ${pending.completeImpulse}`
|
||||
: "Ready";
|
||||
const aimState = combatant.flags?.[SYSTEM_ID]?.aimState;
|
||||
const aimLabel = aimState?.active ? `Aiming since ${aimState.sinceImpulse}` : "Not aiming";
|
||||
|
||||
const options = MAJOR_ACTION_OPTIONS.map((opt) => {
|
||||
const selected = pending?.major && pending?.kind === opt.value ? "selected" : "";
|
||||
return `<option value="${opt.value}" ${selected}>${opt.label}</option>`;
|
||||
}).join("");
|
||||
const minorOptions = MINOR_ACTION_OPTIONS.map((opt) => {
|
||||
const selected = !pending?.major && pending?.kind === opt.value ? "selected" : "";
|
||||
return `<option value="${opt.value}" ${selected}>${opt.label}</option>`;
|
||||
}).join("");
|
||||
|
||||
return `
|
||||
<div class="rw-imp-row" data-combatant-id="${combatant.id}">
|
||||
<div class="rw-imp-row-head">
|
||||
<span class="rw-imp-name">${esc(combatant.name)}</span>
|
||||
<span class="rw-imp-meta">DEX ${dex} | AR ${ar} + Enc ${encPenalty} = ${effAr} | Imp Mod ${impMod >= 0 ? `+${impMod}` : impMod} | ${esc(pendingLabel)} | ${esc(aimLabel)}</span>
|
||||
</div>
|
||||
<div class="rw-imp-row-controls">
|
||||
<select class="rw-major-kind" ${disabled}>${options}</select>
|
||||
<select class="rw-minor-kind" ${disabled}>${minorOptions}</select>
|
||||
<button type="button" class="rw-major-start" ${disabled}>Major</button>
|
||||
<button type="button" class="rw-minor-start" ${disabled}>Minor</button>
|
||||
<button type="button" class="rw-cancel-start" ${disabled}>Cancel</button>
|
||||
<label>Imp Mod <input type="number" class="rw-imp-mod" value="${impMod}" ${disabled} /></label>
|
||||
<button type="button" class="rw-save-mods" ${disabled}>Save Mods</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
const panel = $(`
|
||||
<section class="ringworld-impulse-panel">
|
||||
<div class="rw-impulse-top">
|
||||
<div class="rw-impulse-clock">Impulse <strong>${state.currentImpulse}</strong></div>
|
||||
<div class="rw-impulse-queue">Queued: ${queue.length}</div>
|
||||
<div class="rw-impulse-controls">
|
||||
<button type="button" class="rw-advance-next" ${disabled}>Next Completion</button>
|
||||
<button type="button" class="rw-advance-step" ${disabled}>+1 Impulse</button>
|
||||
<button type="button" class="rw-reset-sequence" ${disabled}>Break / Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rw-imp-list">${rows}</div>
|
||||
</section>
|
||||
`);
|
||||
|
||||
html.prepend(panel);
|
||||
|
||||
// Hide all native combat tracker blocks so only Ringworld impulse UI is shown.
|
||||
const root = html.get(0);
|
||||
if (root) {
|
||||
for (const child of Array.from(root.children)) {
|
||||
if (!(child as HTMLElement).classList.contains("ringworld-impulse-panel")) {
|
||||
(child as HTMLElement).classList.add("rw-hide-core-tracker");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
panel.find(".rw-advance-next").on("click", async (event) => {
|
||||
event.preventDefault();
|
||||
await advanceImpulse(combat, "next");
|
||||
});
|
||||
|
||||
panel.find(".rw-advance-step").on("click", async (event) => {
|
||||
event.preventDefault();
|
||||
await advanceImpulse(combat, "step");
|
||||
});
|
||||
|
||||
panel.find(".rw-reset-sequence").on("click", async (event) => {
|
||||
event.preventDefault();
|
||||
await resetImpulseSequence(combat);
|
||||
});
|
||||
|
||||
panel.find(".rw-major-start").on("click", async (event) => {
|
||||
event.preventDefault();
|
||||
const row = (event.currentTarget as HTMLElement).closest(".rw-imp-row");
|
||||
if (!row) return;
|
||||
const combatantId = String(row.getAttribute("data-combatant-id") || "");
|
||||
const select = row.querySelector(".rw-major-kind") as HTMLSelectElement | null;
|
||||
const kind = String(select?.value || "draw") as MajorActionKind;
|
||||
await scheduleAction(combat, combatantId, kind, true);
|
||||
});
|
||||
|
||||
panel.find(".rw-minor-start").on("click", async (event) => {
|
||||
event.preventDefault();
|
||||
const row = (event.currentTarget as HTMLElement).closest(".rw-imp-row");
|
||||
if (!row) return;
|
||||
const combatantId = String(row.getAttribute("data-combatant-id") || "");
|
||||
const select = row.querySelector(".rw-minor-kind") as HTMLSelectElement | null;
|
||||
const kind = String(select?.value || "minorCustom") as MinorActionKind;
|
||||
await scheduleAction(combat, combatantId, kind, false);
|
||||
});
|
||||
|
||||
panel.find(".rw-cancel-start").on("click", async (event) => {
|
||||
event.preventDefault();
|
||||
const row = (event.currentTarget as HTMLElement).closest(".rw-imp-row");
|
||||
if (!row) return;
|
||||
const combatantId = String(row.getAttribute("data-combatant-id") || "");
|
||||
await cancelAction(combat, combatantId);
|
||||
});
|
||||
|
||||
panel.find(".rw-save-mods").on("click", async (event) => {
|
||||
event.preventDefault();
|
||||
if (!(await ensureGmControl())) return;
|
||||
const row = (event.currentTarget as HTMLElement).closest(".rw-imp-row");
|
||||
if (!row) return;
|
||||
const combatantId = String(row.getAttribute("data-combatant-id") || "");
|
||||
const combatant = combat.combatants.get(combatantId);
|
||||
if (!combatant) return;
|
||||
|
||||
const impModInput = row.querySelector(".rw-imp-mod") as HTMLInputElement | null;
|
||||
const impMod = Math.trunc(num(impModInput?.value, 0));
|
||||
await combatant.update({
|
||||
[`flags.${SYSTEM_ID}.impulseModifier`]: impMod
|
||||
});
|
||||
});
|
||||
|
||||
refreshImpulseOverlays(combat);
|
||||
}
|
||||
|
||||
export function initializeImpulseTracker(): void {
|
||||
Hooks.on("renderCombatTracker", (app: any, htmlLike: JQuery | HTMLElement) => {
|
||||
renderImpulsePanel(app, htmlLike);
|
||||
});
|
||||
|
||||
Hooks.on("createCombat", async (combat: any) => {
|
||||
await setImpulseState(combat, { currentImpulse: 1, queue: [] });
|
||||
refreshImpulseOverlays(combat);
|
||||
});
|
||||
|
||||
Hooks.on("createCombatant", async (combatant: any) => {
|
||||
await syncCombatantActionRanking(combatant);
|
||||
refreshImpulseOverlays(game.combat ?? null);
|
||||
});
|
||||
|
||||
Hooks.on("deleteCombatant", async () => {
|
||||
refreshImpulseOverlays(game.combat ?? null);
|
||||
});
|
||||
|
||||
Hooks.on("updateCombat", (combat: any) => {
|
||||
refreshImpulseOverlays(combat ?? game.combat ?? null);
|
||||
});
|
||||
|
||||
Hooks.on("deleteCombat", () => {
|
||||
refreshImpulseOverlays(null);
|
||||
});
|
||||
|
||||
Hooks.on("renderChatLog", () => {
|
||||
refreshImpulseOverlays(game.combat ?? null);
|
||||
});
|
||||
|
||||
Hooks.on("canvasReady", () => {
|
||||
refreshImpulseOverlays(game.combat ?? null);
|
||||
});
|
||||
|
||||
Hooks.on("updateActor", (actor: any) => {
|
||||
const combat = game.combat;
|
||||
if (!combat) return;
|
||||
const inCombat = combat.combatants.some((c: any) => c.actor?.id === actor.id);
|
||||
if (inCombat) {
|
||||
ui.combat?.render(true);
|
||||
refreshImpulseOverlays(combat);
|
||||
}
|
||||
});
|
||||
|
||||
Hooks.on("updateItem", (item: any) => {
|
||||
const actor = item?.parent;
|
||||
if (!actor) return;
|
||||
const combat = game.combat;
|
||||
if (!combat) return;
|
||||
const inCombat = combat.combatants.some((c: any) => c.actor?.id === actor.id);
|
||||
if (inCombat) {
|
||||
ui.combat?.render(true);
|
||||
refreshImpulseOverlays(combat);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { rollSkillForActor, useWeaponWorkflow } from "../mechanics";
|
||||
|
||||
export class RingworldItem extends Item {
|
||||
async rollSkill(): Promise<void> {
|
||||
if (this.type !== "skill") return;
|
||||
|
||||
const actor = this.actor;
|
||||
if (!actor) {
|
||||
ui.notifications?.warn("Skill must be owned by an Actor to roll.");
|
||||
return;
|
||||
}
|
||||
|
||||
await rollSkillForActor(actor, this.name, Number((this as any).system.value ?? 0));
|
||||
}
|
||||
|
||||
async useWeapon(): Promise<void> {
|
||||
if (this.type !== "weapon") return;
|
||||
|
||||
const actor = this.actor;
|
||||
if (!actor) {
|
||||
ui.notifications?.warn("Weapon must be owned by an Actor to use.");
|
||||
return;
|
||||
}
|
||||
|
||||
await useWeaponWorkflow(actor, this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
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}`;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { getActorHitLocationSchema } from "../../hit-location-schemas";
|
||||
|
||||
const BaseActorSheet = (foundry as any).appv1.sheets.ActorSheet;
|
||||
|
||||
export class RingworldCreatureSheet extends BaseActorSheet {
|
||||
static get defaultOptions(): any {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
classes: ["ringworld", "sheet", "actor", "creature"],
|
||||
width: 760,
|
||||
height: 700
|
||||
});
|
||||
}
|
||||
|
||||
get template(): string {
|
||||
return "systems/ringworld/templates/actor/creature-sheet.hbs";
|
||||
}
|
||||
|
||||
async getData(options?: any): Promise<any> {
|
||||
const data = await super.getData(options);
|
||||
const actor = this.actor as any;
|
||||
|
||||
return {
|
||||
...data,
|
||||
system: actor.system,
|
||||
skills: actor.items.filter((item: any) => item.type === "skill"),
|
||||
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] ?? {})
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
activateListeners(html: JQuery): void {
|
||||
super.activateListeners(html);
|
||||
|
||||
html.find(".item-create").on("click", async (event) => {
|
||||
event.preventDefault();
|
||||
const type = String((event.currentTarget as HTMLElement).dataset.type || "skill");
|
||||
await this.actor.createEmbeddedDocuments("Item", [{ name: `New ${type}`, type }]);
|
||||
});
|
||||
|
||||
html.find(".item-edit").on("click", (event) => {
|
||||
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) => {
|
||||
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) => {
|
||||
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) => {
|
||||
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 () => {
|
||||
await (this.actor as any).rollHitLocation();
|
||||
});
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
const BaseActorSheet = (foundry as any).appv1.sheets.ActorSheet;
|
||||
|
||||
export class RingworldVehicleSheet extends BaseActorSheet {
|
||||
static get defaultOptions(): any {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
classes: ["ringworld", "sheet", "actor", "vehicle"],
|
||||
width: 680,
|
||||
height: 560
|
||||
});
|
||||
}
|
||||
|
||||
get template(): string {
|
||||
return "systems/ringworld/templates/actor/vehicle-sheet.hbs";
|
||||
}
|
||||
|
||||
async getData(options?: any): Promise<any> {
|
||||
const data = await super.getData(options);
|
||||
return {
|
||||
...data,
|
||||
system: (this.actor as any).system
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
const BaseItemSheet = (foundry as any).appv1.sheets.ItemSheet;
|
||||
|
||||
export class RingworldArmorSheet extends BaseItemSheet {
|
||||
static get defaultOptions(): any {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
classes: ["ringworld", "sheet", "item", "armor"],
|
||||
width: 620,
|
||||
height: 560
|
||||
});
|
||||
}
|
||||
|
||||
get template(): string {
|
||||
return "systems/ringworld/templates/item/armor-sheet.hbs";
|
||||
}
|
||||
|
||||
async getData(options?: any): Promise<any> {
|
||||
const data = await super.getData(options);
|
||||
return {
|
||||
...data,
|
||||
system: (this.item as any).system
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
const BaseItemSheet = (foundry as any).appv1.sheets.ItemSheet;
|
||||
|
||||
export class RingworldGearSheet extends BaseItemSheet {
|
||||
static get defaultOptions(): any {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
classes: ["ringworld", "sheet", "item", "gear"],
|
||||
width: 620,
|
||||
height: 520
|
||||
});
|
||||
}
|
||||
|
||||
get template(): string {
|
||||
return "systems/ringworld/templates/item/gear-sheet.hbs";
|
||||
}
|
||||
|
||||
async getData(options?: any): Promise<any> {
|
||||
const data = await super.getData(options);
|
||||
return {
|
||||
...data,
|
||||
system: (this.item as any).system
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
const BaseItemSheet = (foundry as any).appv1.sheets.ItemSheet;
|
||||
|
||||
function asArray(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.map((entry) => String(entry)) : [];
|
||||
}
|
||||
|
||||
export class RingworldPursuitSheet extends BaseItemSheet {
|
||||
static get defaultOptions(): any {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
classes: ["ringworld", "sheet", "item", "pursuit"],
|
||||
width: 680,
|
||||
height: 620
|
||||
});
|
||||
}
|
||||
|
||||
get template(): string {
|
||||
return "systems/ringworld/templates/item/pursuit-sheet.hbs";
|
||||
}
|
||||
|
||||
async getData(options?: any): Promise<any> {
|
||||
const data = await super.getData(options);
|
||||
const system = (this.item as any).system ?? {};
|
||||
const fixedSkills = asArray(system.fixedSkills);
|
||||
const optionalGroups = Array.isArray(system.optionalSkillGroups)
|
||||
? system.optionalSkillGroups.map((group: any) => ({
|
||||
choose: Number(group?.choose ?? 1),
|
||||
skills: asArray(group?.skills)
|
||||
}))
|
||||
: [];
|
||||
|
||||
return {
|
||||
...data,
|
||||
system,
|
||||
fixedSkills,
|
||||
optionalGroups
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
const BaseItemSheet = (foundry as any).appv1.sheets.ItemSheet;
|
||||
|
||||
export class RingworldSkillSheet extends BaseItemSheet {
|
||||
static get defaultOptions(): any {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
classes: ["ringworld", "sheet", "item", "skill"],
|
||||
width: 520,
|
||||
height: 420
|
||||
});
|
||||
}
|
||||
|
||||
get template(): string {
|
||||
return "systems/ringworld/templates/item/skill-sheet.hbs";
|
||||
}
|
||||
|
||||
async getData(options?: any): Promise<any> {
|
||||
const data = await super.getData(options);
|
||||
return {
|
||||
...data,
|
||||
system: (this.item as any).system
|
||||
};
|
||||
}
|
||||
|
||||
activateListeners(html: JQuery): void {
|
||||
super.activateListeners(html);
|
||||
html.find(".roll-skill").on("click", async (event) => {
|
||||
event.preventDefault();
|
||||
await (this.item as any).rollSkill();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
const BaseItemSheet = (foundry as any).appv1.sheets.ItemSheet;
|
||||
|
||||
export class RingworldSpeciesSheet extends BaseItemSheet {
|
||||
static get defaultOptions(): any {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
classes: ["ringworld", "sheet", "item", "species"],
|
||||
width: 620,
|
||||
height: 560
|
||||
});
|
||||
}
|
||||
|
||||
get template(): string {
|
||||
return "systems/ringworld/templates/item/species-sheet.hbs";
|
||||
}
|
||||
|
||||
async getData(options?: any): Promise<any> {
|
||||
const data = await super.getData(options);
|
||||
return {
|
||||
...data,
|
||||
system: (this.item as any).system
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
const BaseItemSheet = (foundry as any).appv1.sheets.ItemSheet;
|
||||
|
||||
export class RingworldWeaponSheet extends BaseItemSheet {
|
||||
static get defaultOptions(): any {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
classes: ["ringworld", "sheet", "item", "weapon"],
|
||||
width: 620,
|
||||
height: 620
|
||||
});
|
||||
}
|
||||
|
||||
get template(): string {
|
||||
return "systems/ringworld/templates/item/weapon-sheet.hbs";
|
||||
}
|
||||
|
||||
async getData(options?: any): Promise<any> {
|
||||
const data = await super.getData(options);
|
||||
const actor = this.item.actor as any;
|
||||
|
||||
return {
|
||||
...data,
|
||||
system: (this.item as any).system,
|
||||
skills: actor ? actor.items.filter((item: any) => item.type === "skill") : [],
|
||||
powerPacks: actor ? actor.items.filter((item: any) => item.type === "gear" && item.system.isPowerPack) : []
|
||||
};
|
||||
}
|
||||
|
||||
activateListeners(html: JQuery): void {
|
||||
super.activateListeners(html);
|
||||
html.find(".use-weapon").on("click", async (event) => {
|
||||
event.preventDefault();
|
||||
await (this.item as any).useWeapon();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { normalizeSkillName, RINGWORLD_BASE_SKILLS } from "./data-base-skills";
|
||||
|
||||
export const DEFAULT_SKILL_ICON = "systems/ringworld/assets/icons/skill.svg";
|
||||
|
||||
function iconSlug(name: string): string {
|
||||
return String(name)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
export function skillIconPath(name: string): string {
|
||||
return `systems/ringworld/assets/icons/skill/${iconSlug(name)}.svg`;
|
||||
}
|
||||
|
||||
export function baseSkillByName(name: string): (typeof RINGWORLD_BASE_SKILLS)[number] | undefined {
|
||||
const key = normalizeSkillName(name);
|
||||
return RINGWORLD_BASE_SKILLS.find((skill) => normalizeSkillName(skill.name) === key);
|
||||
}
|
||||
|
||||
export function resolveSkillIcon(name: string, currentImg?: string): string {
|
||||
const img = String(currentImg || "").trim();
|
||||
if (img && img !== "icons/svg/item-bag.svg") return img;
|
||||
if (baseSkillByName(name)) return skillIconPath(name);
|
||||
return DEFAULT_SKILL_ICON;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
export async function showDiceSoNice(roll: Roll): Promise<void> {
|
||||
const dice3d = (game as any)?.dice3d;
|
||||
if (!dice3d?.showForRoll) return;
|
||||
|
||||
try {
|
||||
await dice3d.showForRoll(roll, game.user, true, null, false);
|
||||
} catch (_error) {
|
||||
// Ignore Dice So Nice integration failures and continue normal flow.
|
||||
}
|
||||
}
|
||||
|
||||
export async function evaluateRollWithDice(formula: string): Promise<Roll> {
|
||||
const roll = await new Roll(formula).evaluate();
|
||||
await showDiceSoNice(roll as any);
|
||||
return roll as any;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { HIT_LOCATION_TABLE, type HitLocationEntry, type HitLocationSlug } from "../constants";
|
||||
import { evaluateRollWithDice } from "./dice";
|
||||
import { getActorHitLocationSchema, getHitLocationSchemaById } from "../hit-location-schemas";
|
||||
|
||||
export interface HitLocationRollResult {
|
||||
roll: number;
|
||||
location: HitLocationEntry;
|
||||
}
|
||||
|
||||
export function getHitLocationFromRoll(roll: number): HitLocationEntry {
|
||||
const found = HIT_LOCATION_TABLE.find((entry) => roll >= entry.min && roll <= entry.max);
|
||||
return found ?? HIT_LOCATION_TABLE[HIT_LOCATION_TABLE.length - 1];
|
||||
}
|
||||
|
||||
function getTableForActor(actor?: any): HitLocationEntry[] {
|
||||
if (!actor) return HIT_LOCATION_TABLE;
|
||||
const schema = getActorHitLocationSchema(actor);
|
||||
return schema.entries;
|
||||
}
|
||||
|
||||
function getHitLocationFromRollForTable(roll: number, table: HitLocationEntry[]): HitLocationEntry {
|
||||
const found = table.find((entry) => roll >= entry.min && roll <= entry.max);
|
||||
return found ?? table[table.length - 1];
|
||||
}
|
||||
|
||||
export async function rollHitLocation(actor?: any): Promise<HitLocationRollResult> {
|
||||
const roll = await evaluateRollWithDice("1d20");
|
||||
const total = roll.total ?? 20;
|
||||
const table = getTableForActor(actor);
|
||||
return {
|
||||
roll: total,
|
||||
location: getHitLocationFromRollForTable(total, table)
|
||||
};
|
||||
}
|
||||
|
||||
export async function chooseOrRollHitLocation(actor?: any): Promise<{
|
||||
slug: HitLocationSlug;
|
||||
label: string;
|
||||
rolled: number | null;
|
||||
} | null> {
|
||||
const schema = actor ? getActorHitLocationSchema(actor) : getHitLocationSchemaById("humanoid");
|
||||
const table = schema.entries;
|
||||
const entriesBySlug = new Map(table.map((entry) => [entry.slug, entry]));
|
||||
const labelFor = (slug: HitLocationSlug, fallback: string) => entriesBySlug.get(slug)?.label ?? fallback;
|
||||
|
||||
const html = `
|
||||
<form class="ringworld-hit-picker schema-${schema.id}">
|
||||
<input type="hidden" name="location" value="random" />
|
||||
<div class="rw-hit-picker-top">
|
||||
<button type="button" class="rw-hit-picker-choice active" data-location="random">
|
||||
Roll Random (1d20)
|
||||
</button>
|
||||
</div>
|
||||
<div class="rw-hit-picker-body">
|
||||
<button type="button" class="rw-hit-picker-choice rw-loc-head" data-location="head">${labelFor("head", "Head")}</button>
|
||||
<button type="button" class="rw-hit-picker-choice rw-loc-l-arm" data-location="lArm">${labelFor("lArm", "Left Arm")}</button>
|
||||
<button type="button" class="rw-hit-picker-choice rw-loc-chest" data-location="chest">${labelFor("chest", "Chest")}</button>
|
||||
<button type="button" class="rw-hit-picker-choice rw-loc-r-arm" data-location="rArm">${labelFor("rArm", "Right Arm")}</button>
|
||||
<button type="button" class="rw-hit-picker-choice rw-loc-abdomen" data-location="abdomen">${labelFor("abdomen", "Abdomen")}</button>
|
||||
<button type="button" class="rw-hit-picker-choice rw-loc-l-leg" data-location="lLeg">${labelFor("lLeg", "Left Leg")}</button>
|
||||
<button type="button" class="rw-hit-picker-choice rw-loc-r-leg" data-location="rLeg">${labelFor("rLeg", "Right Leg")}</button>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
|
||||
const result = await new Promise<string | null>((resolve) => {
|
||||
let settled = false;
|
||||
const done = (value: string | null) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
const dialog = new Dialog({
|
||||
title: "Choose Hit Location",
|
||||
content: html,
|
||||
buttons: {
|
||||
apply: {
|
||||
label: "Apply",
|
||||
callback: (dlgHtml: JQuery) => {
|
||||
const value = String(dlgHtml.find("input[name='location']").val() || "random");
|
||||
done(value);
|
||||
}
|
||||
},
|
||||
cancel: {
|
||||
label: "Cancel",
|
||||
callback: () => done(null)
|
||||
}
|
||||
},
|
||||
default: "apply",
|
||||
render: (dlgHtml: JQuery) => {
|
||||
const picker = dlgHtml.find(".ringworld-hit-picker");
|
||||
picker.find(".rw-hit-picker-choice").on("click", (event) => {
|
||||
event.preventDefault();
|
||||
const button = event.currentTarget as HTMLElement;
|
||||
const location = String(button.dataset.location || "random");
|
||||
picker.find(".rw-hit-picker-choice").removeClass("active");
|
||||
$(button).addClass("active");
|
||||
picker.find("input[name='location']").val(location);
|
||||
});
|
||||
},
|
||||
close: () => done(null)
|
||||
}, {
|
||||
classes: ["ringworld-hit-location-dialog"],
|
||||
width: 500,
|
||||
resizable: false
|
||||
});
|
||||
dialog.render(true);
|
||||
});
|
||||
|
||||
if (!result) return null;
|
||||
|
||||
if (result === "random") {
|
||||
const rolled = await rollHitLocation(actor);
|
||||
return {
|
||||
slug: rolled.location.slug,
|
||||
label: rolled.location.label,
|
||||
rolled: rolled.roll
|
||||
};
|
||||
}
|
||||
|
||||
const entry = table.find((loc) => loc.slug === result);
|
||||
if (!entry) return null;
|
||||
|
||||
return {
|
||||
slug: entry.slug,
|
||||
label: entry.label,
|
||||
rolled: null
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { evaluateRollWithDice } from "./dice";
|
||||
|
||||
export type PercentileOutcome = "special" | "success" | "failure" | "fumble";
|
||||
|
||||
export interface PercentileResult {
|
||||
roll: number;
|
||||
target: number;
|
||||
outcome: PercentileOutcome;
|
||||
isSuccess: boolean;
|
||||
isSpecial: boolean;
|
||||
isFumble: boolean;
|
||||
}
|
||||
|
||||
export async function rollPercentile(target: number): Promise<PercentileResult> {
|
||||
const roll = await evaluateRollWithDice("1d100");
|
||||
return classifyPercentile(roll.total ?? 0, target);
|
||||
}
|
||||
|
||||
export function classifyPercentile(roll: number, target: number): PercentileResult {
|
||||
const cleanTarget = Math.max(0, Math.floor(Number(target) || 0));
|
||||
const specialThreshold = Math.max(1, Math.floor(cleanTarget / 5));
|
||||
const fumbleThreshold = cleanTarget >= 50 ? 100 : 96;
|
||||
|
||||
const isSuccess = roll <= cleanTarget;
|
||||
const isSpecial = isSuccess && roll <= specialThreshold;
|
||||
const isFumble = !isSuccess && roll >= fumbleThreshold;
|
||||
|
||||
let outcome: PercentileOutcome = "failure";
|
||||
if (isFumble) outcome = "fumble";
|
||||
else if (isSpecial) outcome = "special";
|
||||
else if (isSuccess) outcome = "success";
|
||||
|
||||
return {
|
||||
roll,
|
||||
target: cleanTarget,
|
||||
outcome,
|
||||
isSuccess,
|
||||
isSpecial,
|
||||
isFumble
|
||||
};
|
||||
}
|
||||
|
||||
export function outcomeLabel(outcome: PercentileOutcome): string {
|
||||
switch (outcome) {
|
||||
case "special":
|
||||
return "Special Success";
|
||||
case "success":
|
||||
return "Success";
|
||||
case "fumble":
|
||||
return "Fumble";
|
||||
default:
|
||||
return "Failure";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,978 @@
|
||||
import { SYSTEM_ID } from "./constants";
|
||||
import { normalizeSkillName, RINGWORLD_BASE_SKILLS } from "./data-base-skills";
|
||||
import { skillIconPath } from "./skill-icons";
|
||||
|
||||
export type GravityType = "light" | "normal" | "heavy";
|
||||
|
||||
interface SpeciesCatalogEntry {
|
||||
name: string;
|
||||
playable: boolean;
|
||||
gravity: GravityType;
|
||||
homeworld: string;
|
||||
modifiers: {
|
||||
str: number;
|
||||
mas: number;
|
||||
con: number;
|
||||
int: number;
|
||||
pow: number;
|
||||
dex: number;
|
||||
app: number;
|
||||
edu: number;
|
||||
age: number;
|
||||
};
|
||||
notes: string;
|
||||
}
|
||||
|
||||
interface WeaponCatalogEntry {
|
||||
name: string;
|
||||
equipped?: boolean;
|
||||
weight?: number;
|
||||
linkedSkill: string;
|
||||
attackType: "melee" | "ranged";
|
||||
damageFormula: string;
|
||||
range: { close: string; medium: string; long: string };
|
||||
energyUsedPerAttack: number;
|
||||
ammoUsedPerAttack: number;
|
||||
powerSource: "actor" | "powerPack";
|
||||
notes: string;
|
||||
}
|
||||
|
||||
interface ArmorCatalogEntry {
|
||||
name: string;
|
||||
equipped: boolean;
|
||||
weight?: number;
|
||||
value: number;
|
||||
coverage: Record<string, boolean>;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
interface GearCatalogEntry {
|
||||
name: string;
|
||||
equipped?: boolean;
|
||||
weight: number;
|
||||
cost: number;
|
||||
energyUse: number;
|
||||
powerSupplyNotes: string;
|
||||
isPowerPack: boolean;
|
||||
energy: { value: number; max: number };
|
||||
notes: string;
|
||||
}
|
||||
|
||||
interface PursuitCatalogEntry {
|
||||
name: string;
|
||||
rollRange: string;
|
||||
wealthValue: number;
|
||||
occupationPointsPerYear: number;
|
||||
fixedSkills: string[];
|
||||
optionalSkillGroups?: Array<{
|
||||
choose: number;
|
||||
skills: string[];
|
||||
}>;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
interface CatalogSeed {
|
||||
key: string;
|
||||
data: any;
|
||||
folderKey: "species" | "skills" | "pursuits" | "gear" | "armor" | "weaponMelee" | "weaponRanged";
|
||||
img: string;
|
||||
}
|
||||
|
||||
export const RINGWORLD_CATALOG_VERSION = 9;
|
||||
|
||||
const ICONS = {
|
||||
speciesDefault: "systems/ringworld/assets/icons/species.svg",
|
||||
skillDefault: "systems/ringworld/assets/icons/skill.svg",
|
||||
pursuitDefault: "systems/ringworld/assets/icons/pursuit.svg",
|
||||
weaponMelee: "systems/ringworld/assets/icons/weapon-melee.svg",
|
||||
weaponRanged: "systems/ringworld/assets/icons/weapon-ranged.svg",
|
||||
armor: "systems/ringworld/assets/icons/armor.svg",
|
||||
gearDefault: "systems/ringworld/assets/icons/gear.svg",
|
||||
powerPackDefault: "systems/ringworld/assets/icons/power-pack.svg"
|
||||
};
|
||||
|
||||
function iconSlug(name: string): string {
|
||||
return String(name)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
function speciesIconPath(name: string): string {
|
||||
return `systems/ringworld/assets/icons/species/${iconSlug(name)}.svg`;
|
||||
}
|
||||
|
||||
function weaponIconPath(name: string): string {
|
||||
return `systems/ringworld/assets/icons/weapon/${iconSlug(name)}.svg`;
|
||||
}
|
||||
|
||||
function armorIconPath(name: string): string {
|
||||
return `systems/ringworld/assets/icons/armor/${iconSlug(name)}.svg`;
|
||||
}
|
||||
|
||||
function gearIconPath(name: string): string {
|
||||
return `systems/ringworld/assets/icons/gear/${iconSlug(name)}.svg`;
|
||||
}
|
||||
|
||||
function pursuitIconPath(name: string): string {
|
||||
return `systems/ringworld/assets/icons/pursuit/${iconSlug(name)}.svg`;
|
||||
}
|
||||
|
||||
const allCoverage = {
|
||||
rLeg: true,
|
||||
lLeg: true,
|
||||
abdomen: true,
|
||||
chest: true,
|
||||
rArm: true,
|
||||
lArm: true,
|
||||
head: true
|
||||
};
|
||||
|
||||
const torsoCoverage = {
|
||||
rLeg: false,
|
||||
lLeg: false,
|
||||
abdomen: true,
|
||||
chest: true,
|
||||
rArm: false,
|
||||
lArm: false,
|
||||
head: false
|
||||
};
|
||||
|
||||
const headCoverage = {
|
||||
rLeg: false,
|
||||
lLeg: false,
|
||||
abdomen: false,
|
||||
chest: false,
|
||||
rArm: false,
|
||||
lArm: false,
|
||||
head: true
|
||||
};
|
||||
|
||||
const armCoverage = {
|
||||
rLeg: false,
|
||||
lLeg: false,
|
||||
abdomen: false,
|
||||
chest: false,
|
||||
rArm: true,
|
||||
lArm: true,
|
||||
head: false
|
||||
};
|
||||
|
||||
const legCoverage = {
|
||||
rLeg: true,
|
||||
lLeg: true,
|
||||
abdomen: false,
|
||||
chest: false,
|
||||
rArm: false,
|
||||
lArm: false,
|
||||
head: false
|
||||
};
|
||||
|
||||
export const SPECIES_CATALOG: SpeciesCatalogEntry[] = [
|
||||
{
|
||||
name: "Human",
|
||||
playable: true,
|
||||
gravity: "normal",
|
||||
homeworld: "Known Space",
|
||||
modifiers: { str: 0, mas: 0, con: 0, int: 0, pow: 0, dex: 0, app: 0, edu: 0, age: 0 },
|
||||
notes: "Baseline human explorer profile."
|
||||
},
|
||||
{
|
||||
name: "Earth Human",
|
||||
playable: true,
|
||||
gravity: "normal",
|
||||
homeworld: "Earth",
|
||||
modifiers: { str: 0, mas: 0, con: 0, int: 0, pow: 1, dex: 0, app: 0, edu: 0, age: 0 },
|
||||
notes: "Earth-line humans with stronger latent psionic potential."
|
||||
},
|
||||
{
|
||||
name: "Kzinti",
|
||||
playable: true,
|
||||
gravity: "heavy",
|
||||
homeworld: "Kzin",
|
||||
modifiers: { str: 2, mas: 2, con: 1, int: -1, pow: 0, dex: 0, app: -2, edu: 0, age: 0 },
|
||||
notes: "Predatory felinoids with high physical strength."
|
||||
},
|
||||
{
|
||||
name: "Pierson's Puppeteer",
|
||||
playable: true,
|
||||
gravity: "light",
|
||||
homeworld: "Fleet of Worlds",
|
||||
modifiers: { str: -2, mas: -2, con: -1, int: 2, pow: 1, dex: 1, app: 0, edu: 1, age: 0 },
|
||||
notes: "Highly intelligent, cautious engineers."
|
||||
},
|
||||
{
|
||||
name: "Dolphin",
|
||||
playable: true,
|
||||
gravity: "normal",
|
||||
homeworld: "Human Space Oceans",
|
||||
modifiers: { str: -2, mas: 1, con: 1, int: 0, pow: 0, dex: 0, app: 0, edu: 0, age: 0 },
|
||||
notes: "Aquatic specialists with enhanced endurance."
|
||||
},
|
||||
{
|
||||
name: "Trinoc",
|
||||
playable: true,
|
||||
gravity: "light",
|
||||
homeworld: "Trinoc Worlds",
|
||||
modifiers: { str: -1, mas: -1, con: 0, int: 1, pow: 0, dex: 1, app: 0, edu: 0, age: 0 },
|
||||
notes: "Three-eyed flyers with excellent perception."
|
||||
},
|
||||
{
|
||||
name: "Martian",
|
||||
playable: true,
|
||||
gravity: "light",
|
||||
homeworld: "Mars",
|
||||
modifiers: { str: -1, mas: -1, con: -1, int: 1, pow: 0, dex: 1, app: 0, edu: 0, age: 0 },
|
||||
notes: "Light-gravity adapted intellect-focused profile."
|
||||
},
|
||||
{
|
||||
name: "Pierin",
|
||||
playable: true,
|
||||
gravity: "normal",
|
||||
homeworld: "Pierin Space",
|
||||
modifiers: { str: 0, mas: 0, con: 0, int: 1, pow: 0, dex: 0, app: 1, edu: 0, age: 0 },
|
||||
notes: "Socially adept, technically curious explorers."
|
||||
},
|
||||
{
|
||||
name: "Boat People",
|
||||
playable: true,
|
||||
gravity: "normal",
|
||||
homeworld: "Ringworld",
|
||||
modifiers: { str: 0, mas: 0, con: 1, int: 0, pow: 0, dex: 0, app: 0, edu: -1, age: 0 },
|
||||
notes: "Ringworld hominids adapted to river and sea cultures."
|
||||
},
|
||||
{
|
||||
name: "Builder",
|
||||
playable: true,
|
||||
gravity: "normal",
|
||||
homeworld: "Ringworld",
|
||||
modifiers: { str: 1, mas: 1, con: 0, int: 0, pow: 0, dex: 0, app: -1, edu: 0, age: 0 },
|
||||
notes: "Ringworld labor stock with robust build."
|
||||
},
|
||||
{
|
||||
name: "Ghoul",
|
||||
playable: true,
|
||||
gravity: "normal",
|
||||
homeworld: "Ringworld",
|
||||
modifiers: { str: 0, mas: 0, con: 1, int: -1, pow: 0, dex: 1, app: -2, edu: -1, age: 0 },
|
||||
notes: "Scavenger subspecies with strong survival instincts."
|
||||
},
|
||||
{
|
||||
name: "Bandersnatchi",
|
||||
playable: false,
|
||||
gravity: "heavy",
|
||||
homeworld: "Known Space",
|
||||
modifiers: { str: 4, mas: 4, con: 3, int: 0, pow: 0, dex: -2, app: -3, edu: 0, age: 10 },
|
||||
notes: "Massive engineered beings; typically GM creature profile."
|
||||
}
|
||||
];
|
||||
|
||||
export const WEAPON_CATALOG: WeaponCatalogEntry[] = [
|
||||
{
|
||||
name: "Variable Sword",
|
||||
equipped: true,
|
||||
weight: 1.5,
|
||||
linkedSkill: "V. Sword, F. Laser",
|
||||
attackType: "melee",
|
||||
damageFormula: "1d10+1",
|
||||
range: { close: "Melee", medium: "-", long: "-" },
|
||||
energyUsedPerAttack: 1,
|
||||
ammoUsedPerAttack: 0,
|
||||
powerSource: "powerPack",
|
||||
notes: "High-energy blade weapon."
|
||||
},
|
||||
{
|
||||
name: "Monofilament Knife",
|
||||
equipped: true,
|
||||
weight: 0.2,
|
||||
linkedSkill: "Archaic Melee Weapon",
|
||||
attackType: "melee",
|
||||
damageFormula: "1d6+1",
|
||||
range: { close: "Melee", medium: "-", long: "-" },
|
||||
energyUsedPerAttack: 0,
|
||||
ammoUsedPerAttack: 0,
|
||||
powerSource: "actor",
|
||||
notes: "Fine-edge close combat blade."
|
||||
},
|
||||
{
|
||||
name: "Shock Baton",
|
||||
equipped: true,
|
||||
weight: 1.2,
|
||||
linkedSkill: "Unarmed Combat",
|
||||
attackType: "melee",
|
||||
damageFormula: "1d8+1",
|
||||
range: { close: "Melee", medium: "-", long: "-" },
|
||||
energyUsedPerAttack: 1,
|
||||
ammoUsedPerAttack: 0,
|
||||
powerSource: "powerPack",
|
||||
notes: "Close-quarters stun baton."
|
||||
},
|
||||
{
|
||||
name: "Flashlight Laser",
|
||||
equipped: true,
|
||||
weight: 0.8,
|
||||
linkedSkill: "V. Sword, F. Laser",
|
||||
attackType: "ranged",
|
||||
damageFormula: "1d10",
|
||||
range: { close: "Short", medium: "Medium", long: "Long" },
|
||||
energyUsedPerAttack: 2,
|
||||
ammoUsedPerAttack: 0,
|
||||
powerSource: "powerPack",
|
||||
notes: "Compact laser unit."
|
||||
},
|
||||
{
|
||||
name: "Laser Pistol",
|
||||
equipped: true,
|
||||
weight: 1.4,
|
||||
linkedSkill: "Handgun, Energy",
|
||||
attackType: "ranged",
|
||||
damageFormula: "1d10+1",
|
||||
range: { close: "Short", medium: "Medium", long: "Long" },
|
||||
energyUsedPerAttack: 2,
|
||||
ammoUsedPerAttack: 0,
|
||||
powerSource: "powerPack",
|
||||
notes: "Common energy sidearm."
|
||||
},
|
||||
{
|
||||
name: "Laser Rifle",
|
||||
equipped: true,
|
||||
weight: 4.5,
|
||||
linkedSkill: "Heavy Weapon, Energy",
|
||||
attackType: "ranged",
|
||||
damageFormula: "2d6+2",
|
||||
range: { close: "Short", medium: "Long", long: "Extreme" },
|
||||
energyUsedPerAttack: 3,
|
||||
ammoUsedPerAttack: 0,
|
||||
powerSource: "powerPack",
|
||||
notes: "Standard long-arm laser weapon."
|
||||
},
|
||||
{
|
||||
name: "Stunner",
|
||||
equipped: true,
|
||||
weight: 1.0,
|
||||
linkedSkill: "Handgun, Energy",
|
||||
attackType: "ranged",
|
||||
damageFormula: "1d6",
|
||||
range: { close: "Short", medium: "Medium", long: "Long" },
|
||||
energyUsedPerAttack: 1,
|
||||
ammoUsedPerAttack: 0,
|
||||
powerSource: "powerPack",
|
||||
notes: "Low-lethality disruption sidearm."
|
||||
},
|
||||
{
|
||||
name: "Handgun (Projectile)",
|
||||
equipped: true,
|
||||
weight: 1.2,
|
||||
linkedSkill: "Handgun, Projectile",
|
||||
attackType: "ranged",
|
||||
damageFormula: "1d10",
|
||||
range: { close: "Short", medium: "Medium", long: "Long" },
|
||||
energyUsedPerAttack: 0,
|
||||
ammoUsedPerAttack: 1,
|
||||
powerSource: "actor",
|
||||
notes: "Conventional projectile sidearm."
|
||||
},
|
||||
{
|
||||
name: "Rifle (Projectile)",
|
||||
equipped: true,
|
||||
weight: 5.0,
|
||||
linkedSkill: "Heavy Weapon, Projectile",
|
||||
attackType: "ranged",
|
||||
damageFormula: "2d6",
|
||||
range: { close: "Short", medium: "Long", long: "Extreme" },
|
||||
energyUsedPerAttack: 0,
|
||||
ammoUsedPerAttack: 1,
|
||||
powerSource: "actor",
|
||||
notes: "Conventional projectile rifle."
|
||||
},
|
||||
{
|
||||
name: "Slug Thrower Carbine",
|
||||
equipped: true,
|
||||
weight: 3.8,
|
||||
linkedSkill: "Heavy Weapon, Projectile",
|
||||
attackType: "ranged",
|
||||
damageFormula: "2d6+1",
|
||||
range: { close: "Short", medium: "Long", long: "Extreme" },
|
||||
energyUsedPerAttack: 0,
|
||||
ammoUsedPerAttack: 1,
|
||||
powerSource: "actor",
|
||||
notes: "Compact projectile carbine."
|
||||
}
|
||||
];
|
||||
|
||||
export const ARMOR_CATALOG: ArmorCatalogEntry[] = [
|
||||
{ name: "Cloth Vest", equipped: true, weight: 2, value: 1, coverage: torsoCoverage, notes: "Basic torso protection." },
|
||||
{ name: "Soft Combat Vest", equipped: true, weight: 4, value: 2, coverage: torsoCoverage, notes: "Flexible tactical vest." },
|
||||
{ name: "Combat Harness", equipped: true, weight: 5, value: 2, coverage: { ...torsoCoverage, rArm: true, lArm: true }, notes: "Torso + upper arms." },
|
||||
{ name: "Armored Coat", equipped: true, weight: 7, value: 3, coverage: { ...torsoCoverage, rArm: true, lArm: true }, notes: "Heavy coat with arm plating." },
|
||||
{ name: "Leg Guards", equipped: true, weight: 4, value: 2, coverage: legCoverage, notes: "Lower-body armor." },
|
||||
{ name: "Arm Bracers", equipped: true, weight: 2, value: 2, coverage: armCoverage, notes: "Forearm and upper arm guards." },
|
||||
{ name: "Combat Helmet", equipped: true, weight: 1.5, value: 2, coverage: headCoverage, notes: "Cranial protection." },
|
||||
{ name: "Full Combat Suit", equipped: true, weight: 12, value: 3, coverage: allCoverage, notes: "Full-body armored suit." },
|
||||
{ name: "Impact Suit", equipped: true, weight: 16, value: 4, coverage: allCoverage, notes: "High-end impact and hazard suit." }
|
||||
];
|
||||
|
||||
export const GEAR_CATALOG: GearCatalogEntry[] = [
|
||||
{
|
||||
name: "Power Pack R20",
|
||||
weight: 1,
|
||||
cost: 100,
|
||||
energyUse: 0,
|
||||
powerSupplyNotes: "Rechargeable power pack",
|
||||
isPowerPack: true,
|
||||
energy: { value: 20, max: 20 },
|
||||
notes: "Standard personal battery pack."
|
||||
},
|
||||
{
|
||||
name: "Power Pack R50",
|
||||
weight: 2,
|
||||
cost: 220,
|
||||
energyUse: 0,
|
||||
powerSupplyNotes: "Rechargeable power pack",
|
||||
isPowerPack: true,
|
||||
energy: { value: 50, max: 50 },
|
||||
notes: "Extended personal battery pack."
|
||||
},
|
||||
{
|
||||
name: "Power Pack R100",
|
||||
weight: 4,
|
||||
cost: 400,
|
||||
energyUse: 0,
|
||||
powerSupplyNotes: "Rechargeable power pack",
|
||||
isPowerPack: true,
|
||||
energy: { value: 100, max: 100 },
|
||||
notes: "High-capacity field battery."
|
||||
},
|
||||
{ name: "Portable Generator", weight: 8, cost: 700, energyUse: 0, powerSupplyNotes: "Provides charging capacity", isPowerPack: false, energy: { value: 0, max: 0 }, notes: "Portable recharge generator." },
|
||||
{ name: "MicroMind Unit", weight: 0.5, cost: 900, energyUse: 1, powerSupplyNotes: "Requires external power", isPowerPack: false, energy: { value: 0, max: 0 }, notes: "Portable advanced computation module." },
|
||||
{ name: "Memory Bubble Reader", weight: 0.5, cost: 450, energyUse: 1, powerSupplyNotes: "Standard battery compatible", isPowerPack: false, energy: { value: 0, max: 0 }, notes: "Reads and writes memory bubbles." },
|
||||
{ name: "Autodoc (Portable)", weight: 18, cost: 3500, energyUse: 6, powerSupplyNotes: "High draw medical station", isPowerPack: false, energy: { value: 0, max: 0 }, notes: "Field medical support unit." },
|
||||
{ name: "Medkit", weight: 1, cost: 120, energyUse: 0, powerSupplyNotes: "None", isPowerPack: false, energy: { value: 0, max: 0 }, notes: "Manual emergency treatment supplies." },
|
||||
{ name: "Bioscanner", weight: 1, cost: 300, energyUse: 1, powerSupplyNotes: "Battery or generator", isPowerPack: false, energy: { value: 0, max: 0 }, notes: "Vitals and hazard scanning tool." },
|
||||
{ name: "Survey Scanner", weight: 2, cost: 500, energyUse: 2, powerSupplyNotes: "Battery or generator", isPowerPack: false, energy: { value: 0, max: 0 }, notes: "Terrain and structure analysis scanner." },
|
||||
{ name: "Translator Module", weight: 0.5, cost: 650, energyUse: 1, powerSupplyNotes: "Battery compatible", isPowerPack: false, energy: { value: 0, max: 0 }, notes: "Linguistic conversion and support." },
|
||||
{ name: "Holo Projector", weight: 1.5, cost: 800, energyUse: 2, powerSupplyNotes: "Battery compatible", isPowerPack: false, energy: { value: 0, max: 0 }, notes: "Short-range holographic projection." },
|
||||
{ name: "Stasis Box", weight: 12, cost: 2600, energyUse: 5, powerSupplyNotes: "Continuous power required", isPowerPack: false, energy: { value: 0, max: 0 }, notes: "Suspended animation container." },
|
||||
{ name: "Reactionless Flycycle", weight: 35, cost: 4200, energyUse: 4, powerSupplyNotes: "Vehicle power core", isPowerPack: false, energy: { value: 0, max: 0 }, notes: "Single-rider reactionless vehicle." },
|
||||
{ name: "Field Toolkit", weight: 2, cost: 180, energyUse: 0, powerSupplyNotes: "None", isPowerPack: false, energy: { value: 0, max: 0 }, notes: "Mechanical and electronics repair kit." },
|
||||
{ name: "Vac Suit", weight: 7, cost: 1400, energyUse: 1, powerSupplyNotes: "Suit battery required", isPowerPack: false, energy: { value: 0, max: 0 }, notes: "Pressure and vacuum survival suit." },
|
||||
{ name: "Air Reprocessor", weight: 3, cost: 950, energyUse: 2, powerSupplyNotes: "Continuous power", isPowerPack: false, energy: { value: 0, max: 0 }, notes: "Atmosphere recycling support unit." },
|
||||
{ name: "Signal Beacon", weight: 1, cost: 140, energyUse: 1, powerSupplyNotes: "Battery compatible", isPowerPack: false, energy: { value: 0, max: 0 }, notes: "Emergency and navigation beacon." }
|
||||
];
|
||||
|
||||
export const PURSUIT_CATALOG: PursuitCatalogEntry[] = [
|
||||
{
|
||||
name: "Artist",
|
||||
rollRange: "01-07",
|
||||
wealthValue: 0,
|
||||
occupationPointsPerYear: 20,
|
||||
fixedSkills: ["Athletics", "Computers", "Fast Talk", "Fine Arts", "Hide", "Musicianship", "Observe", "Perform", "Psychology", "Scent", "Theology"],
|
||||
notes: "Creative specialist pursuit."
|
||||
},
|
||||
{
|
||||
name: "Colonist",
|
||||
rollRange: "08-27",
|
||||
wealthValue: 1,
|
||||
occupationPointsPerYear: 20,
|
||||
fixedSkills: ["Atmospheric Craft", "Bargain", "Ground Vehicle", "Farming", "Handgun, Energy", "Personal Flyer", "Listen", "Heavy Weapon, Energy", "Observe", "Planetology", "Search", "Track"],
|
||||
notes: "Settlement and frontier operations pursuit."
|
||||
},
|
||||
{
|
||||
name: "Criminal",
|
||||
rollRange: "28-29",
|
||||
wealthValue: 1,
|
||||
occupationPointsPerYear: 20,
|
||||
fixedSkills: ["Bargain", "Computers", "Fast Talk", "Hide", "Personal Flyer", "Handgun, Energy", "Handgun, Projectile", "Sneak", "Unarmed Combat"],
|
||||
notes: "Underground survival pursuit."
|
||||
},
|
||||
{
|
||||
name: "Dilettante",
|
||||
rollRange: "30",
|
||||
wealthValue: 5,
|
||||
occupationPointsPerYear: 15,
|
||||
fixedSkills: [],
|
||||
notes: "Any skills allowed; uses 15 occupation points per year."
|
||||
},
|
||||
{
|
||||
name: "Engineer",
|
||||
rollRange: "31-36",
|
||||
wealthValue: 2,
|
||||
occupationPointsPerYear: 20,
|
||||
fixedSkills: ["Astronomy", "Chemistry", "Computers", "Engineering", "Ground Vehicle", "Mathematics", "Personal Flyer", "Physics", "Planetology", "Reaction Drive", "Reactionless Drive", "Repair"],
|
||||
notes: "Technical design and maintenance pursuit."
|
||||
},
|
||||
{
|
||||
name: "Entertainer",
|
||||
rollRange: "37-38",
|
||||
wealthValue: 2,
|
||||
occupationPointsPerYear: 20,
|
||||
fixedSkills: ["Athletics", "Bargain", "Own Language", "Musicianship", "Observe", "Orate", "Perform", "Psychology", "Search", "Sneak"],
|
||||
notes: "Performance and social influence pursuit."
|
||||
},
|
||||
{
|
||||
name: "Explorer",
|
||||
rollRange: "39-52",
|
||||
wealthValue: 1,
|
||||
occupationPointsPerYear: 20,
|
||||
fixedSkills: ["Athletics", "Atmospheric Craft", "Bargain", "Biology", "Botany", "Emergency Treatment", "Engineering", "Ground Vehicle", "Handgun, Energy", "Heavy Weapon, Energy", "Hyperdrive", "Observe", "Planetology", "Reactionless Drive", "Repair", "Search", "Track", "Zoology"],
|
||||
notes: "Survey and expedition pursuit."
|
||||
},
|
||||
{
|
||||
name: "Functionary",
|
||||
rollRange: "53-56",
|
||||
wealthValue: 3,
|
||||
occupationPointsPerYear: 20,
|
||||
fixedSkills: ["Bargain", "Debate", "Fast Talk", "History", "Law", "Observe", "Orate", "Psychology", "Strategy", "Theology"],
|
||||
notes: "Administrative and governance pursuit."
|
||||
},
|
||||
{
|
||||
name: "Journalist/Writer",
|
||||
rollRange: "57-59",
|
||||
wealthValue: 1,
|
||||
occupationPointsPerYear: 20,
|
||||
fixedSkills: ["Anthropology", "Bargain", "Computers", "Debate", "History", "Fine Arts", "Own Language", "Law", "Observe", "Psychology", "Search", "Sneak", "Unarmed Combat", "Weapons System"],
|
||||
notes: "Investigation and reporting pursuit."
|
||||
},
|
||||
{
|
||||
name: "Miner",
|
||||
rollRange: "60-65",
|
||||
wealthValue: 1,
|
||||
occupationPointsPerYear: 20,
|
||||
fixedSkills: ["Atmospheric Craft", "Aquatic Vehicle", "Bargain", "Climb (Custom)", "Computers", "Emergency Treatment", "Engineering", "Handgun, Energy", "Heavy Weapon, Energy", "Observe", "Personal Flyer", "Planetology", "Reaction Drive", "Reactionless Drive", "Repair", "Search"],
|
||||
notes: "Resource extraction frontier pursuit."
|
||||
},
|
||||
{
|
||||
name: "Pilot",
|
||||
rollRange: "66-68",
|
||||
wealthValue: 3,
|
||||
occupationPointsPerYear: 20,
|
||||
fixedSkills: ["Astronomy", "Atmospheric Craft", "Computers", "Engineering", "Hyperdrive", "History", "Law", "Mathematics", "Physics", "Reaction Drive", "Reactionless Drive", "Repair", "Strategy", "Weapons System"],
|
||||
notes: "Flight and spacecraft operations pursuit."
|
||||
},
|
||||
{
|
||||
name: "Police Officer",
|
||||
rollRange: "69-76",
|
||||
wealthValue: 1,
|
||||
occupationPointsPerYear: 20,
|
||||
fixedSkills: ["Atmospheric Craft", "Bargain", "Computers", "Fast Talk", "Emergency Treatment", "Handgun, Energy", "Handgun, Projectile", "Heavy Weapon, Energy", "Heavy Weapon, Projectile", "Law", "Observe", "Personal Flyer", "Psychology", "Search", "Unarmed Combat"],
|
||||
notes: "Law enforcement and response pursuit."
|
||||
},
|
||||
{
|
||||
name: "Politician",
|
||||
rollRange: "77-78",
|
||||
wealthValue: 3,
|
||||
occupationPointsPerYear: 20,
|
||||
fixedSkills: ["Bargain", "Computers", "Debate", "Fast Talk", "History", "Orate", "Own Language", "Perform", "Psychology"],
|
||||
optionalSkillGroups: [
|
||||
{
|
||||
choose: 1,
|
||||
skills: ["Engineering", "Farming", "Second Languages", "Strategy", "Theology"]
|
||||
}
|
||||
],
|
||||
notes: "Public office and influence pursuit."
|
||||
},
|
||||
{
|
||||
name: "Prisoner",
|
||||
rollRange: "79-80",
|
||||
wealthValue: 0,
|
||||
occupationPointsPerYear: 20,
|
||||
fixedSkills: ["Bargain", "Fast Talk", "Hide", "Observe", "Psychology", "Search", "Sneak", "Unarmed Combat"],
|
||||
notes: "Carceral survival pursuit."
|
||||
},
|
||||
{
|
||||
name: "Scholar",
|
||||
rollRange: "81-84",
|
||||
wealthValue: 1,
|
||||
occupationPointsPerYear: 20,
|
||||
fixedSkills: [],
|
||||
optionalSkillGroups: [
|
||||
{
|
||||
choose: 2,
|
||||
skills: ["Anthropology", "Astronomy", "Biology", "Botany", "Chemistry", "Computers", "Engineering", "Fine Arts", "History", "Law", "Mathematics", "Own Language", "Physics", "Planetology", "Second Languages", "Theology", "Zoology"]
|
||||
}
|
||||
],
|
||||
notes: "Academic pursuit; pick up to two listed skills."
|
||||
},
|
||||
{
|
||||
name: "Scientific Researcher",
|
||||
rollRange: "85-94",
|
||||
wealthValue: 1,
|
||||
occupationPointsPerYear: 20,
|
||||
fixedSkills: [],
|
||||
optionalSkillGroups: [
|
||||
{
|
||||
choose: 3,
|
||||
skills: ["Anthropology", "Astronomy", "Bargain", "Biology", "Botany", "Chemistry", "Computers", "Engineering", "History", "Mathematics", "Observe", "Personal Flyer", "Physics", "Planetology", "Psychology", "Repair", "Search", "Zoology"]
|
||||
}
|
||||
],
|
||||
notes: "Research pursuit; pick up to three listed skills."
|
||||
},
|
||||
{
|
||||
name: "Soldier",
|
||||
rollRange: "95-98",
|
||||
wealthValue: 1,
|
||||
occupationPointsPerYear: 20,
|
||||
fixedSkills: ["Athletics", "Atmospheric Craft", "Aquatic Vehicle", "Computers", "Emergency Treatment", "Fast Talk", "Ground Vehicle", "Handgun, Energy", "Handgun, Projectile", "Heavy Weapon, Energy", "Heavy Weapon, Projectile"],
|
||||
notes: "Military operations pursuit."
|
||||
},
|
||||
{
|
||||
name: "Zealot",
|
||||
rollRange: "99-00",
|
||||
wealthValue: 0,
|
||||
occupationPointsPerYear: 20,
|
||||
fixedSkills: ["Bargain", "Debate", "Fast Talk", "Fine Arts", "Handgun, Energy", "Handgun, Projectile", "Hide", "Own Language", "Law", "Strategy", "Orate", "Psychology", "Theology"],
|
||||
notes: "Ideological dedication pursuit."
|
||||
}
|
||||
];
|
||||
|
||||
export function speciesCatalogForGeneration(): SpeciesCatalogEntry[] {
|
||||
return SPECIES_CATALOG.filter((entry) => entry.playable);
|
||||
}
|
||||
|
||||
function itemKey(type: string, name: string): string {
|
||||
return `${type}:${normalizeSkillName(name)}`;
|
||||
}
|
||||
|
||||
function itemTypeAvailable(type: string): boolean {
|
||||
const directTypes = (getDocumentClass("Item") as any)?.TYPES;
|
||||
if (Array.isArray(directTypes)) return directTypes.includes(type);
|
||||
|
||||
const docTypes = (game as any)?.system?.documentTypes?.Item;
|
||||
if (Array.isArray(docTypes)) return docTypes.includes(type);
|
||||
if (docTypes && typeof docTypes === "object") return Object.prototype.hasOwnProperty.call(docTypes, type);
|
||||
|
||||
const configModels = (CONFIG as any)?.Item?.dataModels;
|
||||
if (configModels && typeof configModels === "object" && Object.prototype.hasOwnProperty.call(configModels, type)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const gameModels = (game as any)?.model?.Item;
|
||||
if (gameModels && typeof gameModels === "object" && Object.prototype.hasOwnProperty.call(gameModels, type)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function toSkillItem(entry: (typeof RINGWORLD_BASE_SKILLS)[number]): CatalogSeed {
|
||||
return {
|
||||
key: itemKey("skill", entry.name),
|
||||
folderKey: "skills",
|
||||
img: skillIconPath(entry.name) || ICONS.skillDefault,
|
||||
data: {
|
||||
name: entry.name,
|
||||
type: "skill",
|
||||
system: {
|
||||
value: entry.base,
|
||||
category: entry.category,
|
||||
specialization: entry.specialization ?? ""
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function toSpeciesItem(entry: SpeciesCatalogEntry): CatalogSeed {
|
||||
const img = speciesIconPath(entry.name) || ICONS.speciesDefault;
|
||||
return {
|
||||
key: itemKey("species", entry.name),
|
||||
folderKey: "species",
|
||||
img,
|
||||
data: {
|
||||
name: entry.name,
|
||||
type: "species",
|
||||
system: {
|
||||
playable: entry.playable,
|
||||
gravity: entry.gravity,
|
||||
homeworld: entry.homeworld,
|
||||
modifiers: entry.modifiers,
|
||||
notes: entry.notes
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function toWeaponItem(entry: WeaponCatalogEntry): CatalogSeed {
|
||||
const folderKey = entry.attackType === "melee" ? "weaponMelee" : "weaponRanged";
|
||||
const img = weaponIconPath(entry.name) || (entry.attackType === "melee" ? ICONS.weaponMelee : ICONS.weaponRanged);
|
||||
return {
|
||||
key: itemKey("weapon", entry.name),
|
||||
folderKey,
|
||||
img,
|
||||
data: {
|
||||
name: entry.name,
|
||||
type: "weapon",
|
||||
system: {
|
||||
equipped: entry.equipped ?? true,
|
||||
stowed: false,
|
||||
weight: entry.weight ?? 0,
|
||||
linkedSkill: entry.linkedSkill,
|
||||
linkedSkillId: "",
|
||||
attackType: entry.attackType,
|
||||
damageFormula: entry.damageFormula,
|
||||
range: entry.range,
|
||||
energyUsedPerAttack: entry.energyUsedPerAttack,
|
||||
ammoUsedPerAttack: entry.ammoUsedPerAttack,
|
||||
powerSource: entry.powerSource,
|
||||
powerPackId: "",
|
||||
notes: entry.notes
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function toArmorItem(entry: ArmorCatalogEntry): CatalogSeed {
|
||||
const img = armorIconPath(entry.name) || ICONS.armor;
|
||||
return {
|
||||
key: itemKey("armor", entry.name),
|
||||
folderKey: "armor",
|
||||
img,
|
||||
data: {
|
||||
name: entry.name,
|
||||
type: "armor",
|
||||
system: {
|
||||
equipped: entry.equipped,
|
||||
stowed: false,
|
||||
weight: entry.weight ?? 0,
|
||||
value: entry.value,
|
||||
coverage: entry.coverage,
|
||||
perLocation: {
|
||||
rLeg: 0,
|
||||
lLeg: 0,
|
||||
abdomen: 0,
|
||||
chest: 0,
|
||||
rArm: 0,
|
||||
lArm: 0,
|
||||
head: 0
|
||||
},
|
||||
notes: entry.notes
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function toGearItem(entry: GearCatalogEntry): CatalogSeed {
|
||||
const img = gearIconPath(entry.name) || (entry.isPowerPack ? ICONS.powerPackDefault : ICONS.gearDefault);
|
||||
return {
|
||||
key: itemKey("gear", entry.name),
|
||||
folderKey: "gear",
|
||||
img,
|
||||
data: {
|
||||
name: entry.name,
|
||||
type: "gear",
|
||||
system: {
|
||||
equipped: entry.equipped ?? true,
|
||||
stowed: false,
|
||||
weight: entry.weight,
|
||||
cost: entry.cost,
|
||||
energyUse: entry.energyUse,
|
||||
powerSupplyNotes: entry.powerSupplyNotes,
|
||||
isPowerPack: entry.isPowerPack,
|
||||
energy: entry.energy,
|
||||
notes: entry.notes
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function toPursuitItem(entry: PursuitCatalogEntry): CatalogSeed {
|
||||
const img = pursuitIconPath(entry.name) || ICONS.pursuitDefault;
|
||||
return {
|
||||
key: itemKey("pursuit", entry.name),
|
||||
folderKey: "pursuits",
|
||||
img,
|
||||
data: {
|
||||
name: entry.name,
|
||||
type: "pursuit",
|
||||
system: {
|
||||
wealthValue: entry.wealthValue,
|
||||
occupationPointsPerYear: entry.occupationPointsPerYear,
|
||||
rollRange: entry.rollRange,
|
||||
fixedSkills: entry.fixedSkills,
|
||||
optionalSkillGroups: entry.optionalSkillGroups ?? [],
|
||||
notes: entry.notes
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function pursuitAsGearNotes(entry: PursuitCatalogEntry): string {
|
||||
const fixed = entry.fixedSkills.length ? `Fixed skills: ${entry.fixedSkills.join(", ")}` : "Fixed skills: (none)";
|
||||
const optional = (entry.optionalSkillGroups ?? []).map((group) => `Choose ${group.choose}: ${group.skills.join(", ")}`).join(" | ");
|
||||
const optionalText = optional ? ` Optional: ${optional}.` : "";
|
||||
return `${entry.notes} ${fixed}.${optionalText} Wealth ${entry.wealthValue}. Occupation points/year ${entry.occupationPointsPerYear}.`;
|
||||
}
|
||||
|
||||
function toPursuitFallbackGearItem(entry: PursuitCatalogEntry): CatalogSeed {
|
||||
const img = pursuitIconPath(entry.name) || ICONS.pursuitDefault;
|
||||
const itemName = `Pursuit: ${entry.name}`;
|
||||
return {
|
||||
key: itemKey("gear", itemName),
|
||||
folderKey: "pursuits",
|
||||
img,
|
||||
data: {
|
||||
name: itemName,
|
||||
type: "gear",
|
||||
system: {
|
||||
equipped: false,
|
||||
stowed: true,
|
||||
weight: 0,
|
||||
cost: 0,
|
||||
energyUse: 0,
|
||||
powerSupplyNotes: "Pursuit reference",
|
||||
isPowerPack: false,
|
||||
energy: { value: 0, max: 0 },
|
||||
notes: pursuitAsGearNotes(entry)
|
||||
},
|
||||
flags: {
|
||||
ringworld: {
|
||||
pursuitData: {
|
||||
pursuitName: entry.name,
|
||||
rollRange: entry.rollRange,
|
||||
wealthValue: entry.wealthValue,
|
||||
occupationPointsPerYear: entry.occupationPointsPerYear,
|
||||
fixedSkills: entry.fixedSkills,
|
||||
optionalSkillGroups: entry.optionalSkillGroups ?? []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function getOrCreateItemFolder(name: string, parentId: string | null): Promise<any> {
|
||||
const existing = game.folders.find(
|
||||
(folder: any) => folder.type === "Item" && folder.name === name && (folder.folder?.id ?? null) === parentId
|
||||
);
|
||||
if (existing) return existing;
|
||||
|
||||
return Folder.create({
|
||||
name,
|
||||
type: "Item",
|
||||
folder: parentId,
|
||||
sorting: "a"
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureCatalogFolders(): Promise<Record<CatalogSeed["folderKey"], string>> {
|
||||
const root = await getOrCreateItemFolder("Ringworld Catalog", null);
|
||||
const species = await getOrCreateItemFolder("Species", root.id);
|
||||
const skills = await getOrCreateItemFolder("Skills", root.id);
|
||||
const pursuits = await getOrCreateItemFolder("Pursuits", root.id);
|
||||
const gear = await getOrCreateItemFolder("Gear", root.id);
|
||||
const armor = await getOrCreateItemFolder("Armor", gear.id);
|
||||
const weapons = await getOrCreateItemFolder("Weapons", root.id);
|
||||
const weaponMelee = await getOrCreateItemFolder("Melee Weapons", weapons.id);
|
||||
const weaponRanged = await getOrCreateItemFolder("Ranged Weapons", weapons.id);
|
||||
|
||||
return {
|
||||
species: species.id,
|
||||
skills: skills.id,
|
||||
pursuits: pursuits.id,
|
||||
gear: gear.id,
|
||||
armor: armor.id,
|
||||
weaponMelee: weaponMelee.id,
|
||||
weaponRanged: weaponRanged.id
|
||||
};
|
||||
}
|
||||
|
||||
function buildAllSeeds(): CatalogSeed[] {
|
||||
const canUsePursuitType = itemTypeAvailable("pursuit");
|
||||
return [
|
||||
...SPECIES_CATALOG.map(toSpeciesItem),
|
||||
...RINGWORLD_BASE_SKILLS.map(toSkillItem),
|
||||
...(canUsePursuitType ? PURSUIT_CATALOG.map(toPursuitItem) : PURSUIT_CATALOG.map(toPursuitFallbackGearItem)),
|
||||
...WEAPON_CATALOG.map(toWeaponItem),
|
||||
...ARMOR_CATALOG.map(toArmorItem),
|
||||
...GEAR_CATALOG.map(toGearItem)
|
||||
];
|
||||
}
|
||||
|
||||
export async function seedWorldCatalog(options?: { notify?: boolean }): Promise<{ added: number; updated: number }> {
|
||||
if (!game.user?.isGM) return { added: 0, updated: 0 };
|
||||
|
||||
const folderIds = await ensureCatalogFolders();
|
||||
const seeds = buildAllSeeds();
|
||||
|
||||
const existing = new Map(
|
||||
game.items.map((item: any) => [itemKey(item.type, item.name), item])
|
||||
);
|
||||
|
||||
const createPayload: any[] = [];
|
||||
const updatePayload: any[] = [];
|
||||
|
||||
for (const seed of seeds) {
|
||||
const folderId = folderIds[seed.folderKey];
|
||||
const existingItem = existing.get(seed.key);
|
||||
|
||||
if (!existingItem) {
|
||||
createPayload.push({
|
||||
...seed.data,
|
||||
img: seed.img,
|
||||
folder: folderId,
|
||||
flags: {
|
||||
ringworld: {
|
||||
catalog: true,
|
||||
catalogKey: seed.key
|
||||
}
|
||||
}
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const update: Record<string, unknown> = { _id: existingItem.id };
|
||||
|
||||
if ((existingItem.folder?.id ?? null) !== folderId) {
|
||||
update.folder = folderId;
|
||||
}
|
||||
|
||||
if (String(existingItem.img || "") !== seed.img) {
|
||||
update.img = seed.img;
|
||||
}
|
||||
|
||||
if (existingItem.flags?.ringworld?.catalog !== true) {
|
||||
update["flags.ringworld.catalog"] = true;
|
||||
}
|
||||
|
||||
if (existingItem.flags?.ringworld?.catalogKey !== seed.key) {
|
||||
update["flags.ringworld.catalogKey"] = seed.key;
|
||||
}
|
||||
|
||||
const existingSystem = foundry.utils.deepClone(existingItem.system ?? {});
|
||||
const mergedSystem = foundry.utils.mergeObject(existingSystem, seed.data.system ?? {}, {
|
||||
inplace: false,
|
||||
insertKeys: true,
|
||||
insertValues: true,
|
||||
overwrite: false
|
||||
});
|
||||
const systemDiff = foundry.utils.diffObject(existingSystem, mergedSystem);
|
||||
if (Object.keys(systemDiff).length > 0) {
|
||||
update.system = mergedSystem;
|
||||
}
|
||||
|
||||
if (Object.keys(update).length > 1) {
|
||||
updatePayload.push(update);
|
||||
}
|
||||
}
|
||||
|
||||
if (createPayload.length) {
|
||||
await Item.createDocuments(createPayload as any[]);
|
||||
}
|
||||
|
||||
if (updatePayload.length) {
|
||||
await Item.updateDocuments(updatePayload as any[]);
|
||||
}
|
||||
|
||||
if (options?.notify) {
|
||||
ui.notifications?.info(`Ringworld catalog synced: +${createPayload.length} new, ${updatePayload.length} updated.`);
|
||||
}
|
||||
|
||||
return { added: createPayload.length, updated: updatePayload.length };
|
||||
}
|
||||
|
||||
export function getPlayableSpeciesFromWorld(): any[] {
|
||||
return game.items.filter((item: any) => item.type === "species" && item.system.playable);
|
||||
}
|
||||
|
||||
export function ringworldSystemSettingKey(key: string): string {
|
||||
return `${SYSTEM_ID}.${key}`;
|
||||
}
|
||||
Reference in New Issue
Block a user