Skip to content

Commit dfd6b26

Browse files
NocTempreclaude
andcommitted
Depend on libWrapper, socketlib, simple-timekeeping instead of hand-rolled infra
Per the module-library direction: use vetted community libraries as prerequisites rather than recreating core infrastructure, and prefer editing this module over injecting another layer. - module.json hard-requires lib-wrapper, socketlib, and simple-timekeeping. - measure-fuzz.mjs wraps the ruler classes' _getWaypointLabelContext via libWrapper (WRAPPER on CONFIG.*.rulerClass paths) so it composes with other ruler modules instead of subclass-clobbering. - New socket.mjs centralizes socketlib registration with an import-time queue; map-items fog reload uses executeForEveryone, player-requests uses executeAsGM. Removes the hand-rolled game.socket channel and manual active-GM routing. - simple-timekeeping owns the world clock and scene-darkness sync; dungeon turns already feed it through game.time.advance, and the module only reads scene darkness (never writes it), so they never conflict. Documented in DESIGN.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent dcaab84 commit dfd6b26

6 files changed

Lines changed: 116 additions & 51 deletions

File tree

docs/DESIGN.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22

33
Follows the conventions of `acks-influence` (ApplicationV2 + HandlebarsApplicationMixin, Foundry v13/v14, `acks` system).
44

5+
## Required libraries
6+
7+
Rather than recreate core infrastructure, the module hard-requires three community libraries (`relationships.requires` in `module.json`):
8+
9+
- **libWrapper** — wraps the ruler classes' `_getWaypointLabelContext` (`measure-fuzz.mjs`) so it composes with other ruler modules instead of clobbering the class.
10+
- **socketlib** — all cross-client messaging (`socket.mjs`): named handlers with GM routing for player action requests and fog reloads.
11+
- **simple-timekeeping** — owns the world clock, calendar, and scene-darkness sync. Dungeon turns feed it through the standard `game.time.advance` contract; the module only *reads* scene darkness (`isPartyInDark`), never writes it, so the two never fight over darkness.
12+
513
## Files
614

715
| File | Responsibility |
@@ -12,6 +20,7 @@ Follows the conventions of `acks-influence` (ApplicationV2 + HandlebarsApplicati
1220
| `scripts/formation-app.mjs` | The formation window (GM controls, player read-only). |
1321
| `scripts/encounter-zone.mjs` | `acks-formation.encounterZone` RegionBehavior subtype (table UUID + cadence overrides) and point-in-region lookup for the party token (core `testPoint` when available, manual shape math as a headless fallback). |
1422
| `scripts/scene-sync.mjs` | Mapper-gated fog (`scene.fog.exploration`, original value stashed in a scene flag) and party-token light emission mirroring lit sources. Reconciled by the primary GM after every formation change (idempotent, compare-before-write). |
23+
| `scripts/socket.mjs` | socketlib registration (`socketlib.ready`) and a queue so handlers can register at import time; exposes `getSocket`/`registerHandler`. |
1524
| `scripts/module.mjs` | Settings, hooks, scene-control button, `/formation` chat command, public API. |
1625

1726
## Data flow

module.json

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"id": "acks-formation",
33
"title": "ACKS II — Exploration Formations",
44
"description": "Condenses the party into exploration formations for cleaner dungeon delving. Members join a formation and are represented on the canvas by a single party token that moves at exploration speed. Movement automatically consumes dungeon turns (10 minutes), driving light-source burn, rest requirements, wandering-monster checks, spell-duration expiry, and ration tracking per the ACKS II dungeon delve sequence of play.",
5-
"version": "0.15.0",
5+
"version": "0.16.0",
66
"socket": true,
77
"compatibility": {
88
"minimum": "14",
@@ -76,6 +76,32 @@
7676
"minimum": "13"
7777
}
7878
}
79+
],
80+
"requires": [
81+
{
82+
"id": "lib-wrapper",
83+
"type": "module",
84+
"reason": "Wraps ruler measurement labels safely alongside other ruler modules.",
85+
"compatibility": {
86+
"minimum": "1.12.0"
87+
}
88+
},
89+
{
90+
"id": "socketlib",
91+
"type": "module",
92+
"reason": "Routes player action requests and fog-reload messages between clients.",
93+
"compatibility": {
94+
"minimum": "1.1.0"
95+
}
96+
},
97+
{
98+
"id": "simple-timekeeping",
99+
"type": "module",
100+
"reason": "Shared world clock, calendar, and scene-darkness synchronisation for dungeon turns.",
101+
"compatibility": {
102+
"minimum": "2.0.0"
103+
}
104+
}
79105
]
80106
},
81107
"url": "https://github.com/NocTempre/acks-formation",

scripts/map-items.mjs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
mapperIsProficient,
88
updateFormation,
99
} from "./formation-model.mjs";
10+
import { getSocket, registerHandler } from "./socket.mjs";
1011

1112
/**
1213
* Maps as objects (FOG-DESIGN.md Phases 1–2).
@@ -34,7 +35,6 @@ import {
3435
*/
3536

3637
export const MAP_FLAG = "map";
37-
export const SOCKET_NAME = `module.${MODULE_ID}`;
3838

3939
function loc(key, data = {}) {
4040
return game.i18n.format(`ACKS-FORMATION.${key}`, data);
@@ -360,8 +360,11 @@ export async function anchorMap(formation, itemUuid) {
360360
}
361361

362362
await item.setFlag(MODULE_ID, MAP_FLAG, { ...map, anchored: true });
363-
game.socket.emit(SOCKET_NAME, { action: "reloadFog", sceneId });
364-
await reloadFog(sceneId);
363+
// Reload fog on every client (executeForEveryone runs here too, so the
364+
// guard inside reloadFog handles clients not viewing this scene).
365+
const socket = getSocket();
366+
if (socket) await socket.executeForEveryone("reloadFog", sceneId);
367+
else await reloadFog(sceneId);
365368
await announce(formation, loc("map.anchored", { name: item.name }));
366369
if (map.quality === "distorted") {
367370
await announce(formation, loc("map.anchoredDistorted"), { whisper: true });
@@ -417,10 +420,7 @@ async function reloadFog(sceneId) {
417420
canvas.perception.initialize();
418421
}
419422

423+
/** Register the fog-reload handler with socketlib. */
420424
export function registerMapSocket() {
421-
game.socket.on(SOCKET_NAME, (data) => {
422-
if (data?.action === "reloadFog") {
423-
reloadFog(data.sceneId).catch((err) => console.error(`${MODULE_ID} | fog reload failed`, err));
424-
}
425-
});
425+
registerHandler("reloadFog", reloadFog);
426426
}

scripts/measure-fuzz.mjs

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
/* global game, canvas, CONFIG, Hooks */
1+
/* global game, canvas, CONFIG, libWrapper */
22
import { MODULE_ID } from "./constants.mjs";
33

44
/**
@@ -12,9 +12,9 @@ import { MODULE_ID } from "./constants.mjs";
1212
* - mode "unknown" (no working mapper): distances show as "?".
1313
*
1414
* GMs always see true values. Both the ruler tool (CONFIG.Canvas.rulerClass)
15-
* and token drag measurement (CONFIG.Token.rulerClass) are wrapped by
16-
* subclassing whatever class is configured at setup and overriding the
17-
* documented protected `_getWaypointLabelContext`.
15+
* and token drag measurement (CONFIG.Token.rulerClass) have their documented
16+
* protected `_getWaypointLabelContext` wrapped via **libWrapper** (WRAPPER
17+
* type), so other ruler modules that touch the same method still compose.
1818
*
1919
* Honest limits: the canvas geometry itself stays exact — players can still
2020
* count grid squares. This is a deterrent and a mood-setter.
@@ -77,23 +77,26 @@ function applyFuzz(context, waypoint) {
7777
}
7878

7979
/**
80-
* Wrap the configured ruler classes. Called once at "setup" so other modules'
81-
* init-time replacements are respected (we extend whatever is configured).
80+
* Wrap the configured ruler classes' label builder. Called once at "setup" so
81+
* other modules' init-time class replacements are already in place — libWrapper
82+
* targets the live prototype method, letting every wrapper in the chain run.
8283
*/
8384
export function registerFuzzyRulers() {
84-
const RulerBase = CONFIG.Canvas.rulerClass;
85-
CONFIG.Canvas.rulerClass = class FuzzyRuler extends RulerBase {
86-
/** @override */
87-
_getWaypointLabelContext(waypoint, state) {
88-
return applyFuzz(super._getWaypointLabelContext(waypoint, state), waypoint);
89-
}
85+
const wrap = function (wrapped, waypoint, state) {
86+
return applyFuzz(wrapped(waypoint, state), waypoint);
9087
};
91-
92-
const TokenRulerBase = CONFIG.Token.rulerClass;
93-
CONFIG.Token.rulerClass = class FuzzyTokenRuler extends TokenRulerBase {
94-
/** @override */
95-
_getWaypointLabelContext(waypoint, state) {
96-
return applyFuzz(super._getWaypointLabelContext(waypoint, state), waypoint);
88+
// CONFIG paths are resolved by libWrapper from global scope, so we wrap
89+
// whatever ruler class is live at setup without needing its name to be a
90+
// global (v14 ruler classes live under foundry.canvas.*).
91+
const targets = [
92+
"CONFIG.Canvas.rulerClass.prototype._getWaypointLabelContext",
93+
"CONFIG.Token.rulerClass.prototype._getWaypointLabelContext",
94+
];
95+
for (const target of targets) {
96+
try {
97+
libWrapper.register(MODULE_ID, target, wrap, "WRAPPER");
98+
} catch (err) {
99+
console.error(`${MODULE_ID} | could not wrap ${target}`, err);
97100
}
98-
};
101+
}
99102
}

scripts/player-requests.mjs

Lines changed: 16 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
/* global game, foundry, ChatMessage, ui */
2-
import { MODULE_ID } from "./constants.mjs";
32
import { getFormation } from "./formation-model.mjs";
4-
import { SOCKET_NAME } from "./map-items.mjs";
3+
import { getSocket, registerHandler } from "./socket.mjs";
54
import { rollPartyCheck } from "./party-rolls.mjs";
65
import { addLight, addSpell, advanceTurns } from "./turn-engine.mjs";
76

@@ -18,7 +17,7 @@ import { addLight, addSpell, advanceTurns } from "./turn-engine.mjs";
1817
* what; check results still go to the GM per the usual secrecy rules.
1918
*/
2019

21-
const REQUEST_ACTION = "partyRequest";
20+
const REQUEST_HANDLER = "partyRequest";
2221

2322
function loc(key, data = {}) {
2423
return game.i18n.format(`ACKS-FORMATION.${key}`, data);
@@ -81,9 +80,17 @@ async function executeRequest(formation, user, type, payload) {
8180
}
8281
}
8382

83+
/** socketlib handler: runs on the active GM with the declaring user's id. */
84+
async function handlePartyRequest(formationId, type, payload, userId) {
85+
const formation = getFormation(formationId);
86+
const user = game.users.get(userId);
87+
if (!formation || !user) return;
88+
await executeRequest(formation, user, type, payload ?? {});
89+
}
90+
8491
/**
8592
* Declare a party action. GMs execute directly; players relay to the active
86-
* GM via the module socket.
93+
* GM via socketlib (executeAsGM routes to exactly one GM client).
8794
*/
8895
export async function requestPartyAction(formationId, type, payload = {}) {
8996
const formation = getFormation(formationId);
@@ -92,30 +99,16 @@ export async function requestPartyAction(formationId, type, payload = {}) {
9299
await executeRequest(formation, game.user, type, payload);
93100
return;
94101
}
95-
if (!game.users.activeGM) {
102+
const socket = getSocket();
103+
if (!socket || !game.users.activeGM) {
96104
ui.notifications.warn(game.i18n.localize("ACKS-FORMATION.request.noGM"));
97105
return;
98106
}
99-
game.socket.emit(SOCKET_NAME, {
100-
action: REQUEST_ACTION,
101-
formationId,
102-
type,
103-
payload,
104-
userId: game.user.id,
105-
});
107+
await socket.executeAsGM(REQUEST_HANDLER, formationId, type, payload, game.user.id);
106108
ui.notifications.info(game.i18n.localize("ACKS-FORMATION.request.sent"));
107109
}
108110

109-
/** GM-side socket listener (registered on every client; guards internally). */
111+
/** Register the GM-side request handler with socketlib. */
110112
export function registerRequestSocket() {
111-
game.socket.on(SOCKET_NAME, (data) => {
112-
if (data?.action !== REQUEST_ACTION) return;
113-
if (!game.users.activeGM?.isSelf) return;
114-
const formation = getFormation(data.formationId);
115-
const user = game.users.get(data.userId);
116-
if (!formation || !user) return;
117-
executeRequest(formation, user, data.type, data.payload ?? {}).catch((err) =>
118-
console.error(`${MODULE_ID} | player request failed`, err),
119-
);
120-
});
113+
registerHandler(REQUEST_HANDLER, handlePartyRequest);
121114
}

scripts/socket.mjs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/* global Hooks, socketlib */
2+
import { MODULE_ID } from "./constants.mjs";
3+
4+
/**
5+
* All cross-client messaging goes through **socketlib**: named handlers with
6+
* GM routing, instead of a hand-rolled `game.socket` channel. Handlers are
7+
* registered on the `socketlib.ready` hook (fires after init, before ready);
8+
* the two consumers register their own functions via `onSocketReady`.
9+
*/
10+
11+
let socket = null;
12+
const pending = [];
13+
14+
/** The socketlib socket for this module, or null before socketlib.ready. */
15+
export function getSocket() {
16+
return socket;
17+
}
18+
19+
/**
20+
* Register a socket handler function. Safe to call at import time: if the
21+
* socket is not up yet the registration is queued until `socketlib.ready`.
22+
* @param {string} name Unique handler name.
23+
* @param {Function} fn Handler; its return value is delivered to callers.
24+
*/
25+
export function registerHandler(name, fn) {
26+
if (socket) socket.register(name, fn);
27+
else pending.push([name, fn]);
28+
}
29+
30+
Hooks.once("socketlib.ready", () => {
31+
socket = socketlib.registerModule(MODULE_ID);
32+
for (const [name, fn] of pending) socket.register(name, fn);
33+
pending.length = 0;
34+
});

0 commit comments

Comments
 (0)