Compare commits

..

No commits in common. "main" and "v1.1.0" have entirely different histories.
main ... v1.1.0

132 changed files with 4580 additions and 5807 deletions

View file

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

View file

@ -1,96 +0,0 @@
on: [ workflow_dispatch ]
jobs:
create-artifacts:
name: "Create artifacts"
runs-on: act
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install dependencies
run: npm clean-install
- id: version
run: cat system.json | echo version=`jq -r ".version"` >> "$FORGEJO_OUTPUT"
- name: Assert that the tag doesn't exist
run: node scripts/tagExists.mjs
env:
TAG_NAME: "v${{steps.version.outputs.version}}"
# Compendia steps
- name: Build compendia
run: "npm run data:build"
- name: Remove compendia source
run: "rm -rf packs/**/_source"
- name: Compress files
run: zip -r release.zip langs module styles templates README.md assets
- name: Upload artifacts
uses: https://data.forgejo.org/forgejo/upload-artifact@v4
with:
path: |
system.json
release.zip
scripts/*.mjs
package-lock.json
package.json
retention-days: 7
if-no-files-found: error
forgejo-release:
name: "Create Forgejo release"
runs-on: act
needs:
- create-artifacts
if: vars.RELEASE_TO_FORGEJO == 'yes'
steps:
- name: Download artifacts
uses: https://data.forgejo.org/forgejo/download-artifact@v4
with:
merge-multiple: true
- name: Install dependencies
run: npm i
- id: version
run: cat system.json | echo version=`jq -r ".version"` >> "$FORGEJO_OUTPUT"
- name: Update manifest
run: node scripts/prepareManifest.mjs
env:
DOWNLOAD_URL: "${{forgejo.server_url}}/${{forgejo.repository}}/releases/download/v${{steps.version.outputs.version}}/release.zip"
LATEST_URL: "${{forgejo.server_url}}/${{forgejo.repository}}/releases/download/latest/system.json"
- name: Add manifest into release archive
run: zip release.zip --update system.json
- name: Upload archive to s3
run: node scripts/uploadToS3.mjs
env:
TAG: "v${{steps.version.outputs.version}}"
FILE: "release.zip"
S3_BUCKET: "${{vars.S3_BUCKET}}"
S3_REGION: "${{vars.S3_REGION}}"
S3_KEY: "${{secrets.S3_KEY}}"
S3_SECRET: "${{secrets.S3_SECRET}}"
S3_ENDPOINT: "${{vars.S3_ENDPOINT}}"
- name: Upload manifest to s3
run: node scripts/uploadToS3.mjs
env:
TAG: "v${{steps.version.outputs.version}}"
FILE: "system.json"
S3_BUCKET: "${{vars.S3_BUCKET}}"
S3_REGION: "${{vars.S3_REGION}}"
S3_KEY: "${{secrets.S3_KEY}}"
S3_SECRET: "${{secrets.S3_SECRET}}"
S3_ENDPOINT: "${{vars.S3_ENDPOINT}}"
- name: Create draft release
run: node scripts/createForgejoRelease.mjs
env:
TAG: "v${{steps.version.outputs.version}}"
CDN_URL: "${{vars.CDN_URL}}"

View file

@ -1,9 +0,0 @@
on:
release:
types: [published]
jobs:
release-to-foundry:
runs-on: docker
steps:
- name: retrieve release URLS
- name: publish to Foundry

58
.github/workflows/draft-release.yaml vendored Normal file
View file

@ -0,0 +1,58 @@
name: Create Draft Release
on: [workflow_dispatch]
jobs:
everything:
runs-on: ubuntu-latest
steps:
# Checkout the repository
- uses: actions/checkout@v4
# Install node and NPM
- uses: actions/setup-node@v4
with:
node-version: "20"
# Install required packages
- run: npm install
- name: Reading the system.json for the version
id: "version"
run: cat system.json | echo version=`jq -r ".version"` >> "$GITHUB_OUTPUT"
# Check that tag doesn't exist
- uses: mukunku/tag-exists-action@v1.5.0
id: check-tag
with:
tag: "v${{ steps.version.outputs.version }}"
- name: "Ensure that the tag doesn't exist"
if: ${{ steps.check-tag.outputs.exists == 'true' }}
run: exit 1
- name: Ensure there are specific files to release
if: ${{ vars.files_to_release == '' }}
run: exit 1
# Compile the stuff that needs to be compiled
- run: npm run build
- run: node scripts/buildCompendia.mjs
- name: Move system.json to a temp file
id: manifest-move
run: mv system.json system.temp.json
- name: Update the download property in the manifest
id: manifest-update
run: cat system.temp.json | jq -r --tab '.download = "https://github.com/${{ github.repository }}/releases/download/v${{ steps.version.outputs.version }}/${{ vars.zip_name }}.zip"' > system.json
- name: Create the zip
run: zip -r ${{ vars.zip_name || 'release' }}.zip ${{ vars.files_to_release }}
- name: Create the draft release
uses: ncipollo/release-action@v1
with:
tag: "v${{ steps.version.outputs.version }}"
commit: ${{ github.ref }}
draft: true
generateReleaseNotes: true
artifacts: "${{vars.zip_name || 'release'}}.zip,system.json"

4
.gitignore vendored
View file

@ -1,4 +1,2 @@
node_modules/ node_modules/
deprecated .styles
.env
/foundry

Binary file not shown.

Before

Width:  |  Height:  |  Size: 705 KiB

View file

@ -8,9 +8,9 @@
"git.branchProtection": [], "git.branchProtection": [],
"files.exclude": { "files.exclude": {
"*.lock": true, "*.lock": true,
".styles": false,
"node_modules": true, "node_modules": true,
"packs": true, "packs": true,
"foundry": true
}, },
"html.customData": [ "html.customData": [
"./.vscode/components.html-data.json" "./.vscode/components.html-data.json"

View file

@ -2,17 +2,3 @@
This is an intentionally bare-bones system that features a text-only character This is an intentionally bare-bones system that features a text-only character
sheet, allowing the playing of games that may not otherwise have a Foundry system sheet, allowing the playing of games that may not otherwise have a Foundry system
implementation. implementation.
## Unlisted Releases
Some of the versions of Text-Based Actors are not available in the [Releases list](https://git.varify.ca/Foundry/taf/releases),
these versions are installable manually by using the appropriate manifest link
below:
| Version | Manifest URL
| ------- | ------------
| v2.2.1 | https://cdn.varify.ca/Foundry/taf/v2.2.1/system.json
| v2.2.0 | https://cdn.varify.ca/Foundry/taf/v2.2.0/system.json
| v2.1.0 | https://cdn.varify.ca/Foundry/taf/v2.1.0/system.json
| v2.0.0 | https://cdn.varify.ca/Foundry/taf/v2.0.0/system.json
| v1.1.0 | https://cdn.varify.ca/Foundry/taf/v1.1.0/system.json
| v1.0.0 | https://cdn.varify.ca/Foundry/taf/v1.0.0/system.json

View file

@ -1,3 +0,0 @@
<svg version="1.1" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<path d="m91.562 48.438c0.9375-2.8125 1.5625-5.625 1.5625-8.4375 0-13.438-10.938-24.375-24.062-24.375-6.875 0-13.75 3.125-18.125 8.125-3.4375-2.8125-7.8125-4.375-12.5-4.375-11.25 0-20.312 9.0625-20.312 20.312 0 1.25 0 2.5 0.3125 3.75-9.6875 1.5625-16.562 10-16.562 20 0 11.25 9.0625 20.312 20.312 20.312h56.25c11.25 0 20.312-9.0625 20.312-20.312-0.3125-5.3125-2.8125-10.938-7.1875-15zm-28.125 13.125c0.9375 0.9375 0.9375 2.5 0 3.4375-0.3125 0.3125-0.9375 0.625-1.5625 0.625s-1.25-0.3125-1.5625-0.625l-7.8125-7.8125-7.8125 7.8125c-0.3125 0.3125-0.9375 0.625-1.5625 0.625s-1.25-0.3125-1.5625-0.625c-0.9375-0.9375-0.9375-2.5 0-3.4375l7.8125-7.8125-7.8125-7.8125c-0.9375-0.9375-0.9375-2.5 0-3.4375s2.5-0.9375 3.4375 0l7.8125 7.8125 7.8125-7.8125c0.9375-0.9375 2.5-0.9375 3.4375 0s0.9375 2.5 0 3.4375l-7.8125 7.8125z"/>
</svg>

Before

Width:  |  Height:  |  Size: 900 B

View file

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="100pt" height="100pt" version="1.1" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<path d="m45.832 75c0 4.582-3.75 8.332-8.332 8.332s-8.332-3.75-8.332-8.332 3.75-8.332 8.332-8.332 8.332 3.75 8.332 8.332zm-8.332-33.332c-4.582 0-8.332 3.75-8.332 8.332s3.75 8.332 8.332 8.332 8.332-3.75 8.332-8.332-3.75-8.332-8.332-8.332zm0-25c-4.582 0-8.332 3.75-8.332 8.332s3.75 8.332 8.332 8.332 8.332-3.75 8.332-8.332-3.75-8.332-8.332-8.332zm25 16.664c4.582 0 8.332-3.75 8.332-8.332s-3.75-8.332-8.332-8.332-8.332 3.75-8.332 8.332 3.75 8.332 8.332 8.332zm0 8.3359c-4.582 0-8.332 3.75-8.332 8.332s3.75 8.332 8.332 8.332 8.332-3.75 8.332-8.332-3.75-8.332-8.332-8.332zm0 25c-4.582 0-8.332 3.75-8.332 8.332s3.75 8.332 8.332 8.332 8.332-3.75 8.332-8.332-3.75-8.332-8.332-8.332z"/>
</svg>

Before

Width:  |  Height:  |  Size: 831 B

5
augments.d.ts vendored
View file

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

View file

@ -4,7 +4,7 @@ import stylistic from "@stylistic/eslint-plugin";
export default [ export default [
// Tell eslint to ignore files that I don't mind being formatted slightly differently // Tell eslint to ignore files that I don't mind being formatted slightly differently
{ ignores: [ `scripts/`, `foundry/*` ] }, { ignores: [ `scripts/` ] },
{ {
languageOptions: { languageOptions: {
globals: globals.browser, globals: globals.browser,
@ -16,11 +16,14 @@ export default [
languageOptions: { languageOptions: {
globals: { globals: {
CONFIG: `writable`, CONFIG: `writable`,
CONST: `readonly`,
game: `readonly`, game: `readonly`,
Handlebars: `readonly`, Handlebars: `readonly`,
Hooks: `readonly`, Hooks: `readonly`,
ui: `readonly`, ui: `readonly`,
Actor: `readonly`,
Actors: `readonly`,
Item: `readonly`,
Items: `readonly`,
ActorSheet: `readonly`, ActorSheet: `readonly`,
ItemSheet: `readonly`, ItemSheet: `readonly`,
foundry: `readonly`, foundry: `readonly`,
@ -28,8 +31,6 @@ export default [
ActiveEffect: `readonly`, ActiveEffect: `readonly`,
Dialog: `readonly`, Dialog: `readonly`,
renderTemplate: `readonly`, renderTemplate: `readonly`,
fromUuid: `readonly`,
fromUuidSync: `readonly`,
}, },
}, },
}, },
@ -41,7 +42,6 @@ export default [
languageOptions: { languageOptions: {
globals: { globals: {
Logger: `readonly`, Logger: `readonly`,
taf: `readonly`,
}, },
}, },
rules: { rules: {
@ -49,7 +49,6 @@ export default [
"func-names": [`warn`, `as-needed`], "func-names": [`warn`, `as-needed`],
"grouped-accessor-pairs": `error`, "grouped-accessor-pairs": `error`,
"no-alert": `error`, "no-alert": `error`,
"no-empty": [`error`, { allowEmptyCatch: true }],
"no-implied-eval": `error`, "no-implied-eval": `error`,
"no-invalid-this": `error`, "no-invalid-this": `error`,
"no-lonely-if": `error`, "no-lonely-if": `error`,
@ -73,7 +72,7 @@ export default [
"@stylistic/eol-last": `warn`, "@stylistic/eol-last": `warn`,
"@stylistic/operator-linebreak": [`warn`, `before`], "@stylistic/operator-linebreak": [`warn`, `before`],
"@stylistic/indent": [`warn`, `tab`], "@stylistic/indent": [`warn`, `tab`],
"@stylistic/brace-style": [`off`], "@stylistic/brace-style": [`warn`, `1tbs`, { "allowSingleLine": true }],
"@stylistic/quotes": [`warn`, `backtick`, { "avoidEscape": true }], "@stylistic/quotes": [`warn`, `backtick`, { "avoidEscape": true }],
"@stylistic/comma-dangle": [`warn`, { arrays: `always-multiline`, objects: `always-multiline`, imports: `always-multiline`, exports: `always-multiline`, functions: `always-multiline` }], "@stylistic/comma-dangle": [`warn`, { arrays: `always-multiline`, objects: `always-multiline`, imports: `always-multiline`, exports: `always-multiline`, functions: `always-multiline` }],
"@stylistic/comma-style": [`warn`, `last`], "@stylistic/comma-style": [`warn`, `last`],

View file

@ -1,19 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
"module": "es2022",
"target": "es2022",
"types": [ "types": [
"./augments.d.ts" "./augments.d.ts"
], ]
"paths": { }
"@client/*": ["./foundry/client/*"],
"@common/*": ["./foundry/common/*"],
}
},
"include": [
"module/**/*",
"foundry/client/client.mjs",
"foundry/client/global.d.mts",
"foundry/common/primitives/global.d.mts"
]
} }

View file

@ -1,60 +0,0 @@
{
"TYPES": {
"Actor": {
"player": "Player"
}
},
"taf": {
"settings": {
"canPlayersManageAttributes": {
"name": "Players Can Manage Attributes",
"hint": "This allows players who have edit access to a document to be able to edit what attributes those characters have via the attribute editor"
}
},
"sheet-names": {
"PlayerSheet": "Player Sheet"
},
"misc": {
"Key": "Key",
"Value": "Value",
"no-data-submitted": "No data submitted",
"data-query-notif-header": "Data Query Notification"
},
"Apps": {
"QueryStatus": {
"title": "Information Request Status",
"user-disconnected-tooltip": "This user is not logged in to Foundry",
"cancel-request": "Cancel Request",
"finish-early": "Finish Request Early",
"send-request": "Send Request"
},
"TAFDocumentSheetConfig": {
"Sizing": "Sizing",
"Width": {
"label": "Width"
},
"Height": {
"label": "Height"
},
"Resizable": {
"label": "Resizable"
},
"tabs": {
"foundry": "Foundry",
"system": "Text-Based Actors"
}
}
},
"sockets": {
"user-list-required": "A list fo users must be provided"
},
"notifs": {
"error": {
"missing-id": "An ID must be provided",
"invalid-socket": "Invalid socket data received, this means a module or system bug is present.",
"unknown-socket-event": "An unknown socket event was received: {event}",
"malformed-socket-payload": "Socket event \"{event}\" received with malformed payload. Details: {details}"
}
}
}
}

View file

@ -1,36 +0,0 @@
// Apps
import { Ask } from "./apps/Ask.mjs";
import { AttributeManager } from "./apps/AttributeManager.mjs";
import { PlayerSheet } from "./apps/PlayerSheet.mjs";
import { QueryStatus } from "./apps/QueryStatus.mjs";
// Utils
import { attributeSorter } from "./utils/attributeSort.mjs";
import { DialogManager } from "./utils/DialogManager.mjs";
import { localizer } from "./utils/localizer.mjs";
import { QueryManager } from "./utils/QueryManager.mjs";
import { toID } from "./utils/toID.mjs";
const { deepFreeze } = foundry.utils;
Object.defineProperty(
globalThis,
`taf`,
{
value: deepFreeze({
DialogManager,
QueryManager,
Apps: {
Ask,
AttributeManager,
PlayerSheet,
QueryStatus,
},
utils: {
attributeSorter,
localizer,
toID,
},
}),
},
);

View file

@ -1,133 +0,0 @@
import { __ID__, filePath } from "../consts.mjs";
const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api;
const validInputTypes = [
`checkbox`,
`details`,
`divider`,
`error`,
`input`,
`select`,
];
export class Ask extends HandlebarsApplicationMixin(ApplicationV2) {
// #region Options
static DEFAULT_OPTIONS = {
tag: `dialog`,
classes: [
__ID__,
`dialog`, // accesses some Foundry-provided styling
`Ask`,
],
position: {
width: 330,
},
window: {
title: `Questions`,
resizable: true,
minimizable: true,
contentTag: `form`,
},
form: {
closeOnSubmit: true,
submitOnChange: false,
handler: this.#submit,
},
actions: {
cancel: this.#cancel,
},
};
static PARTS = {
inputs: {
template: filePath(`templates/Ask/inputs.hbs`),
templates: validInputTypes.map(type => filePath(`templates/Ask/inputs/${type}.hbs`)),
},
controls: {
template: filePath(`templates/Ask/controls.hbs`),
},
};
// #endregion Options
// #region Instance
_inputs = [];
alwaysUseAnswerObject = false;
/** @type {string | undefined} */
_description = undefined;
/** @type {Function | undefined} */
_userOnConfirm;
/** @type {Function | undefined} */
_userOnCancel;
/** @type {Function | undefined} */
_userOnClose;
constructor({
inputs = [],
description = undefined,
onConfirm,
onCancel,
onClose,
alwaysUseAnswerObject,
...options
} = {}) {
super(options);
this.alwaysUseAnswerObject = alwaysUseAnswerObject;
for (const input of inputs) {
if (!validInputTypes.includes(input.type)) {
input.details = `Invalid input type provided: ${input.type}`;
input.type = `error`;
};
};
this._inputs = inputs;
this._description = description;
this._userOnCancel = onCancel;
this._userOnConfirm = onConfirm;
this._userOnClose = onClose;
};
// #endregion Instance
// #region Lifecycle
async _onFirstRender() {
super._onFirstRender();
this.element.show();
};
async _prepareContext() {
return {
inputs: this._inputs,
description: this._description,
};
};
async _onClose() {
super._onClose();
this._userOnClose?.();
};
// #endregion Lifecycle
// #region Actions
/** @this {AskDialog} */
static async #submit(_event, _element, formData) {
const answers = formData.object;
const keys = Object.keys(answers);
if (keys.length === 1 && !this.alwaysUseAnswerObject) {
this._userOnConfirm?.(answers[keys[0]]);
return;
};
this._userOnConfirm?.(answers);
};
/** @this {AskDialog} */
static async #cancel() {
this._userOnCancel?.();
this.close();
};
// #endregion Actions
};

View file

@ -1,266 +0,0 @@
import { __ID__, filePath } from "../consts.mjs";
import { attributeSorter } from "../utils/attributeSort.mjs";
import { toID } from "../utils/toID.mjs";
const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api;
const { deepClone, diffObject, mergeObject, performIntegerSort, randomID, setProperty } = foundry.utils;
const { DragDrop, TextEditor } = foundry.applications.ux;
export class AttributeManager extends HandlebarsApplicationMixin(ApplicationV2) {
// #region Options
static DEFAULT_OPTIONS = {
tag: `form`,
classes: [
__ID__,
`AttributeManager`,
],
position: {
width: 400,
height: `auto`,
},
window: {
resizable: true,
},
form: {
submitOnChange: false,
closeOnSubmit: true,
handler: this.#onSubmit,
},
actions: {
addNew: this.#addNew,
removeAttribute: this.#remove,
},
};
static PARTS = {
attributes: { template: filePath(`templates/AttributeManager/attribute-list.hbs`) },
controls: { template: filePath(`templates/AttributeManager/controls.hbs`) },
};
// #endregion Options
// #region Instance Data
/** @type {string | null} */
#doc = null;
#attributes;
constructor({ document , ...options } = {}) {
super(options);
this.#doc = document;
this.#attributes = deepClone(document.system.attr);
};
get title() {
return `Attributes: ${this.#doc.name}`;
};
// #endregion Instance Data
// #region Lifecycle
async _onRender(context, options) {
await super._onRender(context, options);
const elements = this.element
.querySelectorAll(`[data-bind]`);
for (const input of elements) {
input.addEventListener(`change`, this.#bindListener.bind(this));
};
new DragDrop.implementation({
dragSelector: `.draggable`,
permissions: {
dragstart: this._canDragStart.bind(this),
drop: this._canDragDrop.bind(this),
},
callbacks: {
dragstart: this._onDragStart.bind(this),
drop: this._onDrop.bind(this),
},
}).bind(this.element);
};
// #endregion Lifecycle
// #region Data Prep
async _preparePartContext(partId) {
const ctx = {};
ctx.actor = this.#doc;
switch (partId) {
case `attributes`: {
await this._prepareAttributeContext(ctx);
};
};
return ctx;
};
async _prepareAttributeContext(ctx) {
const attrs = [];
for (const [id, data] of Object.entries(this.#attributes)) {
if (data == null) { continue };
attrs.push({
id,
name: data.name,
displayName: data.isNew ? `New Attribute` : data.name,
sort: data.sort,
isRange: data.isRange,
isNew: data.isNew ?? false,
});
};
ctx.attrs = attrs.sort(attributeSorter);
};
// #endregion Data Prep
// #region Actions
/**
* @param {Event} event
*/
async #bindListener(event) {
const target = event.target;
const data = target.dataset;
const binding = data.bind;
let value = target.value;
switch (target.type) {
case `checkbox`: {
value = target.checked;
};
};
setProperty(this.#attributes, binding, value);
await this.render({ parts: [ `attributes` ]});
};
/** @this {AttributeManager} */
static async #addNew() {
const id = randomID();
this.#attributes[id] = {
name: ``,
sort: Number.MAX_SAFE_INTEGER,
isRange: false,
isNew: true,
};
await this.render({ parts: [ `attributes` ]});
};
/** @this {AttributeManager} */
static async #remove($e, element) {
const attribute = element.closest(`[data-attribute]`)?.dataset.attribute;
if (!attribute) { return };
delete this.#attributes[attribute];
this.#attributes[`-=${attribute}`] = null;
await this.render({ parts: [ `attributes` ] });
};
/** @this {AttributeManager} */
static async #onSubmit() {
const entries = Object.entries(this.#attributes)
.map(([id, attr]) => {
if (attr == null) {
return [ id, attr ];
};
if (attr.isNew) {
delete attr.isNew;
return [ toID(attr.name), attr ];
};
return [ id, attr ];
});
const data = Object.fromEntries(entries);
const diff = diffObject(
this.#doc.system.attr,
data,
{ inner: false, deletionKeys: true },
);
await this.#doc.update({ "system.attr": diff });
};
// #endregion Actions
// #region Drag & Drop
_canDragStart() {
return this.#doc.isOwner;
};
_canDragDrop() {
return this.#doc.isOwner;
};
_onDragStart(event) {
const target = event.currentTarget.closest(`[data-attribute]`);
if (`link` in event.target.dataset) { return };
let dragData;
if (target.dataset.attribute) {
const attributeID = target.dataset.attribute;
const attribute = this.#attributes[attributeID];
dragData = {
_id: attributeID,
sort: attribute.sort,
};
};
if (!dragData) { return };
event.dataTransfer.setDragImage(target, 16, 23);
event.dataTransfer.setData(`text/plain`, JSON.stringify(dragData));
};
_onDrop(event) {
const dropped = TextEditor.implementation.getDragEventData(event);
const dropTarget = event.target.closest(`[data-attribute]`);
if (!dropTarget) { return };
const targetID = dropTarget.dataset.attribute;
let target;
// Not moving location, ignore drop event
if (targetID === dropped._id) { return };
// Determine all of the siblings and create sort data
const siblings = [];
for (const element of dropTarget.parentElement.children) {
const siblingID = element.dataset.attribute;
const attr = this.#attributes[siblingID];
const sibling = {
_id: siblingID,
sort: attr.sort,
};
if (siblingID && siblingID !== dropped._id) {
siblings.push(sibling);
};
if (siblingID === targetID) {
target = sibling;
}
};
const sortUpdates = performIntegerSort(
dropped,
{
target,
siblings,
},
);
const updateEntries = sortUpdates.map(({ target, update }) => {
return [ `${target._id}.sort`, update.sort ];
});
const update = Object.fromEntries(updateEntries);
mergeObject(
this.#attributes,
update,
{
insertKeys: false,
insertValues: false,
inplace: true,
performDeletions: false,
},
);
this.render({ parts: [ `attributes` ] });
};
// #endregion Drag & Drop
};

View file

@ -1,164 +0,0 @@
import { __ID__, filePath } from "../consts.mjs";
import { AttributeManager } from "./AttributeManager.mjs";
import { attributeSorter } from "../utils/attributeSort.mjs";
import { TAFDocumentSheetConfig } from "./TAFDocumentSheetConfig.mjs";
const { HandlebarsApplicationMixin } = foundry.applications.api;
const { ActorSheetV2 } = foundry.applications.sheets;
const { getProperty } = foundry.utils;
export class PlayerSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
// #region Options
static DEFAULT_OPTIONS = {
classes: [
__ID__,
`PlayerSheet`,
],
position: {
width: 575,
height: 740,
},
window: {
resizable: true,
},
form: {
submitOnChange: true,
closeOnSubmit: false,
},
actions: {
manageAttributes: this.#manageAttributes,
configureSheet: this.#configureSheet,
},
};
static PARTS = {
header: { template: filePath(`templates/PlayerSheet/header.hbs`) },
attributes: { template: filePath(`templates/PlayerSheet/attributes.hbs`) },
content: { template: filePath(`templates/PlayerSheet/content.hbs`) },
};
// #endregion Options
// #region Lifecycle
_initializeApplicationOptions(options) {
const sizing = getProperty(options.document, `flags.${__ID__}.PlayerSheet.size`) ?? {};
options.window ??= {};
switch (sizing.resizable) {
case `false`:
options.window.resizable ??= false;
break;
case `true`:
options.window.resizable ??= true;
break;
};
options.position ??= {};
if (sizing.width) {
options.position.width ??= sizing.width;
};
if (sizing.height) {
options.position.height ??= sizing.height;
};
return super._initializeApplicationOptions(options);
};
_getHeaderControls() {
const controls = super._getHeaderControls();
controls.push({
icon: `fa-solid fa-at`,
label: `Manage Attributes`,
action: `manageAttributes`,
visible: () => {
const isGM = game.user.isGM;
const allowPlayerEdits = game.settings.get(__ID__, `canPlayersManageAttributes`);
const editable = this.isEditable;
return isGM || (allowPlayerEdits && editable);
},
});
return controls;
};
async close() {
this.#attributeManager?.close();
this.#attributeManager = null;
return super.close();
};
// #endregion Lifecycle
// #region Data Prep
async _preparePartContext(partID) {
let ctx = {
actor: this.actor,
system: this.actor.system,
editable: this.isEditable,
};
switch (partID) {
case `attributes`: {
await this._prepareAttributes(ctx);
break;
};
case `content`: {
await this._prepareContent(ctx);
break;
};
};
return ctx;
};
async _prepareAttributes(ctx) {
ctx.hasAttributes = this.actor.system.hasAttributes;
const attrs = [];
for (const [id, data] of Object.entries(this.actor.system.attr)) {
attrs.push({
...data,
id,
path: `system.attr.${id}`,
});
};
ctx.attrs = attrs.toSorted(attributeSorter);
};
async _prepareContent(ctx) {
const TextEditor = foundry.applications.ux.TextEditor.implementation;
ctx.enriched = {
system: {
content: await TextEditor.enrichHTML(this.actor.system.content),
},
};
};
// #endregion Data Prep
// #region Actions
#attributeManager = null;
/** @this {PlayerSheet} */
static async #manageAttributes() {
this.#attributeManager ??= new AttributeManager({ document: this.actor });
if (this.#attributeManager.rendered) {
await this.#attributeManager.bringToFront();
} else {
await this.#attributeManager.render({ force: true });
};
};
static async #configureSheet(event) {
event.stopPropagation();
if ( event.detail > 1 ) { return }
// const docSheetConfigWidth = TAFDocumentSheetConfig.DEFAULT_OPTIONS.position.width;
new TAFDocumentSheetConfig({
document: this.document,
position: {
top: this.position.top + 40,
left: this.position.left + ((this.position.width - 60) / 2),
},
}).render({ force: true });
};
// #endregion Actions
};

View file

@ -1,111 +0,0 @@
import { __ID__, filePath } from "../consts.mjs";
import { cancel, finish, get as getQuery, requery } from "../utils/QueryManager.mjs";
import { Logger } from "../utils/Logger.mjs";
const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api;
export class QueryStatus extends HandlebarsApplicationMixin(ApplicationV2) {
// #region Options
static DEFAULT_OPTIONS = {
classes: [
__ID__,
`QueryStatus`,
],
position: {
width: 300,
height: `auto`,
},
window: {
title: `taf.Apps.QueryStatus.title`,
resizable: true,
},
actions: {
promptUser: this.promptUser,
finishEarly: this.finishEarly,
cancelRequest: this.cancelRequest,
},
};
static PARTS = {
users: {
template: filePath(`templates/QueryStatus/users.hbs`),
},
controls: {
template: filePath(`templates/QueryStatus/controls.hbs`),
},
};
// #endregion Options
// #region Instance
/** @type {string} */
#requestID;
constructor({
requestID,
...opts
}) {
if (!requestID) {
Logger.error(`A requestID must be provided for QueryStatus applications`);
return null;
};
super(opts);
this.#requestID = requestID;
};
get requestID() {
return this.#requestID;
};
// #endregion Instance
// #region Lifecycle
async _preparePartContext(partID) {
const ctx = {};
switch (partID) {
case `users`: {
this._prepareUsers(ctx);
break;
};
};
return ctx;
};
async _prepareUsers(ctx) {
const query = getQuery(this.#requestID);
if (!query) { return };
const users = [];
for (const userID of query.users) {
const user = game.users.get(userID);
users.push({
id: userID,
name: user.name,
active: user.active,
answers: query.responses[userID] ?? null,
status: query.status[userID],
});
};
ctx.users = users;
};
// #endregion Lifecycle
// #region Actions
/** @this {QueryStatus} */
static async promptUser($e, element) {
const userID = element.closest(`[data-user-id]`)?.dataset.userId;
if (!userID) { return };
requery(this.#requestID, [ userID ]);
};
/** @this {QueryStatus} */
static async cancelRequest() {
cancel(this.#requestID);
};
/** @this {QueryStatus} */
static async finishEarly() {
finish(this.#requestID);
};
// #endregion Actions
};

View file

@ -1,171 +0,0 @@
import { __ID__, filePath } from "../consts.mjs";
import { getDefaultSizing } from "../utils/getSizing.mjs";
const { diffObject, expandObject, flattenObject } = foundry.utils;
const { DocumentSheetConfig } = foundry.applications.apps;
const { CONST } = foundry;
export class TAFDocumentSheetConfig extends DocumentSheetConfig {
// #region Options
static DEFAULT_OPTIONS = {
classes: [`taf`],
form: {
handler: this.#onSubmit,
},
};
static get PARTS() {
const { form, footer } = super.PARTS;
return {
tabs: { template: `templates/generic/tab-navigation.hbs` },
foundryTab: {
...form,
template: filePath(`templates/TAFDocumentSheetConfig/foundry.hbs`),
templates: [ `templates/sheets/document-sheet-config.hbs` ],
},
systemTab: {
template: filePath(`templates/TAFDocumentSheetConfig/system.hbs`),
classes: [`standard-form`],
},
footer,
};
};
static TABS = {
main: {
initial: `system`,
labelPrefix: `taf.Apps.TAFDocumentSheetConfig.tabs`,
tabs: [
{ id: `system` },
{ id: `foundry` },
],
},
};
// #endregion Options
// #region Data Prep
async _preparePartContext(partID, context, options) {
this._prepareTabs(`main`);
context.meta = {
idp: this.id,
};
switch (partID) {
case `foundryTab`: {
await this._prepareFormContext(context, options);
break;
};
case `systemTab`: {
await this._prepareSystemSettingsContext(context, options);
break;
};
case `footer`: {
await this._prepareFooterContext(context, options);
break;
};
};
return context;
};
async _prepareSystemSettingsContext(context, _options) {
// Inherited values for placeholders
const defaults = getDefaultSizing();
context.placeholders = {
...defaults,
resizable: defaults.resizable ? `Resizable` : `Not Resizable`,
};
// Custom values from document itself
const sheetConfig = this.document.getFlag(__ID__, `PlayerSheet`) ?? {};
const sizing = sheetConfig.size ?? {};
context.values = {
width: sizing.width,
height: sizing.height,
resizable: sizing.resizable ?? ``,
};
// Static prep
context.resizeOptions = [
{ label: `Default (${context.placeholders.resizable})`, value: `` },
{ label: `Resizable`, value: `true` },
{ label: `No Resizing`, value: `false` },
];
};
// #endregion Data Prep
// #region Actions
/** @this {TAFDocumentSheetConfig} */
static async #onSubmit(event, form, formData) {
const foundryReopen = await TAFDocumentSheetConfig.#submitFoundry.call(this, event, form, formData);
const systemReopen = await TAFDocumentSheetConfig.#submitSystem.call(this, event, form, formData);
if (foundryReopen || systemReopen) {
this.document._onSheetChange({ sheetOpen: true });
};
};
/**
* This method is mostly the form submission handler that foundry uses in
* DocumentSheetConfig, however because we clobber that in order to save our
* own config stuff as well, we need to duplicate Foundry's handling and tweak
* it a bit to make it work nicely with our custom saving.
*
* @this {TAFDocumentSheetConfig}
*/
static async #submitFoundry(_event, _form, formData) {
const { object } = formData;
const { documentName, type = CONST.BASE_DOCUMENT_TYPE } = this.document;
// Update themes.
const themes = game.settings.get(`core`, `sheetThemes`);
const defaultTheme = foundry.utils.getProperty(themes, `defaults.${documentName}.${type}`);
const documentTheme = themes.documents?.[this.document.uuid];
const themeChanged = (object.defaultTheme !== defaultTheme) || (object.theme !== documentTheme);
if (themeChanged) {
foundry.utils.setProperty(themes, `defaults.${documentName}.${type}`, object.defaultTheme);
themes.documents ??= {};
themes.documents[this.document.uuid] = object.theme;
await game.settings.set(`core`, `sheetThemes`, themes);
}
// Update sheets.
const { defaultClass } = this.constructor.getSheetClassesForSubType(documentName, type);
const sheetClass = this.document.getFlag(`core`, `sheetClass`) ?? ``;
const defaultSheetChanged = object.defaultClass !== defaultClass;
const documentSheetChanged = object.sheetClass !== sheetClass;
if (themeChanged || (game.user.isGM && defaultSheetChanged)) {
if (game.user.isGM && defaultSheetChanged) {
const setting = game.settings.get(`core`, `sheetClasses`);
foundry.utils.setProperty(setting, `${documentName}.${type}`, object.defaultClass);
await game.settings.set(`core`, `sheetClasses`, setting);
}
// This causes us to manually rerender the sheet due to the theme or default
// sheet class changing resulting in no update making it to the client-document's
// _onUpdate handling
if (!documentSheetChanged) {
return true;
}
}
// Update the document-specific override.
if (documentSheetChanged) {
this.document.setFlag(`core`, `sheetClass`, object.sheetClass);
};
return false;
};
/** @this {TAFDocumentSheetConfig} */
static async #submitSystem(_event, _form, formData) {
const { FLAGS: flags } = expandObject(formData.object);
const diff = flattenObject(diffObject(this.document.flags, flags));
const hasChanges = Object.keys(diff).length > 0;
if (hasChanges) {
await this.document.update({ flags });
};
return hasChanges;
};
// #endregion Actions
};

View file

@ -1,11 +0,0 @@
import { TafSVGLoader } from "./svgLoader.mjs";
/**
Attributes:
@property {string} name - The name of the icon, takes precedence over the path
@property {string} path - The path of the icon file
*/
export class TafIcon extends TafSVGLoader {
static elementName = `taf-icon`;
static _stylePath = `icon.css`;
};

View file

@ -1,24 +0,0 @@
import { Logger } from "../../utils/Logger.mjs";
import { TafIcon } from "./Icon.mjs";
import { TafSVGLoader } from "./svgLoader.mjs";
const components = [
TafSVGLoader,
TafIcon,
];
export function registerCustomComponents() {
(CONFIG.CACHE ??= {}).componentListeners ??= [];
for (const component of components) {
if (!window.customElements.get(component.elementName)) {
Logger.debug(`Registering component "${component.elementName}"`);
window.customElements.define(
component.elementName,
component,
);
if (component.formAssociated) {
CONFIG.CACHE.componentListeners.push(component.elementName);
}
};
}
};

View file

@ -1,9 +0,0 @@
export const __ID__ = `taf`;
// MARK: filePath
export function filePath(path) {
if (path.startsWith(`/`)) {
path = path.slice(1);
};
return `systems/${__ID__}/${path}`;
};

View file

@ -1,30 +0,0 @@
export class PlayerData extends foundry.abstract.TypeDataModel {
static defineSchema() {
const fields = foundry.data.fields;
return {
content: new fields.HTMLField({
blank: true,
trim: true,
initial: ``,
}),
attr: new fields.TypedObjectField(
new fields.SchemaField({
name: new fields.StringField({ blank: false, trim: true }),
sort: new fields.NumberField({ min: 1, initial: 1, integer: true, nullable: false }),
value: new fields.NumberField({ min: 0, initial: 0, integer: true, nullable: false }),
max: new fields.NumberField({ min: 0, initial: null, integer: true, nullable: true }),
isRange: new fields.BooleanField({ initial: false, nullable: false }),
}),
{
initial: {},
nullable: false,
required: true,
},
),
};
};
get hasAttributes() {
return Object.keys(this.attr).length > 0;
};
};

View file

@ -1,45 +0,0 @@
const { Actor } = foundry.documents;
export class TAFActor extends Actor {
async modifyTokenAttribute(attribute, value, isDelta = false, isBar = true) {
const attr = foundry.utils.getProperty(this.system, attribute);
const current = isBar ? attr.value : attr;
const update = isDelta ? current + value : value;
if ( update === current ) {
return this;
};
// Determine the updates to make to the actor data
let updates;
if (isBar) {
updates = {[`system.${attribute}.value`]: Math.clamp(update, 0, attr.max)};
} else {
updates = {[`system.${attribute}`]: update};
};
// Allow a hook to override these changes
const allowed = Hooks.call(`modifyTokenAttribute`, {attribute, value, isDelta, isBar}, updates, this);
return allowed !== false ? this.update(updates) : this;
};
getRollData() {
const data = {};
if (`attr` in this.system) {
for (const attrID in this.system.attr) {
const attr = this.system.attr[attrID];
if (attr.isRange) {
data[attrID] = {
value: attr.value,
max: attr.max,
};
} else {
data[attrID] = attr.value;
};
};
};
return data;
};
};

View file

@ -1,7 +0,0 @@
const { Item } = foundry.documents;
export class TAFItem extends Item {
async _preCreate() {
return false;
};
};

View file

@ -1,109 +0,0 @@
const { TokenDocument } = foundry.documents;
const { getProperty, getType, hasProperty, isSubclass } = foundry.utils;
export class TAFTokenDocument extends TokenDocument {
/**
* @override
* This override's purpose is to make it so that Token attributes and bars can
* be accessed from the data model's values directly instead of relying on only
* the schema, which doesn't account for my TypedObjectField of attributes.
*/
static getTrackedAttributes(data, _path = []) {
// Case 1 - Infer attributes from schema structure.
if ( (data instanceof foundry.abstract.DataModel) || isSubclass(data, foundry.abstract.DataModel) ) {
return this._getTrackedAttributesFromObject(data, _path);
}
if ( data instanceof foundry.data.fields.SchemaField ) {
return this._getTrackedAttributesFromSchema(data, _path);
}
// Case 2 - Infer attributes from object structure.
if ( [`Object`, `Array`].includes(getType(data)) ) {
return this._getTrackedAttributesFromObject(data, _path);
}
// Case 3 - Retrieve explicitly configured attributes.
if ( !data || (typeof data === `string`) ) {
const config = this._getConfiguredTrackedAttributes(data);
if ( config ) {
return config;
}
data = undefined;
}
// Track the path and record found attributes
if ( data !== undefined ) {
return {bar: [], value: []};
}
// Case 4 - Infer attributes from system template.
const bar = new Set();
const value = new Set();
for ( const [type, model] of Object.entries(game.model.Actor) ) {
const dataModel = CONFIG.Actor.dataModels?.[type];
const inner = this.getTrackedAttributes(dataModel ?? model, _path);
inner.bar.forEach(attr => bar.add(attr.join(`.`)));
inner.value.forEach(attr => value.add(attr.join(`.`)));
}
return {
bar: Array.from(bar).map(attr => attr.split(`.`)),
value: Array.from(value).map(attr => attr.split(`.`)),
};
};
/**
* @override
*/
getBarAttribute(barName, {alternative} = {}) {
const attribute = alternative || this[barName]?.attribute;
if (!attribute || !this.actor) {
return null;
};
const system = this.actor.system;
// Get the current attribute value
const data = getProperty(system, attribute);
if (data == null) {
return null;
};
if (Number.isNumeric(data)) {
let editable = hasProperty(system, attribute);
return {
type: `value`,
attribute,
value: Number(data),
editable,
};
};
if (`value` in data && `max` in data) {
let editable = hasProperty(system, `${attribute}.value`);
const isRange = getProperty(system, `${attribute}.isRange`);
if (isRange) {
return {
type: `bar`,
attribute,
value: parseInt(data.value || 0),
max: parseInt(data.max || 0),
editable,
};
} else {
return {
type: `value`,
attribute: `${attribute}.value`,
value: Number(data.value),
editable,
};
};
};
// Otherwise null
return null;
};
};

View file

@ -1,7 +0,0 @@
import { filePath } from "../consts.mjs";
import { options } from "./options.mjs";
export default {
systemFilePath: filePath,
"taf-options": options,
};

View file

@ -1,48 +0,0 @@
// Apps
import { PlayerSheet } from "../apps/PlayerSheet.mjs";
// Data Models
import { PlayerData } from "../data/Player.mjs";
// Documents
import { TAFActor } from "../documents/Actor.mjs";
import { TAFItem } from "../documents/Item.mjs";
import { TAFTokenDocument } from "../documents/Token.mjs";
// Settings
import { registerWorldSettings } from "../settings/world.mjs";
// Utils
import { __ID__ } from "../consts.mjs";
import helpers from "../handlebarsHelpers/_index.mjs";
import { Logger } from "../utils/Logger.mjs";
import { registerCustomComponents } from "../apps/elements/_index.mjs";
import { registerSockets } from "../sockets/_index.mjs";
Hooks.on(`init`, () => {
Logger.debug(`Initializing`);
CONFIG.Token.documentClass = TAFTokenDocument;
CONFIG.Actor.documentClass = TAFActor;
CONFIG.Actor.dataModels.player = PlayerData;
// We disable items in the system for now
CONFIG.Item.documentClass = TAFItem;
delete CONFIG.ui.sidebar.TABS.items;
foundry.documents.collections.Actors.registerSheet(
__ID__,
PlayerSheet,
{
makeDefault: true,
label: `taf.sheet-names.PlayerSheet`,
},
);
registerWorldSettings();
registerSockets();
registerCustomComponents();
Handlebars.registerHelper(helpers);
});

View file

@ -1,6 +0,0 @@
import { userActivity } from "../utils/QueryManager.mjs";
Hooks.on(`userConnected`, (user, connected) => {
if (user.isSelf) { return };
userActivity(user.id, connected);
});

View file

@ -1,3 +0,0 @@
import "./api.mjs";
import "./hooks/init.mjs";
import "./hooks/userConnected.mjs";

View file

@ -1,12 +0,0 @@
import { __ID__ } from "../consts.mjs";
export function registerWorldSettings() {
game.settings.register(__ID__, `canPlayersManageAttributes`, {
name: `taf.settings.canPlayersManageAttributes.name`,
hint: `taf.settings.canPlayersManageAttributes.hint`,
config: true,
type: Boolean,
default: false,
scope: `world`,
});
};

View file

@ -1,33 +0,0 @@
import { Logger } from "../utils/Logger.mjs";
import { queryCancel } from "./query/cancel.mjs";
import { queryNotify } from "./query/notify.mjs";
import { queryPrompt } from "./query/prompt.mjs";
import { querySubmit } from "./query/submit.mjs";
const events = {
// Data Request sockets
"query.cancel": queryCancel,
"query.notify": queryNotify,
"query.prompt": queryPrompt,
"query.submit": querySubmit,
};
export function registerSockets() {
Logger.info(`Setting up socket listener`);
game.socket.on(`system.taf`, (data, userID) => {
const { event, payload } = data ?? {};
if (event == null || payload === undefined) {
ui.notifications.error(game.i18n.format(`taf.notifs.error.invalid-socket`));
return;
};
if (events[event] == null) {
ui.notifications.error(game.i18n.format(`taf.notifs.error.unknown-socket-event`, { event }));
return;
};
const user = game.users.get(userID);
events[event](payload, user);
});
};

View file

@ -1,19 +0,0 @@
import { DialogManager } from "../../utils/DialogManager.mjs";
import { localizer } from "../../utils/localizer.mjs";
export async function queryCancel(payload) {
const { id } = payload;
if (!id) {
ui.notifications.error(localizer(
`taf.notifs.error.malformed-socket-payload`,
{
event: `query.cancel`,
details: `taf.notifs.error.missing-id`,
},
));
return;
};
await DialogManager.close(id);
};

View file

@ -1,25 +0,0 @@
import { localizer } from "../../utils/localizer.mjs";
import { respondedToQueries } from "../../utils/QueryManager.mjs";
export function queryNotify(payload) {
const { id, userID, content, includeGM } = payload;
if (userID !== game.user.id) { return };
// Ensure that each user can only get one notification about a query
if (!respondedToQueries.has(id)) { return };
let whisper = [game.user.id];
if (includeGM) {
whisper = game.users.filter(u => u.isGM).map(u => u.id);
};
ChatMessage.implementation.create({
flavor: localizer(`taf.misc.data-query-notif-header`),
content,
whisper,
style: CONST.CHAT_MESSAGE_STYLES.OOC,
});
respondedToQueries.delete(id);
};

View file

@ -1,54 +0,0 @@
import { DialogManager } from "../../utils/DialogManager.mjs";
import { localizer } from "../../utils/localizer.mjs";
import { respondedToQueries } from "../../utils/QueryManager.mjs";
export async function queryPrompt(payload) {
const {
id,
users,
config,
request,
} = payload;
if (!id) {
ui.notifications.error(localizer(
`taf.notifs.error.malformed-socket-payload`,
{
event: `query.cancel`,
details: `taf.notifs.error.missing-id`,
},
));
return;
};
// null/undefined is a special case for "all users but me" by default
if (users != null && !Array.isArray(users)) {
ui.notifications.error(localizer(
`taf.notifs.error.malformed-socket-payload`,
{
event: `query.cancel`,
details: `taf.sockets.user-list-required`,
},
));
return;
};
if (users != null && !users.includes(game.user.id)) { return };
request.id = id;
const result = await DialogManager.ask(request, config);
if (result.state === `fronted`) {
return;
} else if (result.state === `errored`) {
ui.notifications.error(result.error);
} else if (result.state === `prompted`) {
respondedToQueries.add(request.id);
game.socket.emit(`system.taf`, {
event: `query.submit`,
payload: {
id: request.id,
answers: result.answers,
},
});
};
};

View file

@ -1,23 +0,0 @@
import { addResponse, has as hasQuery } from "../../utils/QueryManager.mjs";
import { localizer } from "../../utils/localizer.mjs";
export function querySubmit(payload, user) {
const {
id,
answers,
} = payload;
if (!id) {
ui.notifications.error(localizer(
`taf.notifs.error.malformed-socket-payload`,
{
event: `query.cancel`,
details: `taf.notifs.error.missing-id`,
},
));
return;
};
if (!hasQuery(id)) { return };
addResponse(id, user.id, answers);
};

View file

@ -1,114 +0,0 @@
import { Ask } from "../apps/Ask.mjs";
/** @type {Map<string, Promise>} */
const promises = new Map();
/** @type {Map<string, ApplicationV2>} */
const dialogs = new Map();
export function close(id) {
dialogs.get(id)?.close();
dialogs.delete(id);
promises.delete(id);
};
/**
* Asks the user to provide a simple piece of information, this is primarily
* intended to be used within macros so that it can have better info gathering
* as needed. This returns an object of input keys/labels to the value the user
* input for that label, if there is only one input, this will return the value
* without an object wrapper, allowing for easier access.
*
* @param {AskConfig} data
* @param {AskOptions} opts
* @returns {AskResult}
*/
export async function ask(
data,
{
onlyOneWaiting = true,
alwaysUseAnswerObject = true,
} = {},
) {
if (!data.id) {
return {
state: `errored`,
error: `An ID must be provided`,
};
};
if (!data.inputs.length) {
return {
state: `errored`,
error: `At least one input must be provided`,
};
};
const id = data.id;
// Don't do multi-thread waiting
if (dialogs.has(id)) {
const app = dialogs.get(id);
app.bringToFront();
if (onlyOneWaiting) {
return { state: `fronted` };
} else {
return promises.get(id);
};
};
let autofocusClaimed = false;
for (const i of data.inputs) {
i.id ??= foundry.utils.randomID(16);
i.key ??= i.label;
switch (i.type) {
case `input`: {
i.inputType ??= `text`;
}
}
// Only ever allow one input to claim autofocus
i.autofocus &&= !autofocusClaimed;
autofocusClaimed ||= i.autofocus;
// Set the value's attribute name if it isn't specified explicitly
if (!i.valueAttribute) {
switch (i.inputType) {
case `checkbox`:
i.type = `checkbox`;
delete i.valueAttribute;
delete i.inputType;
break;
default:
i.valueAttribute = `value`;
};
};
};
const promise = new Promise((resolve) => {
const app = new Ask({
...data,
alwaysUseAnswerObject,
onClose: () => {
dialogs.delete(id);
promises.delete(id);
resolve({ state: `prompted` });
},
onConfirm: (answers) => resolve({ state: `prompted`, answers }),
});
app.render({ force: true });
dialogs.set(id, app);
});
promises.set(id, promise);
return promise;
};
export function size() {
return dialogs.size;
};
export const DialogManager = {
close,
ask,
size,
};

View file

@ -1,289 +0,0 @@
import { filePath } from "../consts.mjs";
import { Logger } from "./Logger.mjs";
import { QueryStatus } from "../apps/QueryStatus.mjs";
/**
* An object containing information about the current status for all
* users involved with the data request.
* @typedef {Record<
* string,
* "finished" | "waiting" | "disconnected" | "unprompted"
* >} UserStatus
*/
/**
* @typedef QueryData
* @property {string[]} users
* @property {Function} resolve
* @property {Record<string, object>} responses
* @property {(() => Promise<void>)|null} onSubmit
* @property {QueryStatus|null} app
* @property {UserStatus} status
* @property {object} request The data used to form the initial request
* @property {object} config The data used to create the initial config
*/
/**
* This internal API is used in order to prevent the query.notify event
* from being fired off in situations where the user hasn't responded,
* wasn't part of the query, or has already been notified.
* @type {Set<string>}
*/
export const respondedToQueries = new Set();
/** @type {Map<string, QueryData>} */
const queries = new Map();
/** @type {Map<string, Promise>} */
const promises = new Map();
async function sendBasicNotification(requestID, userID, answers) {
const content = await foundry.applications.handlebars.renderTemplate(
filePath(`templates/query-response.hbs`),
{ answers },
);
await notify(requestID, userID, content, { includeGM: false });
};
export function has(requestID) {
return queries.has(requestID);
};
/** @returns {Omit<QueryData, "resolve"|"onSubmit"|"app">} */
export function get(requestID) {
if (!queries.has(requestID)) { return null };
const query = queries.get(requestID);
const cloned = foundry.utils.deepClone(query);
delete cloned.onSubmit;
delete cloned.resolve;
delete cloned.app;
return foundry.utils.deepFreeze(cloned);
};
export async function query(
request,
{
onSubmit = sendBasicNotification,
users = null,
showStatusApp = true,
...config
} = {},
) {
if (!request.id) {
ui.notifications.error(game.i18n.localize(`taf.notifs.error.missing-id`));
return;
};
game.socket.emit(`system.taf`, {
event: `query.prompt`,
payload: {
id: request.id,
users,
request,
config,
},
});
if (promises.has(request.id)) {
return null;
};
users ??= game.users
.filter(u => u.id !== game.user.id)
.map(u => u.id);
const promise = new Promise((resolve) => {
/** @type {UserStatus} */
const status = {};
for (const user of users) {
status[user] = game.users.get(user).active ? `waiting` : `disconnected`;
};
queries.set(
request.id,
{
users,
request,
config,
responses: {},
resolve,
onSubmit,
app: null,
status,
},
);
});
if (showStatusApp) {
const app = new QueryStatus({ requestID: request.id });
app.render({ force: true });
queries.get(request.id).app = app;
};
return promise;
};
export async function requery(requestID, users) {
const query = queries.get(requestID);
if (!query) { return };
game.socket.emit(`system.taf`, {
event: `query.prompt`,
payload: {
id: requestID,
users,
request: query.request,
config: query.config,
},
});
for (const user of users) {
query.status[user] = `waiting`;
};
query.app?.render({ parts: [ `users` ] });
};
export async function addResponse(requestID, userID, answers) {
if (!queries.has(requestID)) { return };
const query = queries.get(requestID);
// User closed the popup manually
if (answers == null) {
query.status[userID] = `unprompted`;
}
// User submitted the answers as expected
else {
query.responses[userID] = answers;
query.status[userID] = `finished`;
await query.onSubmit?.(requestID, userID, answers);
};
await maybeResolve(requestID);
};
async function maybeResolve(requestID) {
const query = queries.get(requestID);
// Determine how many users are considered "finished"
let finishedUserCount = 0;
for (const user of query.users) {
const hasApp = query.app != null;
switch (query.status[user]) {
case `finished`: {
finishedUserCount++;
break;
};
case `cancelled`:
case `disconnected`:
case `unprompted`: {
if (!hasApp) {
finishedUserCount++;
};
break;
};
};
};
// Ensure that we have a finished response from everyone prompted
if (query.users.length === finishedUserCount) {
query.app?.close();
query.resolve(query.responses);
queries.delete(requestID);
promises.delete(requestID);
} else {
query.app?.render({ parts: [ `users` ] });
};
};
export async function notify(requestID, userID, content, { includeGM = false } = {}) {
// Prevent sending notifications for not-your queries
if (!queries.has(requestID)) { return };
game.socket.emit(`system.taf`, {
event: `query.notify`,
payload: {
id: requestID,
userID,
content,
includeGM,
},
});
};
export async function finish(requestID) {
// prevent finishing other people's queries
if (!queries.has(requestID)) { return };
const query = queries.get(requestID);
query.app?.close();
query.resolve(query.responses);
queries.delete(requestID);
promises.delete(requestID);
game.socket.emit(`system.taf`, {
event: `query.cancel`,
payload: { id: requestID },
});
};
export async function cancel(requestID) {
// prevent cancelling other people's queries
if (!queries.has(requestID)) { return };
const query = queries.get(requestID);
query.app?.close();
query.resolve(null);
queries.delete(requestID);
promises.delete(requestID);
game.socket.emit(`system.taf`, {
event: `query.cancel`,
payload: { id: requestID },
});
};
export async function setApplication(requestID, app) {
if (!queries.has(requestID)) { return };
if (!(app instanceof QueryStatus)) { return };
const query = queries.get(requestID);
if (query.app) {
Logger.error(`Cannot set an application for a query that has one already`);
return;
};
query.app = app;
};
export async function userActivity(userID, connected) {
for (const [id, query] of queries.entries()) {
if (query.users.includes(userID)) {
// Update the user's status to allow for the app to re-prompt them
if (query.status[userID] !== `finished`) {
if (connected) {
query.status[userID] = `unprompted`;
} else {
query.status[userID] = `disconnected`;
};
maybeResolve(id);
};
query.app?.render({ parts: [ `users` ] });
};
};
};
export const QueryManager = {
has, get,
query, requery,
addResponse,
notify,
finish, cancel,
setApplication,
userActivity,
};

View file

@ -1,6 +0,0 @@
export function attributeSorter(a, b) {
if (a.sort === b.sort) {
return a.name.localeCompare(b.name);
};
return Math.sign(a.sort - b.sort);
};

View file

@ -1,32 +0,0 @@
import { PlayerSheet } from "../apps/PlayerSheet.mjs";
/**
* @typedef SheetSizing
* @property {number} width The initial width of the application
* @property {number} height The initial height of the application
* @property {boolean} resizable Whether or not the application
* is able to be resized with a drag handle.
*/
/**
* Retrieves the computed default sizing data based on world settings
* and the sheet class' DEFAULT_OPTIONS
* @returns {SheetSizing}
*/
export function getDefaultSizing() {
/** @type {SheetSizing} */
const sizing = {
width: undefined,
height: undefined,
resizable: undefined,
};
// TODO: defaults from world settings
// Defaults from the sheet class itself
sizing.height ||= PlayerSheet.DEFAULT_OPTIONS.position.height;
sizing.width ||= PlayerSheet.DEFAULT_OPTIONS.position.width;
sizing.resizable ||= PlayerSheet.DEFAULT_OPTIONS.window.resizable;
return sizing;
};

View file

@ -1,32 +0,0 @@
const config = Object.preventExtensions({
subKeyPattern: /@(?<key>[a-zA-Z.]+)/gm,
maxDepth: 10,
});
export function localizer(key, args = {}, depth = 0) {
/** @type {string} */
let localized = game.i18n.format(key, args);
const subkeys = localized.matchAll(config.subKeyPattern);
// Short-cut to help prevent infinite recursion
if (depth > config.maxDepth) {
return localized;
};
/*
Helps prevent recursion on the same key so that we aren't doing excess work.
*/
const localizedSubkeys = new Map();
for (const match of subkeys) {
const subkey = match.groups.key;
if (localizedSubkeys.has(subkey)) { continue };
localizedSubkeys.set(subkey, localizer(subkey, args, depth + 1));
};
return localized.replace(
config.subKeyPattern,
(_fullMatch, subkey) => {
return localizedSubkeys.get(subkey);
},
);
};

View file

@ -1,13 +0,0 @@
/**
* A helper method that converts an arbitrary string into a format that can be
* used as an object key easily.
*
* @param {string} text The text to convert
* @returns The converted ID
*/
export function toID(text) {
return text
.toLowerCase()
.replace(/\s+/g, `_`)
.replace(/\W/g, ``);
};

5896
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,18 +1,16 @@
{ {
"devDependencies": { "devDependencies": {
"@aws-sdk/client-s3": "^3.934.0",
"@eslint/js": "^9.8.0", "@eslint/js": "^9.8.0",
"@foundryvtt/foundryvtt-cli": "^1.0.3", "@foundryvtt/foundryvtt-cli": "^1.0.3",
"@league-of-foundry-developers/foundry-vtt-types": "^9.280.0",
"@stylistic/eslint-plugin": "^2.6.1", "@stylistic/eslint-plugin": "^2.6.1",
"axios": "^1.13.2",
"dotenv": "^17.2.2",
"eslint": "^9.8.0", "eslint": "^9.8.0",
"globals": "^15.9.0" "globals": "^15.9.0",
"sass": "^1.77.8"
}, },
"scripts": { "scripts": {
"data:build": "node scripts/buildCompendia.mjs", "css": "sass --watch --embed-source-map --no-error-css styles/:.styles/",
"data:extract": "node scripts/extractCompendia.mjs", "build": "sass --embed-source-map --no-error-css styles/:.styles/",
"link": "node scripts/linkFoundry.mjs",
"lint": "eslint --fix", "lint": "eslint --fix",
"lint:nofix": "eslint" "lint:nofix": "eslint"
} }

View file

@ -1,52 +0,0 @@
import axios from "axios";
const {
TAG,
FORGEJO_API_URL: API,
FORGEJO_REPOSITORY: REPO,
FORGEJO_TOKEN: TOKEN,
CDN_URL,
} = process.env;
async function addReleaseAsset(releaseID, name) {
return axios.post(
`${API}/repos/${REPO}/releases/${releaseID}/assets`,
{ external_url: `${CDN_URL}/${REPO}/${TAG}/${name}`, },
{
headers: {
Authorization: `token ${TOKEN}`,
"Content-Type": `multipart/form-data`,
},
params: { name },
}
);
};
async function main() {
// Initial Release Data
const release = await axios.post(
`${API}/repos/${REPO}/releases`,
{
name: TAG,
tag_name: TAG,
draft: true,
hide_archive_links: true,
},
{
headers: { Authorization: `token ${TOKEN}` },
}
);
try {
await addReleaseAsset(release.data.id, `release.zip`);
await addReleaseAsset(release.data.id, `system.json`);
} catch (e) {
console.error(`Failed to add assets to the release`);
process.exit(1);
};
console.log(`Release created`);
};
main();

View file

@ -1,47 +0,0 @@
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

@ -0,0 +1,77 @@
async function rollDice() {
const sidesOnDice = 6;
const answers = await DialogManager.ask({
id: `eat-the-reich-dice-pool`,
question: `Set up your dice pool:`,
inputs: [
{
key: `statBase`,
inputType: `number`,
defaultValue: 2,
label: `Number of Dice`,
autofocus: true,
},
{
key: `successThreshold`,
inputType: `number`,
defaultValue: 3,
label: `Success Threshold (d${sidesOnDice} > X)`,
},
{
key: `critsEnabled`,
inputType: `checkbox`,
defaultValue: true,
label: `Enable Criticals`,
},
],
});
const { statBase, successThreshold, critsEnabled } = answers;
let rollMode = game.settings.get(`core`, `rollMode`);
let successes = 0;
let critsOnly = 0;
const results = [];
for (let i = statBase; i > 0; i--) {
let r = new Roll(`1d${sidesOnDice}`);
await r.evaluate();
let classes = `roll die d6`;
// Determine the success count and class modifications for the chat
if (r.total > successThreshold) {
successes++;
}
else {
classes += ` failure`
}
if (r.total === sidesOnDice && critsEnabled) {
successes++;
critsOnly++;
classes += ` success`;
}
results.push(`<li class="${classes}">${r.total}</li>`);
}
let content = `Rolls:<div class="dice-tooltip"><ol class="dice-rolls">${results.join(``)}</ol></div><hr>Successes: ${successes}<br>Crits: ${critsOnly}`;
if (rollMode === CONST.DICE_ROLL_MODES.BLIND) {
ui.notifications.warn(`Cannot make a blind roll from the macro, rolling with mode "Private GM Roll" instead`);
rollMode = CONST.DICE_ROLL_MODES.PRIVATE;
}
const chatData = ChatMessage.applyRollMode(
{
title: `Dice Pool`,
content,
},
rollMode,
);
await ChatMessage.implementation.create(chatData);
}
rollDice()

View file

@ -1,45 +0,0 @@
/*
The intent of this script is to do all of the modifications of the
manifest file that we need to do in order to release the system.
This can include removing dev-only fields/attributes that end
users will never, and should never, care about nor need.
*/
import { readFile, writeFile } from "fs/promises";
const MANIFEST_PATH = `system.json`;
const {
DOWNLOAD_URL,
LATEST_URL,
} = process.env;
let manifest;
try {
manifest = JSON.parse(await readFile(MANIFEST_PATH, `utf-8`));
console.log(`Manifest loaded from disk`);
} catch {
console.error(`Failed to parse manifest file.`);
process.exit(1);
};
console.log(`Updating download/manifest URLs`)
manifest.download = DOWNLOAD_URL;
manifest.manifest = LATEST_URL;
// Filter out dev-only resources
if (manifest.esmodules) {
console.log(`Removing dev-only esmodules`);
manifest.esmodules = manifest.esmodules.filter(
filepath => !filepath.startsWith(`dev/`)
);
};
// Remove dev flags
console.log(`Cleaning up flags`);
delete manifest.flags?.hotReload;
if (Object.keys(manifest.flags).length === 0) {
delete manifest.flags;
};
await writeFile(MANIFEST_PATH, JSON.stringify(manifest, undefined, `\t`));
console.log(`Manifest written back to disk`);

View file

@ -1,38 +0,0 @@
import axios from "axios";
const {
TAG_NAME,
FORGEJO_API_URL: API_URL,
FORGEJO_REPOSITORY: REPO,
FORGEJO_TOKEN: TOKEN,
} = process.env;
async function main() {
if (!TAG_NAME) {
console.log(`Tag name must not be blank`);
process.exit(1);
};
const requestURL = `${API_URL}/repos/${REPO}/tags/${TAG_NAME}`;
const response = await axios.get(
requestURL,
{
headers: { Authorization: `token ${TOKEN}` },
validateStatus: () => true,
},
);
// We actually *want* an error when the tag exists, instead of when
// it doesn't
if (response.status === 200) {
console.log(`Tag with name "${TAG_NAME}" already exists`);
process.exit(1);
};
console.log(`Tag with name "${TAG_NAME}" not found, proceeding`);
};
main();

View file

@ -1,65 +0,0 @@
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { createReadStream } from "fs";
const requiredEnvVariables = [
`TAG`, `FILE`,
`FORGEJO_REPOSITORY`,
`S3_BUCKET`, `S3_REGION`, `S3_KEY`, `S3_SECRET`, `S3_ENDPOINT`,
];
async function main() {
// Assert all of the required env variables are present
const missing = [];
for (const envVar of requiredEnvVariables) {
if (!(envVar in process.env)) {
missing.push(envVar);
};
};
if (missing.length > 0) {
console.error(`Missing the following required environment variables: ${missing.join(`, `)}`);
process.exit(1);
};
const {
TAG,
S3_ENDPOINT,
S3_REGION,
S3_KEY,
S3_SECRET,
S3_BUCKET,
FILE,
FORGEJO_REPOSITORY: REPO,
} = process.env;
const s3Client = new S3Client({
endpoint: S3_ENDPOINT,
forcePathStyle: false,
region: S3_REGION,
credentials: {
accessKeyId: S3_KEY,
secretAccessKey: S3_SECRET
},
});
const name = FILE.split(`/`).at(-1);
const params = {
Bucket: S3_BUCKET,
Key: `${REPO}/${TAG}/${name}`,
Body: createReadStream(FILE),
ACL: "public-read",
METADATA: {
"x-repo-version": TAG,
},
};
try {
const response = await s3Client.send(new PutObjectCommand(params));
console.log("Upload successful");
} catch (err) {
console.error("Upload to s3 failed");
};
};
main();

32
src/components/_index.mjs Normal file
View file

@ -0,0 +1,32 @@
import { SystemIcon } from "./icon.mjs";
import { SystemIncrementer } from "./incrementer.mjs";
import { SystemRange } from "./range.mjs";
/**
* A list of element classes to register, expects all of them to have a static
* property of "elementName" that is the namespaced name that the component will
* be registered under. Any elements that are formAssociated have their name added
* to the "CONFIG.CACHE.componentListeners" array and should be listened to for
* "change" events in sheets.
*/
const components = [
SystemIcon,
SystemIncrementer,
SystemRange,
];
export function registerCustomComponents() {
(CONFIG.CACHE ??= {}).componentListeners ??= [];
for (const component of components) {
if (!window.customElements.get(component.elementName)) {
console.debug(`${game.system.id} | Registering component "${component.elementName}"`);
window.customElements.define(
component.elementName,
component,
);
if (component.formAssociated) {
CONFIG.CACHE.componentListeners.push(component.elementName);
}
};
};
};

View file

@ -1,18 +1,16 @@
import { filePath } from "../../consts.mjs"; import { StyledShadowElement } from "./mixins/Styles.mjs";
import { Logger } from "../../utils/Logger.mjs";
import { StyledShadowElement } from "./StyledShadowElement.mjs";
/** /**
Attributes: Attributes:
@property {string} name - The name of the icon, takes precedence over the path @property {string} name - The name of the icon, takes precedence over the path
@property {string} path - The path of the icon file @property {string} path - The path of the icon file
*/ */
export class TafSVGLoader extends StyledShadowElement(HTMLElement) { export class SystemIcon extends StyledShadowElement(HTMLElement) {
static elementName = `taf-svg`; static elementName = `dd-icon`;
static formAssociated = false; static formAssociated = false;
/* Stuff for the mixin to use */ /* Stuff for the mixin to use */
static _stylePath = `svg-loader.css`; static _stylePath = ``;
static _cache = new Map(); static _cache = new Map();
@ -22,8 +20,12 @@ export class TafSVGLoader extends StyledShadowElement(HTMLElement) {
/** @type {null | string} */ /** @type {null | string} */
_path; _path;
/* Stored IDs for all of the hooks that are in this component */
#svgHmr;
constructor() { constructor() {
super(); super();
// this._shadow = this.attachShadow({ mode: `open`, delegatesFocus: true });
this.#container = document.createElement(`div`); this.#container = document.createElement(`div`);
this._shadow.appendChild(this.#container); this._shadow.appendChild(this.#container);
@ -32,7 +34,7 @@ export class TafSVGLoader extends StyledShadowElement(HTMLElement) {
_mounted = false; _mounted = false;
async connectedCallback() { async connectedCallback() {
super.connectedCallback(); super.connectedCallback();
if (this._mounted) { return }; if (this._mounted) { return }
this._name = this.getAttribute(`name`); this._name = this.getAttribute(`name`);
this._path = this.getAttribute(`path`); this._path = this.getAttribute(`path`);
@ -54,7 +56,7 @@ export class TafSVGLoader extends StyledShadowElement(HTMLElement) {
*/ */
let content; let content;
if (this._name) { if (this._name) {
content = await this.#getIcon(filePath(`assets/${this._name}.svg`)); content = await this.#getIcon(`./systems/dotdungeon/assets/${this._name}.svg`);
}; };
if (this._path && !content) { if (this._path && !content) {
@ -65,12 +67,28 @@ export class TafSVGLoader extends StyledShadowElement(HTMLElement) {
this.#container.appendChild(content.cloneNode(true)); this.#container.appendChild(content.cloneNode(true));
}; };
/*
This is so that when we get an HMR event from Foundry we can appropriately
handle it using our logic to update the component and the icon cache.
*/
if (game.settings.get(game.system.id, `devMode`)) {
this.#svgHmr = Hooks.on(`${game.system.id}-hmr:svg`, (iconName, data) => {
if (this._name === iconName || this._path?.endsWith(data.path)) {
const svg = this.#parseSVG(data.content);
this.constructor._cache.set(iconName, svg);
this.#container.replaceChildren(svg.cloneNode(true));
};
});
};
this._mounted = true; this._mounted = true;
}; };
disconnectedCallback() { disconnectedCallback() {
super.disconnectedCallback(); super.disconnectedCallback();
if (!this._mounted) { return }; if (!this._mounted) { return }
Hooks.off(`${game.system.id}-hmr:svg`, this.#svgHmr);
this._mounted = false; this._mounted = false;
}; };
@ -78,7 +96,7 @@ export class TafSVGLoader extends StyledShadowElement(HTMLElement) {
async #getIcon(path) { async #getIcon(path) {
// Cache hit! // Cache hit!
if (this.constructor._cache.has(path)) { if (this.constructor._cache.has(path)) {
Logger.debug(`Image ${path} cache hit`); Logger.debug(`Icon ${path} cache hit`);
return this.constructor._cache.get(path); return this.constructor._cache.get(path);
}; };
@ -92,7 +110,7 @@ export class TafSVGLoader extends StyledShadowElement(HTMLElement) {
return; return;
}; };
Logger.debug(`Adding image ${path} to the cache`); Logger.debug(`Adding icon ${path} to the cache`);
const svg = this.#parseSVG(await r.text()); const svg = this.#parseSVG(await r.text());
this.constructor._cache.set(path, svg); this.constructor._cache.set(path, svg);
return svg; return svg;

View file

@ -0,0 +1,153 @@
import { StyledShadowElement } from "./mixins/Styles.mjs";
import { SystemIcon } from "./icon.mjs";
/**
Attributes:
@property {string} name - The path to the value to update
@property {number} value - The actual value of the input
@property {number} min - The minimum value of the input
@property {number} max - The maximum value of the input
@property {number?} smallStep - The step size used for the buttons and arrow keys
@property {number?} largeStep - The step size used for the buttons + Ctrl and page up / down
Styling:
- `--height`: Controls the height of the element + the width of the buttons (default: 1.25rem)
- `--width`: Controls the width of the number input (default 50px)
*/
export class SystemIncrementer extends StyledShadowElement(HTMLElement) {
static elementName = `dd-incrementer`;
static formAssociated = true;
static _stylePath = `v1/components/incrementer.scss`;
_internals;
#input;
_min;
_max;
_smallStep;
_largeStep;
constructor() {
super();
// Form internals
this._internals = this.attachInternals();
this._internals.role = `spinbutton`;
};
get form() {
return this._internals.form;
}
get name() {
return this.getAttribute(`name`);
}
set name(value) {
this.setAttribute(`name`, value);
}
get value() {
return this.getAttribute(`value`);
};
set value(value) {
this.setAttribute(`value`, value);
};
get type() {
return `number`;
}
connectedCallback() {
super.connectedCallback();
this.replaceChildren();
// Attribute parsing / registration
const value = this.getAttribute(`value`);
this._min = parseInt(this.getAttribute(`min`) ?? 0);
this._max = parseInt(this.getAttribute(`max`) ?? 0);
this._smallStep = parseInt(this.getAttribute(`smallStep`) ?? 1);
this._largeStep = parseInt(this.getAttribute(`largeStep`) ?? 5);
this._internals.ariaValueMin = this._min;
this._internals.ariaValueMax = this._max;
const container = document.createElement(`div`);
// The input that the user can see / modify
const input = document.createElement(`input`);
this.#input = input;
input.type = `number`;
input.ariaHidden = true;
input.min = this.getAttribute(`min`);
input.max = this.getAttribute(`max`);
input.addEventListener(`change`, this.#updateValue.bind(this));
input.value = value;
// plus button
const increment = document.createElement(SystemIcon.elementName);
increment.setAttribute(`name`, `ui/plus`);
increment.setAttribute(`var:size`, `0.75rem`);
increment.setAttribute(`var:fill`, `currentColor`);
increment.ariaHidden = true;
increment.classList.value = `increment`;
increment.addEventListener(`mousedown`, this.#increment.bind(this));
// minus button
const decrement = document.createElement(SystemIcon.elementName);
decrement.setAttribute(`name`, `ui/minus`);
decrement.setAttribute(`var:size`, `0.75rem`);
decrement.setAttribute(`var:fill`, `currentColor`);
decrement.ariaHidden = true;
decrement.classList.value = `decrement`;
decrement.addEventListener(`mousedown`, this.#decrement.bind(this));
// Construct the DOM
container.appendChild(decrement);
container.appendChild(input);
container.appendChild(increment);
this._shadow.appendChild(container);
/*
This converts all of the namespace prefixed properties on the element to
CSS variables so that they don't all need to be provided by doing style=""
*/
for (const attrVar of this.attributes) {
if (attrVar.name?.startsWith(`var:`)) {
const prop = attrVar.name.replace(`var:`, ``);
this.style.setProperty(`--` + prop, attrVar.value);
};
};
};
#updateValue() {
let value = parseInt(this.#input.value);
if (this.getAttribute(`min`)) {
value = Math.max(this._min, value);
}
if (this.getAttribute(`max`)) {
value = Math.min(this._max, value);
}
this.#input.value = value;
this.value = value;
this.dispatchEvent(new Event(`change`, { bubbles: true }));
};
/** @param {Event} $e */
#increment($e) {
$e.preventDefault();
let value = parseInt(this.#input.value);
value += $e.ctrlKey ? this._largeStep : this._smallStep;
this.#input.value = value;
this.#updateValue();
};
/** @param {Event} $e */
#decrement($e) {
$e.preventDefault();
let value = parseInt(this.#input.value);
value -= $e.ctrlKey ? this._largeStep : this._smallStep;
this.#input.value = value;
this.#updateValue();
};
};

View file

@ -1,5 +1,3 @@
import { filePath } from "../../consts.mjs";
/** /**
* @param {HTMLElement} Base * @param {HTMLElement} Base
*/ */
@ -13,9 +11,9 @@ export function StyledShadowElement(Base) {
/** /**
* The stringified CSS to use * The stringified CSS to use
* @type {Map<string, string>} * @type {string}
*/ */
static _styles = new Map(); static _styles;
/** /**
* The HTML element of the stylesheet * The HTML element of the stylesheet
@ -26,6 +24,12 @@ export function StyledShadowElement(Base) {
/** @type {ShadowRoot} */ /** @type {ShadowRoot} */
_shadow; _shadow;
/**
* The hook ID for this element's CSS hot reload
* @type {number}
*/
#cssHmr;
constructor() { constructor() {
super(); super();
@ -36,28 +40,38 @@ export function StyledShadowElement(Base) {
#mounted = false; #mounted = false;
connectedCallback() { connectedCallback() {
if (this.#mounted) { return }; if (this.#mounted) { return }
this._getStyles(); this._getStyles();
if (game.settings.get(`dotdungeon`, `devMode`)) {
this.#cssHmr = Hooks.on(`dd-hmr:css`, (data) => {
if (data.path.endsWith(this.constructor._stylePath)) {
this._style.innerHTML = data.content;
};
});
};
this.#mounted = true; this.#mounted = true;
}; };
disconnectedCallback() { disconnectedCallback() {
if (!this.#mounted) { return }; if (!this.#mounted) { return }
if (this.#cssHmr != null) {
Hooks.off(`dd-hmr:css`, this.#cssHmr);
this.#cssHmr = null;
};
this.#mounted = false; this.#mounted = false;
}; };
_getStyles() { _getStyles() {
// TODO: Cache the CSS content in a more sane way that doesn't break if (this.constructor._styles) {
const stylePath = this.constructor._stylePath; this._style.innerHTML = this.constructor._styles;
if (this.constructor._styles.has(stylePath)) {
this._style.innerHTML = this.constructor._styles.get(stylePath);
} else { } else {
fetch(filePath(`styles/components/${stylePath}`)) fetch(`./systems/dotdungeon/.styles/${this.constructor._stylePath}`)
.then(r => r.text()) .then(r => r.text())
.then(t => { .then(t => {
this.constructor._styles.set(stylePath, t); this.constructor._styles = t;
this._style.innerHTML = t; this._style.innerHTML = t;
}); });
} }

138
src/components/range.mjs Normal file
View file

@ -0,0 +1,138 @@
import { StyledShadowElement } from "./mixins/Styles.mjs";
/**
Attributes:
@property {string} name - The path to the value to update in the datamodel
@property {number} value - The actual value of the input
@property {number} max - The maximum value that this range has
@extends {HTMLElement}
*/
export class SystemRange
extends StyledShadowElement(
HTMLElement,
{ mode: `open`, delegatesFocus: true },
) {
static elementName = `dd-range`;
static formAssociated = true;
static observedAttributes = [`max`];
static _stylePath = `v3/components/range.css`;
_internals;
#input;
constructor() {
super();
// Form internals
this._internals = this.attachInternals();
this._internals.role = `spinbutton`;
};
get form() {
return this._internals.form;
};
get name() {
return this.getAttribute(`name`);
};
set name(value) {
this.setAttribute(`name`, value);
};
get value() {
try {
return parseInt(this.getAttribute(`value`));
} catch {
throw new Error(`Failed to parse attribute: "value" - Make sure it's an integer`);
};
};
set value(value) {
this.setAttribute(`value`, value);
};
get max() {
try {
return parseInt(this.getAttribute(`max`));
} catch {
throw new Error(`Failed to parse attribute: "max" - Make sure it's an integer`);
};
};
set max(value) {
this.setAttribute(`max`, value);
};
get type() {
return `number`;
};
connectedCallback() {
super.connectedCallback();
// Attribute validation
if (!this.hasAttribute(`max`)) {
throw new Error(`dotdungeon | Cannot have a range without a maximum value`);
};
// Keyboard accessible input for the thing
this.#input = document.createElement(`input`);
this.#input.type = `number`;
this.#input.min = 0;
this.#input.max = this.max;
this.#input.value = this.value;
this.#input.addEventListener(`change`, () => {
const inputValue = parseInt(this.#input.value);
if (inputValue === this.value) { return };
this._updateValue.bind(this)(Math.sign(this.value - inputValue));
this._updateValue(Math.sign(this.value - inputValue));
});
this._shadow.appendChild(this.#input);
// Shadow-DOM construction
this._elements = new Array(this.max);
const container = document.createElement(`div`);
container.classList.add(`container`);
// Creating the node for filled content
const filledContainer = document.createElement(`div`);
filledContainer.classList.add(`range-increment`, `filled`);
const filledNode = this.querySelector(`[slot="filled"]`);
if (filledNode) { filledContainer.appendChild(filledNode) };
const emptyContainer = document.createElement(`div`);
emptyContainer.classList.add(`range-increment`, `empty`);
const emptyNode = this.querySelector(`[slot="empty"]`);
if (emptyNode) { emptyContainer.appendChild(emptyNode) };
this._elements.fill(filledContainer, 0, this.value);
this._elements.fill(emptyContainer, this.value);
container.append(...this._elements.map((slot, i) => {
const node = slot.cloneNode(true);
node.setAttribute(`data-index`, i + 1);
node.addEventListener(`click`, () => {
const filled = node.classList.contains(`filled`);
this._updateValue(filled ? -1 : 1);
});
return node;
}));
this._shadow.appendChild(container);
/*
This converts all of the namespace prefixed properties on the element to
CSS variables so that they don't all need to be provided by doing style=""
*/
for (const attrVar of this.attributes) {
if (attrVar.name?.startsWith(`var:`)) {
const prop = attrVar.name.replace(`var:`, ``);
this.style.setProperty(`--` + prop, attrVar.value);
};
};
};
_updateValue(delta) {
this.value += delta;
this.dispatchEvent(new Event(`change`, { bubbles: true }));
};
};

3
src/consts.mjs Normal file
View file

@ -0,0 +1,3 @@
export const FEATURE_FLAGS = Object.freeze({
ROLLMODECONTENT: `Roll Mode Message Content`,
});

View file

@ -0,0 +1,11 @@
import { createDocumentProxy } from "../../utils/createDocumentProxy.mjs";
/**
* An object of Foundry-types to in-code Document classes.
*/
const classes = {};
/** The class that will be used if no type-specific class is defined */
const defaultClass = ActiveEffect;
export const ActiveEffectProxy = createDocumentProxy(defaultClass, classes);

View file

@ -0,0 +1,5 @@
export class Player extends Actor {
getRollData() {
return this.system;
};
};

View file

@ -0,0 +1,12 @@
export class PlayerData extends foundry.abstract.TypeDataModel {
static defineSchema() {
const fields = foundry.data.fields;
return {
content: new fields.HTMLField({
blank: true,
trim: true,
initial: ``,
}),
};
};
};

View file

@ -0,0 +1,11 @@
import { createDocumentProxy } from "../../utils/createDocumentProxy.mjs";
/**
* An object of Foundry-types to in-code Document classes.
*/
const classes = {};
/** The class that will be used if no type-specific class is defined */
const defaultClass = Actor;
export const ActorProxy = createDocumentProxy(defaultClass, classes);

View file

@ -0,0 +1,11 @@
import { createDocumentProxy } from "../../utils/createDocumentProxy.mjs";
/**
* An object of Foundry-types to in-code Document classes.
*/
const classes = {};
/** The class that will be used if no type-specific class is defined */
const defaultClass = ChatMessage;
export const ChatMessageProxy = createDocumentProxy(defaultClass, classes);

View file

@ -0,0 +1,11 @@
import { createDocumentProxy } from "../../utils/createDocumentProxy.mjs";
/**
* An object of Foundry-types to in-code Document classes.
*/
const classes = {};
/** The class that will be used if no type-specific class is defined */
const defaultClass = Item;
export const ItemProxy = createDocumentProxy(defaultClass, classes);

18
src/helpers/_index.mjs Normal file
View file

@ -0,0 +1,18 @@
import { handlebarsLocalizer, localizer } from "../utils/localizer.mjs";
import { options } from "./options.mjs";
export function registerHandlebarsHelpers() {
const helperPrefix = game.system.id;
return {
// MARK: Complex helpers
[`${helperPrefix}-i18n`]: handlebarsLocalizer,
[`${helperPrefix}-options`]: options,
// MARK: Simple helpers
[`${helperPrefix}-stringify`]: v => JSON.stringify(v, null, ` `),
[`${helperPrefix}-empty`]: v => v.length == 0,
[`${helperPrefix}-set-has`]: (s, k) => s.has(k),
[`${helperPrefix}-empty-state`]: (v) => v ?? localizer(`${game.system.id}.common.empty`),
};
};

View file

@ -1,3 +1,5 @@
import { localizer } from "../utils/localizer.mjs";
/** /**
* @typedef {object} Option * @typedef {object} Option
* @property {string} [label] * @property {string} [label]
@ -6,9 +8,8 @@
*/ */
/** /**
* @param {string | number} selected The selected value * @param {string | number} selected
* @param {Array<Option | string>} opts The options that are valid * @param {Array<Option | string>} opts
* @param {any} meta The Handlebars meta processing
*/ */
export function options(selected, opts, meta) { export function options(selected, opts, meta) {
const { localize = false } = meta.hash; const { localize = false } = meta.hash;
@ -16,7 +17,7 @@ export function options(selected, opts, meta) {
const htmlOptions = []; const htmlOptions = [];
for (let opt of opts) { for (let opt of opts) {
if (typeof opt === `string`) { if (foundry.utils.getType(opt) === `string`) {
opt = { label: opt, value: opt }; opt = { label: opt, value: opt };
}; };
opt.value = Handlebars.escapeExpression(opt.value); opt.value = Handlebars.escapeExpression(opt.value);
@ -26,9 +27,9 @@ export function options(selected, opts, meta) {
${selected === opt.value ? `selected` : ``} ${selected === opt.value ? `selected` : ``}
${opt.disabled ? `disabled` : ``} ${opt.disabled ? `disabled` : ``}
> >
${localize ? game.i18n.format(opt.label) : opt.label} ${localize ? localizer(opt.label) : opt.label}
</option>`, </option>`,
); );
}; };
return new Handlebars.SafeString(htmlOptions.join(`\n`)); return htmlOptions.join(`\n`);
}; };

18
src/hooks/hotReload.mjs Normal file
View file

@ -0,0 +1,18 @@
const loaders = {
svg(data) {
const iconName = data.path.split(`/`).slice(-1)[0].slice(0, -4);
Logger.debug(`hot-reloading icon: ${iconName}`);
Hooks.call(`${game.system.id}-hmr:svg`, iconName, data);
},
js() {window.location.reload()},
mjs() {window.location.reload()},
css(data) {
Logger.debug(`Hot-reloading CSS: ${data.path}`);
Hooks.call(`${game.system.id}-hmr:css`, data);
},
};
Hooks.on(`hotReload`, async (data) => {
if (!loaders[data.extension]) {return}
return loaders[data.extension](data);
});

View file

@ -0,0 +1,21 @@
import { FEATURE_FLAGS } from "../consts.mjs";
Hooks.on(`renderChatMessage`, (msg, html) => {
// Short-Circuit when the flag isn't set for the message
if (msg.getFlag(`taf`, `rollModedContent`)) {
return;
}
const featureFlags = game.settings.get(game.system.id, `flags`);
const featureFlagEnabled = featureFlags.includes(FEATURE_FLAGS.ROLLMODECONTENT);
const contentElement = html.find(`.message-content`)[0];
let content = contentElement.innerHTML;
if (featureFlagEnabled && msg.blind && !game.user.isGM) {
content = content.replace(/-=.*?=-/gm, `???`);
} else {
content = content.replace(/-=|=-/gm, ``);
}
contentElement.innerHTML = content;
});

59
src/main.mjs Normal file
View file

@ -0,0 +1,59 @@
// Document Imports
import { ActiveEffectProxy } from "./documents/ActiveEffect/_proxy.mjs";
import { ActorProxy } from "./documents/Actor/_proxy.mjs";
import { ChatMessageProxy } from "./documents/ChatMessage/_proxy.mjs";
import { ItemProxy } from "./documents/Item/_proxy.mjs";
// DataModel Imports
import { PlayerData } from "./documents/Actor/Player/Model.mjs";
// Hook Imports
import "./hooks/renderChatMessage.mjs";
import "./hooks/hotReload.mjs";
// Misc Imports
import "./utils/globalTaf.mjs";
import "./utils/logger.mjs";
import "./utils/DialogManager.mjs";
import { registerCustomComponents } from "./components/_index.mjs";
import { registerHandlebarsHelpers } from "./helpers/_index.mjs";
import { registerSettings } from "./settings/_index.mjs";
import { registerSheets } from "./sheets/_index.mjs";
// MARK: init hook
Hooks.once(`init`, () => {
Logger.info(`Initializing`);
CONFIG.ActiveEffect.legacyTransferral = false;
registerSettings();
// Data Models
CONFIG.Actor.dataModels.player = PlayerData;
// Update document classes
CONFIG.Actor.documentClass = ActorProxy;
CONFIG.Item.documentClass = ItemProxy;
CONFIG.ActiveEffect.documentClass = ActiveEffectProxy;
CONFIG.ChatMessage.documentClass = ChatMessageProxy;
registerSheets();
registerHandlebarsHelpers();
registerCustomComponents();
});
// MARK: ready hook
Hooks.once( `ready`, () => {
Logger.info(`Ready`);
let defaultTab = game.settings.get(game.system.id, `defaultTab`);
if (defaultTab) {
if (!ui.sidebar?.tabs?.[defaultTab]) {
Logger.error(`Couldn't find a sidebar tab with ID:`, defaultTab);
} else {
Logger.debug(`Switching sidebar tab to:`, defaultTab);
ui.sidebar.tabs[defaultTab].activate();
};
};
});

10
src/settings/_index.mjs Normal file
View file

@ -0,0 +1,10 @@
import { registerClientSettings } from "./client_settings.mjs";
import { registerDevSettings } from "./dev_settings.mjs";
import { registerWorldSettings } from "./world_settings.mjs";
export function registerSettings() {
Logger.debug(`Registering settings`);
registerClientSettings();
registerWorldSettings();
registerDevSettings();
};

View file

@ -0,0 +1,2 @@
export function registerClientSettings() {
};

View file

@ -0,0 +1,16 @@
export function registerDevSettings() {
game.settings.register(game.system.id, `devMode`, {
scope: `client`,
type: Boolean,
config: false,
default: false,
requiresReload: true,
});
game.settings.register(game.system.id, `defaultTab`, {
scope: `client`,
type: String,
config: false,
requiresReload: false,
});
};

View file

@ -0,0 +1,24 @@
import { FEATURE_FLAGS } from "../consts.mjs";
export function registerWorldSettings() {
game.settings.register(game.system.id, `flags`, {
name: `Feature Flags`,
hint: `World-based feature flags that are used to enable/disable specific behaviours`,
scope: `world`,
type: new foundry.data.fields.SetField(
new foundry.data.fields.StringField(
{
empty: false,
trim: true,
options: Object.values(FEATURE_FLAGS),
},
),
{
required: false,
initial: new Set(),
},
),
config: true,
requiresReload: true,
});
};

26
src/sheets/Player/v1.mjs Normal file
View file

@ -0,0 +1,26 @@
export class PlayerSheetv1 extends ActorSheet {
static get defaultOptions() {
let opts = foundry.utils.mergeObject(
super.defaultOptions,
{
template: `systems/${game.system.id}/templates/Player/v1/main.hbs`,
classes: [],
},
);
opts.classes = [`actor--player`, `style-v1`];
return opts;
};
async getData() {
const ctx = {};
ctx.editable = this.isEditable;
const actor = ctx.actor = this.actor;
ctx.system = actor.system;
ctx.enriched = { system: {} };
ctx.enriched.system.content = await TextEditor.enrichHTML(actor.system.content);
return ctx;
};
}

11
src/sheets/_index.mjs Normal file
View file

@ -0,0 +1,11 @@
import { PlayerSheetv1 } from "./Player/v1.mjs";
export function registerSheets() {
Logger.debug(`Registering sheets`);
Actors.registerSheet(game.system.id, PlayerSheetv1, {
makeDefault: true,
types: [`player`],
label: `Hello`,
});
};

178
src/utils/DialogManager.mjs Normal file
View file

@ -0,0 +1,178 @@
import { localizer } from "./localizer.mjs";
/**
* A utility class that allows managing Dialogs that are created for various
* purposes such as deleting items, help popups, etc. This is a singleton class
* that upon instantiating after the first time will just return the first instance
*/
export class DialogManager {
/** @type {Map<string, Dialog>} */
static #dialogs = new Map();
/**
* Focuses a dialog if it already exists, or creates a new one and renders it.
*
* @param {string} dialogId The ID to associate with the dialog, should be unique
* @param {object} data The data to pass to the Dialog constructor
* @param {DialogOptions} opts The options to pass to the Dialog constructor
* @returns {Dialog} The Dialog instance
*/
static async createOrFocus(dialogId, data, opts = {}) {
if (DialogManager.#dialogs.has(dialogId)) {
const dialog = DialogManager.#dialogs.get(dialogId);
dialog.bringToTop();
return dialog;
};
/*
This makes sure that if I provide a close function as a part of the data,
that the dialog still gets removed from the set once it's closed, otherwise
it could lead to dangling references that I don't care to keep. Or if I don't
provide the close function, it just sets the function as there isn't anything
extra that's needed to be called.
*/
if (data?.close) {
const provided = data.close;
data.close = () => {
DialogManager.#dialogs.delete(dialogId);
provided();
};
} else {
data.close = () => DialogManager.#dialogs.delete(dialogId);
};
// Create the Dialog with the modified data
const dialog = new Dialog(data, opts);
DialogManager.#dialogs.set(dialogId, dialog);
dialog.render(true);
return dialog;
};
/**
* Closes a dialog if it is rendered
*
* @param {string} dialogId The ID of the dialog to close
*/
static async close(dialogId) {
const dialog = DialogManager.#dialogs.get(dialogId);
dialog?.close();
};
static async helpDialog(
helpId,
helpContent,
helpTitle = `dotdungeon.common.help`,
localizationData = {},
) {
DialogManager.createOrFocus(
helpId,
{
title: localizer(helpTitle, localizationData),
content: localizer(helpContent, localizationData),
buttons: {},
},
{ resizable: true },
);
};
/**
* Asks the user to provide a simple piece of information, this is primarily
* intended to be used within macros so that it can have better info gathering
* as needed. This returns an object of input keys/labels to the value the user
* input for that label, if there is only one input, this will return the value
* without an object wrapper, allowing for easier access.
*/
static async ask(data, opts = {}) {
if (!data.id) {
throw new Error(`Asking the user for input must contain an ID`);
}
if (!data.inputs.length) {
throw new Error(`Must include at least one input specification when prompting the user`);
}
let autofocusClaimed = false;
for (const i of data.inputs) {
i.id ??= foundry.utils.randomID(16);
i.inputType ??= `text`;
// Only ever allow one input to claim autofocus
i.autofocus &&= !autofocusClaimed;
autofocusClaimed ||= i.autofocus;
// Set the value's attribute name if it isn't specified explicitly
if (!i.valueAttribute) {
switch (i.inputType) {
case `checkbox`:
i.valueAttribute = `checked`;
break;
default:
i.valueAttribute = `value`;
};
};
};
opts.jQuery = true;
data.default ??= `confirm`;
data.title ??= `System Question`;
data.content = await renderTemplate(
`systems/${game.system.id}/templates/Dialogs/ask.hbs`,
data,
);
return new Promise((resolve, reject) => {
DialogManager.createOrFocus(
data.id,
{
...data,
buttons: {
confirm: {
label: `Confirm`,
callback: (html) => {
const answers = {};
/*
Retrieve the answer for every input provided using the ID
determined during initial data prep, and assign the value
to the property of the label in the object.
*/
for (const i of data.inputs) {
const element = html.find(`#${i.id}`)[0];
let value = element.value;
switch (i.inputType) {
case `number`:
value = parseFloat(value);
break;
case `checkbox`:
value = element.checked;
break;
}
Logger.debug(`Ask response: ${value} (type: ${typeof value})`);
answers[i.key ?? i.label] = value;
if (data.inputs.length === 1) {
resolve(value);
return;
}
}
resolve(answers);
},
},
cancel: {
label: `Cancel`,
callback: () => reject(`User cancelled the prompt`),
},
},
},
opts,
);
});
};
static get size() {
return DialogManager.#dialogs.size;
}
};
globalThis.DialogManager = DialogManager;

View file

@ -0,0 +1,39 @@
export function createDocumentProxy(defaultClass, classes) {
// eslint-disable-next-line func-names
return new Proxy(function () {}, {
construct(_target, args) {
const [data] = args;
if (!classes[data.type]) {
return new defaultClass(...args);
}
return new classes[data.type](...args);
},
get(_target, prop, _receiver) {
if ([`create`, `createDocuments`].includes(prop)) {
return (data, options) => {
if (data.constructor === Array) {
return data.map(i => this.constructor.create(i, options));
}
if (!classes[data.type]) {
return defaultClass.create(data, options);
}
return classes[data.type].create(data, options);
};
};
if (prop == Symbol.hasInstance) {
return (instance) => {
if (instance instanceof defaultClass) {return true}
return Object.values(classes).some(i => instance instanceof i);
};
};
return defaultClass[prop];
},
});
};

View file

@ -0,0 +1,10 @@
import { FEATURE_FLAGS } from "../../consts.mjs";
export function hideMessageText(content) {
const featureFlags = game.settings.get(game.system.id, `flags`);
const hideContent = featureFlags.includes(FEATURE_FLAGS.ROLLMODECONTENT);
if (hideContent) {
return `-=${content}=-`;
}
return content;
};

11
src/utils/globalTaf.mjs Normal file
View file

@ -0,0 +1,11 @@
import { FEATURE_FLAGS } from "../consts.mjs";
import { hideMessageText } from "./feature_flags/rollModeMessageContent.mjs";
globalThis.taf = Object.freeze({
utils: {
hideMessageText,
},
const: {
FEATURE_FLAGS,
},
});

45
src/utils/localizer.mjs Normal file
View file

@ -0,0 +1,45 @@
/** A handlebars helper that utilizes the recursive localizer */
export function handlebarsLocalizer(key, ...args) {
let data = args[0];
if (args.length === 1) { data = args[0].hash }
if (key instanceof Handlebars.SafeString) {key = key.toString()}
const localized = localizer(key, data);
return localized;
};
/**
* A localizer that allows recursively localizing strings so that localized strings
* that want to use other localized strings can.
*
* @param {string} key The localization key to retrieve
* @param {object?} args The arguments provided to the localizer for replacement
* @param {number?} depth The current depth of the localizer
* @returns The localized string
*/
export function localizer(key, args = {}, depth = 0) {
/** @type {string} */
let localized = game.i18n.format(key, args);
const subkeys = localized.matchAll(/@(?<key>[a-zA-Z.]+)/gm);
// Short-cut to help prevent infinite recursion
if (depth > 10) {
return localized;
};
/*
Helps prevent localization on the same key so that we aren't doing excess work.
*/
const localizedSubkeys = new Map();
for (const match of subkeys) {
const subkey = match.groups.key;
if (localizedSubkeys.has(subkey)) {continue}
localizedSubkeys.set(subkey, localizer(subkey, args, depth + 1));
};
return localized.replace(
/@(?<key>[a-zA-Z.]+)/gm,
(_fullMatch, subkey) => {
return localizedSubkeys.get(subkey);
},
);
};

View file

@ -12,10 +12,10 @@ const augmentedProps = new Set([
]); ]);
/** @type {Console} */ /** @type {Console} */
export const Logger = new Proxy(console, { globalThis.Logger = new Proxy(console, {
get(target, prop, _receiver) { get(target, prop, _receiver) {
if (augmentedProps.has(prop)) { if (augmentedProps.has(prop)) {
return target[prop].bind(target, game.system.id, `|`); return (...args) => target[prop](game.system.id, `|`, ...args);
}; };
return target[prop]; return target[prop];
}, },

View file

@ -1,55 +0,0 @@
.taf.Ask {
min-width: 330px;
.prompt {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 1rem;
align-items: center;
}
.window-content {
gap: 1rem;
overflow: auto;
}
.dialog-content {
display: flex;
flex-direction: column;
gap: 8px;
}
.control-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
label {
color: var(--color-form-label);
font-weight: bold;
}
p {
margin: 0;
text-indent: 1em;
&.error {
font-size: 1.1rem;
padding: 6px 8px;
box-shadow: 0 0 10px var(--color-shadow-dark);
color: var(--color-text-light-1);
border-radius: 5px;
text-align: center;
background: var(--color-level-error-bg);
border: 1px solid var(--color-level-error);
text-indent: 0;
}
}
input[type="checkbox"] {
align-self: center;
justify-self: right;
margin: 0;
}
}

View file

@ -1,50 +0,0 @@
.taf.AttributeManager {
.attributes {
display: flex;
flex-direction: column;
gap: 8px;
}
.attribute {
display: grid;
grid-template-columns: min-content 1fr repeat(3, auto);
align-items: center;
gap: 8px;
padding: 8px;
border: 1px solid rebeccapurple;
border-radius: 4px;
label {
display: flex;
flex-direction: row;
align-items: center;
&.vertical {
flex-direction: column;
}
}
/* Used to style the actual element as dragging */
&:has(taf-icon:active) {
background: var(--background);
}
}
taf-icon {
cursor: grab;
&:active {
cursor: grabbing;
}
}
.controls {
display: flex;
flex-direction: row;
gap: 8px;
button {
flex-grow: 1;
}
}
}

View file

@ -1,58 +0,0 @@
.taf.PlayerSheet {
.sheet-header, fieldset, .content {
border-radius: 8px;
border: 1px solid rebeccapurple;
}
.sheet-header {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
padding: 4px;
img {
border-radius: 4px;
}
}
.attributes {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-around;
gap: 0.5rem;
}
.attr-range {
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
width: 100px;
margin: 0 auto;
> input {
text-align: center;
}
}
.content {
flex-grow: 1;
overflow: hidden;
--table-row-color-odd: var(--table-header-bg-color);
&:not(:has(> prose-mirror)) {
padding: 0.5rem;
}
}
prose-mirror {
height: 100%;
menu {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
}
}

View file

@ -1,33 +0,0 @@
.taf.QueryStatus {
.user-list {
display: flex;
flex-direction: column;
gap: 4px;
list-style-type: none;
margin: 0;
padding: 0;
li {
display: flex;
flex-direction: column;
margin: 0;
border: 1px solid rebeccapurple;
border-radius: 4px;
padding: 4px 8px;
> .user-summary {
display: flex;
flex-direction: row;
align-items: center;
/* Same height as the icons used for loading/disconnected */
height: 35px;
}
}
}
.control-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
}

View file

@ -1,15 +0,0 @@
.taf.sheet-config {
section {
display: flex;
flex-direction: column;
gap: 1rem;
}
.tab {
display: none;
}
.tab.active {
display: unset;
}
}

View file

@ -1,9 +0,0 @@
.taf {
> .window-content {
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
overflow: auto;
}
}

View file

@ -1,23 +0,0 @@
:host {
display: inline-block;
}
div {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}
svg {
width: var(--size, 1rem);
height: var(--size, 1rem);
fill: var(--fill);
}
path {
stroke: var(--stroke);
stroke-width: var(--stroke-width);
stroke-linejoin: var(--stroke-linejoin);
}

View file

@ -1,22 +0,0 @@
:host {
display: inline-block;
}
div {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}
svg {
fill: var(--fill);
stroke: var(--stroke);
}
path {
stroke: var(--stroke);
stroke-width: var(--stroke-width);
stroke-linejoin: var(--stroke-linejoin);
}

View file

@ -1,20 +0,0 @@
.taf > .window-content div {
&.chip {
display: inline flex;
color: var(--chip-color);
background: var(--chip-background);
border: 1px solid var(--chip-border-color);
border-radius: 4px;
.key {
padding: 2px 4px;
}
.value {
padding: 2px 4px;
border-radius: 0 4px 4px 0;
color: var(--chip-value-color);
background: var(--chip-value-background);
}
}
}

View file

@ -1,5 +0,0 @@
.taf > .window-content {
h1, h2, h3, h4, h5, h6 {
margin: 0;
}
}

View file

@ -1,7 +0,0 @@
.taf > .window-content hr {
height: 1px;
background: rebeccapurple;
border-radius: 0;
margin: 0;
padding: 0;
}

View file

@ -1,56 +0,0 @@
.taf > .window-content input {
&.large {
--input-height: 2.5rem;
font-size: 1.75rem;
}
&[type="checkbox"] {
--checkbox-checked-color: var(--color-warm-1);
width: var(--checkbox-size);
height: var(--checkbox-size);
background: var(--input-background-color);
border: 2px solid var(--color-cool-3);
position: relative;
border-radius: 4px;
cursor: pointer;
&::before, &::after {
display: none;
}
&:focus-visible {
outline: 2px solid var(--checkbox-checked-color);
outline-offset: 3px;
}
&:checked::after {
display: block;
position: absolute;
inset: 4px;
z-index: 1;
content: "";
border-radius: 4px;
background: var(--checkbox-checked-color);
cursor: pointer;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
&::before {
display: block;
position: absolute;
inset: 0;
content: "";
background: var(--color-level-error-bg);
border-radius: 2px;
cursor: not-allowed;
}
&::after {
cursor: not-allowed;
}
}
}
}

View file

@ -1,9 +0,0 @@
.taf > .window-content p {
&:first-child {
margin-top: 0;
}
&:last-child {
margin-bottom: 0;
}
}

View file

@ -1,13 +0,0 @@
.taf > .window-content prose-mirror {
background: var(--prosemirror-background);
gap: 0;
.editor-content {
padding: 8px;
}
.tableWrapper th,
.tableWrapper td {
border-color: rebeccapurple;
}
}

View file

@ -1,45 +0,0 @@
@keyframes rotate {
0% { transform: rotate(0deg); }
50% { transform: rotate(360deg); }
100% { transform: rotate(720deg); }
}
@keyframes prixClipFix {
0%, 100% {
clip-path: polygon(50% 50%,0 0,0 0,0 0,0 0,0 0);
}
25%, 63% {
clip-path: polygon(50% 50%,0 0,100% 0,100% 0,100% 0,100% 0);
}
37%, 50% {
clip-path: polygon(50% 50%,0 0,100% 0,100% 100%,100% 100%,100% 100%);
}
}
.taf > .window-content span {
&.loader {
--size: 35px;
width: var(--size);
height: var(--size);
border-radius: 50%;
position: relative;
animation: rotate 2s linear infinite;
display: block;
&::before, &::after {
content: "";
box-sizing: border-box;
position: absolute;
inset: 0px;
border-radius: 50%;
border: 5px solid var(--spinner-outer-colour, #fff);
animation: prixClipFix 4s linear infinite;
}
&::after{
inset: 8px;
transform: rotate3d(90, 90, 0, 180deg );
border-color: var(--spinner-inner-colour, #ff3d00);
}
}
}

View file

@ -1,21 +0,0 @@
/*
This styling is unscoped in order to make it so that it still applies
to the chat messages which are not within a scope I control.
*/
table.taf-query-summary {
margin: 0px;
tr:hover > td {
background-color: var(--table-header-border-highlight);
}
td {
padding: 4px 8px;
border: 1px solid var(--table-header-border-color);
width: 40%;
&:first-of-type {
width: 60%;
}
}
}

View file

@ -1,3 +0,0 @@
.taf > .window-content {
.grow { flex-grow: 1; }
}

View file

@ -1,29 +0,0 @@
@layer resets, themes, elements, components, partials, apps, exceptions;
/* Resets */
@import url("./resets/hr.css") layer(resets);
@import url("./resets/inputs.css") layer(resets);
@import url("./resets/button.css") layer(resets);
/* Themes */
@import url("./themes/dark.css") layer(themes);
@import url("./themes/light.css") layer(themes);
/* Elements */
@import url("./elements/utils.css") layer(elements);
@import url("./elements/div.css") layer(elements);
@import url("./elements/headers.css") layer(elements);
@import url("./elements/hr.css") layer(elements);
@import url("./elements/input.css") layer(elements);
@import url("./elements/p.css") layer(elements);
@import url("./elements/prose-mirror.css") layer(elements);
@import url("./elements/span.css") layer(elements);
@import url("./elements/table.css") layer(elements);
/* Apps */
@import url("./Apps/common.css") layer(apps);
@import url("./Apps/Ask.css") layer(apps);
@import url("./Apps/AttributeManager.css") layer(apps);
@import url("./Apps/PlayerSheet.css") layer(apps);
@import url("./Apps/QueryStatus.css") layer(apps);
@import url("./Apps/TAFDocumentSheetConfig.css") layer(apps);

View file

@ -1,3 +0,0 @@
.taf > .window-content button {
height: initial;
}

View file

@ -1,3 +0,0 @@
.taf > .window-content hr {
all: initial;
}

Some files were not shown because too many files have changed in this diff Show more