Compare commits

...

7 commits
v1.0.3 ... main

Author SHA1 Message Date
Eldritch-Oliver
9955966375 Add required package for the linkFoundry to package.json 2025-09-28 00:49:47 -06:00
Eldritch-Oliver
e84e921bec Add scripts and infra required to get Foundry intellisense working 2025-09-28 00:45:48 -06:00
2cb4268400
Merge pull request #42 from Oliver-Akins/feature/chatMessageHook
Update parsing for the roll auto-tracking to provide a more robust privacy detection when roll mode isn't provided
2025-06-22 10:32:32 -06:00
Oliver-Akins
1a8fcf04ab Remove stray log 2025-06-22 10:30:23 -06:00
Oliver-Akins
8905cb05bc Update the message listening to use createChatMessage instead of preCreateChatMessage 2025-06-13 19:28:08 -06:00
Oliver-Akins
8c42f1b240 Add a utility to the API for inferring a chat message's roll mode (and update the docs) 2025-06-13 19:27:39 -06:00
Oliver-Akins
cbc2691a0e Update privacy detection to default to Self if it's not able to be otherwise determined 2025-06-12 22:04:01 -06:00
14 changed files with 203 additions and 54 deletions

2
.env.template Normal file
View file

@ -0,0 +1,2 @@
# The absolute path to the Foundry installation to create symlinks to
FOUNDRY_ROOT=""

2
.gitignore vendored
View file

@ -17,6 +17,8 @@ lerna-debug.log*
node_modules node_modules
/*.dist /*.dist
*.local *.local
.env
/foundry
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*

14
augments.d.ts vendored Normal file
View file

@ -0,0 +1,14 @@
declare global {
class Hooks extends foundry.helpers.Hooks {};
const fromUuid = foundry.utils.fromUuid;
}
interface Actor {
/** The system-specific data */
system: any;
};
interface Item {
/** The system-specific data */
system: any;
};

View file

@ -1,10 +1,22 @@
{ {
"compilerOptions": { "compilerOptions": {
"module": "ES2020", "module": "ES2020",
"target": "ES2020" "target": "ES2020",
"types": [
"./augments.d.ts"
],
"paths": {
"@client/*": ["./foundry/client/*"],
"@common/*": ["./foundry/common/*"],
}
}, },
"exclude": ["node_modules", "**/node_modules/*"], "exclude": ["node_modules", "**/node_modules/*"],
"include": ["module/**/*"], "include": [
"module/**/*",
"foundry/client/client.mjs",
"foundry/client/global.d.mts",
"foundry/common/primitives/global.d.mts"
],
"typeAcquisition": { "typeAcquisition": {
"include": ["joi"] "include": ["joi"]
} }

View file

@ -13,6 +13,7 @@ import { UserFlagDatabase } from "./utils/databases/UserFlag.mjs";
import { barGraphSchema, numberBucketSchema, rowSchema, stringBucketSchema, tableSchema } from "./utils/databases/model.mjs"; import { barGraphSchema, numberBucketSchema, rowSchema, stringBucketSchema, tableSchema } from "./utils/databases/model.mjs";
import { determinePrivacyFromRollMode, filterPrivateRows, PrivacyMode } from "./utils/privacy.mjs"; import { determinePrivacyFromRollMode, filterPrivateRows, PrivacyMode } from "./utils/privacy.mjs";
import { validateBucketConfig, validateValue } from "./utils/buckets.mjs"; import { validateBucketConfig, validateValue } from "./utils/buckets.mjs";
import { inferRollMode } from "./utils/inferRollMode.mjs";
const { deepFreeze } = foundry.utils; const { deepFreeze } = foundry.utils;
@ -25,6 +26,7 @@ export const api = deepFreeze({
}, },
utils: { utils: {
determinePrivacyFromRollMode, determinePrivacyFromRollMode,
inferRollMode,
filterPrivateRows, filterPrivateRows,
validateValue, validateValue,
validateBucketConfig, validateBucketConfig,

View file

@ -0,0 +1,40 @@
import { determinePrivacyFromRollMode } from "../utils/privacy.mjs";
import { inferRollMode } from "../utils/inferRollMode.mjs";
Hooks.on(`createChatMessage`, (message, options, author) => {
const isSelf = author === game.user.id;
const isNew = options.action === `create`;
const hasRolls = message.rolls?.length > 0;
const autoTracking = game.settings.get(__ID__, `autoTrackRolls`);
if (!isSelf || !isNew || !hasRolls || !autoTracking) { return };
/** An object of dice denomination to database rows */
const rows = {};
const privacy = determinePrivacyFromRollMode(options.rollMode ?? inferRollMode(message));
/*
Goes through all of the dice within the message and grabs their result in order
to batch-save them all to the database handler.
*/
for (const roll of message.rolls) {
for (const die of roll.dice) {
const size = die.denomination;
rows[size] ??= [];
for (const result of die.results) {
rows[size].push({ privacy, value: result.result });
};
};
};
// save all the rows, then rerender once we're properly done
for (const denomination in rows) {
CONFIG.stats.db.createRows(
`Dice/${denomination}`,
author,
rows[denomination],
{ rerender: false },
);
};
CONFIG.stats.db.render({ userUpdated: author });
});

View file

@ -1,30 +0,0 @@
import { determinePrivacyFromRollMode } from "../utils/privacy.mjs";
Hooks.on(`preCreateChatMessage`, (_message, context, options, author) => {
const isNew = options.action === `create`;
const hasRolls = context.rolls?.length > 0;
const autoTracking = game.settings.get(__ID__, `autoTrackRolls`);
if (!isNew || !hasRolls || !autoTracking) { return };
/** An object of dice denomination to rows to add */
const rows = {};
const privacy = determinePrivacyFromRollMode(options.rollMode);
for (const roll of context.rolls) {
for (const die of roll.dice) {
const size = die.denomination;
rows[size] ??= [];
for (const result of die.results) {
rows[size].push({ privacy, value: result.result });
};
};
};
for (const denomination in rows) {
CONFIG.stats.db.createRows(
`Dice/${denomination}`,
author,
rows[denomination],
);
};
});

View file

@ -5,7 +5,7 @@ import "./hooks/init.mjs";
import "./hooks/ready.mjs"; import "./hooks/ready.mjs";
// Document Hooks // Document Hooks
import "./hooks/preCreateChatMessage.mjs"; import "./hooks/createChatMessage.mjs";
// Dev Only imports // Dev Only imports
if (import.meta.env.DEV) { if (import.meta.env.DEV) {

View file

@ -0,0 +1,36 @@
/**
* A helper function to try and infer what roll mode was used when creating a
* chat message in case the roll mode was not provided during the createChatMessage
* hook for whatever reason.
*
* **Disclaimer**: This inference is not totally correct. Particularly when inferring
* a GM's message, as it won't be able to distinguish between a self-roll and a
* private GM roll when it's
*
* @param {ChatMessage} message The ChatMessage document to infer from
* @returns The Foundry-specified roll mode
*/
export function inferRollMode(message) {
const whisperCount = message.whisper.length;
if (whisperCount === 0) {
return CONST.DICE_ROLL_MODES.PUBLIC;
};
if (whisperCount === 1 && message.whisper[0] === game.user.id) {
return CONST.DICE_ROLL_MODES.SELF;
};
let allGMs = true;
for (const userID of message.whisper) {
const user = game.users.get(userID);
if (!user) { continue };
allGMs &&= user.isGM;
};
if (!allGMs) {
return CONST.DICE_ROLL_MODES.PUBLIC;
};
return message.blind
? CONST.DICE_ROLL_MODES.BLIND
: CONST.DICE_ROLL_MODES.PRIVATE;
};

View file

@ -11,6 +11,8 @@ export const PrivacyMode = Object.freeze({
export function determinePrivacyFromRollMode(rollMode) { export function determinePrivacyFromRollMode(rollMode) {
switch (rollMode) { switch (rollMode) {
case CONST.DICE_ROLL_MODES.PUBLIC:
return PrivacyMode.PUBLIC;
case CONST.DICE_ROLL_MODES.BLIND: case CONST.DICE_ROLL_MODES.BLIND:
return PrivacyMode.GM; return PrivacyMode.GM;
case CONST.DICE_ROLL_MODES.PRIVATE: case CONST.DICE_ROLL_MODES.PRIVATE:
@ -18,7 +20,7 @@ export function determinePrivacyFromRollMode(rollMode) {
case CONST.DICE_ROLL_MODES.SELF: case CONST.DICE_ROLL_MODES.SELF:
return PrivacyMode.SELF; return PrivacyMode.SELF;
} }
return PrivacyMode.PUBLIC; return PrivacyMode.SELF;
}; };
/** /**

55
package-lock.json generated
View file

@ -13,10 +13,11 @@
"devDependencies": { "devDependencies": {
"@foundryvtt/foundryvtt-cli": "^1.1.0", "@foundryvtt/foundryvtt-cli": "^1.1.0",
"@stylistic/eslint-plugin": "^4.2.0", "@stylistic/eslint-plugin": "^4.2.0",
"dotenv": "^17.2.2",
"eslint": "^9.25.0", "eslint": "^9.25.0",
"glob": "^11.0.1", "glob": "^11.0.1",
"terser": "^5.39.0", "terser": "^5.39.0",
"vite": "^6.3.1" "vite": "^6.3.4"
} }
}, },
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
@ -1680,6 +1681,19 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/dotenv": {
"version": "17.2.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.2.tgz",
"integrity": "sha512-Sf2LSQP+bOlhKWWyhFsn0UsfdK/kCWRv1iuA2gXAwt3dyNabr6QSj00I2V10pidqz69soatm9ZwZvpQMTIOd5Q==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -2031,10 +2045,14 @@
} }
}, },
"node_modules/fdir": { "node_modules/fdir": {
"version": "6.4.3", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.3.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-PMXmW2y1hDDfTSRc9gaXIuCCRpuoz3Kaz8cUelp3smouvfT632ozg2vrT6lJsHKKOF59YLbOGfAWGUcKEfRMQw==", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true, "dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": { "peerDependencies": {
"picomatch": "^3 || ^4" "picomatch": "^3 || ^4"
}, },
@ -2952,10 +2970,11 @@
"dev": true "dev": true
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "4.0.2", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -3374,13 +3393,14 @@
} }
}, },
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.12", "version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.12.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-qkf4trmKSIiMTs/E63cxH+ojC2unam7rJ0WrauAzpT3ECNTxGRMlaXxVbfxMUC/w0LaYk6jQ4y/nGR9uBO3tww==", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"fdir": "^6.4.3", "fdir": "^6.5.0",
"picomatch": "^4.0.2" "picomatch": "^4.0.3"
}, },
"engines": { "engines": {
"node": ">=12.0.0" "node": ">=12.0.0"
@ -3462,17 +3482,18 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "6.3.2", "version": "6.3.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.2.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz",
"integrity": "sha512-ZSvGOXKGceizRQIZSz7TGJ0pS3QLlVY/9hwxVh17W3re67je1RKYzFHivZ/t0tubU78Vkyb9WnHPENSBCzbckg==", "integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.4.3", "fdir": "^6.4.4",
"picomatch": "^4.0.2", "picomatch": "^4.0.2",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"rollup": "^4.34.9", "rollup": "^4.34.9",
"tinyglobby": "^0.2.12" "tinyglobby": "^0.2.13"
}, },
"bin": { "bin": {
"vite": "bin/vite.js" "vite": "bin/vite.js"

View file

@ -13,6 +13,7 @@
"devDependencies": { "devDependencies": {
"@foundryvtt/foundryvtt-cli": "^1.1.0", "@foundryvtt/foundryvtt-cli": "^1.1.0",
"@stylistic/eslint-plugin": "^4.2.0", "@stylistic/eslint-plugin": "^4.2.0",
"dotenv": "^17.2.2",
"eslint": "^9.25.0", "eslint": "^9.25.0",
"glob": "^11.0.1", "glob": "^11.0.1",
"terser": "^5.39.0", "terser": "^5.39.0",

View file

@ -250,7 +250,7 @@
"image": {}, "image": {},
"text": { "text": {
"format": 1, "format": 1,
"content": "<p>The module provides a multitude of utility functions through it's API for usage however desired. This will go over them and describe their purpose.</p><p></p><h2>filterPrivateRows</h2><p>This method is intended to take @UUID[Compendium.stat-tracker.docs.JournalEntry.pBOyeBDuTeowuDOE.JournalEntryPage.S7Z6mZ0JablJVQJu]{rows} provided by the database and filter out any that the user would not be able to see normally. This is usually called by the database adapters so there's unlikely to be any reason to use it externally.</p><p>Available under <code>&lt;api&gt;.utils.filterPrivateRows</code>.</p><p></p><h2>validateValue</h2><p>Available under <code>&lt;api&gt;.utils.validateValue</code>.</p><p></p><h2>validateBucketConfig</h2><p>Available under <code>&lt;api&gt;.utils.validateBucketConfig</code>.</p>" "content": "<p>The module provides a multitude of utility functions through it's API for usage however desired. This will go over them and describe their purpose.</p><p></p><h2>filterPrivateRows</h2><p>This method is intended to take @UUID[Compendium.stat-tracker.docs.JournalEntry.pBOyeBDuTeowuDOE.JournalEntryPage.S7Z6mZ0JablJVQJu]{rows} provided by the database and filter out any that the user would not be able to see normally. This is usually called by the database adapters so there's unlikely to be any reason to use it externally.</p><p>Available under <code>&lt;api&gt;.utils.filterPrivateRows</code>.</p><p></p><h2>inferRollMode</h2><p>This utility is intended to try and determine what roll mode was used to create a chat message. The inference is not entirely accurate because it struggles to differentiate between a GM rolling with a Private GM Roll and a Self Roll when there is only one GM present in the world.</p><p>Available under <code>&lt;api&gt;.utils.inferRollMode</code></p><p></p><h2>validateValue</h2><p>Available under <code>&lt;api&gt;.utils.validateValue</code>.</p><p></p><h2>validateBucketConfig</h2><p>Available under <code>&lt;api&gt;.utils.validateBucketConfig</code>.</p>"
}, },
"video": { "video": {
"controls": true, "controls": true,
@ -266,11 +266,11 @@
"compendiumSource": null, "compendiumSource": null,
"duplicateSource": null, "duplicateSource": null,
"exportSource": null, "exportSource": null,
"coreVersion": "13.344", "coreVersion": "13.345",
"systemId": "empty-system", "systemId": "empty-system",
"systemVersion": "0.0.0", "systemVersion": "0.0.0",
"createdTime": 1748330904988, "createdTime": 1748330904988,
"modifiedTime": 1748394635911, "modifiedTime": 1749864406851,
"lastModifiedBy": "t2sWGWEYSMFrfBu3" "lastModifiedBy": "t2sWGWEYSMFrfBu3"
}, },
"_key": "!journal.pages!pBOyeBDuTeowuDOE.TQzWrVTEz4oQhLPD" "_key": "!journal.pages!pBOyeBDuTeowuDOE.TQzWrVTEz4oQhLPD"

47
scripts/linkFoundry.mjs Normal file
View file

@ -0,0 +1,47 @@
import { existsSync } from "fs";
import { symlink, unlink } from "fs/promises";
import { join } from "path";
import { config } from "dotenv";
config({ quiet: true });
const root = process.env.FOUNDRY_ROOT;
// Early exit
if (!root) {
console.error(`Must provide a FOUNDRY_ROOT environment variable`);
process.exit(1);
};
// Assert Foundry exists
if (!existsSync(root)) {
console.error(`Foundry root not found.`);
process.exit(1);
};
// Removing existing symlink
if (existsSync(`foundry`)) {
console.log(`Attempting to unlink foundry instance`);
try {
await unlink(`foundry`);
} catch {
console.error(`Failed to unlink foundry folder.`);
process.exit(1);
};
};
// Account for if the root is pointing at an Electron install
let targetRoot = root;
if (existsSync(join(root, `resources`, `app`))) {
console.log(`Switching to use the "${root}/resources/app" directory`);
targetRoot = join(root, `resources`, `app`);
};
// Create symlink
console.log(`Linking foundry source into folder`)
try {
await symlink(targetRoot, `foundry`);
} catch (e) {
console.error(e);
process.exit(1);
};