Data Request API helper #10

Merged
Oliver merged 94 commits from feat/data-requests into main 2025-11-22 02:51:15 +00:00
42 changed files with 5587 additions and 2187 deletions

View file

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

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

View file

@ -1,55 +0,0 @@
name: Create Draft Release
on: [workflow_dispatch]
jobs:
draft:
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
# Compile the stuff that needs to be compiled
- run: npm run build
- run: node scripts/buildCompendia.mjs
- name: Update the manifest with the relevant properties
id: manifest-update
uses: microsoft/variable-substitution@v1
with:
files: "system.json"
env:
download: "https://github.com/${{ github.repository }}/releases/download/v${{ steps.version.outputs.version }}/release.zip"
- name: Create the zip
run: zip -r release.zip langs module styles templates system.json README.md assets
- name: Create the draft release
uses: ncipollo/release-action@v1
with:
tag: "v${{ steps.version.outputs.version }}"
commit: ${{ github.ref }}
draft: true
body: <img aria-hidden="true" src="https://img.shields.io/github/downloads/${{ github.repository }}/v${{ steps.version.outputs.version }}/release.zip?style=flat-square&color=%2300aa00">
generateReleaseNotes: true
artifacts: "release.zip,system.json"

View file

@ -8,7 +8,6 @@
"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 "foundry": true

View file

@ -2,3 +2,17 @@
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

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

After

Width:  |  Height:  |  Size: 900 B

View file

@ -16,6 +16,7 @@ export default [
languageOptions: { languageOptions: {
globals: { globals: {
CONFIG: `writable`, CONFIG: `writable`,
CONST: `readonly`,
game: `readonly`, game: `readonly`,
Handlebars: `readonly`, Handlebars: `readonly`,
Hooks: `readonly`, Hooks: `readonly`,
@ -72,7 +73,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": [`warn`, `1tbs`, { "allowSingleLine": true }], "@stylistic/brace-style": [`off`],
"@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

@ -14,7 +14,20 @@
"sheet-names": { "sheet-names": {
"PlayerSheet": "Player Sheet" "PlayerSheet": "Player Sheet"
}, },
"misc": {
"Key": "Key",
"Value": "Value",
"no-data-submitted": "No data submitted",
"data-query-notif-header": "Data Query Notification"
},
"Apps": { "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": { "TAFDocumentSheetConfig": {
"Sizing": "Sizing", "Sizing": "Sizing",
"Width": { "Width": {
@ -31,6 +44,17 @@
"system": "Text-Based Actors" "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

@ -2,10 +2,13 @@
import { Ask } from "./apps/Ask.mjs"; import { Ask } from "./apps/Ask.mjs";
import { AttributeManager } from "./apps/AttributeManager.mjs"; import { AttributeManager } from "./apps/AttributeManager.mjs";
import { PlayerSheet } from "./apps/PlayerSheet.mjs"; import { PlayerSheet } from "./apps/PlayerSheet.mjs";
import { QueryStatus } from "./apps/QueryStatus.mjs";
// Utils // Utils
import { attributeSorter } from "./utils/attributeSort.mjs"; import { attributeSorter } from "./utils/attributeSort.mjs";
import { DialogManager } from "./utils/DialogManager.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"; import { toID } from "./utils/toID.mjs";
const { deepFreeze } = foundry.utils; const { deepFreeze } = foundry.utils;
@ -16,13 +19,16 @@ Object.defineProperty(
{ {
value: deepFreeze({ value: deepFreeze({
DialogManager, DialogManager,
QueryManager,
Apps: { Apps: {
Ask, Ask,
AttributeManager, AttributeManager,
PlayerSheet, PlayerSheet,
QueryStatus,
}, },
utils: { utils: {
attributeSorter, attributeSorter,
localizer,
toID, toID,
}, },
}), }),

View file

@ -12,6 +12,7 @@ const validInputTypes = [
]; ];
export class Ask extends HandlebarsApplicationMixin(ApplicationV2) { export class Ask extends HandlebarsApplicationMixin(ApplicationV2) {
// #region Options
static DEFAULT_OPTIONS = { static DEFAULT_OPTIONS = {
tag: `dialog`, tag: `dialog`,
classes: [ classes: [
@ -47,7 +48,9 @@ export class Ask extends HandlebarsApplicationMixin(ApplicationV2) {
template: filePath(`templates/Ask/controls.hbs`), template: filePath(`templates/Ask/controls.hbs`),
}, },
}; };
// #endregion Options
// #region Instance
_inputs = []; _inputs = [];
alwaysUseAnswerObject = false; alwaysUseAnswerObject = false;
@ -88,6 +91,7 @@ export class Ask extends HandlebarsApplicationMixin(ApplicationV2) {
this._userOnConfirm = onConfirm; this._userOnConfirm = onConfirm;
this._userOnClose = onClose; this._userOnClose = onClose;
}; };
// #endregion Instance
// #region Lifecycle // #region Lifecycle
async _onFirstRender() { async _onFirstRender() {

111
module/apps/QueryStatus.mjs Normal file
View file

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

@ -17,6 +17,7 @@ import { __ID__ } from "../consts.mjs";
import helpers from "../handlebarsHelpers/_index.mjs"; import helpers from "../handlebarsHelpers/_index.mjs";
import { Logger } from "../utils/Logger.mjs"; import { Logger } from "../utils/Logger.mjs";
import { registerCustomComponents } from "../apps/elements/_index.mjs"; import { registerCustomComponents } from "../apps/elements/_index.mjs";
import { registerSockets } from "../sockets/_index.mjs";
Hooks.on(`init`, () => { Hooks.on(`init`, () => {
Logger.debug(`Initializing`); Logger.debug(`Initializing`);
@ -41,6 +42,7 @@ Hooks.on(`init`, () => {
registerWorldSettings(); registerWorldSettings();
registerSockets();
registerCustomComponents(); registerCustomComponents();
Handlebars.registerHelper(helpers); Handlebars.registerHelper(helpers);
}); });

View file

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

View file

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

33
module/sockets/_index.mjs Normal file
View file

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

@ -0,0 +1,19 @@
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`,
Oliver marked this conversation as resolved

Localizing this would be nice

Localizing this would be nice
details: `taf.notifs.error.missing-id`,
},
));
return;
};
await DialogManager.close(id);
};

View file

@ -0,0 +1,25 @@
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({
Oliver marked this conversation as resolved

Localizing this would be nice

Localizing this would be nice
flavor: localizer(`taf.misc.data-query-notif-header`),
content,
whisper,
style: CONST.CHAT_MESSAGE_STYLES.OOC,
});
respondedToQueries.delete(id);
};

View file

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

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

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

@ -0,0 +1,32 @@
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);
},
);
};

4404
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,16 +1,17 @@
{ {
"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", "dotenv": "^17.2.2",
"eslint": "^9.8.0", "eslint": "^9.8.0",
"globals": "^15.9.0" "globals": "^15.9.0"
}, },
"scripts": { "scripts": {
"build": "node scripts/buildCompendia.mjs", "data:build": "node scripts/buildCompendia.mjs",
"extract": "node scripts/extractCompendia.mjs", "data:extract": "node scripts/extractCompendia.mjs",
"link": "node scripts/linkFoundry.mjs", "link": "node scripts/linkFoundry.mjs",
"lint": "eslint --fix", "lint": "eslint --fix",
"lint:nofix": "eslint" "lint:nofix": "eslint"

2119
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

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

@ -0,0 +1,45 @@
/*
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`);

38
scripts/tagExists.mjs Normal file
View file

@ -0,0 +1,38 @@
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();

65
scripts/uploadToS3.mjs Normal file
View file

@ -0,0 +1,65 @@
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) {
Oliver marked this conversation as resolved

Remove log

Remove log
console.error("Upload to s3 failed");
};
Oliver marked this conversation as resolved

Remove inclusion of err

Remove inclusion of `err`
};
main();

View file

@ -23,10 +23,6 @@
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 1rem; gap: 1rem;
button {
flex-grow: 1;
}
} }
label { label {

View file

@ -0,0 +1,33 @@
.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;
}
}

20
styles/elements/div.css Normal file
View file

@ -0,0 +1,20 @@
.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);
}
}
}

45
styles/elements/span.css Normal file
View file

@ -0,0 +1,45 @@
@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);
}
}
}

21
styles/elements/table.css Normal file
View file

@ -0,0 +1,21 @@
/*
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

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

View file

@ -3,21 +3,27 @@
/* Resets */ /* Resets */
@import url("./resets/hr.css") layer(resets); @import url("./resets/hr.css") layer(resets);
@import url("./resets/inputs.css") layer(resets); @import url("./resets/inputs.css") layer(resets);
@import url("./resets/button.css") layer(resets);
/* Themes */ /* Themes */
@import url("./themes/dark.css") layer(themes); @import url("./themes/dark.css") layer(themes);
@import url("./themes/light.css") layer(themes); @import url("./themes/light.css") layer(themes);
/* Elements */ /* 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/headers.css") layer(elements);
@import url("./elements/hr.css") layer(elements); @import url("./elements/hr.css") layer(elements);
@import url("./elements/input.css") layer(elements); @import url("./elements/input.css") layer(elements);
@import url("./elements/p.css") layer(elements); @import url("./elements/p.css") layer(elements);
@import url("./elements/prose-mirror.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 */ /* Apps */
@import url("./Apps/common.css") layer(apps); @import url("./Apps/common.css") layer(apps);
@import url("./Apps/Ask.css") layer(apps); @import url("./Apps/Ask.css") layer(apps);
@import url("./Apps/AttributeManager.css") layer(apps); @import url("./Apps/AttributeManager.css") layer(apps);
@import url("./Apps/PlayerSheet.css") layer(apps); @import url("./Apps/PlayerSheet.css") layer(apps);
@import url("./Apps/QueryStatus.css") layer(apps);
@import url("./Apps/TAFDocumentSheetConfig.css") layer(apps); @import url("./Apps/TAFDocumentSheetConfig.css") layer(apps);

3
styles/resets/button.css Normal file
View file

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

View file

@ -1,3 +1,13 @@
.theme-dark { .theme-dark {
--prosemirror-background: var(--color-cool-5); --prosemirror-background: var(--color-cool-5);
--spinner-outer-colour: white;
--spinner-inner-colour: #FF3D00;
/* Chip Variables */
--chip-color: #fff7ed;
--chip-background: #2b3642;
--chip-value-color: #fff7ed;
--chip-value-background: #10161d;
--chip-border-color: var(--chip-value-background);
} }

View file

@ -1,3 +1,13 @@
.theme-light { .theme-light {
--prosemirror-background: white; --prosemirror-background: white;
--spinner-outer-colour: black;
--spinner-inner-colour: #FF3D00;
/* Chip Variables */
--chip-color: #18181b;
--chip-background: #fafafa;
--chip-value-color: #18181b;
--chip-value-background: #d4d4d8aa;
--chip-border-color: var(--chip-value-background);
} }

View file

@ -2,10 +2,10 @@
"id": "taf", "id": "taf",
"title": "Text-Based Actors", "title": "Text-Based Actors",
"description": "An intentionally minimalist system that enables you to play rules-light games without getting in your way!", "description": "An intentionally minimalist system that enables you to play rules-light games without getting in your way!",
"version": "2.3.0", "version": "2.4.0",
"download": "#{DOWNLOAD}#", "download": "",
"manifest": "https://github.com/Eldritch-Oliver/Text-Actors-Foundry/releases/latest/download/system.json", "manifest": "",
"url": "https://github.com/Eldritch-Oliver/Text-Actors-Foundry", "url": "https://git.varify.ca/Foundry/taf",
"compatibility": { "compatibility": {
"minimum": 13, "minimum": 13,
"verified": 13, "verified": 13,
@ -41,6 +41,7 @@
}, },
"Item": {} "Item": {}
}, },
"socket": true,
"flags": { "flags": {
"hotReload": { "hotReload": {
"extensions": ["css", "hbs", "json", "js", "mjs", "svg"], "extensions": ["css", "hbs", "json", "js", "mjs", "svg"],

View file

@ -0,0 +1,8 @@
<div class="control-row">
<button data-action="cancelRequest">
{{ localize "taf.Apps.QueryStatus.cancel-request" }}
</button>
<button data-action="finishEarly">
{{ localize "taf.Apps.QueryStatus.finish-early" }}
</button>
</div>

View file

@ -0,0 +1,46 @@
<ul class="user-list">
{{#each users as | user |}}
<li
style="--spinner-inner-colour: var(--user-color-{{user.id}})"
data-user-id="{{ user.id }}"
>
<div class="user-summary">
<div class="grow">
{{ user.name }}
</div>
{{#if (eq user.status "waiting")}}
<span class="loader"></span>
{{else if (eq user.status "disconnected")}}
<taf-icon
data-tooltip="taf.Apps.QueryStatus.user-disconnected-tooltip"
name="icons/disconnected"
var:size="35px"
var:stroke="currentColor"
var:fill="currentColor"
></taf-icon>
{{else if (eq user.status "unprompted")}}
<button
type="button"
data-action="promptUser"
>
{{ localize "taf.Apps.QueryStatus.send-request" }}
</button>
{{/if}}
Oliver marked this conversation as resolved

Localize

Localize
</div>
{{#if (eq user.status "finished")}}
<div class="chip-list">
{{#each user.answers as | answer |}}
<div class="chip">
<span class="key">
{{ @key }}
</span>
<span class="value">
{{ answer }}
</span>
</div>
{{/each}}
</div>
{{/if}}
</li>
{{/each}}
</ul>

View file

@ -0,0 +1,16 @@
{{#if answers}}
<table class="taf-query-summary">
<tr>
<td>{{ localize "taf.misc.Key" }}</td>
<td>{{ localize "taf.misc.Value" }}</td>
</tr>
{{#each answers as | answer |}}
<tr>
<td>{{ @key }}</td>
<td>{{ answer }}</td>
</tr>
{{/each}}
</table>
{{else}}
{{ localize "taf.misc.no-data-submitted" }}
{{/if}}