Skip to content

Commit

Permalink
V2.0.0 (#54)
Browse files Browse the repository at this point in the history
* PF1 support - effect cleanup is macro based so buyer beware
* PF2e support parked due to active effect incompatibility
* Experimental: Converted Perception roll results into roll pairs for Spot, rolling an extra die if needed
* Experimental: Dim and Hidden conditions on viewed token applies disadvantage to Perception during visibility test
  • Loading branch information
Eligarf authored Jan 4, 2023
1 parent 48e39d6 commit c5f8c78
Show file tree
Hide file tree
Showing 7 changed files with 106 additions and 129 deletions.
14 changes: 5 additions & 9 deletions ChangeLog.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
# Pending
* Built proper interface for stealth engine
- dnd5e: fully encapsulated
- pf2e: need to adapt the active effects to the PF2e system implementation
- pf1: actually seems to "work"
- I assume take-10 perception for tokens without an active spot effect. It isn't RAW, but perhaps this is acceptable to the PF1 community.
- the PF1 actor sheet UI doesn't seem to have a way to delete the effects once they've been added by rolling
* Experimental: Convert Perception roll results into roll pairs for Spot, rolling an extra die if needed
* Experimental: Implement Dim/Dark flag effects on Perception tests using Foundry darkvision model
# v2.0.0
* PF1 support - effect cleanup is macro based so buyer beware
* PF2e support parked due to active effect incompatibility
* Experimental: Converted Perception roll results into roll pairs for Spot, rolling an extra die if needed
* Experimental: Dim and Hidden conditions on viewed token applies disadvantage to Perception during visibility test

# v1.6.1
* Fixed error creating default spot effect
Expand Down
57 changes: 38 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,20 @@

A module for [FoundryVTT](https://foundryvtt.com) that adds perception vs stealth testing to Foundry's visibility tests.

## Purpose
# Purpose

During visibility tests, Stealthy filters out any objects with the Hidden condition if the viewing Perception value fails to beat the object's Stealth value.

## Features

### **Rolling Stealth checks applies the Hidden condition**
## **Rolling Stealth checks applies the Hidden condition**
Rolling a Stealth skill check will apply the Hidden condition to the actor and record the result of the check in that condition for later comparisons, replacing the stored result if the Hidden condition is already present. Stealthy's default Hidden effect can be overriden by adding a custom Hidden effect in either Convenient Effects or CUB.

***See [Handling Hidden removal](#handling-hidden-removal)***

![stealth-roll](https://user-images.githubusercontent.com/16523503/209989026-e0d2dad2-8dc1-459c-8824-a2332ce8a9cd.gif)

### **Rolling Perception checks applies the Spot condition**
## **Rolling Perception checks applies the Spot condition**
Rolling a Perception check will add a Spot condition to the actor which records the result of that perception check (the passive value for Perception is used if this condition isn't present on the actor).

A toggle named 'Active Spot' is available under token controls to suspend adding of the Spot condition as the GM sees fit. Toggling it off will also clear out all Spot effects.
Expand All @@ -33,46 +33,44 @@ A toggle named 'Active Spot' is available under token controls to suspend adding
![perception](https://user-images.githubusercontent.com/16523503/209989470-aac2bdb4-fee4-44c0-a6b7-916e69353081.gif)
![control](https://user-images.githubusercontent.com/16523503/210176825-3fcb3183-81db-4f64-836a-81f29199b580.png)

### **GM Overrides**
## **GM Overrides**
Once the Hidden or Spot conditions are applied, GMs will see token buttons with an input box on the bottom which shows the rolled values, or the passive values if the condition was added directly without rolling. Perception is on the left, Stealth is on the right. Changing the value in this input box will alter the stored results for future visibility tests while that condition remains.

![stealth-override](https://user-images.githubusercontent.com/16523503/209896031-675ab0e3-93e6-4d9c-8eeb-c11abe39fdab.gif)

### **dnd5e: Umbral Sight affects darkvision**
## **dnd5e: Umbral Sight affects darkvision**
Characters with Umbral Sight will no longer be visible to the Darkvision mode, but they can still be seen if Basic Vision can see them. The GM has the option to disable this for friendly token visibility tests.

![umbral-sight](https://user-images.githubusercontent.com/16523503/209987083-487aee33-b75e-452f-9433-7302ffdaeab3.gif)

### **Invisible characters can hide from See Invisibility**
## **Invisible characters can hide from See Invisibility**
An invisible actor that also has the 'Hidden' condition will check Perception vs Stealth before showing up in the 'See Invisibility' vision mode.

![invisible](https://user-images.githubusercontent.com/16523503/210176827-03fda57a-6d09-4144-8253-b8b7cd9155ac.gif)

### **Friendly tokens can still be viewed**
## **Friendly tokens can still be viewed**
The GM has the option for allowing Hidden tokens to be seen by other tokens of the same disposition.

# Limitations

## Handling Hidden removal
Stealthy will not automatically remove the Hidden condition - the dnd5e [Skulker](https://www.dndbeyond.com/feats/skulker) feat demonstrates why removing Hidden gets complicated without heavier automation support provided by modules like the excellent [Midi-QOL](https://foundryvtt.com/packages/midi-qol) which handles this for my games. I suggest [DFreds Effects Panel](https://foundryvtt.com/packages/dfreds-effects-panel) as an easier way to manually remove it, especially for low automation level games.

## dnd5e: Stealth vs Perception Ties
D&D 5E treats skill contest ties as preserving the status quo, so use of passive value for either skill makes a claim of owning the status quo and thus winning ties. If Perception and Stealth are both passive, I assume Stealth takes the active role of wanting to change the status quo from visible to hidden. An active Perception check is only necessary if the passive Perception was beaten by Stealth, so in this case Hidden is now the status quo condition and Stealth wins ties with the active result. More simply, **ties are won by passive Perception and lost by active Perception.**

## Other systems
- PF1 sort of works!
- I assume take-10 perception for tokens without an active spot effect. It isn't RAW, but perhaps this is acceptable to the PF1 community.
- the PF1 actor sheet UI doesn't seem to have a way to delete the effects once they've been added by rolling
- PF2e seems to have completely replaced the Active Effect system which Stealthy uses as its backbone, so some heavy lifting has to be done to adapt it.
## Visibility changes are only reflected on token updates
The visibility results are cached, so changes in visibility brought about by making skill checks, adjusting the result values manually, or removing the Spot/Hidden effects don't immediately change the visible state. This means sometimes you have force a token update by moving the token or selecting a different token.

# Systems
I've isolated out all the specific dnd5e code I wrote into its own engine object and built a sort-of working implementation for PF1 - see changelog for caveats. I'd be happy to take any help offered to make Stealthy work in other systems! Stealth engines don't have to live inside Stealthy's codebase - they can be added externally like below:
```
Hooks.once('init', () => {
Stealthy.RegisterEngine('pf1', () => new StealthyPF1());
});
```
## dnd5e
### Stealth vs Perception Ties
D&D 5E treats skill contest ties as preserving the status quo, so use of passive value for either skill makes a claim of owning the status quo and thus winning ties. If Perception and Stealth are both passive, I assume Stealth takes the active role of wanting to change the status quo from visible to hidden. An active Perception check is only necessary if the passive Perception was beaten by Stealth, so in this case Hidden is now the status quo condition and Stealth wins ties with the active result. More simply, **ties are won by passive Perception and lost by active Perception.**

## Experimental

### Lighting effects on Perception vs Hidden token
### Experimental - Lighting effects on Perception vs Hidden token
For this approach we are only looking at dnd5e and we've broken this down into three pieces:
- Detecting the light level on the token itself, which is independant of viewer. Stealthy is decoupled from figuring that out by just looking for 'Dim' or 'Dark' conditions on the token; a different module will manage this. Fingers crossed.
- Remapping the dim/dark light level per viewer based on their viewing mode. At least 3 different mapping tables are needed:
Expand All @@ -83,7 +81,28 @@ For this approach we are only looking at dnd5e and we've broken this down into t
After the light level is remapped, objects in 'Dark' get rejected and objects in 'Dim' would be tested against using disadvantaged perception.
- Capturing the advantage/disadvantage state of the viewers perception in order to do the right thing when applying disadvantage in dim vision. We get these flags on the active rolls, and can generate an extra roll result we can store in our flag so that we have a result for disadvantage should we need it. **We don't have a cost-effective way to figure out pre-existing passive disadvantage on perception, so this edge case will cause those tokens to end up taking the -5 penalty twice. You have been warned.**

## Required modules
## pf1
- I assume take-10 perception for tokens without an active spot effect. It isn't RAW, but perhaps this is acceptable to the PF1 community.
- the PF1 actor sheet UI doesn't seem to have a way to delete the effects once they've been added by rolling, so I made this macro (I assume there is a smarter way)

'Remove Hidden' Script Macro:
```
const controlled = canvas.tokens.controlled;
const label = game.i18n.localize('stealthy-hidden-label');
controlled.forEach(token => {
const actor = token.actor;
const effects = actor.effects.filter(e => e.label === label).map(e => e.id);
if (effects.length > 0) {
actor.deleteEmbeddedDocuments('ActiveEffect', effects);
}
});
```
*Remove Spot is the same with a 'stealthy-spot-label' substitution*

## pf2e
PF2e seems to have completely replaced the Active Effect system which Stealthy uses as its backbone, so some heavy lifting has to be done to finish adapting it.

# Required modules
* [lib-wrapper](https://foundryvtt.com/packages/lib-wrapper)
* [socketlib](https://github.com/manuelVo/foundryvtt-socketlib)
## Optional modules
Expand Down
8 changes: 6 additions & 2 deletions module.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"flags": {}
}
],
"version": "1.6.1",
"version": "2.0.0",
"compatibility": {
"minimum": "10.291",
"verified": "10.291"
Expand All @@ -33,6 +33,10 @@
}
]
},
"system": [
"dnd5e",
"pf1"
],
"esmodules": [
"scripts/config.js",
"scripts/stealthy.js",
Expand All @@ -51,7 +55,7 @@
}
],
"socket": true,
"download": "https://github.com/Eligarf/stealthy/releases/download/v1.6.1/stealthy.zip",
"download": "https://github.com/Eligarf/stealthy/releases/download/v2.0.0/stealthy.zip",
"changelog": "https://raw.githubusercontent.com/eligarf/stealthy/release/ChangeLog.md",
"url": "https://github.com/Eligarf/stealthy",
"manifest": "https://github.com/Eligarf/stealthy/releases/latest/download/module.json",
Expand Down
44 changes: 44 additions & 0 deletions scripts/stealthy.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,50 @@ export class StealthyBaseEngine {
return wrapped(visionSource, mode, config);
}

makeHiddenEffect(label) {
return (flag, source) => {
let hidden = {
label,
icon: 'icons/magic/perception/shadow-stealth-eyes-purple.webp',
changes: [],
flags: {
convenientDescription: game.i18n.localize("stealthy-hidden-description"),
stealthy: flag,
core: { statusId: '1' },
},
};
if (source === 'ae') {
if (typeof TokenMagic !== 'undefined') {
hidden.changes.push({
key: 'macro.tokenMagic',
mode: CONST.ACTIVE_EFFECT_MODES.CUSTOM,
value: 'fog'
});
}
else if (typeof ATLUpdate !== 'undefined') {
hidden.changes.push({
key: 'ATL.alpha',
mode: CONST.ACTIVE_EFFECT_MODES.OVERRIDE,
value: '0.5'
});
}
}
return hidden;
};
}

makeSpotEffect(label) {
return (flag, source) => ({
label,
icon: 'icons/commodities/biological/eye-blue.webp',
duration: { turns: 1, seconds: 6 },
flags: {
convenientDescription: game.i18n.localize("stealthy-spot-description"),
stealthy: flag
},
});
}

async updateOrCreateEffect({ label, actor, flag, makeEffect }) {
let effect = actor.effects.find(e => e.label === label);

Expand Down
42 changes: 3 additions & 39 deletions scripts/systems/dnd5e.js
Original file line number Diff line number Diff line change
Expand Up @@ -124,15 +124,7 @@ export class Stealthy5e extends StealthyBaseEngine {
label,
actor,
flag: { spot: perception },
makeEffect: (flag, source) => ({
label,
icon: 'icons/commodities/biological/eye-blue.webp',
duration: { turns: 1, seconds: 6 },
flags: {
convenientDescription: game.i18n.localize("stealthy-spot-description"),
stealthy: flag
},
})
makeEffect: this.makeSpotEffect(label)
});
}

Expand All @@ -144,35 +136,7 @@ export class Stealthy5e extends StealthyBaseEngine {
label,
actor,
flag: { hidden: roll.total },
makeEffect: (flag, source) => {
let hidden = {
label,
icon: 'icons/magic/perception/shadow-stealth-eyes-purple.webp',
changes: [],
flags: {
convenientDescription: game.i18n.localize("stealthy-hidden-description"),
stealthy: flag,
core: { statusId: '1' },
},
};
if (source === 'ae') {
if (typeof TokenMagic !== 'undefined') {
hidden.changes.push({
key: 'macro.tokenMagic',
mode: CONST.ACTIVE_EFFECT_MODES.CUSTOM,
value: 'fog'
});
}
else if (typeof ATLUpdate !== 'undefined') {
hidden.changes.push({
key: 'ATL.alpha',
mode: CONST.ACTIVE_EFFECT_MODES.OVERRIDE,
value: '0.5'
});
}
}
return hidden;
}
makeEffect: this.makeHiddenEffect(label)
});
}

Expand Down Expand Up @@ -242,7 +206,7 @@ export class Stealthy5e extends StealthyBaseEngine {
debugData.seesBright = perception;
}

// Stealthy.log('adjustForLightingConditions5e', debugData);
Stealthy.log('adjustForLightingConditions5e', debugData);
return perception;
}

Expand Down
40 changes: 2 additions & 38 deletions scripts/systems/pf1.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,15 +62,7 @@ export class StealthyPF1 extends StealthyBaseEngine {
label,
actor,
flag: { spot: message.rolls[0].total },
makeEffect: (flag, source) => ({
label,
icon: 'icons/commodities/biological/eye-blue.webp',
duration: { turns: 1, seconds: 6 },
flags: {
convenientDescription: game.i18n.localize("stealthy-spot-description"),
stealthy: flag
},
})
makeEffect: this.makeSpotEffect(label)
});
}

Expand All @@ -82,35 +74,7 @@ export class StealthyPF1 extends StealthyBaseEngine {
label,
actor,
flag: { hidden: message.rolls[0].total },
makeEffect: (flag, source) => {
let hidden = {
label,
icon: 'icons/magic/perception/shadow-stealth-eyes-purple.webp',
changes: [],
flags: {
convenientDescription: game.i18n.localize("stealthy-hidden-description"),
stealthy: flag,
core: { statusId: '1' },
},
};
if (source === 'ae') {
if (typeof TokenMagic !== 'undefined') {
hidden.changes.push({
key: 'macro.tokenMagic',
mode: CONST.ACTIVE_EFFECT_MODES.CUSTOM,
value: 'fog'
});
}
else if (typeof ATLUpdate !== 'undefined') {
hidden.changes.push({
key: 'ATL.alpha',
mode: CONST.ACTIVE_EFFECT_MODES.OVERRIDE,
value: '0.5'
});
}
}
return hidden;
}
makeEffect: this.makeHiddenEffect(label)
});
}
}
Expand Down
30 changes: 8 additions & 22 deletions scripts/systems/pf2e.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,37 +9,23 @@ export class StealthyPF2e extends StealthyBaseEngine {
console.warn(`Stealthy for '${game.system.id}' is stubbed out, needs development`);
}

testStealth(visionSource, config) {
const target = config.object?.actor;
const ignoreFriendlyStealth =
game.settings.get('stealthy', 'ignoreFriendlyStealth') &&
config.object.document?.disposition === visionSource.object.document?.disposition;

if (!ignoreFriendlyStealth) {
const hidden = target?.effects.find(e => e.label === game.i18n.localize("stealthy-hidden-label") && !e.disabled);
if (hidden) {
if (this.isHidden(visionSource, hidden, target, config)) return false;
}
}

return true;
}

isHidden(visionSource, hidden, target, config) {
// Implement your system's method for testing spot data vs hidden data
// This should would in the absence of a spot effect on the viewer, using
// a passive or default value as necessary
return false;
}

basicVision(wrapped, visionSource, mode, config) {
// Any special filtering beyond stealth testing is handled here, like being invisible to darkvision/etc.
return wrapped(visionSource, mode, config);
makeHiddenEffect(label) {
console.error(`'${game.system.id}' can't make a Hidden effect. Heavy lifting goes here.`);
}

makeSpotEffect(label) {
console.error(`'${game.system.id}' isn't make a Spot effect. Heavy lifting goes here.`);
}

seeInvisibility(wrapped, visionSource, mode, config) {
// Any special filtering beyond stealth testing is handled here.
return wrapped(visionSource, mode, config);
async updateOrCreateEffect({ label, actor, flag, makeEffect }) {
console.error(`'${game.system.id}' isn't compatible with Active Effect use. Heavy lifting goes here.`);
}

getHiddenFlagAndValue(hidden) {
Expand Down

0 comments on commit c5f8c78

Please sign in to comment.