Restyled character creation dialog.
Added dialog factory.
This commit is contained in:
@@ -14,11 +14,92 @@ import {
|
||||
* @param {number} base
|
||||
* @returns {string}
|
||||
*/
|
||||
function getActiveEffectModificationClass(current, base) {
|
||||
export function getActiveEffectModificationClass(current, base) {
|
||||
if (current == null || base == null || current === base) return "";
|
||||
return current > base ? "ae-mod-increase" : "ae-mod-decrease";
|
||||
}
|
||||
|
||||
/**
|
||||
* Sheet color for roll-target modifiers (uses modifier sign, not clamped effective vs base).
|
||||
* @param {number} modifier
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getRollTargetDisplayClassFromModifier(modifier) {
|
||||
const numericModifier = Number(modifier) || 0;
|
||||
if (!numericModifier) return "";
|
||||
return numericModifier > 0 ? "ae-mod-increase" : "ae-mod-decrease";
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a roll-target Active Effect modifier with the same clamp rules as percentile rolls.
|
||||
* @param {number} base
|
||||
* @param {number} modifier
|
||||
* @param {{ allowOver99?: boolean }} [options]
|
||||
* @returns {number}
|
||||
*/
|
||||
export function clampPercentileRollTarget(
|
||||
base,
|
||||
modifier,
|
||||
{ allowOver99 = false } = {},
|
||||
) {
|
||||
const numericBase = Number(base);
|
||||
const numericModifier = Number(modifier) || 0;
|
||||
if (!numericModifier || !Number.isFinite(numericBase)) return numericBase;
|
||||
|
||||
let target = Math.round(numericBase + numericModifier);
|
||||
target = Math.max(1, target);
|
||||
if (!allowOver99) {
|
||||
target = Math.min(target, 99);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} entry
|
||||
* @param {number} modifier
|
||||
*/
|
||||
function attachRollTargetDisplay(entry, modifier) {
|
||||
const numericModifier = Number(modifier) || 0;
|
||||
if (!numericModifier) {
|
||||
entry.rollTargetDisplayClass = "";
|
||||
return;
|
||||
}
|
||||
|
||||
entry.rollTargetDisplayClass =
|
||||
getRollTargetDisplayClassFromModifier(numericModifier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sheet display for agent roll-target Active Effects (skills, stats x5, SAN).
|
||||
* @param {Actor} actor
|
||||
* @returns {void}
|
||||
*/
|
||||
export function prepareAgentRollTargetDisplay(actor) {
|
||||
if (actor.type !== "agent") return;
|
||||
|
||||
const system = actor.system;
|
||||
const rollTarget = system.rollTarget;
|
||||
if (!rollTarget) return;
|
||||
|
||||
const allSkillsMod = Number(rollTarget.allSkills) || 0;
|
||||
const sanityMod = Number(rollTarget.sanity) || 0;
|
||||
const statisticsMod = Number(rollTarget.statistics) || 0;
|
||||
|
||||
for (const skill of Object.values(system.skills ?? {})) {
|
||||
attachRollTargetDisplay(skill, allSkillsMod);
|
||||
}
|
||||
|
||||
for (const skill of Object.values(system.typedSkills ?? {})) {
|
||||
attachRollTargetDisplay(skill, allSkillsMod);
|
||||
}
|
||||
|
||||
attachRollTargetDisplay(system.sanity, sanityMod);
|
||||
|
||||
for (const stat of Object.values(system.statistics ?? {})) {
|
||||
attachRollTargetDisplay(stat, statisticsMod);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach sheet display classes comparing prepared data to persisted source (pre–AE).
|
||||
* @param {Actor} actor
|
||||
@@ -82,6 +163,7 @@ export function refreshDerivedAfterActiveEffects(actor) {
|
||||
|
||||
const sourceStatistics = actor._source?.system?.statistics;
|
||||
prepareStatisticsX5(system.statistics, sourceStatistics);
|
||||
prepareAgentRollTargetDisplay(actor);
|
||||
applyAgentResourceMaxBonuses(system, sourceStatistics);
|
||||
setSkillTargetProficiencies(system.skills);
|
||||
setSkillTargetProficiencies(system.typedSkills);
|
||||
|
||||
@@ -75,7 +75,12 @@ const AGENT_SKILL_DEFAULTS = /** @type {Record<string, number>} */ ({
|
||||
|
||||
const BONUS_SKILL_COUNT = 8;
|
||||
const BONUS_SKILL_INCREMENT = 20;
|
||||
|
||||
/** @type {ReadonlySet<string>} */
|
||||
const BONUS_SKILL_CATALOG_EXCLUDED_FIXED = new Set(["unnatural"]);
|
||||
const SKILL_CAP = 80;
|
||||
/** Bonus picks that would waste this many points or more are rejected (e.g. 80 + 20% at cap). */
|
||||
const MAX_ALLOWED_BONUS_WASTE = 20;
|
||||
|
||||
const TYPED_KEY_PATTERN =
|
||||
/^(Art|Craft|Foreign Language|ForeignLanguage|Military Science|MilitaryScience|Pilot|Science|Other)\s*\(([^)]+)\)\s*$/i;
|
||||
@@ -126,16 +131,6 @@ export function isChooseOneProfessionSkillKey(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} mapKey
|
||||
* @param {Record<string, ProfessionSkillMeta>} automaticMeta
|
||||
* @param {Record<string, ProfessionSkillMeta>} optionMeta
|
||||
* @returns {ProfessionSkillMeta}
|
||||
*/
|
||||
export function getProfessionSkillMeta(mapKey, automaticMeta, optionMeta) {
|
||||
return automaticMeta[mapKey] ?? optionMeta[mapKey] ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} params
|
||||
* @param {string} params.group
|
||||
@@ -393,6 +388,19 @@ export function buildSkillCatalog() {
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill catalog for character-creation bonus picks (excludes Unnatural).
|
||||
*
|
||||
* @returns {SkillCatalogEntry[]}
|
||||
*/
|
||||
export function buildBonusSkillCatalog() {
|
||||
return buildSkillCatalog().filter(
|
||||
(entry) =>
|
||||
entry.ref.kind !== "fixed" ||
|
||||
!BONUS_SKILL_CATALOG_EXCLUDED_FIXED.has(entry.ref.key),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} catalogId
|
||||
* @param {string} [typedLabel]
|
||||
@@ -420,6 +428,119 @@ export function catalogIdToSkillRef(
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ProfessionSkillRef} ref
|
||||
* @returns {string}
|
||||
*/
|
||||
function getBonusTrackKey(ref) {
|
||||
if (ref.kind === "fixed") return `fixed:${ref.key}`;
|
||||
return formatProfessionSkillKey(ref);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} trackKey
|
||||
* @returns {string}
|
||||
*/
|
||||
function getBonusTrackLabel(trackKey) {
|
||||
if (trackKey.startsWith("fixed:")) {
|
||||
const key = trackKey.slice(6);
|
||||
return formatProfessionSkillLabel(
|
||||
/** @type {FixedSkillRef} */ ({ kind: "fixed", key }),
|
||||
);
|
||||
}
|
||||
const ref = parseProfessionSkillKey(trackKey);
|
||||
if (ref) return formatProfessionSkillLabel(ref);
|
||||
return trackKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} trackKey
|
||||
* @param {Record<string, number>} baseFixed
|
||||
* @param {Record<string, { group: string, label: string, value: number }>} baseTyped
|
||||
* @param {Record<string, number>} defaults
|
||||
* @returns {number}
|
||||
*/
|
||||
function getBonusTrackBaseValue(trackKey, baseFixed, baseTyped, defaults) {
|
||||
if (trackKey.startsWith("fixed:")) {
|
||||
const key = trackKey.slice(6);
|
||||
return baseFixed[key] ?? defaults[key] ?? 0;
|
||||
}
|
||||
return baseTyped[trackKey]?.value ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bonus skill slots that would waste 20+ points or raise a skill already at the creation cap.
|
||||
*
|
||||
* @param {Record<string, number>} baseFixed
|
||||
* @param {Record<string, { group: string, label: string, value: number }>} baseTyped
|
||||
* @param {string[]} bonusCatalogIds
|
||||
* @param {string[]} bonusTypedLabels
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function collectBonusCapValidationErrors(
|
||||
baseFixed,
|
||||
baseTyped,
|
||||
bonusCatalogIds,
|
||||
bonusTypedLabels,
|
||||
) {
|
||||
const defaults = getAgentSkillDefaults();
|
||||
/** @type {Record<string, number>} */
|
||||
const counts = {};
|
||||
/** @type {(string | null)[]} */
|
||||
const slotTrackKeys = [];
|
||||
|
||||
for (let i = 0; i < BONUS_SKILL_COUNT; i++) {
|
||||
const catalogId = bonusCatalogIds?.[i];
|
||||
if (!catalogId) {
|
||||
slotTrackKeys.push(null);
|
||||
continue;
|
||||
}
|
||||
const ref = catalogIdToSkillRef(
|
||||
catalogId,
|
||||
bonusTypedLabels?.[i] ?? "",
|
||||
);
|
||||
if (!ref) {
|
||||
slotTrackKeys.push(null);
|
||||
continue;
|
||||
}
|
||||
const trackKey = getBonusTrackKey(ref);
|
||||
slotTrackKeys.push(trackKey);
|
||||
counts[trackKey] = (counts[trackKey] ?? 0) + 1;
|
||||
}
|
||||
|
||||
/** @type {Set<string>} */
|
||||
const violatingTrackKeys = new Set();
|
||||
|
||||
for (const [trackKey, count] of Object.entries(counts)) {
|
||||
const base = getBonusTrackBaseValue(trackKey, baseFixed, baseTyped, defaults);
|
||||
const final = base + count * BONUS_SKILL_INCREMENT;
|
||||
const waste = Math.max(0, final - SKILL_CAP);
|
||||
if (base >= SKILL_CAP || waste >= MAX_ALLOWED_BONUS_WASTE) {
|
||||
violatingTrackKeys.add(trackKey);
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {string[]} */
|
||||
const errors = [];
|
||||
for (let i = 0; i < BONUS_SKILL_COUNT; i++) {
|
||||
const trackKey = slotTrackKeys[i];
|
||||
if (!trackKey || !violatingTrackKeys.has(trackKey)) continue;
|
||||
|
||||
const base = getBonusTrackBaseValue(trackKey, baseFixed, baseTyped, defaults);
|
||||
const count = counts[trackKey] ?? 0;
|
||||
const final = base + count * BONUS_SKILL_INCREMENT;
|
||||
const waste = Math.max(0, final - SKILL_CAP);
|
||||
|
||||
if (base >= SKILL_CAP) {
|
||||
errors.push(`bonusAtCap:${i}|${trackKey}`);
|
||||
} else {
|
||||
errors.push(`bonusWaste:${i}|${waste}|${trackKey}`);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, number>} automaticSkills
|
||||
* @param {Record<string, number>} optionSkills
|
||||
@@ -634,6 +755,15 @@ export function computeSkillValues(
|
||||
bondCount,
|
||||
});
|
||||
|
||||
validationErrors.push(
|
||||
...collectBonusCapValidationErrors(
|
||||
baseFixed,
|
||||
baseTyped,
|
||||
bonusIds,
|
||||
bonusLabels,
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
fixedValues,
|
||||
typedValues,
|
||||
@@ -768,6 +898,8 @@ export function formatProfessionValidationMessages(
|
||||
let needsBonusSkillType = false;
|
||||
/** @type {Set<string>} */
|
||||
const typedNameRequiredGroups = new Set();
|
||||
/** @type {string[]} */
|
||||
const bonusAtCapTrackKeys = [];
|
||||
|
||||
for (const code of errors) {
|
||||
if (code === "optionPicks") {
|
||||
@@ -782,11 +914,29 @@ export function formatProfessionValidationMessages(
|
||||
needsBondRelationship = true;
|
||||
continue;
|
||||
}
|
||||
const bonusAtCap = code.match(/^bonusAtCap:\d+\|(.+)$/);
|
||||
if (bonusAtCap) {
|
||||
bonusAtCapTrackKeys.push(bonusAtCap[1]);
|
||||
continue;
|
||||
}
|
||||
|
||||
const bonusWaste = code.match(/^bonusWaste:\d+\|(\d+)\|(.+)$/);
|
||||
if (bonusWaste) {
|
||||
push(
|
||||
game.i18n.format("DG.Profession.Dialog.BonusSkillWasteTooHigh", {
|
||||
skill: getBonusTrackLabel(bonusWaste[2]),
|
||||
waste: Number(bonusWaste[1]),
|
||||
maxWaste: MAX_ALLOWED_BONUS_WASTE - 1,
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (code.startsWith("bonusType")) {
|
||||
needsBonusSkillType = true;
|
||||
continue;
|
||||
}
|
||||
if (code.startsWith("bonus")) {
|
||||
if (/^bonus\d+$/.test(code)) {
|
||||
needsBonusSkill = true;
|
||||
continue;
|
||||
}
|
||||
@@ -816,12 +966,6 @@ export function formatProfessionValidationMessages(
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (needsBondName) {
|
||||
push(game.i18n.localize("DG.Profession.Dialog.BondNameRequired"));
|
||||
}
|
||||
if (needsBondRelationship) {
|
||||
push(game.i18n.localize("DG.Profession.Dialog.BondRelationshipRequired"));
|
||||
}
|
||||
if (needsBonusSkill) {
|
||||
push(
|
||||
game.i18n.format("DG.Profession.Dialog.BonusSkillRequired", {
|
||||
@@ -839,6 +983,20 @@ export function formatProfessionValidationMessages(
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (needsBondName) {
|
||||
push(game.i18n.localize("DG.Profession.Dialog.BondNameRequired"));
|
||||
}
|
||||
if (needsBondRelationship) {
|
||||
push(game.i18n.localize("DG.Profession.Dialog.BondRelationshipRequired"));
|
||||
}
|
||||
|
||||
for (const trackKey of bonusAtCapTrackKeys) {
|
||||
push(
|
||||
game.i18n.format("DG.Profession.Dialog.BonusSkillAlreadyAtCap", {
|
||||
skill: getBonusTrackLabel(trackKey),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
@@ -915,4 +1073,4 @@ export function prepareProfessionSkillRows(skillMap, skillMeta = {}) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
export { BONUS_SKILL_COUNT, BONUS_SKILL_INCREMENT, SKILL_CAP };
|
||||
export { BONUS_SKILL_COUNT };
|
||||
|
||||
@@ -94,8 +94,8 @@ export async function rollStatisticScores({
|
||||
*/
|
||||
export function buildRollStatsChatContent(rolls) {
|
||||
const cells = rolls.map(
|
||||
(entry) =>
|
||||
`<td class="roll-stats-chat-value">${foundry.utils.escapeHTML(String(entry.total))}</td>`,
|
||||
(entry, index) =>
|
||||
`<td class="roll-stats-chat-value roll-stats-chat-value--${index % 2 === 0 ? "a" : "b"}">${foundry.utils.escapeHTML(String(entry.total))}</td>`,
|
||||
);
|
||||
const rows = [];
|
||||
for (let i = 0; i < cells.length; i += 3) {
|
||||
|
||||
@@ -16,14 +16,6 @@ export default function registerHandlebarsHelpers() {
|
||||
return outStr;
|
||||
});
|
||||
|
||||
Handlebars.registerHelper("toLowerCase", (str) => {
|
||||
try {
|
||||
return str.toLowerCase();
|
||||
} catch (error) {
|
||||
return "";
|
||||
}
|
||||
});
|
||||
|
||||
Handlebars.registerHelper("toUpperCase", (str) => {
|
||||
try {
|
||||
return str.toUpperCase();
|
||||
@@ -39,13 +31,6 @@ export default function registerHandlebarsHelpers() {
|
||||
return opts.inverse(this);
|
||||
});
|
||||
|
||||
Handlebars.registerHelper("if_not_eq", (a, b, opts) => {
|
||||
if (a !== b) {
|
||||
return opts.fn(this);
|
||||
}
|
||||
return opts.inverse(this);
|
||||
});
|
||||
|
||||
Handlebars.registerHelper("if_gt", (a, b, trueVal, falseVal) => {
|
||||
if (a > b) {
|
||||
return trueVal;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { createDGChatMessage } from "../chat/dg-chat-card.js";
|
||||
import {
|
||||
createDGChatMessage,
|
||||
createDGRollChatMessage,
|
||||
} from "../chat/dg-chat-card.js";
|
||||
|
||||
/**
|
||||
* @param {object} params
|
||||
@@ -7,6 +10,8 @@ import { createDGChatMessage } from "../chat/dg-chat-card.js";
|
||||
* @param {string} params.contentKey i18n key for message body.
|
||||
* @param {string} params.labelKey i18n key for card subtitle.
|
||||
* @param {Record<string, string|number>} params.i18nData
|
||||
* @param {string|null} [params.extraContentKey] Optional i18n key appended on a new line after the main body.
|
||||
* @param {Roll} [params.roll] When set, attaches the roll to the chat message for display and Dice So Nice.
|
||||
* @returns {Promise<ChatMessage>}
|
||||
*/
|
||||
export async function createAgentResourceChatMessage({
|
||||
@@ -15,13 +20,32 @@ export async function createAgentResourceChatMessage({
|
||||
contentKey,
|
||||
labelKey,
|
||||
i18nData,
|
||||
extraContentKey = null,
|
||||
roll = null,
|
||||
}) {
|
||||
const content = game.i18n.format(contentKey, i18nData);
|
||||
let content = game.i18n.format(contentKey, i18nData);
|
||||
if (extraContentKey) {
|
||||
content = `${content}<br>${game.i18n.format(extraContentKey, i18nData)}`;
|
||||
}
|
||||
const label = game.i18n.localize(labelKey);
|
||||
const messageMode = game.settings.get("core", "messageMode");
|
||||
|
||||
if (roll) {
|
||||
return createDGRollChatMessage({
|
||||
actor,
|
||||
token,
|
||||
roll,
|
||||
label,
|
||||
content,
|
||||
messageMode,
|
||||
});
|
||||
}
|
||||
|
||||
return createDGChatMessage({
|
||||
actor,
|
||||
token,
|
||||
label: game.i18n.localize(labelKey),
|
||||
label,
|
||||
content,
|
||||
messageMode: game.settings.get("core", "messageMode"),
|
||||
messageMode,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import DG from "../config.js";
|
||||
import { ROLL_TARGET_FIELD_KEYS } from "../active-effect/effect-fields.js";
|
||||
import { clampPercentileRollTarget } from "./active-effect-derived.js";
|
||||
import DGUtils from "./utility-functions.js";
|
||||
|
||||
/** @typedef {"system.rollTarget.allSkills"|"system.rollTarget.sanity"|"system.rollTarget.statistics"} RollTargetFieldKey */
|
||||
|
||||
/**
|
||||
* @param {ActiveEffect} effect
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isRollTargetEffectActive(effect) {
|
||||
if (effect.disabled) return false;
|
||||
if (effect.duration?.expired) return false;
|
||||
if (effect.isSuppressed) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ActiveEffect} effect
|
||||
* @returns {string}
|
||||
*/
|
||||
function getRollTargetEffectLabel(effect) {
|
||||
if (effect.getFlag(DG.ID, "exhaustion")) {
|
||||
return game.i18n.localize("DG.Physical.ExhaustionEffectName");
|
||||
}
|
||||
|
||||
if (effect.name?.trim()) return effect.name;
|
||||
|
||||
const parent = effect.parent;
|
||||
if (parent?.documentName === "Item") return parent.name ?? "";
|
||||
|
||||
return effect.name ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Actor} actor
|
||||
* @param {RollTargetFieldKey} rollTargetFieldKey
|
||||
* @returns {Array<{ name: string, modifier: number }>}
|
||||
*/
|
||||
export function collectRollTargetContributions(actor, rollTargetFieldKey) {
|
||||
if (actor.type !== "agent" || !ROLL_TARGET_FIELD_KEYS.includes(rollTargetFieldKey)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const contributions = [];
|
||||
|
||||
for (const effect of actor.appliedEffects ?? []) {
|
||||
if (!isRollTargetEffectActive(effect)) continue;
|
||||
|
||||
const change = (effect.changes ?? []).find(
|
||||
(entry) => entry.key === rollTargetFieldKey && entry.type === "add",
|
||||
);
|
||||
if (!change) continue;
|
||||
|
||||
const modifier = Number(change.value);
|
||||
if (!Number.isFinite(modifier) || modifier === 0) continue;
|
||||
|
||||
contributions.push({
|
||||
name: getRollTargetEffectLabel(effect),
|
||||
modifier,
|
||||
});
|
||||
}
|
||||
|
||||
return contributions;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} value
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatTargetPercent(value) {
|
||||
return `${Math.round(value)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} modifier
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatModifierPercent(modifier) {
|
||||
return `${DGUtils.formatStringWithLeadingPlus(modifier)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} base
|
||||
* @param {Array<{ name: string, modifier: number }>} contributions
|
||||
* @param {{ allowOver99?: boolean }} [options]
|
||||
* @returns {string}
|
||||
*/
|
||||
function buildRollTargetBreakdownTable(
|
||||
base,
|
||||
contributions,
|
||||
{ allowOver99 = false } = {},
|
||||
) {
|
||||
const rows = [
|
||||
[
|
||||
game.i18n.localize("DG.Tooltip.RollTarget.BaseTarget"),
|
||||
formatTargetPercent(base),
|
||||
],
|
||||
];
|
||||
|
||||
for (const { name, modifier } of contributions) {
|
||||
rows.push([name, formatModifierPercent(modifier)]);
|
||||
}
|
||||
|
||||
const totalModifier = contributions.reduce(
|
||||
(sum, entry) => sum + entry.modifier,
|
||||
0,
|
||||
);
|
||||
const finalTarget = clampPercentileRollTarget(base, totalModifier, {
|
||||
allowOver99,
|
||||
});
|
||||
|
||||
rows.push([
|
||||
game.i18n.localize("DG.Tooltip.RollTarget.FinalTarget"),
|
||||
formatTargetPercent(finalTarget),
|
||||
]);
|
||||
|
||||
const body = rows
|
||||
.map(
|
||||
([label, value]) =>
|
||||
`<tr><td>${foundry.utils.escapeHTML(label)}</td><td>${foundry.utils.escapeHTML(value)}</td></tr>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
return `<table class="dg-roll-target-tooltip"><tbody>${body}</tbody></table>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} base
|
||||
* @returns {string}
|
||||
*/
|
||||
function buildRollTargetTargetLine(base) {
|
||||
return game.i18n.format("DG.Tooltip.RollTarget.Target", {
|
||||
value: Math.round(base),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} existingTooltip
|
||||
* @param {Actor} actor
|
||||
* @param {RollTargetFieldKey} rollTargetFieldKey
|
||||
* @param {number} base
|
||||
* @param {{ allowOver99?: boolean, showTargetWhenUnmodified?: boolean }} [options]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function appendRollTargetTooltipSection(
|
||||
existingTooltip,
|
||||
actor,
|
||||
rollTargetFieldKey,
|
||||
base,
|
||||
options = {},
|
||||
) {
|
||||
if (actor.type !== "agent") return existingTooltip;
|
||||
|
||||
const { showTargetWhenUnmodified = true, ...tableOptions } = options;
|
||||
|
||||
const contributions = collectRollTargetContributions(
|
||||
actor,
|
||||
rollTargetFieldKey,
|
||||
);
|
||||
|
||||
let appendix = "";
|
||||
if (contributions.length > 0) {
|
||||
appendix = buildRollTargetBreakdownTable(base, contributions, tableOptions);
|
||||
} else if (showTargetWhenUnmodified) {
|
||||
appendix = buildRollTargetTargetLine(base);
|
||||
}
|
||||
|
||||
if (!appendix) return existingTooltip;
|
||||
if (!existingTooltip) return appendix;
|
||||
return `${existingTooltip}<br><br>${appendix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stat and sanity roll-target tooltips for the agent sheet left bar (not skills).
|
||||
*
|
||||
* @param {Actor} actor
|
||||
* @returns {void}
|
||||
*/
|
||||
export function prepareAgentStatSanityTooltips(actor) {
|
||||
if (actor.type !== "agent") return;
|
||||
|
||||
const system = actor.system;
|
||||
|
||||
for (const [key, stat] of Object.entries(system.statistics ?? {})) {
|
||||
const base = Number(stat.x5) || 0;
|
||||
const existing = game.i18n.localize(`DG.Attributes.Tooltip.${key}`);
|
||||
stat.tooltip = appendRollTargetTooltipSection(
|
||||
existing,
|
||||
actor,
|
||||
"system.rollTarget.statistics",
|
||||
base,
|
||||
{ allowOver99: base > 99 },
|
||||
);
|
||||
}
|
||||
|
||||
const sanity = system.sanity;
|
||||
if (sanity) {
|
||||
const existing = `${game.i18n.localize("DG.Tooltip.CurrentSanityPartOne")}${sanity.currentBreakingPoint}${game.i18n.localize("DG.Tooltip.CurrentSanityPartTwo")}`;
|
||||
const base = Number(sanity.value) || 0;
|
||||
sanity.tooltip = appendRollTargetTooltipSection(
|
||||
existing,
|
||||
actor,
|
||||
"system.rollTarget.sanity",
|
||||
base,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,21 @@
|
||||
/**
|
||||
* Splits a flat list into column arrays for vertical stacking (top-to-bottom per column).
|
||||
*
|
||||
* @template T
|
||||
* @param {T[]} arr
|
||||
* @param {number} numCols
|
||||
* @returns {T[][]}
|
||||
*/
|
||||
export function splitIntoColumns(arr, numCols) {
|
||||
if (numCols <= 1) return [arr];
|
||||
const columns = Array.from({ length: numCols }, () => /** @type {T[]} */ ([]));
|
||||
const perCol = Math.ceil(arr.length / numCols);
|
||||
for (let col = 0; col < numCols; col++) {
|
||||
columns[col] = arr.slice(col * perCol, (col + 1) * perCol);
|
||||
}
|
||||
return columns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorders a flat array into column-major order for grid display.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
/** @typedef {import("./profession-skills.js").ProfessionSkillRef} ProfessionSkillRef */
|
||||
/** @typedef {"dialog" | "sheet"} SkillTooltipContext */
|
||||
|
||||
import { appendRollTargetTooltipSection } from "./roll-target-tooltip.js";
|
||||
|
||||
/**
|
||||
* Descriptive skill tooltip only (no roll target or 0% appendix).
|
||||
*
|
||||
* @param {ProfessionSkillRef} ref
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getSkillDescriptiveTooltip(ref) {
|
||||
if (ref.kind === "fixed") {
|
||||
const key = `DG.Skills.Tooltip.${ref.key}`;
|
||||
const localized = game.i18n.localize(key);
|
||||
return localized === key ? "" : localized;
|
||||
}
|
||||
|
||||
const key = `DG.TypeSkills.Tooltip.${ref.group}`;
|
||||
const localized = game.i18n.localize(key);
|
||||
return localized === key ? "" : localized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a skill tooltip for character creation or the agent sheet.
|
||||
*
|
||||
* @param {SkillTooltipContext} context
|
||||
* @param {Actor | null} actor
|
||||
* @param {ProfessionSkillRef} ref
|
||||
* @param {{ proficiency?: number }} [options]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function buildSkillTooltip(
|
||||
context,
|
||||
actor,
|
||||
ref,
|
||||
{ proficiency = 0 } = {},
|
||||
) {
|
||||
let tooltip = getSkillDescriptiveTooltip(ref);
|
||||
|
||||
if (ref.kind === "fixed" && !tooltip) {
|
||||
tooltip = game.i18n.localize(`DG.Skills.Tooltip.${ref.key}`);
|
||||
}
|
||||
|
||||
if (context === "dialog") return tooltip;
|
||||
|
||||
if (ref.kind === "fixed" && !proficiency) {
|
||||
tooltip = tooltip.concat(
|
||||
"<br><br>",
|
||||
game.i18n.localize("DG.Tooltip.CannotRollSkillLabel"),
|
||||
);
|
||||
}
|
||||
|
||||
if (actor?.type === "agent") {
|
||||
tooltip = appendRollTargetTooltipSection(
|
||||
tooltip,
|
||||
actor,
|
||||
"system.rollTarget.allSkills",
|
||||
Number(proficiency) || 0,
|
||||
{ showTargetWhenUnmodified: false },
|
||||
);
|
||||
}
|
||||
|
||||
return tooltip;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent sheet special-training row tooltip (attribute-based, not ProfessionSkillRef).
|
||||
*
|
||||
* @param {Actor} actor
|
||||
* @param {string} attributeTooltipKey i18n key for the underlying attribute/skill
|
||||
* @param {"system.rollTarget.allSkills"|"system.rollTarget.sanity"|"system.rollTarget.statistics"} rollTargetFieldKey
|
||||
* @param {number} base
|
||||
* @returns {string}
|
||||
*/
|
||||
export function buildAgentSpecialTrainingTooltip(
|
||||
actor,
|
||||
attributeTooltipKey,
|
||||
rollTargetFieldKey,
|
||||
base,
|
||||
) {
|
||||
return appendRollTargetTooltipSection(
|
||||
game.i18n.localize(attributeTooltipKey),
|
||||
actor,
|
||||
rollTargetFieldKey,
|
||||
base,
|
||||
{
|
||||
allowOver99:
|
||||
rollTargetFieldKey === "system.rollTarget.statistics" && base > 99,
|
||||
showTargetWhenUnmodified: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply client skillTooltipDisplay setting (hover / hoverShift / never).
|
||||
*
|
||||
* @param {HTMLElement} root
|
||||
* @returns {void}
|
||||
*/
|
||||
export function applySkillTooltipDisplayMode(root) {
|
||||
const mode = game.settings.get("deltagreen", "skillTooltipDisplay");
|
||||
|
||||
if (mode !== "hoverShift" && mode !== "never") return;
|
||||
|
||||
const nodes = root.querySelectorAll("[data-tooltip],[title]");
|
||||
|
||||
if (mode === "never") {
|
||||
nodes.forEach((el) => {
|
||||
if (el.dataset.shiftTooltipInstalled === "true") return;
|
||||
el.removeAttribute("data-tooltip");
|
||||
el.removeAttribute("title");
|
||||
el.dataset.shiftTooltipInstalled = "true";
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
nodes.forEach((el) => {
|
||||
if (el.dataset.shiftTooltipInstalled === "true") return;
|
||||
|
||||
let html = el.getAttribute("data-tooltip");
|
||||
let isHtml = true;
|
||||
|
||||
if (!html) {
|
||||
const title = el.getAttribute("title");
|
||||
if (title) {
|
||||
html = foundry.utils.escapeHTML(title);
|
||||
isHtml = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!html) return;
|
||||
|
||||
el.removeAttribute("data-tooltip");
|
||||
el.removeAttribute("title");
|
||||
el.dataset.shiftTooltipInstalled = "true";
|
||||
|
||||
const opts = isHtml ? { html } : { text: html };
|
||||
|
||||
const show = () => game.tooltip.activate(el, opts);
|
||||
const hide = () => game.tooltip.deactivate();
|
||||
|
||||
const onKey = (ev) => {
|
||||
if (ev.key !== "Shift") return;
|
||||
if (!document.body.contains(el)) {
|
||||
window.removeEventListener("keydown", onKey);
|
||||
window.removeEventListener("keyup", onKey);
|
||||
return;
|
||||
}
|
||||
if (ev.type === "keydown") show();
|
||||
else hide();
|
||||
};
|
||||
|
||||
const onEnter = (ev) => {
|
||||
if (ev.shiftKey) show();
|
||||
window.addEventListener("keydown", onKey);
|
||||
window.addEventListener("keyup", onKey);
|
||||
};
|
||||
|
||||
const onLeave = () => {
|
||||
hide();
|
||||
window.removeEventListener("keydown", onKey);
|
||||
window.removeEventListener("keyup", onKey);
|
||||
};
|
||||
|
||||
el.addEventListener("pointerenter", onEnter, { passive: true });
|
||||
el.addEventListener("pointerleave", onLeave, { passive: true });
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user