Initial Ringworld system publish with companion content module and starter world data

This commit is contained in:
2026-03-04 14:21:21 +00:00
commit 162bbde1fe
188 changed files with 15660 additions and 0 deletions
+335
View File
@@ -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);
}
}
+178
View File
@@ -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;
}
+77
View File
@@ -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 });
}
+37
View File
@@ -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);
}
+85
View File
@@ -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)));
}
+191
View File
@@ -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];
}
+756
View File
@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("\"", "&quot;");
}
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);
}
});
}
+27
View File
@@ -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);
}
}
+525
View File
@@ -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}`;
}
+73
View File
@@ -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
+23
View File
@@ -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
};
}
}
+23
View File
@@ -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
};
}
}
+23
View File
@@ -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
};
}
}
+39
View File
@@ -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
};
}
}
+31
View File
@@ -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();
});
}
}
+23
View File
@@ -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
};
}
}
+35
View File
@@ -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();
});
}
}
+27
View File
@@ -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;
}
+16
View File
@@ -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;
}
+130
View File
@@ -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
};
}
+54
View File
@@ -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";
}
}
+978
View File
@@ -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}`;
}