architecture token interactions - Drowbe/coffee-pub-blacksmith GitHub Wiki

Token Interactions Architecture

Audience: us, and any Coffee Pub module maintainer who needs to change how token gesture claims work.

Scope: how blacksmith.tokens intercepts a canvas gesture, and why it does it this way rather than the three more obvious ways.

The public surface is specified in api/api-tokens.md. Implementation is scripts/api-token-interactions.js.

The constraint that shapes everything

Foundry decides whether a gesture may happen before it decides what happens. MouseInteractionManager#can is consulted at client/canvas/interaction/mouse-handler.mjs:494, and only on a true result does #handleClickLeft2 dispatch. The gesture-to-predicate map at client/canvas/placeables/placeable-object.mjs:792 binds clickLeft2 to Token#_canView (client/canvas/placeables/token.mjs:4254), which requires LIMITED on the Actor.

A player has no permission on a corpse, so nothing downstream of that check ever runs. No hook can help: by the time any handler could fire, the decision is made. Token#_onClickLeft2 (client/canvas/placeables/token.mjs:4319) renders the Actor sheet directly and fires nothing at all.

This is the whole reason the feature is an API. HookManager is not the limitation - it accepts any hook name (manager-hooks.js:94) - Foundry simply never emits one, and HookManager propagates return false only for pre* names (manager-hooks.js:80), so a veto-by-hook shape would not work either.

A context menu is not a way around it: Token#_canHUD (token.mjs:4227) requires GM or OWNER, which is stricter than LIMITED.

The mechanism

PlaceableObject#activateListeners (placeable-object.mjs:775) builds a MouseInteractionManager and assigns it to this.mouseInteractionManager. The manager stores its permissions and callbacks arguments as plain own properties (mouse-handler.mjs:64-65), and reads them per gesture by key (mouse-handler.mjs:294 and :311), invoking each with the placeable as this.

Two consequences make interception possible without touching a prototype. The entries are replaceable at runtime, because they are looked up on each gesture rather than captured. And their lifetime is exactly the placeable's draw lifetime, because activateListeners has one caller - placeable-object.mjs:434, inside draw().

Blacksmith already wraps Token.prototype.draw (manager-libwrapper.js) and fires postCoffeePubTokenDraw after the wrapped call resolves, which is after activateListeners has run. The registry listens there and replaces the entries for each claimed gesture on that token's own manager.

So no libWrapper registration is added for this feature, and nothing on a prototype changes.

Two invariants that are invisible in correct code

Both of these look like redundant caution when the code is working, and both are destructive when removed. Neither is recoverable by reading the shipped code, which is why they are written here.

Fail closed on a throwing handler. When a claim's handler throws, the gesture does nothing. It must not fall through to Foundry's handler, because bypassPermission may already have granted the gesture in the predicate - falling through would open the Actor sheet to a user who could not otherwise open it. A try/catch that "recovers" by calling the original handler converts an error into a permission leak.

The OURS marker is load-bearing. draw() builds a fresh manager, so the patch runs again on the same token. The marker is how a second pass tells Foundry's real entry from a replacement of ours. Without it, a replacement could be captured as the "original", and the pass-through path would call itself. Removing the marker on the grounds that Token.prototype._canView is always the same reference makes correctness depend on Foundry never binding a per-instance predicate.

The callback re-checks the real predicate when no claim matches. Foundry evaluates permission and dispatches the handler in two separate calls (mouse-handler.mjs:494 then the callback), so a claim can match during the permission call - granting the gesture through bypassPermission - and stop matching before dispatch, because matches reads live document state. Falling straight through to Foundry's handler at that point would run it on a gesture only the bypass allowed. The no-claim branch therefore re-evaluates the displaced predicate and suppresses the gesture if it denies. This looks like a redundant permission check sitting next to Foundry's own, which is exactly why it would be removed by someone tidying up; it is the same leak the fail-closed rule above prevents, reached through a different door.

Why matches resolves at gesture time

The registry patches every drawn token while any claim exists, and evaluates matches when the gesture fires rather than when the token draws.

Deciding at draw time would be cheaper and would leave unmatched tokens untouched, but it is wrong for the case the feature exists to serve: a creature that dies mid-session becomes lootable without redrawing, so its corpse would stay unclaimable until something happened to redraw it.

The cost is bounded because the claimable gestures are double-clicks - human-rate events, not the pointermove path. What the design does preserve is that nothing happens until the first claim: the draw hook is registered lazily by registerInteraction, and with an empty registry no token is patched.

This is also why matches cannot be async. The permission predicate is synchronous by Foundry's contract, and a promise is truthy, so returning one would grant the gesture unconditionally.

Restoration

The originals displaced on each token are held in a WeakMap keyed by the placeable, so a torn-down scene's tokens are not retained. unregisterInteraction restores every token on the canvas and then re-applies the claims that remain, which is what makes teardown immediate instead of waiting for a redraw, without dropping another module's claims in the process.

Bounds

Tokens only. The interception point is the Coffee Pub token draw hook, so tiles, notes, and drawings are not covered. Adding one would mean either an equivalent draw hook per placeable class or moving to the class-level fallback below.

Two gestures only. clickLeft2 and clickRight2. The other entries in the permissions map drive token control, selection, dragging, and hover, and replacing those removes machinery the canvas depends on. The registry is gesture-keyed throughout, so widening it is a change to one frozen array plus whatever verification the new gesture needs.

Invisible to libWrapper conflict detection. Instance patching is not a registered wrapper, so if anything else replaces the same keys the last writer wins silently. The registry owning those keys for the whole suite is the mitigation, and is the reason this is an API rather than a technique each module applies.

Scoped by construction, not by a current fact. Token#_canView has exactly one consumer in the v13 client - the clickLeft2 entry at placeable-object.mjs:792 - so even a broad relaxation would not reach the Actor sheet by another route today. The design does not rely on that, because it is a property of the current version rather than a contract.

The fallback, if a non-Token placeable ever needs this

Wrap foundry.canvas.interaction.MouseInteractionManager.prototype.can and .callback (client/canvas/interaction/_module.mjs:3). Both receive the gesture name as an explicit argument (mouse-handler.mjs:293 and :310) and expose the placeable as this.object, so a relaxation stays scoped to one named gesture by construction, and two wrappers cover every gesture on every placeable class.

The trade is that matches then runs on every gesture evaluation on every placeable, including the pointer paths, and a wrapper sits in that path whether or not anything is registered. That is the right shape for a registry serving several placeable types and the wrong one for a single consumer that needs tokens.

Two alternatives were considered and rejected. A CONFIG.Token.objectClass subclass avoids monkeypatching but occupies a single slot any module may also claim, and a module that replaces rather than extends it wins silently. Making BlacksmithLayer interactive to catch the click above the token layer means an event-capturing layer over the whole canvas plus manual hit-testing, which risks stealing gestures from everything else.