Skip to content

Commit

Permalink
Condition handler (#20)
Browse files Browse the repository at this point in the history
* Critical rolls on initiative affect the degree of success of the sneak evaluation
* Added a condition handler setting, allowing a choice of how to handle setting the `hidden`, `undetected`, and `unnoticed` settings at your table.
  • Loading branch information
Eligarf authored Sep 25, 2024
1 parent 1b6eb07 commit 3a504f3
Show file tree
Hide file tree
Showing 3 changed files with 150 additions and 71 deletions.
4 changes: 4 additions & 0 deletions ChangeLog.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# v1.8.0
* Critical rolls on initiative affect the degree of success of the sneak evaluation
* Added a condition handler setting, allowing a choice of how to handle setting the `hidden`, `undetected`, and `unnoticed` settings at your table.

# v1.7.0
* Fixed a dumb typo preventing the 1.6 feature from working
* If override is allowed, remove any pf2e-perception flags on a token if actor gets a condition item change to `hidden`, `undetected`, or `unnoticed`. This typically comes from using 'Assign Status Effects' on the token HUD; I use it to quickly clear out pf2e-perception's mix of hidden/undetected states after a token attacks by setting then clearing `hidden` in the token HUD.
Expand Down
207 changes: 139 additions & 68 deletions esmodules/main.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
const CONSOLE_COLORS = ['background: #222; color: #80ffff', 'color: #fff'];
const HIDDEN = ['action:hide', 'action:create-a-diversion', 'action:sneak'];
const MODULE_ID = 'pf2e-avoid-notice';
const PERCEPTION_ID = 'pf2e-perception';
const PF2E_PERCEPTION_ID = 'pf2e-perception';
const PERCEPTIVE_ID = 'perceptive';

function colorizeOutput(format, ...args) {
return [
Expand Down Expand Up @@ -76,12 +77,14 @@ Hooks.once('init', () => {
// });

Hooks.on('combatStart', async (encounter, ...args) => {
const perceptionApi = game.modules.get(PERCEPTION_ID)?.api;
const conditionHandler = game.settings.get(MODULE_ID, 'conditionHandler');
const pf2ePerceptionApi = game.modules.get(PF2E_PERCEPTION_ID)?.api;
const useUnnoticed = game.settings.get(MODULE_ID, 'useUnnoticed');
const revealTokens = game.settings.get(MODULE_ID, 'removeGmHidden');
const overridePerception = perceptionApi && game.settings.get(MODULE_ID, 'override');
const computeCover = perceptionApi && game.settings.get(MODULE_ID, 'computeCover');
const overridePf2ePerception = pf2ePerceptionApi && conditionHandler === 'perception';
const computeCover = pf2ePerceptionApi && game.settings.get(MODULE_ID, 'computeCover');
const requireActivity = game.settings.get(MODULE_ID, 'requireActivity');
const perceptiveApi = game.modules.get(PERCEPTIVE_ID)?.api;
let nonAvoidingPcs = [];

let avoiders = encounter.combatants.contents.filter((c) =>
Expand All @@ -98,18 +101,31 @@ Hooks.once('init', () => {
const familiars = canvas.scene.tokens
.filter((t) => t?.actor?.system?.master)
.filter((t) => encounter.combatants.contents.some((c) => c.actor._id == t.actor.system.master.id));

const eidolons = canvas.scene.tokens
.filter((t) => t?.actor?.system?.details?.class?.trait == 'eidolon');

const unrevealedIds = encounter.combatants.contents
.map((c) => c.token instanceof Token ? c.token.document : c.token)
.filter((t) => t.hidden && t.actor.type !== 'hazard')
.filter((t) => t.hidden && !(typeof pf2ePerceptionApi !== 'undefined' && t.actor.type === 'hazard'))
.map((t) => t.id);
let perceptionChanges = {};

let pf2ePerceptionChanges = {};
for (const avoider of avoiders) {
// log('avoider', avoider);

// Find the last initiative chat for the avoider
const messages = game.messages.contents.filter((m) =>
m.speaker.token === avoider.tokenId && m.flags?.core?.initiativeRoll
);
if (!messages.length) {
log(`Couldn't find initiative card for ${avoider.token.name}`);
continue;
}
const initiativeMessage = await game.messages.get(messages.pop()._id);
const initRoll = initiativeMessage.rolls[0].dice[0].total;
const dosDelta = (initRoll == 1) ? -1 : (initRoll == 20) ? 1 : 0;

// Only check against non-allies
const disposition = avoider.token.disposition;
const nonAllies = encounter.combatants.contents
Expand All @@ -130,9 +146,10 @@ Hooks.once('init', () => {
break;
}

perceptionChanges[avoiderTokenDoc.id] = {};
let otherUpdate = perceptionChanges[avoiderTokenDoc.id];
pf2ePerceptionChanges[avoiderTokenDoc.id] = {};
let pf2ePerceptionUpdate = pf2ePerceptionChanges[avoiderTokenDoc.id];
let messageData = {};
let results = {};
for (const other of nonAllies) {
const otherTokenDoc = other?.token instanceof Token ? other.token.document : other?.token ?? other;
const otherToken = other?.token ?? other;
Expand All @@ -146,12 +163,12 @@ Hooks.once('init', () => {
};

// We give priority to the per-token states in PF2e Perception
const perceptionData = perceptionApi ? avoiderTokenDoc?.flags?.[PERCEPTION_ID]?.data : undefined;
const pf2ePerceptionData = pf2ePerceptionApi ? avoiderTokenDoc?.flags?.[PF2E_PERCEPTION_ID]?.data : undefined;
let coverBonus = baseCoverBonus;
if (perceptionApi) {
if (pf2ePerceptionApi) {
const cover = (computeCover)
? perceptionApi.token.getCover(avoider.token._object, otherToken._object)
: perceptionData?.[otherToken.id]?.cover;
? pf2ePerceptionApi.token.getCover(avoider.token._object, otherToken._object)
: pf2ePerceptionData?.[otherToken.id]?.cover;

switch (cover) {
case 'standard':
Expand Down Expand Up @@ -180,26 +197,27 @@ Hooks.once('init', () => {

// Handle critical failing to win at stealth
const delta = avoider.initiative + coverBonus - target.dc;
if (delta < -9) {
const dos = dosDelta + ((delta < -9) ? 0 : (delta < 0) ? 1 : (delta < 9) ? 2 : 3);
if (dos < 1) {
target.result = 'observed';
target.delta = `${delta}`;

// Remove any existing perception flag as we are observed
if (overridePerception && perceptionData && otherToken.id in perceptionData)
otherUpdate[`flags.${PERCEPTION_ID}.data.-=${otherToken.id}`] = true;
if (overridePf2ePerception && pf2ePerceptionData && otherToken.id in pf2ePerceptionData)
pf2ePerceptionUpdate[`flags.${PF2E_PERCEPTION_ID}.data.-=${otherToken.id}`] = true;
}

// Normal fail is hidden
else if (delta < 0) {
else if (dos < 2) {
const visibility = 'hidden';
target.result = visibility;
target.delta = `${delta}`;

// Remove any existing perception flag as we are observed
if (perceptionData?.[otherToken.id]?.visibility !== visibility &&
(overridePerception || (perceptionData && !(otherToken.id in perceptionData)))
if (pf2ePerceptionData?.[otherToken.id]?.visibility !== visibility &&
(overridePf2ePerception || (pf2ePerceptionData && !(otherToken.id in pf2ePerceptionData)))
)
otherUpdate[`flags.${PERCEPTION_ID}.data.${otherToken.id}.visibility`] = visibility;
pf2ePerceptionUpdate[`flags.${PF2E_PERCEPTION_ID}.data.${otherToken.id}.visibility`] = visibility;
}

// avoider beat the other token at the stealth battle
Expand All @@ -212,10 +230,16 @@ Hooks.once('init', () => {
target.result = visibility;

// Update the perception flags if there is a difference
if (perceptionData?.[otherToken.id]?.visibility !== visibility &&
(overridePerception || (perceptionData && !(otherToken.id in perceptionData)))
if (pf2ePerceptionData?.[otherToken.id]?.visibility !== visibility &&
(overridePf2ePerception || (pf2ePerceptionData && !(otherToken.id in pf2ePerceptionData)))
)
otherUpdate[`flags.${PERCEPTION_ID}.data.${otherToken.id}.visibility`] = visibility;
pf2ePerceptionUpdate[`flags.${PF2E_PERCEPTION_ID}.data.${otherToken.id}.visibility`] = visibility;
}

if (!(target.result in results)) {
results[target.result] = [target];
} else {
results[target.result].push(target);
}

// Add a new category if necessary, and put this other token's result in the message data
Expand All @@ -231,19 +255,52 @@ Hooks.once('init', () => {
}
}

// Find the last initiative chat for the avoider
const messages = game.messages.contents.filter((m) =>
m.speaker.token === avoider.tokenId && m.flags?.core?.initiativeRoll
);
if (!messages.length) {
log(`Couldn't find initiative card for ${avoider.token.name}`);
continue;
if (['best','worst'].includes(conditionHandler)) {
async function tweakStatuses(actor, off, on) {
const removals = actor.items
.filter((i) => i.type === 'condition' && off.includes(i.system.slug))
.map((i) => i.system.slug);
for (const c of removals) {
await actor.toggleCondition(c, { active: false });
}
if (on) {
await actor.toggleCondition(on, {active: true});
}
}

// best refers to the dc, not the degree of success
if (conditionHandler === 'best') {
if (results?.observed) {
await tweakStatuses(avoider.actor, ['hidden', 'undetected', 'unnoticed']);
}
else if (results?.hidden) {
await tweakStatuses(avoider.actor, ['undetected', 'unnoticed'], 'hidden');
}
else if (results?.undetected) {
await tweakStatuses(avoider.actor, ['hidden', 'unnoticed'], 'undetected');
}
else if (results?.unnoticed) {
await tweakStatuses(avoider.actor, ['hidden', 'undetected'], 'unnoticed');
}
}
else {
if (results?.unnoticed) {
await tweakStatuses(avoider.actor, ['hidden', 'undetected'], 'unnoticed');
}
else if (results?.undetected) {
await tweakStatuses(avoider.actor, ['hidden', 'unnoticed'], 'undetected');
}
else if (results?.hidden) {
await tweakStatuses(avoider.actor, ['undetected', 'unnoticed'], 'hidden');
}
else if (results?.observed) {
await tweakStatuses(avoider.actor, ['hidden', 'undetected', 'unnoticed']);
}
}
}

// Push the new detection statuses into that message
const lastMessage = await game.messages.get(messages.pop()._id);
log(`messageData updates for ${avoiderTokenDoc.name}`, messageData);
let content = renderInitiativeDice(lastMessage.rolls[0]);
let content = renderInitiativeDice(initiativeMessage.rolls[0]);

for (const t of ['unnoticed', 'undetected', 'hidden', 'observed']) {
const status = messageData[t];
Expand Down Expand Up @@ -278,9 +335,10 @@ Hooks.once('init', () => {
}
}

await lastMessage.update({ content });
await initiativeMessage.update({ content });
}

// Print out the warnings for PCs that aren't using Avoid Notice
for (const nonAvoider of nonAvoidingPcs) {
const messages = game.messages.contents.filter((m) =>
m.speaker.token === nonAvoider.tokenId && m.flags?.core?.initiativeRoll
Expand All @@ -296,32 +354,32 @@ Hooks.once('init', () => {
}

// If PF2e-perception is around, move any non-empty changes into an update array
let updates = [];
if (perceptionApi) {
for (const id in perceptionChanges) {
const update = perceptionChanges[id];
let tokenUpdates = [];
if (pf2ePerceptionApi) {
for (const id in pf2ePerceptionChanges) {
const update = pf2ePerceptionChanges[id];
if (Object.keys(update).length)
updates.push({ _id: id, ...update });
tokenUpdates.push({ _id: id, ...update });
}
}

// Reveal combatant tokens
if (revealTokens) {
for (const t of unrevealedIds) {
let update = updates.find((u) => u._id === t);
let update = tokenUpdates.find((u) => u._id === t);
if (update) {
update.hidden = false;
}
else {
updates.push({ _id: t, hidden: false });
tokenUpdates.push({ _id: t, hidden: false });
}
}
}

// Update all the tokens at once, skipping an empty update
if (updates.length > 0) {
log('token updates', updates);
canvas.scene.updateEmbeddedDocuments("Token", updates);
if (tokenUpdates.length > 0) {
log('token updates', tokenUpdates);
canvas.scene.updateEmbeddedDocuments("Token", tokenUpdates);
}
});
});
Expand All @@ -335,35 +393,38 @@ function migrate(moduleVersion, oldVersion) {
Hooks.once('ready', () => {

async function clearPf2ePerceptionFlags(item, options, userId) {
// Only do stuff if we are changing hidden, undetected, or unnoticed in perception
const perceptionApi = game.modules.get(PERCEPTION_ID)?.api;
if (!perceptionApi) return;
const overridePerception = game.settings.get(MODULE_ID, 'override');
if (!overridePerception) return;
// Only do stuff if we are changing hidden, undetected, or unnoticed conditions and using pf2e-perception
const pf2ePerceptionApi = game.modules.get(PF2E_PERCEPTION_ID)?.api;
if (!pf2ePerceptionApi) return;
const conditionHandler = game.settings.get(MODULE_ID, 'conditionHandler');
const overridePf2ePerception = conditionHandler === 'perception';
if (!overridePf2ePerception) return;
if (item?.type !== 'condition' || !['hidden', 'undetected', 'unnoticed'].includes(item?.system?.slug)) return;

// Get the token on the current scene
const token = options.parent?.parent ?? canvas.scene.tokens.find((t) => t.actorId === options.parent.id);
if (!token) return;

// Remove any ids that perception is tracking if there are any
const perceptionData = token.flags?.[PERCEPTION_ID]?.data;
// Remove any ids that perception is tracking if any
const perceptionData = token.flags?.[PF2E_PERCEPTION_ID]?.data;
if (!Object.keys(perceptionData).length) return;
let tokenUpdate = {};
for (let id in perceptionData) {
tokenUpdate[`flags.${PERCEPTION_ID}.data.-=${id}`] = true;
tokenUpdate[`flags.${PF2E_PERCEPTION_ID}.data.-=${id}`] = true;
}
const updates = [{ _id: token.id, ...tokenUpdate }];
await canvas.scene.updateEmbeddedDocuments("Token", updates);
}

Hooks.on("deleteItem", async (item, options, userId) => {
await clearPf2ePerceptionFlags(item, options, userId);
});
if (game.modules.get(PF2E_PERCEPTION_ID)?.active) {
Hooks.on("deleteItem", async (item, options, userId) => {
await clearPf2ePerceptionFlags(item, options, userId);
});

Hooks.on("createItem", async (item, options, userId) => {
await clearPf2ePerceptionFlags(item, options, userId);
});
Hooks.on("createItem", async (item, options, userId) => {
await clearPf2ePerceptionFlags(item, options, userId);
});
}

});

Expand Down Expand Up @@ -398,22 +459,32 @@ Hooks.once('setup', () => {
default: true,
});

const perception = game.modules.get(PERCEPTION_ID)?.active;
if (perception) {
game.settings.register(MODULE_ID, 'override', {
name: game.i18n.localize(`${MODULE_ID}.override.name`),
hint: game.i18n.localize(`${MODULE_ID}.override.perceptionHint`),
scope: 'world',
config: true,
type: Boolean,
default: true,
});
const perception = game.modules.get(PF2E_PERCEPTION_ID)?.active;
const perceptive = game.modules.get(PERCEPTIVE_ID)?.active;

let choices = {
'ignore': `${MODULE_ID}.conditionHandler.ignore`,
'best': `${MODULE_ID}.conditionHandler.best`,
'worst': `${MODULE_ID}.conditionHandler.worst`,
};
if (perception) choices.perception = `${MODULE_ID}.conditionHandler.perception`;

game.settings.register(MODULE_ID, 'conditionHandler', {
name: game.i18n.localize(`${MODULE_ID}.conditionHandler.name`),
hint: game.i18n.localize(`${MODULE_ID}.conditionHandler.hint`),
scope: 'world',
config: true,
type: String,
choices,
default: (perception) ? 'perception' : 'ignore'
})

if (perception) {
game.settings.register(MODULE_ID, 'computeCover', {
name: game.i18n.localize(`${MODULE_ID}.computeCover.name`),
hint: game.i18n.localize(`${MODULE_ID}.computeCover.hint`),
scope: 'world',
config: true,
config: perception,
type: Boolean,
default: false,
});
Expand Down
10 changes: 7 additions & 3 deletions languages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,13 @@
"name": "Require 'Avoid Notice' exploration activity",
"hint": "PCs are required to set the 'Avoid Notice' exploration activity active to hide at combat start. If disabled, PCs instead need to use stealth for initiative."
},
"override": {
"name": "Override",
"perceptionHint": "Allow 'PF2e Avoid Notice' to override any existing 'PF2e-perception' visibility flags on a token"
"conditionHandler": {
"name": "Condition Handler",
"hint": "Worst dc visually favors the sneakers, whereas best dc visually favors the seekers.",
"ignore": "Don't set the status conditions",
"best": "Use the best perception dc to determine the condition",
"worst": "Use the worst perception dc to determine the condition",
"perception": "Let the pf2e-perception module handle it"
},
"computeCover": {
"name": "Compute Cover at Combat Start",
Expand Down

0 comments on commit 3a504f3

Please sign in to comment.