Files
DeltaGreen/module/sheets/agent-sheet.js
T

761 lines
22 KiB
JavaScript

import DG, { BASE_TEMPLATE_PATH } from "../config/index.js";
import { prepareAgentSkillColumns } from "../utils/skill-layout.js";
import { formatProfessionSkillLabel } from "../profession/index.js";
import { buildSkillTooltip } from "../utils/skill-tooltip.js";
import createAgentResourceChatMessage, {
buildWillpowerChangeSpan,
} from "../chat/resource-chat.js";
import {
createSkillImprovementChatMessage,
evaluateSkillImprovementRolls,
getSkillImprovementFormulaAsPercent,
} from "../roll/skill-improvement-roll.js";
import DGActorSheet from "./base-actor-sheet.js";
import ActorEditStatForm from "../applications/edit-stats.js";
import EffectsTabMixin from "./mixins/effects-tab-mixin.js";
import {
applyStimulantEffect,
clearStimulantEffects,
getEffectiveSuppressExhaustion,
hasActiveStimulantEffect,
} from "../active-effect/runtime/stimulant-effect.js";
import assignProfessionToAgent from "../applications/profession-setup-flow.js";
import { showDgDialog } from "../applications/dg-dialog.js";
import showRenameProfessionDialog from "../applications/rename-profession-dialog.js";
import { PROFESSION_OPTION_PICKS_KEY } from "../data/item/profession.js";
const { renderTemplate } = foundry.applications.handlebars;
const AgentSheetBase = EffectsTabMixin(DGActorSheet);
/** @extends {AgentSheetBase} */
export default class DGAgentSheet extends AgentSheetBase {
/** @override */
static DEFAULT_OPTIONS = /** @type {const} */ ({
classes: ["agent-sheet"],
position: { width: 978, height: 720 },
actions: {
// Resets.
clearBondDamage: DGAgentSheet._clearBondDamage,
resetBreakingPoint: DGAgentSheet._resetBreakingPoint,
// Other actions.
applySkillImprovements: DGAgentSheet._processSkillImprovements,
openStatsEdit: DGAgentSheet._openStatsEdit,
toggleCustomSkillsEdit: function toggleCustomSkillsEdit(event) {
event.preventDefault();
event.stopPropagation();
this._customSkillsEditMode = !this._customSkillsEditMode;
this._syncCustomSkillsEditModeUi();
return this.render();
},
createEffect: AgentSheetBase.createEffect,
openEffect: AgentSheetBase.openEffect,
deleteEffect: AgentSheetBase.deleteEffect,
toggleEffect: AgentSheetBase.toggleEffect,
openEffectOrigin: AgentSheetBase.openEffectOrigin,
toggleAcuteEpisode: DGAgentSheet._toggleAcuteEpisode,
exhaustAgent: DGAgentSheet._exhaustAgent,
restAgent: DGAgentSheet._restAgent,
takeStimulants: DGAgentSheet._takeStimulants,
openProfessionItem: DGAgentSheet._openProfessionItem,
},
});
/** @type {boolean} */
_customSkillsEditMode = false;
/** @override */
static TABS = /** @type {const} */ ({
primary: {
initial: "skills",
labelPrefix: "DG.Navigation.Agent",
tabs: [
{ id: "skills" },
{ id: "combat" },
{ id: "motivations" },
{ id: "gear" },
{ id: "personal" },
{ id: "effects" },
{ id: "about", icon: "fas fa-question-circle", label: "" },
],
},
leftBarRestSanity: {
initial: "sanity",
labelPrefix: "DG.Navigation.Agent.LeftBar",
tabs: [{ id: "sanity" }, { id: "rest" }],
},
});
/** @override */
static PARTS = /** @type {const} */ ({
leftBar: {
template: `${this.TEMPLATE_PATH}/parts/left-bar.html`,
templates: [
`${this.TEMPLATE_PATH}/partials/sanity-partial.html`,
`${this.TEMPLATE_PATH}/partials/left-bar-exhaustion-section.html`,
`${this.TEMPLATE_PATH}/partials/left-bar-rest-tab.html`,
`${this.TEMPLATE_PATH}/partials/left-bar-sanity-tab.html`,
`${this.TEMPLATE_PATH}/partials/profession-subname.html`,
],
},
tabs: this.BASE_PARTS.tabs,
rightBar: {
template: `${this.TEMPLATE_PATH}/parts/right-bar.html`,
templates: [
`${this.TEMPLATE_PATH}/parts/skills-tab-agent.html`,
`${this.TEMPLATE_PATH}/parts/combat-tab-agent.html`,
`${this.TEMPLATE_PATH}/parts/motivations-tab-agent.html`,
`${this.TEMPLATE_PATH}/parts/gear-tab-agent.html`,
`${this.TEMPLATE_PATH}/parts/personal-tab-agent.html`,
`${this.TEMPLATE_PATH}/parts/effects-tab.html`,
`${this.TEMPLATE_PATH}/parts/about-tab.html`,
`${this.TEMPLATE_PATH}/partials/agent-skill-row-partial.html`,
`${this.TEMPLATE_PATH}/partials/agent-special-training-row-partial.html`,
`${this.TEMPLATE_PATH}/partials/custom-skills-partial-agent.html`,
`${this.TEMPLATE_PATH}/partials/bonds-section-partial.html`,
...this.GEAR_SECTION_PARTIALS,
`${this.TEMPLATE_PATH}/partials/injuries-section-partial.html`,
`${this.TEMPLATE_PATH}/partials/notes-partial.html`,
],
},
});
/** @override */
_configureRenderOptions(options) {
super._configureRenderOptions(options);
if (!this.actor.limited) return;
options.parts = ["leftBar", "tabs", "rightBar"];
}
/** @override */
_prepareTabs(group) {
const tabs = super._prepareTabs(group);
if (
!this.actor.limited ||
this.actor.type !== "agent" ||
group !== "primary"
) {
return tabs;
}
return {
personal: { ...tabs.personal, active: true, cssClass: "active" },
};
}
/** @override */
async _prepareContext(options) {
const context = await super._prepareContext(options);
context.tabs = this._prepareTabs("primary");
context.leftBarRestSanityTabs = this._prepareTabs("leftBarRestSanity");
if (this.actor.type !== "agent") return context;
const sortSkillsByColumn = game.settings.get("deltagreen", "sortSkills");
context.sortSkillsByColumn = sortSkillsByColumn;
context.skillColumns = prepareAgentSkillColumns(
this.actor.system.sortedSkills,
sortSkillsByColumn,
);
context.specialTrainingRows = this._prepareSpecialTrainingBlocks();
context.showSpecialTrainingSection = context.specialTrainingRows.length > 0;
context.specialTrainingColumns = prepareAgentSkillColumns(
context.specialTrainingRows,
sortSkillsByColumn,
);
context.customSkillsEditMode = this._customSkillsEditMode;
context.physicalUi = this._preparePhysicalUi();
context.professionItem = this.actor.items.find(
(i) => i.type === "profession",
);
return context;
}
/** @returns {object} */
_preparePhysicalUi() {
const { physical } = this.actor.system;
const isExhausted = DGAgentSheet._isActorExhausted(this.actor);
const stimulantActive = hasActiveStimulantEffect(this.actor);
const suppressExhaustion = getEffectiveSuppressExhaustion(this.actor);
return {
isExhausted,
displayPenalty: isExhausted ? physical.exhaustedPenalty : 0,
suppressExhaustionInactive: !isExhausted,
suppressExhaustionInputDisabled: !isExhausted || stimulantActive,
suppressExhaustionChecked: isExhausted && suppressExhaustion,
};
}
/** @override */
_sortSkills() {
const sortedSkills = [];
for (const [key, skill] of Object.entries(this.actor.system.skills)) {
let sortLabel;
if (game.i18n.lang === "ja") {
sortLabel = game.i18n.localize(`DG.Skills.ruby.${key}`);
} else {
sortLabel = game.i18n.localize(`DG.Skills.${key}`);
}
if (sortLabel === "" || sortLabel === `DG.Skills.${key}`) {
sortLabel = key;
}
sortedSkills.push({
...skill,
skillKind: "fixed",
key,
sortLabel,
});
}
for (const [key, skill] of Object.entries(this.actor.system.typedSkills)) {
const displayLabel = formatProfessionSkillLabel({
kind: "typed",
group: skill.group,
label: skill.label,
});
sortedSkills.push({
...skill,
skillKind: "typed",
key,
displayLabel,
sortLabel: displayLabel,
actorType: this.actor.type,
});
}
sortedSkills.sort((a, b) =>
a.sortLabel.localeCompare(b.sortLabel, game.i18n.lang),
);
this.actor.system.sortedSkills = sortedSkills;
}
/** @override */
_prepareSkillTooltips() {
for (const skill of this.actor.system.sortedSkills) {
if (skill.skillKind === "typed") {
skill.tooltip = buildSkillTooltip(
"sheet",
this.actor,
{ kind: "typed", group: skill.group, label: skill.label },
{ proficiency: Number(skill.proficiency) || 0 },
);
} else {
skill.tooltip = buildSkillTooltip(
"sheet",
this.actor,
{ kind: "fixed", key: skill.key },
{ proficiency: Number(skill.proficiency) || 0 },
);
}
}
}
/** @override */
async _onRender(context, options) {
await super._onRender(context, options);
this._syncCustomSkillsEditModeUi();
}
/** @returns {void} */
_syncCustomSkillsEditModeUi() {
this.element?.classList.toggle(
"custom-skills-edit-mode",
Boolean(this._customSkillsEditMode),
);
}
/**
* @param {PointerEvent} event
* @param {HTMLElement} target
*/
static async _toggleAcuteEpisode(event, target) {
event.preventDefault();
const itemId =
target.dataset.itemId ??
target.closest("[data-item-id]")?.dataset?.itemId;
const item = this.actor.items.get(itemId);
if (!item || item.type !== "motivation") return;
await item.update({
"system.acuteEpisode": !item.system.acuteEpisode,
});
}
/**
* @param {PointerEvent} _event
* @param {HTMLElement} target
*/
static async _openProfessionItem(_event, target) {
const itemId =
target.dataset.itemId ??
target.closest("[data-item-id]")?.dataset?.itemId;
const item = this.actor.items.get(itemId);
if (item?.type !== "profession") return;
await showRenameProfessionDialog(item);
}
static _clearBondDamage() {
for (const bond of this.actor.itemTypes.bond) {
if (bond.system.hasBeenDamagedSinceLastHomeScene) {
bond.update({ "system.hasBeenDamagedSinceLastHomeScene": false });
}
}
}
/**
* Resets the actor's current breaking point by recalculating it as the difference
* between the actor's sanity value and POW value, ensuring it is non-negative,
* and updates the actor's system with this new breaking point.
*
* @returns {void}
*/
static _resetBreakingPoint() {
const pow =
this.actor.system.statistics.pow.effectiveValue ??
this.actor.system.statistics.pow.value;
const currentBreakingPoint = Math.max(
this.actor.system.sanity.value - pow,
0,
);
const dataToUpdate = {
"system.sanity.currentBreakingPoint": currentBreakingPoint,
};
if (!this.actor.system.sanity.adaptations.violence.isAdapted) {
dataToUpdate["system.sanity.adaptations.violence.incident1"] = false;
dataToUpdate["system.sanity.adaptations.violence.incident2"] = false;
dataToUpdate["system.sanity.adaptations.violence.incident3"] = false;
}
if (!this.actor.system.sanity.adaptations.helplessness.isAdapted) {
dataToUpdate["system.sanity.adaptations.helplessness.incident1"] = false;
dataToUpdate["system.sanity.adaptations.helplessness.incident2"] = false;
dataToUpdate["system.sanity.adaptations.helplessness.incident3"] = false;
}
this.actor.update(dataToUpdate);
}
/**
* Sorted special-training blocks for the skills tab.
*
* @returns {object[]}
*/
_prepareSpecialTrainingBlocks() {
const trainings = this._prepareSpecialTrainings().map((training) => ({
...training,
sortLabel: training.name.toUpperCase(),
actorType: this.actor.type,
}));
trainings.sort((a, b) =>
a.sortLabel.localeCompare(b.sortLabel, game.i18n.lang),
);
return trainings;
}
static _openStatsEdit() {
const form = new ActorEditStatForm({
actor: this.actor,
});
form.render(true);
}
/**
* @param {number} raw
* @returns {number}
*/
static _normalizeExhaustionPenalty(raw) {
const value = Number(raw);
if (Number.isNaN(value) || value === 0) return -20;
return value > 0 ? -1 * Math.abs(value) : value;
}
static async _exhaustAgent() {
const { actor } = this;
const content = await renderTemplate(
`${BASE_TEMPLATE_PATH}/dialog/exhaust-agent.html`,
{
penalty: actor.system.physical.exhaustedPenalty ?? -20,
},
);
let penalty = null;
const confirmed = await showDgDialog({
modifier: "exhaust-agent",
content,
window: {
title: game.i18n.localize("DG.Physical.ExhaustDialogTitle"),
},
buttons: [
{
action: "rollWpLoss",
label: game.i18n.localize("DG.Physical.RollWillpowerLoss"),
default: true,
icon: "fas fa-dice",
callback: (_event, _button, dialog) => {
const input = dialog.element.querySelector(
'[name="exhaustionPenalty"]',
);
penalty = DGAgentSheet._normalizeExhaustionPenalty(input?.value);
return true;
},
},
],
});
if (!confirmed || penalty === null) return;
const wpRoll = await new Roll("1d6").evaluate();
const loss = wpRoll.total;
const currentWp = Number(actor.system.wp.value) || 0;
const maxWp = Number(actor.system.wp.max) || 0;
const newWp = Math.max(0, currentWp - loss);
await actor.update({
"system.physical.exhausted": true,
"system.physical.exhaustedPenalty": penalty,
"system.wp.value": newWp,
});
await createAgentResourceChatMessage({
actor,
token: this.token,
roll: wpRoll,
rollLabelKey: "DG.Physical.Chat.ExhaustedRollLabel",
i18nData: {
willpowerChange: buildWillpowerChangeSpan({
amount: loss,
current: newWp,
max: maxWp,
}),
},
});
}
static async _restAgent() {
const { actor } = this;
const wasExhausted = DGAgentSheet._isActorExhausted(actor);
const wpRoll = await new Roll("1d6").evaluate();
const gain = wpRoll.total;
const currentWp = Number(actor.system.wp.value) || 0;
const maxWp = Number(actor.system.wp.max) || 0;
const newWp = Math.min(maxWp, currentWp + gain);
await clearStimulantEffects(actor);
await actor.update({
"system.physical.exhausted": false,
"system.physical.suppressExhaustion": false,
"system.wp.value": newWp,
});
await createAgentResourceChatMessage({
actor,
token: this.token,
roll: wpRoll,
rollLabelKey: wasExhausted
? "DG.Physical.Chat.RestedRollLabelExhausted"
: "DG.Physical.Chat.RestedRollLabel",
i18nData: {
willpowerChange: buildWillpowerChangeSpan({
amount: gain,
current: newWp,
max: maxWp,
}),
},
});
}
/**
* @param {Actor} actor
* @returns {boolean}
*/
static _isActorExhausted(actor) {
return Boolean(actor._source?.system?.physical?.exhausted);
}
static async _takeStimulants() {
const { actor } = this;
if (!DGAgentSheet._isActorExhausted(actor)) {
ui.notifications.info(
game.i18n.format("DG.Physical.StimulantsNotExhaustedWarning", {
name: actor.name,
}),
);
return;
}
const content = await renderTemplate(
`${BASE_TEMPLATE_PATH}/dialog/take-stimulants.html`,
{},
);
const choice = await showDgDialog({
modifier: "stimulants",
content,
window: {
title: game.i18n.localize("DG.Physical.StimulantsDialogTitle"),
},
buttons: [
{
action: "regular",
label: game.i18n.localize("DG.Physical.StimulantsRegular"),
default: true,
callback: () => "regular",
},
{
action: "hard",
label: game.i18n.localize("DG.Physical.StimulantsHard"),
callback: () => "hard",
},
],
});
if (!choice) return;
const formula = choice === "hard" ? "2d6" : "1d6";
const hoursRoll = await new Roll(formula).evaluate();
const hours = hoursRoll.total;
const rollLabelKey =
choice === "hard"
? "DG.Physical.Chat.StimulantsHardRollLabel"
: "DG.Physical.Chat.StimulantsRegularRollLabel";
const appliedHours = await applyStimulantEffect(actor, hours);
await createAgentResourceChatMessage({
actor,
token: this.token,
roll: hoursRoll,
rollLabelKey,
i18nData: {
hours: appliedHours,
},
});
}
/**
* @param {Record<string, object>} skillMap
* @returns {object[]}
*/
static _collectFailedSkills(skillMap) {
return Object.entries(skillMap)
.filter(([, skill]) => skill.failure && !skill.cannotBeImprovedByFailure)
.map(([key, skill]) => ({ ...skill, key }));
}
/**
* Runs through the whole process of improving skills,
* i.e., prompting the user, rolling, and creating the chat card.
*
* @returns {Promise<void>}
*/
static async _processSkillImprovements() {
const { skills, typedSkills } = this.actor.system;
const failedSkills = DGAgentSheet._collectFailedSkills(skills);
const failedTypedSkills = DGAgentSheet._collectFailedSkills(typedSkills);
if (failedSkills.length + failedTypedSkills.length === 0) {
ui.notifications.warn("DG.Skills.ApplySkillImprovements.Warning", {
localize: true,
});
return null;
}
const baseRollFormula = game.settings.get(DG.ID, "skillImprovementFormula");
const prompt = await DGAgentSheet._createSkillImprovementDialog(
baseRollFormula,
failedSkills,
failedTypedSkills,
);
if (!prompt) return null;
const resultObj = await evaluateSkillImprovementRolls(
this.actor,
baseRollFormula,
failedSkills,
failedTypedSkills,
);
await createSkillImprovementChatMessage({
actor: this.actor,
token: this.token,
baseFormula: baseRollFormula,
failedSkills,
failedTypedSkills,
resultObj,
});
return this._applySkillImprovements(
failedSkills,
failedTypedSkills,
resultObj,
);
}
/**
* A map of skill keys -> number to improve them by
* @typedef {Object<string, number>} ResultObj
*/
/**
* @typedef {Object} FailedSkill
*/
/**
* @typedef {FailedSkill} FailedTypedSkill
*/
/**
* The formula used to calculate skill improvements
* Note. There is not a leading number of dice here, just 1 or dX-Y.
* @typedef {"1"|"d3"|"d4"|"d4-1"} SkillImprovementFormula
*/
/**
* Creates and displays a dialog to approve applying skill improvements to failed skills.
*
* @param {SkillImprovementFormula} baseFormula - The formula used to calculate skill improvements.
* @param {FailedSkill[]} failedSkills - An array of failed skills.
* @param {FailedTypedSkill[]} failedTypedSkills - An array of failed typed skills.
*
* @returns {Promise<Boolean>} - A promise that resolves to `true` if accepted, `false` otherwise.
*/
static async _createSkillImprovementDialog(
baseFormula,
failedSkills,
failedTypedSkills,
) {
const localizedFailedSkills = failedSkills.map((skill) =>
formatProfessionSkillLabel({ kind: "fixed", key: skill.key }),
);
const localizedFailedTypedSkills = failedTypedSkills.map((skill) =>
formatProfessionSkillLabel({
kind: "typed",
group: skill.group,
label: skill.label,
}),
);
const failedSkillNames = [
...localizedFailedSkills,
...localizedFailedTypedSkills,
].join(", ");
const content = await renderTemplate(
`${BASE_TEMPLATE_PATH}/dialog/apply-skill-improvements.html`,
{
failedSkillNames,
baseFormula: getSkillImprovementFormulaAsPercent(baseFormula),
},
);
return showDgDialog({
modifier: "apply-skill-improvements",
content,
window: {
title: game.i18n.localize("DG.Skills.ApplySkillImprovements.Title"),
},
buttons: [
{
default: true,
action: "apply",
label: game.i18n.localize("DG.Skills.Apply"),
icon: "<i class='fas fa-check'></i>",
},
],
});
}
/**
* @param {DragEvent} event
* @param {Item} item
* @returns {Promise<Item|null>}
*/
async _onDropItem(event, item) {
const dropType = item.type ?? item.system?.type;
if (dropType !== "profession") {
return super._onDropItem(event, item);
}
const itemData =
typeof item.toObject === "function"
? item.toObject()
: foundry.utils.duplicate(item);
return assignProfessionToAgent(this.actor, itemData, {
token: this.token,
});
}
/**
* @param {string} type
* @returns {Promise<Item|Item[]>}
*/
_onItemCreate(type) {
if (type !== "profession") {
return super._onItemCreate(type);
}
const name = game.i18n.format(
game.i18n.translations.DOCUMENT?.New || "DG.FallbackText.newItem",
{
type: game.i18n.localize("TYPES.Item.profession"),
},
);
const itemData = {
name,
type: "profession",
system: {
bonds: 0,
automaticSkills: {},
automaticSkillMeta: {},
optionSkills: { [PROFESSION_OPTION_PICKS_KEY]: 0 },
optionSkillMeta: {},
},
};
return assignProfessionToAgent(this.actor, itemData, {
token: this.token,
}).then((created) => (created ? [created] : []));
}
_applySkillImprovements(failedSkills, failedTypedSkills, resultObj) {
const updateSkills = (skillsArray, updatedTarget) => {
skillsArray.forEach((skill) => {
const increment = resultObj[skill.key] ?? 1;
updatedTarget[skill.key].proficiency += increment;
updatedTarget[skill.key].failure = false;
});
};
const actorData = this.actor.system;
// Get data and update it.
const updatedSkills = foundry.utils.duplicate(actorData.skills);
const updatedTypedSkills = foundry.utils.duplicate(actorData.typedSkills);
updateSkills(failedSkills, updatedSkills);
updateSkills(failedTypedSkills, updatedTypedSkills);
// Send updates to database.
return this.actor.update({
system: { skills: updatedSkills, typedSkills: updatedTypedSkills },
});
}
}