Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[#4861] Add combat activation types that display in turn message #4911

Merged
merged 3 commits into from
Jan 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -160,17 +160,27 @@
}
},
"Category": {
"Combat": "Combat",
"Monster": "Monster",
"Standard": "Standard",
"Time": "Time",
"Monster": "Monster",
"Vehicle": "Vehicle"
},
"Type": {
"Encounter": {
"Label": "Start of Encounter"
},
"Legendary": {
"Counted": {
"one": "{number} legendary action",
"other": "{number} legendary actions"
}
},
"TurnEnd": {
"Label": "End of Turn"
},
"TurnStart": {
"Label": "Start of Turn"
}
},
"Warning": {
Expand Down Expand Up @@ -753,7 +763,8 @@

"DND5E.CHATMESSAGE": {
"TURN": {
"NoCombat": "Combat has ended!",
"Activities": "Activities",
"NoCombatant": "Combatant no longer exists!",
"Recovery": "Recovery"
}
},
Expand Down
10 changes: 5 additions & 5 deletions less/v2/apps.less
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,7 @@
}

.pill-lg {
--gold-icon-size: 32px;
background: var(--dnd5e-background-card);
border: var(--dnd5e-border-gold);
border-radius: 5px;
Expand All @@ -313,11 +314,6 @@
opacity: .25;
}

.gold-icon {
width: 32px;
height: 32px;
}

.name { flex: 1; }

&::before {
Expand Down Expand Up @@ -406,6 +402,10 @@
}

.gold-icon {
block-size: var(--gold-icon-size);
inline-size: var(--gold-icon-size);
flex: 0 0 var(--gold-icon-size);

border: 2px solid var(--dnd5e-color-gold);
box-shadow: 0 0 4px var(--dnd5e-shadow-45);
border-radius: 0;
Expand Down
10 changes: 10 additions & 0 deletions less/v2/chat.less
Original file line number Diff line number Diff line change
Expand Up @@ -785,6 +785,16 @@ enchantment-application {
display: block;
margin-block-start: 1em;
}
.activities ul {
margin-block-start: 8px;

li {
--gold-icon-size: 32px;
gap: 8px;
}

a.rollable:hover .subtitle { text-shadow: none; }
}
.deltas ul {
li {
display: flex;
Expand Down
5 changes: 3 additions & 2 deletions module/applications/actor/npc-sheet.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,9 @@ export default class ActorSheet5eNPC extends ActorSheet5e {
ctx.canToggle = false;
ctx.totalWeight = item.system.totalWeight?.toNearest(0.1);
// Item grouping
ctx.group = item.system.properties?.has("trait") ? "passive"
: item.system.activities?.contents[0]?.activation.type || "passive";
const isPassive = item.system.properties?.has("trait")
|| CONFIG.DND5E.activityActivationTypes[item.system.activities?.contents[0]?.activation.type]?.passive;
ctx.group = isPassive ? "passive" : item.system.activities?.contents[0]?.activation.type || "passive";
ctx.ungroup = "feat";
if ( item.type === "weapon" ) ctx.ungroup = "weapon";
if ( ctx.group === "passive" ) ctx.ungroup = "passive";
Expand Down
22 changes: 19 additions & 3 deletions module/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -933,9 +933,10 @@ preLocalize("abilityActivationTypes");

/**
* @typedef {ActivityActivationTypeConfig}
* @property {string} label Localized label for the activation type.
* @property {string} [group] Localized label for the presentational group.
* @property {boolean} [scalar=false] Does this activation type have a numeric value attached?
* @property {string} label Localized label for the activation type.
* @property {string} [group] Localized label for the presentational group.
* @property {boolean} [passive=false] Classify this item as a passive feature on NPC sheets.
* @property {boolean} [scalar=false] Does this activation type have a numeric value attached?
*/

/**
Expand Down Expand Up @@ -970,6 +971,21 @@ DND5E.activityActivationTypes = {
group: "DND5E.ACTIVATION.Category.Time",
scalar: true
},
encounter: {
label: "DND5E.ACTIVATION.Type.Encounter.Label",
group: "DND5E.ACTIVATION.Category.Combat",
passive: true
},
turnStart: {
label: "DND5E.ACTIVATION.Type.TurnStart.Label",
group: "DND5E.ACTIVATION.Category.Combat",
passive: true
},
turnEnd: {
label: "DND5E.ACTIVATION.Type.TurnEnd.Label",
group: "DND5E.ACTIVATION.Category.Combat",
passive: true
},
legendary: {
label: "DND5E.LegendaryAction.Label",
group: "DND5E.ACTIVATION.Category.Monster",
Expand Down
2 changes: 1 addition & 1 deletion module/data/abstract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -396,7 +396,7 @@ export class ActorDataModel extends SystemDataModel {

/**
* Reset combat-related uses.
* @param {Set<string>} periods Which recovery periods should be considered.
* @param {string[]} periods Which recovery periods should be considered.
* @param {{ actor: {}, item: [] }} updates Updates to perform on the actor and containing items.
*/
async recoverCombatUses(periods, updates) {}
Expand Down
2 changes: 1 addition & 1 deletion module/data/actor/npc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -424,7 +424,7 @@ export default class NPCData extends CreatureTemplate {
/** @override */
async recoverCombatUses(periods, updates) {
// Reset legendary actions at the start of a combat encounter or at the end of the creature's turn
if ( this.resources.legact.max && (periods.has("encounter") || periods.has("turnEnd")) ) {
if ( this.resources.legact.max && (periods.includes("encounter") || periods.includes("turnEnd")) ) {
updates.actor["system.resources.legact.value"] = this.resources.legact.max;
}
}
Expand Down
63 changes: 62 additions & 1 deletion module/data/chat-message/turn-message-data.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const { DocumentIdField, SchemaField, SetField, StringField } = foundry.data.fie
/**
* Data stored in a combat turn chat message.
*
* @property {Set<string>} activations Activities that can be used with these periods, stored as relative UUIDs.
* @property {ActorDeltasData} deltas Actor/item recovery from this turn change.
* @property {object} origin
* @property {string} origin.combat ID of the triggering combat.
Expand All @@ -26,6 +27,7 @@ export default class TurnMessageData extends ChatMessageDataModel {
/** @override */
static defineSchema() {
return {
activations: new SetField(new StringField()),
deltas: new ActorDeltasField(),
origin: new SchemaField({
combat: new DocumentIdField({ nullable: false, required: true }),
Expand All @@ -39,6 +41,9 @@ export default class TurnMessageData extends ChatMessageDataModel {

/** @inheritDoc */
static metadata = Object.freeze(foundry.utils.mergeObject(super.metadata, {
actions: {
use: TurnMessageData.#useActivity
},
template: "systems/dnd5e/templates/chat/turn-card.hbs"
}, { inplace: false }));

Expand All @@ -51,7 +56,7 @@ export default class TurnMessageData extends ChatMessageDataModel {
* @type {Actor5e}
*/
get actor() {
return this.combatant?.actor;
return this.combatant?.actor ?? this.parent.getAssociatedActor();
}

/* -------------------------------------------- */
Expand Down Expand Up @@ -87,6 +92,11 @@ export default class TurnMessageData extends ChatMessageDataModel {
};
if ( !context.actor ) return context;

if ( context.actor.isOwner ) context.activities = Array.from(this.activations)
.map(uuid => fromUuidSync(uuid, { relative: context.actor, strict: false }))
.filter(_ => _)
.sort((lhs, rhs) => (lhs.item.sort - rhs.item.sort) || (lhs.sort - rhs.sort));

const processDelta = (doc, delta) => {
const type = doc instanceof Actor ? "actor" : "item";
return {
Expand All @@ -106,4 +116,55 @@ export default class TurnMessageData extends ChatMessageDataModel {
];
return context;
}

/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */

/** @override */
_onRender(element) {
for ( const e of element.querySelectorAll(".item-tooltip") ) {
const uuid = e.closest("[data-item-uuid]")?.dataset.itemUuid;
if ( !uuid ) continue;
Object.assign(e.dataset, {
tooltip: `<section class="loading" data-uuid="${uuid}"><i class="fas fa-spinner fa-spin-pulse"></i></section>`,
tooltipClass: "dnd5e2 dnd5e-tooltip item-tooltip",
tooltipDirection: "LEFT"
});
}
}

/* -------------------------------------------- */

/**
* Handle using an activity.
* @this {TurnMessageData}
* @param {Event} event Triggering click event.
* @param {HTMLElement} target Button that was clicked.
*/
static async #useActivity(event, target) {
target.disabled = true;
try {
const activity = await fromUuid(target.closest("[data-activity-uuid]")?.dataset.activityUuid);
await activity?.use({ event });
} finally {
target.disabled = false;
}
}

/* -------------------------------------------- */
/* Helpers */
/* -------------------------------------------- */

/**
* Find any activity relative UUIDs on this actor that can be used during a set of combat periods.
* @param {Actor5e} actor
* @param {string[]} periods
* @returns {Set<string>}
*/
static getActivations(actor, periods) {
return actor.items
.map(i => i.system.activities?.filter(a => periods.includes(a.activation?.type)).map(a => a.relativeUUID) ?? [])
.flat();
arbron marked this conversation as resolved.
Show resolved Hide resolved
}
}
43 changes: 23 additions & 20 deletions module/documents/combat.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,41 +7,44 @@ export default class Combat5e extends Combat {
async startCombat() {
await super.startCombat();
this._recoverUses({ encounter: true });
this.combatant?.refreshDynamicRing();
return this;
}

/* -------------------------------------------- */
/* Socket Event Handlers */
/* -------------------------------------------- */

/** @inheritDoc */
async nextTurn() {
const previous = this.combatant;
await super.nextTurn();
this._recoverUses({ turnEnd: previous, turnStart: this.combatant });
if ( previous && (previous !== this.combatant) ) previous.refreshDynamicRing();
this.combatant?.refreshDynamicRing();
return this;
_onUpdate(changed, options, userId) {
super._onUpdate(changed, options, userId);
if ( this.current.combatantId !== this.previous.combatantId ) {
this.combatants.get(this.previous.combatantId)?.refreshDynamicRing();
this.combatants.get(this.current.combatantId)?.refreshDynamicRing();
}
}

/* -------------------------------------------- */

/** @inheritDoc */
async previousTurn() {
const previous = this.combatant;
await super.previousTurn();
if ( previous && (previous !== this.combatant) ) previous.refreshDynamicRing();
this.combatant?.refreshDynamicRing();
return this;
_onDelete(options, userId) {
super._onDelete(options, userId);
this.combatants.get(this.current.combatantId)?.refreshDynamicRing();
}

/* -------------------------------------------- */

/** @inheritDoc */
async endCombat() {
const previous = this.combatant;
await super.endCombat();
previous?.refreshDynamicRing();
return this;
async _onEndTurn(combatant) {
await super._onEndTurn(combatant);
this._recoverUses({ turnEnd: combatant });
}

/* -------------------------------------------- */

/** @inheritDoc */
async _onStartTurn(combatant) {
await super._onStartTurn(combatant);
this._recoverUses({ turnStart: combatant });
}

/* -------------------------------------------- */
Expand All @@ -57,7 +60,7 @@ export default class Combat5e extends Combat {
async _recoverUses(types) {
for ( const combatant of this.combatants ) {
const periods = Object.entries(types).filter(([, v]) => (v === true) || (v === combatant)).map(([k]) => k);
if ( periods.length ) await combatant.recoverCombatUses(new Set(periods));
if ( periods.length ) await combatant.recoverCombatUses(periods);
}
}
}
17 changes: 9 additions & 8 deletions module/documents/combatant.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import TurnMessageData from "../data/chat-message/turn-message-data.mjs";
import { ActorDeltasField } from "../data/chat-message/fields/deltas-field.mjs";

/**
Expand All @@ -22,6 +23,7 @@ export default class Combatant5e extends Combatant {
speaker: ChatMessage.getSpeaker({ actor: this.actor, token: this.token }),
system: {
deltas, periods,
activations: TurnMessageData.getActivations(this.actor, periods),
origin: {
combat: this.combat.id,
combatant: this.id
Expand All @@ -33,9 +35,8 @@ export default class Combatant5e extends Combatant {
};

if ( !foundry.utils.isEmpty(messageConfig.data.system.deltas?.actor)
|| !foundry.utils.isEmpty(messageConfig.data.system.deltas?.item) ) messageConfig.create = true;
// TODO: Also create message if actor has items with relevant activation type
// when implementing https://github.com/foundryvtt/dnd5e/issues/4861
|| !foundry.utils.isEmpty(messageConfig.data.system.deltas?.item)
|| !foundry.utils.isEmpty(messageConfig.data.system.activations) ) messageConfig.create = true;

/**
* A hook event that fires before a combat state change chat message is created.
Expand Down Expand Up @@ -63,15 +64,15 @@ export default class Combatant5e extends Combatant {

/**
* Reset combat-related uses.
* @param {Set<string>} periods Which recovery periods should be considered.
* @param {string[]} periods Which recovery periods should be considered.
*/
async recoverCombatUses(periods) {
/**
* A hook event that fires before combat-related recovery changes.
* @function dnd5e.preCombatRecovery
* @memberof hookEvents
* @param {Combatant5e} combatant Combatant that is being recovered.
* @param {Set<string>} periods Periods to be recovered.
* @param {string[]} periods Periods to be recovered.
* @returns {boolean} Explicitly return `false` to prevent recovery from being performed.
*/
if ( Hooks.call("dnd5e.preCombatRecovery", this, periods) === false ) return;
Expand All @@ -88,7 +89,7 @@ export default class Combatant5e extends Combatant {
* @function dnd5e.combatRecovery
* @memberof hookEvents
* @param {Combatant5e} combatant Combatant that is being recovered.
* @param {Set<string>} periods Periods that were recovered.
* @param {string[]} periods Periods that were recovered.
* @param {{ actor: object, item: object[] }} updates Update that will be applied to the actor and its items.
* @returns {boolean} Explicitly return `false` to prevent updates from being performed.
*/
Expand All @@ -99,14 +100,14 @@ export default class Combatant5e extends Combatant {
if ( !foundry.utils.isEmpty(updates.actor) ) await this.actor.update(updates.actor);
if ( updates.item.length ) await this.actor.updateEmbeddedDocuments("Item", updates.item);

const message = await this.createTurnMessage({ deltas, periods: Array.from(periods) });
const message = await this.createTurnMessage({ deltas, periods });

/**
* A hook event that fires after combat-related recovery changes have been applied.
* @function dnd5e.postCombatRecovery
* @memberof hookEvents
* @param {Combatant5e} combatant Combatant that is being recovered.
* @param {Set<string>} periods Periods that were recovered.
* @param {string[]} periods Periods that were recovered.
* @param {ChatMessage5e|void} message Chat message created, if any.
*/
Hooks.callAll("dnd5e.postCombatRecovery", this, periods, message);
Expand Down
Loading