Compare commits

..

47 commits
v1.0.0 ... 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
Oliver-Akins
a72c33b901 Rename files to better indicate that they're tests 2025-06-12 19:27:20 -06:00
Oliver-Akins
e4f37d56a6 Update the pre-filled draft release template to include the changes header and a download count badge 2025-06-11 01:01:14 -06:00
Oliver-Akins
d8121dbfaa Get GitHub to put both badges on the same row 2025-06-11 00:56:29 -06:00
Oliver-Akins
612f52bf51 Use URL-encoded URL for the badge 2025-06-11 00:52:56 -06:00
Oliver-Akins
7859d23d50 Add a couple of version badges because I want to 2025-06-11 00:51:14 -06:00
34975156c1
Merge pull request #37 from Oliver-Akins/feature/manifest-description
Add a description to the module manifest
2025-06-04 21:33:05 -06:00
Oliver-Akins
a773ce4688 Add description to manifest 2025-06-04 21:31:36 -06:00
c27c47661c
Merge pull request #36 from Oliver-Akins/fix/double-locked-buckets
Prevent the double-locked bucket configuration
2025-06-04 21:25:15 -06:00
c90ee7a6d3
Merge pull request #35 from Oliver-Akins/fix/bucket-type-list
Prevent erroneous range bucket types from existing
2025-06-04 21:21:20 -06:00
Oliver-Akins
00d63d42d1 Remove stray logs 2025-06-04 21:20:54 -06:00
Oliver-Akins
4b11d12f81 Undo code that got commented out 2025-06-04 21:18:10 -06:00
Oliver-Akins
4354a25866 Prevent the double-locked bucket configuration from being possible 2025-06-02 23:10:22 -06:00
Oliver-Akins
1423bf097a Version bump 2025-06-02 23:06:30 -06:00
Oliver-Akins
8f993adb47 Finish removing the Range bucket type that was accidentally still able to be created 2025-06-02 19:26:43 -06:00
Oliver-Akins
3fc8b654c7 Add images for the Foundry package listing 2025-06-01 16:08:34 -06:00
Oliver-Akins
ab3281b288 Update README 2025-06-01 15:47:39 -06:00
Oliver-Akins
cad04690ff Update the release creation to provide a direct manifest url in the description of the release 2025-06-01 15:00:23 -06:00
Oliver-Akins
8e83925abe Have the UserFlagDatabase validate the row's value according to the bucket schema during creation / updating 2025-06-01 14:59:06 -06:00
Oliver-Akins
fc3b041464 Expose the determinePrivacyFromRollMode within the API 2025-06-01 14:23:21 -06:00
Oliver-Akins
3d5e28189a Version bump 2025-06-01 14:23:18 -06:00
Oliver-Akins
bd4c32f65a Update documentation (closes #30) 2025-06-01 13:03:31 -06:00
Oliver-Akins
6ef20e1ec1 Remove action that I haven't implemented yet 2025-06-01 13:03:13 -06:00
Oliver-Akins
21b9cf5b2d Remove logs that aren't helpful for prod 2025-06-01 13:03:03 -06:00
Oliver-Akins
c6161dd312 Prevent errors when the flag is undefined on the user 2025-06-01 12:26:40 -06:00
Oliver-Akins
965cb26b51 Update the tests import not to be bundled for production 2025-06-01 11:25:10 -06:00
Oliver-Akins
c26b4318ee Finish writing the schema tests 2025-06-01 11:24:20 -06:00
Oliver-Akins
946a44edae Add missing import into the extraction script 2025-05-31 23:17:38 -06:00
Oliver-Akins
ac93a3342f Begin work on purging the range bucket type from the codebase 2025-05-31 23:16:13 -06:00
Oliver-Akins
5fe11fda0d Update db schemas 2025-05-31 23:15:42 -06:00
Oliver-Akins
22036c419d Begin writing tests 2025-05-31 23:15:24 -06:00
Oliver-Akins
d49998801f Remove TODO since it is handled by the watcher plugin 2025-05-31 17:28:23 -06:00
Oliver-Akins
60b01c55e1 Fix undefined reference error when updating a table (closes #27) 2025-05-31 15:33:40 -06:00
Oliver-Akins
d11262ad01 Make the bucket validator throw an error rather than returning a weird value 2025-05-31 15:32:22 -06:00
Oliver-Akins
af2dac394f Update the throw to not include the stack trace 2025-05-31 13:26:09 -06:00
Oliver-Akins
de69fdec0f Update vite to make dependabot happier 2025-05-31 11:04:43 -06:00
Oliver-Akins
d11b270019 Throw a more clear error when the compendia build fails 2025-05-31 11:03:55 -06:00
Oliver-Akins
0b89b0e54e Ensure that the manager doesn't error while prepping string buckets without a pre-existing choices config (closes #26) 2025-05-31 10:25:20 -06:00
Oliver-Akins
2c733385ef Close the TableCreator and dice namespace warning when the table is made successfully (closes #25) 2025-05-31 10:17:20 -06:00
Oliver-Akins
c7379a48f4 Increment version number 2025-05-31 10:05:08 -06:00
Oliver-Akins
79780e885b Only copy the licence and readme on production builds 2025-05-31 10:05:00 -06:00
42 changed files with 720 additions and 226 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=""

View file

@ -50,5 +50,13 @@ jobs:
tag: "v${{ steps.version.outputs.version }}" tag: "v${{ steps.version.outputs.version }}"
commit: ${{ github.ref }} commit: ${{ github.ref }}
draft: true draft: true
body: >
| <img aria-hidden="true" src="https://img.shields.io/github/downloads/Oliver-Akins/Foundry-Stat-Tracker/v${{ steps.version.outputs.version }}/release.zip?style=flat-square&color=%2300aa00">
|
| ### Changes:
| -
|
| This version can be installed using this manifest URL: https://github.com/Oliver-Akins/Foundry-Stat-Tracker/releases/download/v${{ steps.version.outputs.version }}/module.json
generateReleaseNotes: true generateReleaseNotes: true
artifacts: "prod.dist/release.zip,prod.dist/module.json" artifacts: "prod.dist/release.zip,prod.dist/module.json"
artifactsErrorsFailBuild: true

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/*

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View file

@ -1,2 +1,30 @@
# Foundry-Stat-Tracker # Stats Tracker
A Foundry module that allows tracking arbitrary stats. This FoundryVTT module aims to provide a clean way of keeping track of any data
points you want within Foundry, whether that be dice rolls, or other things like
how many natural 1s to natural 20s you get.
I was inspired by the dicestats module, however it only allows tracking dice
statistics, which is something I found myself needing to work around and struggle
against, so I decided to make this module to fill that gap while improving upon
the graph rendering.
For more information on how to use this module, check out the "Documentation"
compendium within your world!
## Installation
<div style="display: flex; gap: 8px;">
<img aria-hidden="true" src="https://img.shields.io/github/v/release/Oliver-Akins/Foundry-Stat-Tracker?sort=semver&style=flat-square&label=Latest%20Stable%20Release&color=%23070">
<img aria-hidden="true" src="https://img.shields.io/github/v/release/Oliver-Akins/Foundry-Stat-Tracker?include_prereleases&sort=semver&style=flat-square&color=%23800&label=Latest%20Prerelease">
</div>
You can find a history of all releases on the [Foundry package listing](https://foundryvtt.com/packages/stat-tracker)
or in the [GitHub releases tab](https://github.com/Oliver-Akins/Foundry-Stat-Tracker/releases).
Prereleases will only be released to the GitHub page, so if you want to check
out those releases, you'll need to use those manifest links directly.
## Bugs or Feature Requests
Bugs and Feature Requests can be submitted via the [GitHub issues](https://github.com/Oliver-Akins/Foundry-Stat-Tracker/issues/new/choose).
Planned features can also be seen in the GitHub issues list.
## Contribution
Contribution guidelines / requirements coming soon.

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

@ -21,10 +21,10 @@ export class StatsViewer extends HandlebarsApplicationMixin(ApplicationV2) {
resizable: true, resizable: true,
minimizable: true, minimizable: true,
controls: [ controls: [
{ // {
label: `Add All Users To Graph`, // label: `Add All Users To Graph`,
action: `addAllUsers`, // action: `addAllUsers`,
}, // },
], ],
}, },
position: { position: {

View file

@ -75,7 +75,7 @@ export class TableCreator extends HandlebarsApplicationMixin(ApplicationV2) {
if (this._name.startsWith(`Dice`)) { if (this._name.startsWith(`Dice`)) {
ctx.createButtonDisabled = !this._name.match(diceNamespacePattern); ctx.createButtonDisabled = !this._name.match(diceNamespacePattern);
ctx.typeDisabled = true; ctx.typeDisabled = true;
ctx.type = BucketTypes.RANGE; ctx.type = BucketTypes.NUMBER;
this.#diceNamespaceAlert ??= ui.notifications.info( this.#diceNamespaceAlert ??= ui.notifications.info(
`Tables in the "Dice" namespace must be formatted as "Dice/dX" where X is the number of sides on the die and are restricted to be ranges 1 to X.`, `Tables in the "Dice" namespace must be formatted as "Dice/dX" where X is the number of sides on the die and are restricted to be ranges 1 to X.`,
{ permanent: true }, { permanent: true },
@ -104,11 +104,11 @@ export class TableCreator extends HandlebarsApplicationMixin(ApplicationV2) {
return; return;
}; };
Logger.log(`updating ${binding} value to ${target.value}`);
this[binding] = target.value; this[binding] = target.value;
this.render(); this.render();
}; };
/** @this {TableCreator} */
static async #createTable() { static async #createTable() {
/** @type {string} */ /** @type {string} */
const name = this._name; const name = this._name;
@ -122,25 +122,38 @@ export class TableCreator extends HandlebarsApplicationMixin(ApplicationV2) {
return; return;
}; };
let created = false;
if (name.startsWith(`Dice`)) { if (name.startsWith(`Dice`)) {
if (!name.match(diceNamespacePattern)) { if (!name.match(diceNamespacePattern)) {
ui.notifications.error(`Table name doesn't conform to the "Dice/dX" format required by the Dice namespace.`); ui.notifications.error(`Table name doesn't conform to the "Dice/dX" format required by the Dice namespace.`);
return; return;
}; };
const size = Number(name.replace(`Dice/d`, ``)); const size = Number(name.replace(`Dice/d`, ``));
await CONFIG.stats.db.createTable(createDiceTable(size)); created = await CONFIG.stats.db.createTable(createDiceTable(size));
return; if (created) {
}; this.close();
ui.notifications.remove(this.#diceNamespaceAlert);
this.#diceNamespaceAlert = null;
};
} else {
created = await CONFIG.stats.db.createTable({
name,
buckets: {
type: this._type,
},
graph: {
type: `bar`,
stacked: true,
},
});
}
await CONFIG.stats.db.createTable({ if (created) {
name, this.close();
buckets: { if (this.#diceNamespaceAlert) {
type: this._type, ui.notifications.remove(this.#diceNamespaceAlert);
}, this.#diceNamespaceAlert = null;
graph: { };
type: `bar`, };
stacked: true,
},
});
}; };
}; };

View file

@ -210,7 +210,7 @@ export class TableManager extends HandlebarsApplicationMixin(ApplicationV2) {
}; };
async _prepareStringContext(ctx, table) { async _prepareStringContext(ctx, table) {
ctx.buckets.choices = [...table.buckets.choices]; ctx.buckets.choices = [...(table.buckets.choices ?? [])];
}; };
// #endregion Data Prep // #endregion Data Prep
@ -253,7 +253,6 @@ export class TableManager extends HandlebarsApplicationMixin(ApplicationV2) {
*/ */
static async #deleteTable() { static async #deleteTable() {
const table = await CONFIG.stats.db.getTable(this.activeTableID); const table = await CONFIG.stats.db.getTable(this.activeTableID);
Logger.debug({ table });
if (!table) { if (!table) {
ui.notifications.error( ui.notifications.error(
`You must select a table before you can delete it`, `You must select a table before you can delete it`,

View file

@ -0,0 +1,5 @@
## Testing
The stat-tracker module utilizes [quench](https://foundryvtt.com/packages/quench)
for it's end-to-end tests and unit tests, enabling us to be sure that the module
is as stable as possible and detect when there are breaking changes.

View file

@ -0,0 +1,13 @@
import { barGraphTests } from "./schemas/barGraph.test.mjs";
import { numberBucketTests } from "./schemas/numberBucket.test.mjs";
import { rowTests } from "./schemas/row.test.mjs";
import { stringBucketTests } from "./schemas/stringBucket.test.mjs";
import { tableTests } from "./schemas/table.test.mjs";
Hooks.on(`quenchReady`, (quench) => {
numberBucketTests(quench);
stringBucketTests(quench);
barGraphTests(quench);
tableTests(quench);
rowTests(quench);
});

View file

@ -0,0 +1,51 @@
import { api } from "../../api.mjs";
export function barGraphTests(quench) {
quench.registerBatch(
`${__ID__}.barGraphSchema`,
(ctx) => {
const { describe, it, expect } = ctx;
describe(`the bar graph schema`, () => {
it(`should default any additional properties left out`, () => {
const { value, error } = api.schemas.graphs.bar.validate(
{ type: `bar` },
);
expect(value).to.have.keys(`type`, `stacked`, `showEmptyBuckets`);
expect(error).to.be.undefined;
});
it(`should allow stacked to be provided specifically`, () => {
const { value, error } = api.schemas.graphs.bar.validate(
{ type: `bar`, stacked: true },
);
expect(value).to.have.keys(`type`, `stacked`, `showEmptyBuckets`);
expect(error).to.be.undefined;
});
it(`should allow showEmptyBuckets to be provided specifically`, () => {
const { value, error } = api.schemas.graphs.bar.validate(
{ type: `bar`, showEmptyBuckets: true },
);
expect(value).to.have.keys(`type`, `stacked`, `showEmptyBuckets`);
expect(error).to.be.undefined;
});
it(`should only allow showEmptyBuckets to be a boolean`, () => {
const { value, error } = api.schemas.graphs.bar.validate(
{ type: `bar`, showEmptyBuckets: `a potato` },
);
expect(value).to.have.keys(`type`, `stacked`, `showEmptyBuckets`);
expect(error).not.to.be.undefined;
});
it(`should only allow stacked to be a boolean`, () => {
const { error } = api.schemas.graphs.bar.validate(
{ type: `bar`, stacked: `a potato` },
);
expect(error).not.to.be.undefined;
});
});
},
);
};

View file

@ -0,0 +1,61 @@
import { api } from "../../api.mjs";
export function numberBucketTests(quench) {
quench.registerBatch(
`${__ID__}.numberBucketSchema`,
(ctx) => {
const { describe, it, expect } = ctx;
describe(`the number bucket schema`, () => {
it(`should allow all additional properties to be left out`, () => {
const { error } = api.schemas.buckets.number.validate(
{ type: `number` },
);
expect(error).to.be.undefined;
});
it(`should allow the min additional property if only it is provided with the type`, () => {
const { error } = api.schemas.buckets.number.validate(
{ type: `number`, min: 0 },
);
expect(error).to.be.undefined;
});
it(`should allow the max additional property if only it is provided with the type`, () => {
const { error } = api.schemas.buckets.number.validate(
{ type: `number`, max: 10 },
);
expect(error).to.be.undefined;
});
it(`should not allow the step additional property if only it is provided with the type`, () => {
const { error } = api.schemas.buckets.number.validate(
{ type: `number`, step: 1 },
);
expect(error).not.to.be.undefined;
});
it(`should not allow max to be less than min`, () => {
const { error } = api.schemas.buckets.number.validate(
{ type: `number`, min: 10, max: 5 },
);
expect(error).not.to.be.undefined;
});
it(`should not allow max to be less than min`, () => {
const { error } = api.schemas.buckets.number.validate(
{ type: `number`, min: 10, max: 15 },
);
expect(error).to.be.undefined;
});
it(`should allow step when min is also provided`, () => {
const { error } = api.schemas.buckets.number.validate(
{ type: `number`, min: 10, step: 5 },
);
expect(error).to.be.undefined;
});
});
},
);
};

View file

@ -0,0 +1,96 @@
import { api } from "../../api.mjs";
import { PrivacyMode } from "../../utils/privacy.mjs";
export function rowTests(quench) {
quench.registerBatch(
`${__ID__}.rowSchema`,
(ctx) => {
const { describe, it, expect } = ctx;
describe(`the row schema`, () => {
it(`should allow number-based values`, () => {
const { error } = api.schemas.row.validate(
{
_id: `1`,
timestamp: (new Date()).toISOString(),
value: 1,
privacy: PrivacyMode.PUBLIC,
},
);
expect(error).to.be.undefined;
});
it(`should allow string-based values`, () => {
const { error } = api.schemas.row.validate(
{
_id: `1`,
timestamp: (new Date()).toISOString(),
value: `apple`,
privacy: PrivacyMode.PUBLIC,
},
);
expect(error).to.be.undefined;
});
it(`shouldn't allow invalid privacy modes`, () => {
const { error } = api.schemas.row.validate(
{
_id: `1`,
timestamp: (new Date()).toISOString(),
value: 1,
privacy: `yahaha`,
},
);
expect(error).not.to.be.undefined;
});
it(`shouldn't allow invalid value modes`, () => {
const { error } = api.schemas.row.validate(
{
_id: `1`,
timestamp: (new Date()).toISOString(),
value: true,
privacy: PrivacyMode.PUBLIC,
},
);
expect(error).not.to.be.undefined;
});
it(`shouldn't allow non-ISO date formats`, () => {
const { error } = api.schemas.row.validate(
{
_id: `1`,
timestamp: (new Date()).toDateString(),
value: 1,
privacy: PrivacyMode.PUBLIC,
},
);
expect(error).not.to.be.undefined;
});
it(`should require an ID to be present`, () => {
const { error } = api.schemas.row.validate(
{
timestamp: (new Date()).toISOString(),
value: true,
privacy: PrivacyMode.PUBLIC,
},
);
expect(error).not.to.be.undefined;
});
it(`shouldn't allow empty string as a value`, () => {
const { error } = api.schemas.row.validate(
{
_id: `1`,
timestamp: (new Date()).toISOString(),
value: ``,
privacy: PrivacyMode.PUBLIC,
},
);
expect(error).not.to.be.undefined;
});
});
},
);
};

View file

@ -0,0 +1,40 @@
import { api } from "../../api.mjs";
export function stringBucketTests(quench) {
quench.registerBatch(
`${__ID__}.stringBucketSchema`,
(ctx) => {
const { describe, it, expect } = ctx;
describe(`the string bucket schema`, () => {
it(`should allow all additional properties to be left out`, () => {
const { error } = api.schemas.buckets.string.validate(
{ type: `string` },
);
expect(error).to.be.undefined;
});
it(`should allow specific choices to be provided`, () => {
const { error } = api.schemas.buckets.string.validate(
{ type: `string`, choices: [`choice 1`, `choice 2`] },
);
expect(error).to.be.undefined;
});
it(`shouldn't allow specific choices to be empty`, () => {
const { error } = api.schemas.buckets.string.validate(
{ type: `string`, choices: [] },
);
expect(error).not.to.be.undefined;
});
it(`should only allow specific choices to be strings`, () => {
const { error } = api.schemas.buckets.string.validate(
{ type: `string`, choices: [`choice 1`, 5] },
);
expect(error).not.to.be.undefined;
});
});
},
);
};

View file

@ -0,0 +1,43 @@
import { api } from "../../api.mjs";
const graph = { type: `bar` };
const buckets = { type: `string` };
export function tableTests(quench) {
quench.registerBatch(
`${__ID__}.tableSchema`,
(ctx) => {
const { describe, it, expect } = ctx;
describe(`the table schema`, () => {
it(`should require that name be a non-empty string`, () => {
const { error } = api.schemas.table.validate(
{ name: ``, graph, buckets },
);
expect(error).not.to.be.undefined;
});
it(`should require that name only contain alphanumeric characters`, () => {
const { error } = api.schemas.table.validate(
{ name: `:(`, graph, buckets },
);
expect(error).not.to.be.undefined;
});
it(`should allow the name to contain spaces`, () => {
const { error } = api.schemas.table.validate(
{ name: `a name with spaces`, graph, buckets },
);
expect(error).to.be.undefined;
});
it(`should allow a single forward slash for subtables`, () => {
const { error } = api.schemas.table.validate(
{ name: `Table/subtable`, graph, buckets },
);
expect(error).to.be.undefined;
});
});
},
);
};

View file

@ -11,8 +11,9 @@ import { UserFlagDatabase } from "./utils/databases/UserFlag.mjs";
// Utils // Utils
import { barGraphSchema, numberBucketSchema, rowSchema, stringBucketSchema, tableSchema } from "./utils/databases/model.mjs"; import { barGraphSchema, numberBucketSchema, rowSchema, stringBucketSchema, tableSchema } from "./utils/databases/model.mjs";
import { 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;
@ -24,6 +25,8 @@ export const api = deepFreeze({
TableManager, TableManager,
}, },
utils: { utils: {
determinePrivacyFromRollMode,
inferRollMode,
filterPrivateRows, filterPrivateRows,
validateValue, validateValue,
validateBucketConfig, validateBucketConfig,
@ -38,7 +41,6 @@ export const api = deepFreeze({
}, },
schemas: { schemas: {
buckets: { buckets: {
range: numberBucketSchema,
number: numberBucketSchema, number: numberBucketSchema,
string: stringBucketSchema, string: stringBucketSchema,
}, },

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

@ -29,10 +29,10 @@ Hooks.on(`ready`, () => {
); );
// Fire and forget // Fire and forget
CONFIG.stats.db.migrateData(notif) CONFIG.stats.db.migrateData(lastVersion, notif)
.then(() => { .then(() => {
game.settings.set(__ID__, `lastVersion`, __VERSION__); game.settings.set(__ID__, `lastVersion`, __VERSION__);
setTimeout(() => ui.notifications.remove(notif), 500); setTimeout(() => ui.notifications.remove(notif), 5_000);
}); });
} else { } else {
ui.notifications.error( ui.notifications.error(

View file

@ -5,4 +5,9 @@ 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
if (import.meta.env.DEV) {
import(`./__tests__/registration.mjs`);
};

View file

@ -6,7 +6,6 @@ const { StringField, NumberField } = foundry.data.fields;
export const BucketTypes = { export const BucketTypes = {
STRING: `string`, STRING: `string`,
NUMBER: `number`, NUMBER: `number`,
RANGE: `range`,
}; };
/** /**
@ -52,8 +51,7 @@ export function validateBucketConfig(config) {
const validator = validators[conf.type]; const validator = validators[conf.type];
if (validator == null) { if (validator == null) {
Logger.error(`Failed to find type validator for: ${conf.type}`); throw new Error(`Failed to find type validator for: ${conf.type}`);
return false;
}; };
// Disallow function choices if present // Disallow function choices if present
@ -62,7 +60,7 @@ export function validateBucketConfig(config) {
delete conf.choices; delete conf.choices;
}; };
validator.validateConfig(conf); validator.validateConfig?.(conf);
return conf; return conf;
}; };
@ -75,7 +73,7 @@ const validators = {
opts.trim = true; opts.trim = true;
opts.blank = false; opts.blank = false;
}, },
validateConfig: (config) => { transformConfig: (config) => {
if (config.choices.length === 0) { if (config.choices.length === 0) {
delete config.choices; delete config.choices;
config[`-=choices`] = null; config[`-=choices`] = null;
@ -85,35 +83,6 @@ const validators = {
[BucketTypes.NUMBER]: { [BucketTypes.NUMBER]: {
field: NumberField, field: NumberField,
transformOptions: transformNumberFieldOptions, transformOptions: transformNumberFieldOptions,
validateConfig: (config) => {
if (config.step != null && config.min == null) {
delete config.step;
config[`-=step`] = null;
};
if (
config.min != null
&& config.max != null
&& config.min > config.max
) {
throw new Error(`"min" must be less than "max"`);
}
},
},
[BucketTypes.RANGE]: {
field: NumberField,
transformOptions: transformNumberFieldOptions,
validateConfig: (config) => {
if (config.min == null) {
throw new Error(`"min" must be defined for range buckets`);
};
if (config.max == null) {
throw new Error(`"max" must be defined for range buckets`);
};
if (config.min > config.max) {
throw new Error(`"min" must be less than "max"`);
}
config.step ??= 1;
},
}, },
}; };

View file

@ -28,7 +28,7 @@ Default Subtables:
tables that are parents to other tables. tables that are parents to other tables.
*/ */
const { deleteProperty, diffObject, expandObject, mergeObject } = foundry.utils; const { deleteProperty, diffObject, expandObject, isNewerVersion, mergeObject } = foundry.utils;
/** /**
* The generic Database implementation, any subclasses should implement all of * The generic Database implementation, any subclasses should implement all of
@ -131,8 +131,8 @@ export class Database {
return false; return false;
}; };
const table = this.getTable(tableID); const table = await this.getTable(tableID);
if (!tables[tableID]) { if (!table) {
ui.notifications.error(`Cannot update table that doesn't exist`); ui.notifications.error(`Cannot update table that doesn't exist`);
return false; return false;
}; };
@ -144,7 +144,7 @@ export class Database {
const diff = diffObject( const diff = diffObject(
table, table,
expandObject(changes), expandObject(changes),
{ inner: true, deletionKeys: true }, { deletionKeys: true },
); );
if (Object.keys(diff).length === 0) { return false }; if (Object.keys(diff).length === 0) { return false };
@ -275,7 +275,7 @@ export class Database {
* @returns {boolean} * @returns {boolean}
*/ */
static requiresMigrationFrom(lastVersion) { static requiresMigrationFrom(lastVersion) {
return foundry.utils.isNewerVersion(__VERSION__, lastVersion); return isNewerVersion(__VERSION__, lastVersion);
}; };
/** /**
@ -289,7 +289,27 @@ export class Database {
* @param {Notification} notif The progress bar notification used for * @param {Notification} notif The progress bar notification used for
* user feedback while performing migrations. * user feedback while performing migrations.
*/ */
static async migrateData(lastVersion, notif) {}; static async migrateData(lastVersion, notif) {
const totalSteps = 1;
/*
This migration is for going up to 1.0.3, getting rid of any tables that have
a bucket type of range, since those were not supported within the initial
release, but could still accidentally be created by users.
*/
if (isNewerVersion(`1.0.3`, lastVersion)) {
Logger.log(`Migrating up to the v1.0.3 data structure`);
const tables = game.settings.get(__ID__, `tables`);
for (const table of Object.values(tables)) {
if (table.buckets.type !== `range`) { continue };
table.buckets.type = BucketTypes.NUMBER;
table.buckets.showEmptyBuckets = true;
};
await game.settings.set(__ID__, `tables`, tables);
notif.update({ pct: notif.pct + (1 / totalSteps) });
};
};
}; };
/* eslint-enable no-unused-vars */ /* eslint-enable no-unused-vars */

View file

@ -1,7 +1,6 @@
import { filterPrivateRows, PrivacyMode } from "../privacy.mjs"; import { filterPrivateRows, PrivacyMode } from "../privacy.mjs";
import { createDiceTable } from "./utils.mjs"; import { createDiceTable } from "./utils.mjs";
import { Database } from "./Database.mjs"; import { Database } from "./Database.mjs";
import { Logger } from "../Logger.mjs";
import { validateBucketConfig } from "../buckets.mjs"; import { validateBucketConfig } from "../buckets.mjs";
const { deleteProperty, diffObject, expandObject, mergeObject, randomID } = foundry.utils; const { deleteProperty, diffObject, expandObject, mergeObject, randomID } = foundry.utils;
@ -130,7 +129,6 @@ export class MemoryDatabase extends Database {
row._id ||= randomID(); row._id ||= randomID();
row.timestamp = new Date().toISOString(); row.timestamp = new Date().toISOString();
Logger.debug(`Adding row:`, row);
this.#rows[userID][table].push(row); this.#rows[userID][table].push(row);
if (rerender) { if (rerender) {
this.render({ userUpdated: userID }); this.render({ userUpdated: userID });

View file

@ -2,6 +2,7 @@ import { filterPrivateRows, PrivacyMode } from "../privacy.mjs";
import { Database } from "./Database.mjs"; import { Database } from "./Database.mjs";
import { Logger } from "../Logger.mjs"; import { Logger } from "../Logger.mjs";
import { rowSchema } from "./model.mjs"; import { rowSchema } from "./model.mjs";
import { validateValue } from "../buckets.mjs";
const { hasProperty, mergeObject, randomID } = foundry.utils; const { hasProperty, mergeObject, randomID } = foundry.utils;
@ -27,7 +28,7 @@ export class UserFlagDatabase extends Database {
return false; return false;
}; };
const userData = user.getFlag(__ID__, dataFlag); const userData = user.getFlag(__ID__, dataFlag) ?? {};
userData[tableID] ??= []; userData[tableID] ??= [];
userData[tableID].push(corrected); userData[tableID].push(corrected);
await user.setFlag(__ID__, dataFlag, userData); await user.setFlag(__ID__, dataFlag, userData);
@ -46,6 +47,9 @@ export class UserFlagDatabase extends Database {
const userData = user.getFlag(__ID__, dataFlag) ?? {}; const userData = user.getFlag(__ID__, dataFlag) ?? {};
userData[tableID] ??= []; userData[tableID] ??= [];
let valueErrorPosted = false;
let validationErrorPosted = false;
for (const row of rows) { for (const row of rows) {
row._id = randomID(); row._id = randomID();
row.timestamp = new Date().toISOString(); row.timestamp = new Date().toISOString();
@ -55,12 +59,31 @@ export class UserFlagDatabase extends Database {
{ abortEarly: false, convert: true, dateFormat: `iso`, render: false }, { abortEarly: false, convert: true, dateFormat: `iso`, render: false },
); );
if (error) { if (error) {
ui.notifications.error(`A row being created did not conform to required schema, see console for more information.`, { console: false }); if (!validationErrorPosted) {
ui.notifications.error(
`One or more rows being created did not conform to required schema, skipping row and see console for more information.`,
{ console: false },
);
validationErrorPosted = true;
};
Logger.error(`Failing row:`, row); Logger.error(`Failing row:`, row);
Logger.error(error); Logger.error(error);
continue; continue;
}; };
const validValue = validateValue(corrected.value, table.buckets);
if (!validValue) {
if (!valueErrorPosted) {
ui.notifications.warn(
`One or more rows being created did not contain a valid value, skipping row and see console for more information.`,
{ console: false },
);
valueErrorPosted = true;
};
Logger.warn(`Row with invalid value:`, row);
continue;
};
userData[tableID].push(corrected); userData[tableID].push(corrected);
}; };
@ -100,22 +123,36 @@ export class UserFlagDatabase extends Database {
const table = await this.getTable(tableID); const table = await this.getTable(tableID);
if (!table) { if (!table) {
Logger.error(`Cannot find the table with ID "${tableID}"`); Logger.error(`Cannot find the table with ID "${tableID}"`);
return; return false;
}; };
const user = game.users.get(userID); const user = game.users.get(userID);
if (!user) { if (!user) {
Logger.error(`Can't find the user with ID "${tableID}"`); Logger.error(`Can't find the user with ID "${tableID}"`);
return; return false;
}; };
const userData = user.getFlag(__ID__, dataFlag) ?? {}; const userData = user.getFlag(__ID__, dataFlag) ?? {};
let row = userData[tableID]?.find(row => row._id === rowID); let row = userData[tableID]?.find(row => row._id === rowID);
if (!row) { return }; if (!row) { return false };
if (hasProperty(changes, `value`)) {
const validValue = validateValue(changes.value, table.buckets);
if (!validValue) {
ui.notifications.warn(
`One or more rows being created did not contain a valid value, skipping row and see console for more information.`,
{ console: false },
);
Logger.warn(`Row with invalid value:`, row);
return false;
};
};
mergeObject(row, changes); mergeObject(row, changes);
await user.setFlag(__ID__, dataFlag, userData); await user.setFlag(__ID__, dataFlag, userData);
this.render({ userUpdated: userID }); this.render({ userUpdated: userID });
this.triggerListeners(); this.triggerListeners();
return true;
}; };
static async deleteRow(tableID, userID, rowID) { static async deleteRow(tableID, userID, rowID) {
@ -146,7 +183,6 @@ export class UserFlagDatabase extends Database {
if (this.#listener !== null) { return }; if (this.#listener !== null) { return };
this.#listener = Hooks.on(`updateUser`, (doc, diff, options, userID) => { this.#listener = Hooks.on(`updateUser`, (doc, diff, options, userID) => {
Logger.debug({ diff, userID, doc });
// Shortcircuit when on the client that triggered the update // Shortcircuit when on the client that triggered the update
if (userID === game.user.id) { return }; if (userID === game.user.id) { return };
if (!hasProperty(diff, `flags.${__ID__}.${dataFlag}`)) { return }; if (!hasProperty(diff, `flags.${__ID__}.${dataFlag}`)) { return };

View file

@ -3,31 +3,25 @@ import { PrivacyMode } from "../privacy.mjs";
// MARK: Buckets // MARK: Buckets
export const numberBucketSchema = Joi.object({ export const numberBucketSchema = Joi.object({
type: Joi.string().valid(`number`, `range`).required(), type: Joi.string().valid(`number`).required(),
min: Joi min: Joi
.number() .number()
.integer() .integer()
.when(`type`, { .when(`step`, {
is: Joi.string().valid(`range`), is: Joi.exist(),
then: Joi.required(), then: Joi.required(),
otherwise: Joi.optional(),
}), }),
max: Joi max: Joi
.number() .number()
.integer() .integer()
.when(`type`, { .when(`min`, {
is: Joi.string().valid(`range`), is: Joi.exist(),
then: Joi.required(), then: Joi.number().greater(Joi.ref(`min`)),
otherwise: Joi.optional(),
}), }),
step: Joi step: Joi
.number() .number()
.integer() .integer()
.when(`type`, { .min(1),
is: Joi.string().valid(`range`),
then: Joi.required(),
otherwise: Joi.optional(),
}),
}); });
export const stringBucketSchema = Joi.object({ export const stringBucketSchema = Joi.object({
@ -37,16 +31,15 @@ export const stringBucketSchema = Joi.object({
.items( .items(
Joi.string().trim().invalid(``), Joi.string().trim().invalid(``),
) )
.min(1)
.optional(), .optional(),
}); });
// MARK: Graphs // MARK: Graphs
export const barGraphSchema = Joi.object({ export const barGraphSchema = Joi.object({
type: Joi.string().valid(`bar`).required(), type: Joi.string().valid(`bar`).required(),
stacked: Joi stacked: Joi.boolean().optional().default(true),
.boolean() showEmptyBuckets: Joi.boolean().optional().default(false),
.default(true)
.optional(),
}); });
// MARK: Table // MARK: Table
@ -59,18 +52,34 @@ export const tableSchema = Joi.object({
.pattern(/^[0-9a-z \-_]+(\/[0-9a-z \-_]+)?$/i), .pattern(/^[0-9a-z \-_]+(\/[0-9a-z \-_]+)?$/i),
buckets: Joi buckets: Joi
.alternatives() .alternatives()
.try( .conditional(
numberBucketSchema, `/buckets.type`,
stringBucketSchema, {
switch: [
{
is: `number`,
then: numberBucketSchema,
},
{
is: `string`,
then: stringBucketSchema,
},
],
otherwise: Joi.forbidden(),
},
) )
.match(`one`)
.required(), .required(),
graph: Joi graph: Joi
.alternatives() .alternatives()
.try( .conditional(
barGraphSchema, `/graph.type`,
{
switch: [
{ is: `bar`, then: barGraphSchema },
],
otherwise: Joi.forbidden(),
},
) )
.match(`one`)
.required(), .required(),
}); });

View file

@ -2,7 +2,7 @@ export function createDiceTable(size) {
return { return {
name: `Dice/d${size}`, name: `Dice/d${size}`,
buckets: { buckets: {
type: `range`, type: `number`,
min: 1, min: 1,
max: size, max: size,
step: 1, step: 1,
@ -10,6 +10,7 @@ export function createDiceTable(size) {
graph: { graph: {
type: `bar`, type: `bar`,
stacked: true, stacked: true,
showEmptyBuckets: true,
}, },
}; };
}; };

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;
}; };
/** /**
@ -31,7 +33,6 @@ export function determinePrivacyFromRollMode(rollMode) {
* @returns The filtered rows * @returns The filtered rows
*/ */
export function filterPrivateRows(rows, userID, privacies) { export function filterPrivateRows(rows, userID, privacies) {
console.log({rows, userID, privacies});
const filtered = []; const filtered = [];
const isMe = userID === game.user.id; const isMe = userID === game.user.id;

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

@ -7,17 +7,17 @@
"lint:nofix": "eslint", "lint:nofix": "eslint",
"dev": "NODE_ENV=development vite build --mode dev --watch", "dev": "NODE_ENV=development vite build --mode dev --watch",
"dev:once": "NODE_ENV=development vite build --mode dev", "dev:once": "NODE_ENV=development vite build --mode dev",
"staging": "NODE_ENV=staging vite build --mode staging --watch", "staging": "NODE_ENV=staging vite build --mode staging",
"staging:once": "NODE_ENV=staging vite build --mode staging",
"build": "NODE_ENV=production vite build --mode prod" "build": "NODE_ENV=production vite build --mode prod"
}, },
"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"
}, },
"dependencies": { "dependencies": {
"chart.js": "^4.4.9", "chart.js": "^4.4.9",

View file

@ -133,7 +133,7 @@
"image": {}, "image": {},
"text": { "text": {
"format": 1, "format": 1,
"content": "<p>A bucket is the term used to identify a group of allowed values within a @UUID[Compendium.stat-tracker.docs.JournalEntry.pBOyeBDuTeowuDOE.JournalEntryPage.ugzCCxQskUSYMZR4]{table}, each bucket must have a type, and a number of additional settings depending on what type it is.</p><p></p><h2>String Buckets</h2><p>This is the most simple type of bucket, it allows a string to be added as the row's value. The only additional configuration for this type of bucket is restricting what strings can be added be added.</p><p>e.g. you can limit each row to only have a value of <code>\"Critical Success\"</code>, or <code>\"Critical Failure\"</code> and if someone tries to add <code>\"Apple Sauce\"</code> into the table, it will reject that row.</p><p></p><h2>Number Buckets</h2><p>This type of bucket is likely the one you will utilize the most, it allows storing any number. It accepts an set of additional options described below, all of which are optional.</p><table><tbody><tr><td data-colwidth=\"128\"><p>Setting</p></td><td><p>Description</p></td></tr><tr><td data-colwidth=\"128\"><p>Minimum</p></td><td><p>The minimum allowed value</p></td></tr><tr><td data-colwidth=\"128\"><p>Maximum</p></td><td><p>The maximum allowed value, must be greater than Minimum</p></td></tr><tr><td data-colwidth=\"128\"><p>Step</p></td><td><p><strong>Requires Minimum</strong></p><p>When a step is set it requires each number to be a \"step\" away from the lower one. So if you have a minimum of 2 and a step of 4, the allowed values are: 2, 6, 10, 14, 18, etc.</p></td></tr></tbody></table><p></p><h2>Range Buckets</h2><p>The range bucket is what is used by the auto-generated dice tables, these bucket types use the same options as the @UUID[Compendium.stat-tracker.docs.JournalEntry.pBOyeBDuTeowuDOE.JournalEntryPage.e9FYKidbfFnnTspO#number-buckets]{Number Buckets} but the Minimum/Maximum/Step options are required when the type is range.</p>" "content": "<p>A bucket is the term used to identify a group of allowed values within a @UUID[Compendium.stat-tracker.docs.JournalEntry.pBOyeBDuTeowuDOE.JournalEntryPage.ugzCCxQskUSYMZR4]{table}, each bucket must have a type, and a number of additional settings depending on what type it is.</p><p></p><h2>String Buckets</h2><p>This is the most simple type of bucket, it allows a string to be added as the row's value. The only additional configuration for this type of bucket is restricting what strings can be added be added.</p><p>e.g. you can limit each row to only have a value of <code>\"Critical Success\"</code>, or <code>\"Critical Failure\"</code> and if someone tries to add <code>\"Apple Sauce\"</code> into the table, it will reject that row.</p><p></p><h2>Number Buckets</h2><p>This type of bucket is likely the one you will utilize the most, it allows storing any number. It accepts an set of additional options described below, all of which are optional.</p><table><tbody><tr><td data-colwidth=\"128\"><p>Setting</p></td><td><p>Description</p></td></tr><tr><td data-colwidth=\"128\"><p>Minimum</p></td><td><p>The minimum allowed value.</p><p>Required when Step is provided.</p></td></tr><tr><td data-colwidth=\"128\"><p>Maximum</p></td><td><p>The maximum allowed value, must be greater than Minimum</p></td></tr><tr><td data-colwidth=\"128\"><p>Step</p></td><td><p>When a step is set it requires each number to be a \"step\" away from the lower one. So if you have a minimum of 2 and a step of 4, the allowed values are: 2, 6, 10, 14, 18, etc.</p></td></tr></tbody></table>"
}, },
"video": { "video": {
"controls": true, "controls": true,
@ -153,7 +153,7 @@
"systemId": "empty-system", "systemId": "empty-system",
"systemVersion": "0.0.0", "systemVersion": "0.0.0",
"createdTime": 1748329573212, "createdTime": 1748329573212,
"modifiedTime": 1748499573438, "modifiedTime": 1748803873692,
"lastModifiedBy": "t2sWGWEYSMFrfBu3" "lastModifiedBy": "t2sWGWEYSMFrfBu3"
}, },
"_key": "!journal.pages!pBOyeBDuTeowuDOE.e9FYKidbfFnnTspO" "_key": "!journal.pages!pBOyeBDuTeowuDOE.e9FYKidbfFnnTspO"
@ -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"
@ -348,7 +348,7 @@
"systemId": "empty-system", "systemId": "empty-system",
"systemVersion": "0.0.0", "systemVersion": "0.0.0",
"createdTime": 1748393806045, "createdTime": 1748393806045,
"modifiedTime": 1748499640505, "modifiedTime": 1748803890586,
"lastModifiedBy": "t2sWGWEYSMFrfBu3" "lastModifiedBy": "t2sWGWEYSMFrfBu3"
}, },
"_key": "!journal.pages!pBOyeBDuTeowuDOE.IXZpEBEJsvOpY3OL" "_key": "!journal.pages!pBOyeBDuTeowuDOE.IXZpEBEJsvOpY3OL"
@ -406,7 +406,7 @@
"image": {}, "image": {},
"text": { "text": {
"format": 1, "format": 1,
"content": "<p>All of these enums are available within &lt;api&gt;.enums, they are read-only and cannot be modified by other plugins.</p><p></p><h2>Privacy Modes</h2><p>This enum is used by the module to specify all of the privacy levels that it uses.</p><p>The valid values are:</p><ul><li><p><code>GM</code> - Representing that only gamemasters and assistant gamemasters will be able to see this data entry. This mode is similar to the \"Blind GM Roll\" roll mode.</p></li><li><p><code>PRIVATE</code> - Indicating that this is a piece of private data, that only gamemasters, assistant gamemasters, and the user who owns the piece of data will be able to see it.</p></li><li><p><code>SELF</code> - Similar to the \"GM\" level, but instead of gamemasters, it's only the user who owns the piece of data that's able to see it.</p></li><li><p><code>PUBLIC</code> - Everyone can see it.</p></li></ul>" "content": "<p>All of these enums are available within <code>&lt;api&gt;.enums</code>, they are read-only and cannot be modified by other plugins.</p><p></p><h2>Privacy Modes</h2><p>This enum is used by the module to specify all of the privacy levels that it uses.</p><p>The valid values are:</p><ul><li><p><code>GM</code> - Representing that only gamemasters and assistant gamemasters will be able to see this data entry. This mode is similar to the \"Blind GM Roll\" roll mode.</p></li><li><p><code>PRIVATE</code> - Indicating that this is a piece of private data, that only gamemasters, assistant gamemasters, and the user who owns the piece of data will be able to see it.</p></li><li><p><code>SELF</code> - Similar to the \"GM\" level, but instead of gamemasters, it's only the user who owns the piece of data that's able to see it.</p></li><li><p><code>PUBLIC</code> - Everyone can see it.</p></li></ul>"
}, },
"video": { "video": {
"controls": true, "controls": true,
@ -426,7 +426,7 @@
"systemId": "empty-system", "systemId": "empty-system",
"systemVersion": "0.0.0", "systemVersion": "0.0.0",
"createdTime": 1748394110971, "createdTime": 1748394110971,
"modifiedTime": 1748657380240, "modifiedTime": 1748803937108,
"lastModifiedBy": "t2sWGWEYSMFrfBu3" "lastModifiedBy": "t2sWGWEYSMFrfBu3"
}, },
"_key": "!journal.pages!pBOyeBDuTeowuDOE.WYaZPgSRDx8L7Zmq" "_key": "!journal.pages!pBOyeBDuTeowuDOE.WYaZPgSRDx8L7Zmq"

View file

@ -1,7 +1,8 @@
{ {
"id": "stat-tracker", "id": "stat-tracker",
"title": "Stats Tracker", "title": "Stats Tracker",
"version": "1.0.0", "description": "<p>A user-first approach to stat tracking. Designed from the ground up with the intent of being able to track whatever statistics you want.</p>",
"version": "1.0.3",
"compatibility": { "compatibility": {
"maximum": 13, "maximum": 13,
"verified": 13, "verified": 13,

View file

@ -1,13 +1,4 @@
<div <div data-bucket-type="number">
class="{{#if buckets.locked}}alert-box locked{{/if}}"
data-bucket-type="number"
>
{{#if buckets.locked}}
<p class="center">
This bucket configuration has been locked, preventing editing
of the settings.
</p>
{{/if}}
<div class="input-group"> <div class="input-group">
<label for="{{meta.idp}}-min"> <label for="{{meta.idp}}-min">
Minimum Minimum

View file

@ -1,42 +0,0 @@
<div class="input-group">
<label for="{{meta.idp}}-min">
Minimum
</label>
<input
id="{{meta.idp}}-min"
type="number"
name="buckets.min"
value="{{ buckets.min }}"
required
{{disabled buckets.locked}}
>
</div>
<div class="input-group">
<label for="{{meta.idp}}-max">
Maximum
</label>
<input
id="{{meta.idp}}-max"
type="number"
name="buckets.max"
value="{{ buckets.max }}"
required
{{disabled buckets.locked}}
>
</div>
<div class="input-group">
<label for="{{meta.idp}}-step">
Step
</label>
<input
id="{{meta.idp}}-step"
type="number"
name="buckets.step"
value="{{ buckets.step }}"
required
{{disabled buckets.locked}}
>
<p class="hint">
The size of the step between values within the range.
</p>
</div>

View file

@ -1,7 +1,4 @@
<div <div data-bucket-type="string">
class="{{#if buckets.locked}}alert-box locked{{/if}}"
data-bucket-type="string"
>
<div class="input-group"> <div class="input-group">
<label for=""> <label for="">
Valid Options Valid Options

View file

@ -1,6 +1,7 @@
import { readFile } from "fs/promises"; import { readFile } from "fs/promises";
import { join } from "path"; import { join } from "path";
import { extractPack } from "@foundryvtt/foundryvtt-cli"; import { extractPack } from "@foundryvtt/foundryvtt-cli";
import { pathToFileURL } from "url";
export async function extractCompendia() { export async function extractCompendia() {
const manifest = JSON.parse(await readFile(`./public/module.json`, `utf-8`)); const manifest = JSON.parse(await readFile(`./public/module.json`, `utf-8`));

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);
};

View file

@ -68,7 +68,13 @@ function buildPacks() {
return { return {
async writeBundle(options) { async writeBundle(options) {
const buildDir = options.dir; const buildDir = options.dir;
await buildCompendia(); try {
await buildCompendia();
} catch {
const err = new Error(`Compendium building failed, make sure Foundry isn't running`);
err.stack = ``;
throw err;
};
await cp(`${__dirname}/packs`, `${buildDir}/packs`, { recursive: true, force: true }); await cp(`${__dirname}/packs`, `${buildDir}/packs`, { recursive: true, force: true });
for (const file of glob.sync(`${buildDir}/packs/**/_source/`)) { for (const file of glob.sync(`${buildDir}/packs/**/_source/`)) {
await rm(file, { recursive: true, force: true }); await rm(file, { recursive: true, force: true });
@ -98,13 +104,12 @@ export default defineConfig(({ mode }) => {
outMode = `dev`; outMode = `dev`;
}; };
const plugins = [ const plugins = [];
copyFile(`LICENSE`, `LICENSE`),
copyFile(`README.md`, `README.md`),
];
if (isProd) { if (isProd) {
plugins.push( plugins.push(
copyFile(`LICENSE`, `LICENSE`),
copyFile(`README.md`, `README.md`),
buildPacks(), buildPacks(),
); );
} else { } else {
@ -137,7 +142,6 @@ export default defineConfig(({ mode }) => {
rollupOptions: { rollupOptions: {
input: { input: {
module: `./module/main.mjs`, module: `./module/main.mjs`,
// TODO: Figure out how to get handlebars files being used here
}, },
output: { output: {
entryFileNames: `[name].mjs`, entryFileNames: `[name].mjs`,