import { MODULE } from '../module.js';
import { logger } from '../logger.js';
import { queueUpdate } from './update-queue.js'
const NAME = "ActionManagement";
/**
* ActionManagement
* This Module strictly manages token action economy per the dnd5e rules.
*/
export class ActionManagement{
static register(){
logger.info("Registering Action Management");
this.defaults();
this.settings();
this.hooks();
this.patch();
this.globals();
}
static async defaults(){
MODULE[NAME] = {
/* Sub Module Constant Values */
flagKey : "ActionManagement",
default : {
action : 0, reaction : 0, bonus : 0
},
img : {
action : "modules/dnd5e-helpers/assets/action-markers/ACTION2.png",
reaction : "modules/dnd5e-helpers/assets/action-markers/reaction.png",
bonus : "modules/dnd5e-helpers/assets/action-markers/bonus.png",
background : "modules/dnd5e-helpers/assets/action-markers/background.png",
},
orig : {
height : 150, width : 150, x : 0, y : 0,
},
offset : {
action : { h : 5, v : -1},
reaction : { h : 2, v : -1},
bonus : { h : 8, v : -1},
background : { h : 5, v : -1}
}
};
}
static settings(){
const config = false;
const settingData = {
actionMgmtEnable : {
scope : "world", type : Number, group : "combat", default : 0, config,
choices : {
0 : MODULE.localize("option.default.disabled"),
1 : MODULE.localize("option.default.enabled"),
},
onChange : async (v) =>{
/**
* @todo deal with updates based on rapid changes.
*/
},
},
actionsAsStatus : {
scope : "world", type : Number, group : "combat", default : 2, config,
choices : {
0 : MODULE.localize("option.default.disabled"),
1 : MODULE.localize("option.default.enabled"),
2 : MODULE.localize("option.actionsAsStatus.onlyReaction"),
}
},
actionMgmtDisplay : {
scope : "client", type : Number, group : "combat", default : 2, config,
choices : {
0 : MODULE.localize("option.default.disabled"),
1 : MODULE.localize("option.default.enabled"),
2 : MODULE.localize("option.default.enabledHover"),
}
},
/** @todo localize */
effectIconScale : {
scope : "client", type : Number, group : "system", default : 1, config,
onChange : () => {
canvas?.tokens.placeables.forEach( token => token.drawEffects() );
}
}
/**
* @todo add new setting to handle container location
* @todo add new setting for click handler (and dialog availability)
*/
};
MODULE.applySettings(settingData);
/*
additional settings
*/
}
static hooks(){
Hooks.on(`updateCombat`, ActionManagement._updateCombat);
Hooks.on(`controlToken`, ActionManagement._controlToken);
Hooks.on(`updateToken`, ActionManagement._updateToken);
Hooks.on(`createChatMessage`, ActionManagement._createChatMessage);
Hooks.on(`deleteCombat`, ActionManagement._deleteCombat);
Hooks.on(`deleteCombatant`, ActionManagement._deleteCombatant);
Hooks.on('hoverToken', ActionManagement._hoverToken);
}
static patch(){
this._patchToken();
}
static globals(){
}
/**
* Hook Functions
*/
static async _updateCombat(combat, changed, /*options, userid*/){
if(MODULE.setting('actionMgmtEnable') == 0) return;
logger.debug("_updateCombat | DATA | ", {
isFirstTurn : MODULE.isFirstTurn(combat,changed),
isTurnChange : MODULE.isTurnChange(combat, changed),
isFirstGM : MODULE.isFirstGM(),
isFirstOwner : MODULE.isFirstOwner(combat.combatant?.token?.actor),
combat,
changed,
});
if(MODULE.isFirstTurn(combat, changed) && MODULE.isFirstGM())
for(let combatant of combat.combatants){
const token = combatant.token.object;
await token.resetActionFlag();
await token.renderActionContainer(combatant.token.object._controlled && MODULE.setting('actionMgmtDisplay') == 1 );
await token.updateActionMarkers();
}
if(MODULE.isTurnChange(combat, changed) && MODULE.isFirstOwner(combat.combatant.token.actor)){
await combat.combatant.token.object.resetActionFlag();
}
}
static _deleteCombat(combat, /* options, userId */){
const mode = MODULE.setting('actionMgmtEnable');
if(mode == 0) return;
for(const combatant of combat.combatants){
ActionManagement._deleteCombatant(combatant);
}
}
static _deleteCombatant(combatant/*, options, userId */){
/* need to grab a fresh copy in case this
* was triggered from a delete token operation,
* which means this token is already deleted
* and we need to do nothing
*/
const tokenId = combatant.token?.id;
const sceneId = combatant.parent.data.scene
queueUpdate(async () => {
/* this retrieves a token DOCUMENT */
const tokenDoc = game.scenes.get(sceneId).tokens.get(tokenId);
const token = tokenDoc?.object;
if(/*token?.hasActionFlag() &&*/ token.isOwner) {
/* reset its flags to 0 to update status effect icons */
await token.resetActionFlag();
await token.updateActionMarkers();
await token.removeActionFlag();
}
await token.removeActionContainer();
});
}
static _controlToken(token, state){
const mode = MODULE.setting('actionMgmtEnable');
if(mode == 0) return;
const display = MODULE.setting('actionMgmtDisplay');
if(token.inCombat){
queueUpdate( async () => {
if(token.hasActionContainer()) token.toggleActionContainer(display === 0 || !state ? false : true);
else await ActionManagement._renderActionContainer(token, display === 0 || !state ? false : true);
return token.drawEffects();
});
}
}
/* this is where all clients should be updating their rendering, based on flags
* set by the owner */
static _updateToken(tokenDocument, update, /* options, id */){
const mode = MODULE.setting('actionMgmtEnable');
if(mode == 0 || !tokenDocument.inCombat) return;
const display = MODULE.setting('actionMgmtDisplay');
if("width" in update || "height" in update || "scale" in update){
ActionManagement._renderActionContainer(tokenDocument.object, display === 0 || !tokenDocument.object._controlled ? false : true );
}
if("tint" in update || "img" in update || !!getProperty(update, `flags.${MODULE.data.name}`))
tokenDocument.object.updateActionMarkers();
logger.debug("_updateToken | Data | ", {
tokenDocument, mode: display, update, container : tokenDocument.object.getActionContainer(),
});
}
static async _createChatMessage(messageDocument, /*options, userId*/){
const messageData = messageDocument.data;
const types = Object.keys(MODULE[NAME].default);
const speaker = messageData.speaker;
logger.debug("_createChatMessage | DATA | ", {
types, speaker, messageData,
});
/* check validity of message */
if(!speaker || !speaker.scene || !speaker.token) return;
const token = await fromUuid(`Scene.${speaker.scene}.Token.${speaker.token}`);
/* check that the token is in combat */
if ( (token.combatant?.combat?.started ?? false) == false) return;
let item_id = '';
try{
item_id = $(messageData.content).attr("data-item-id");
}catch(e){
/* any error in querying means its not the droids we are looking for */
return;
}
logger.debug("_createChatMessage | DATA | ", {
item_id, token,
});
if(!item_id || !token || !MODULE.isFirstOwner(token.actor)) return;
const item = token.actor.items.get(item_id);
logger.debug("_createChatMessage | DATA | ", {
item,
});
if(!item || !types.includes(item.data.data.activation.type)) return;
let type = item.data.data.activation.type;
let cost = item.data.data.activation.cost ?? 1;
logger.debug("_createChatMessage | DATA | ", {
type,
});
type = ActionManagement._checkForReaction(type, token.combatant);
token.object.iterateActionFlag(type, cost);
}
static _checkForReaction(actionType, combatant){
if (!combatant) return actionType;
/* if this is an action not on your turn, interpret as a reaction */
if(actionType === 'action' && combatant.id !== combatant.combat.current.combatantId) {
return 'reaction';
}
return actionType;
}
static async _hoverToken(token, state){
/* users can hover anything, but only
* should display on owned tokens
*/
if(!token.isOwner || !MODULE.setting('actionMgmtEnable')) return;
const display = MODULE.setting('actionMgmtDisplay');
/* main hover option must be enabled, and we must be in combat
*/
if(display == 2 && token.inCombat){
if(!state) {
setTimeout(function() {
token.renderActionContainer(state);
token.drawEffects();
}, 100)}
setTimeout(function() {
if(!token._hover && state) return;
token.renderActionContainer(state);
token.drawEffects();
}, 500)
}
}
/**
* Patching Functions
*/
static _patchToken(){
Token.prototype.hasActionContainer = function(){
return !!this.children?.find(i => i[NAME]);
}
Token.prototype.toggleActionContainer = function(state){
let container = this.getActionContainer();
if(container) container.visible = state === undefined ? !container.visible : state;
}
Token.prototype.getActionContainer = function(){
return this.children?.find(i => i[NAME]);
}
Token.prototype.updateActionMarkers = function(){
const flag = this.getActionFlag();
const container = this.getActionContainer();
if(!container || !flag) return;
for(const type of Object.keys(MODULE[NAME].default)){
const element = container.children.find(e => e.actionType == type);
if(flag[type] > 0) {
/* has been used */
element.alpha = 0.2;
} else {
/* has been restored */
element.alpha = 1;
}
/* update any needed status icons for this change (requires ownership) */
if(this.isOwner) {
if (ActionManagement._shouldAddEffect(type)) {
queueUpdate( async () => {
await this.toggleEffect( MODULE[NAME].img[type] , {active: flag[type] > 0 ? true : false} );
});
}
}
}
}
Token.prototype.getActionFlag = function(){
return this.document.getFlag(MODULE.data.name, MODULE[NAME].flagKey);
}
Token.prototype.hasActionFlag = function(){
return !!this.getActionFlag();
}
/** @return {Promise<TokenDocument>} */
Token.prototype.iterateActionFlag = function(type, value){
/* dont mess with flags if I am not in combat */
if (!this.combatant) return false;
let flag = this.getActionFlag() ?? duplicate(MODULE[NAME].default);
if(value === undefined) flag[type] += 1;
else flag[type] = value;
logger.debug("iterateActionFlag | DATA | ", {
type, flag, token : this, scope : MODULE.data.name, key : MODULE[NAME].flagKey,
});
return this.document.setFlag(MODULE.data.name, MODULE[NAME].flagKey, flag);
}
Token.prototype.resetActionFlag = async function(){
logger.debug("resetActionFlag | DATA | ", {
token : this, default : MODULE[NAME].default,
});
/* force an update on reset */
return await this.document.update({[`flags.${MODULE.data.name}.${MODULE[NAME].flagKey}`]: MODULE[NAME].default}, {diff: false})
}
Token.prototype.removeActionContainer = function(){
if(this.hasActionContainer()) return this.removeChild(this.getActionContainer());
}
Token.prototype.removeActionFlag = async function(){
if(!!this.getActionFlag()) return this.document.update({[`flags.${MODULE.data.name}.-=${MODULE[NAME].flagKey}`] : null });
}
Token.prototype.renderActionContainer = function(state){
if(this.hasActionContainer())
return this.toggleActionContainer(state);
else
return ActionManagement._renderActionContainer(this, state);
}
/* return: Promise<setFlag> */
Token.prototype.setActionUsed = async function(actionType, overrideCount = undefined) {
const validActions = ['action', 'bonus', 'reaction'];
if (validActions.includes(actionType)){
/* if setting the action went well, return the resulting action usage object */
const success = await this.iterateActionFlag(actionType, overrideCount);
if(success){
return this.getActionFlag();
}
}
return false;
}
//from foundry.js:38015 as of v9.238
Token.prototype._drawEffect = async function (src, index, bg, w, tint) {
let tex = await loadTexture(src);
let icon = this.hud.effects.addChild(new PIXI.Sprite(tex));
//BEGIN D5H
const scale = MODULE.setting('effectIconScale');
icon.width = icon.height = w * scale;
/* if the action hud is visible, offset the start offset
* of the icons */
const actionHeight = this.getActionContainer()?.visible ? this.getActionContainer().getLocalBounds().bottom : 0;
const numColumns = Math.floor(this.data.width/scale * 5);
icon.x = (index % numColumns) * icon.width;
icon.y = actionHeight + Math.floor(index/numColumns) * icon.height;
//END D5H
if ( tint ) icon.tint = tint;
bg.drawRoundedRect(icon.x + 1, icon.y + 1, icon.width - 2, icon.height - 2, 2);
}
}
/**
* Global Accessor Functions
*/
/**
* Module Specific Functions
*/
static _shouldAddEffect(type) {
const preDefAnswers = [false, true, type == 'reaction' ? true : false];
const mode = MODULE.setting('actionMgmtEnable') != 0 ? MODULE.setting('actionsAsStatus') : 0;
return preDefAnswers[mode];
}
static async _loadTextures(orig, obj = {}){
const textures = {};
for(let [k,v] of Object.entries(obj)){
let t = await loadTexture(v);
if(k !== "background") t.orig = orig;
textures[k] = t;
}
return textures;
}
static async _renderActionContainer(token, state){
/* Define Constants */
const actions = token.getActionFlag() ?? duplicate(MODULE[NAME].default);
const container = new PIXI.Container();
const size = token.h, hAlign = token.w / 10, vAlign = token.h / 5, scale = 1/ (600/size);
/* Build Textures, Sprites, Icons, and Container */
container.setParent(token);
container.sortableChildren = true;
container[NAME] = true;
container.visible = state;
const textures = await ActionManagement._loadTextures(MODULE[NAME].orig, MODULE[NAME].img)
for(let [k, v] of Object.entries(textures)){
let s = new PIXI.Sprite(v);
s.anchor.set(0.5);
s.scale.set(scale);
s.position.set(hAlign * MODULE[NAME].offset[k].h, vAlign /* MODULE[NAME].offset[k].v*/);
if(k !== "background"){
s.interactive = true;
s.buttonMode = true;
s.actionType = k;
s.tint = 13421772;
s.alpha = actions[k] === 0 ? 1 : 0.2;
s.on("mousedown", async (event) => {
const actions = token.getActionFlag() ?? (duplicate(MODULE[NAME].default));
const container = token.getActionContainer();
if(container.visible) {
await token.iterateActionFlag(k, actions[k] > 0 ? 0 : 1);
}
logger.debug("_MouseDown | DATA |", {
event, token, container, actions
});
});
}else{
s.zIndex = -1000;
}
let i = container.addChild(s);
logger.debug("_renderAction Container", {
s, i, k, v
});
}
logger.debug("_renderActionContainer | DATA | ", {
actions, container, textures, token, state, size, hAlign, vAlign, scale
});
/* return Container*/
return container;
}
}