feat: update for v10

Unfortunately, ditching TypeScript is needed for that :(
This commit is contained in:
Johannes Loher 2022-11-28 00:52:31 +01:00
parent f96b5cd55e
commit b6411f39dc
34 changed files with 271 additions and 2147 deletions

View file

@ -3,29 +3,36 @@
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
module.exports = { module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: { parserOptions: {
ecmaVersion: 2020,
sourceType: 'module', sourceType: 'module',
}, },
env: { env: {
browser: true, browser: true,
es2022: true,
jquery: true,
}, },
extends: ['plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended'], extends: ['eslint:recommended', 'plugin:prettier/recommended'],
plugins: ['@typescript-eslint'], globals: {
Hooks: 'readonly',
Actor: 'readonly',
CONFIG: 'readonly',
ui: 'readonly',
Game: 'readonly',
game: 'readonly',
foundry: 'readonly',
Dialog: 'readonly',
twist: 'readonly',
},
rules: {}, rules: {},
overrides: [ overrides: [
{ {
files: ['./*.cjs', './*.js'], files: ['./*.cjs', './*.js', './tools/**/*'],
rules: { env: { node: true, browser: false, jquery: false },
'@typescript-eslint/no-var-requires': 'off',
},
}, },
], ],
}; };

2
.gitignore vendored
View file

@ -14,6 +14,8 @@ npm-debug.log
# Local configuration # Local configuration
foundryconfig.json foundryconfig.json
/common
/client
# Distribution files # Distribution files
dist dist

View file

@ -30,15 +30,6 @@ lint:
cache: cache:
<<: *global_cache <<: *global_cache
typecheck:
stage: check
before_script:
- yarn install --immutable
script:
- yarn typecheck
cache:
<<: *global_cache
reuse: reuse:
stage: check stage: check
image: image:

View file

@ -5,8 +5,6 @@
}, },
"eslint.nodePath": ".yarn/sdks", "eslint.nodePath": ".yarn/sdks",
"prettier.prettierPath": ".yarn/sdks/prettier/index.js", "prettier.prettierPath": ".yarn/sdks/prettier/index.js",
"typescript.tsdk": ".yarn/sdks/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true,
"importSorter.generalConfiguration.sortOnBeforeSave": true, "importSorter.generalConfiguration.sortOnBeforeSave": true,
"importSorter.importStringConfiguration.maximumNumberOfImportExpressionsPerLine.type": "newLineEachExpressionAfterCountLimitExceptIfOnlyOne", "importSorter.importStringConfiguration.maximumNumberOfImportExpressionsPerLine.type": "newLineEachExpressionAfterCountLimitExceptIfOnlyOne",
"importSorter.importStringConfiguration.maximumNumberOfImportExpressionsPerLine.count": 120, "importSorter.importStringConfiguration.maximumNumberOfImportExpressionsPerLine.count": 120,

View file

@ -1,6 +1,6 @@
{ {
"name": "eslint", "name": "eslint",
"version": "8.25.0-sdk", "version": "8.28.0-sdk",
"main": "./lib/api.js", "main": "./lib/api.js",
"type": "commonjs" "type": "commonjs"
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "prettier", "name": "prettier",
"version": "2.7.1-sdk", "version": "2.8.0-sdk",
"main": "./index.js", "main": "./index.js",
"type": "commonjs" "type": "commonjs"
} }

View file

@ -1,20 +0,0 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/bin/tsc
require(absPnpApiPath).setup();
}
}
// Defer to the real typescript/bin/tsc your application uses
module.exports = absRequire(`typescript/bin/tsc`);

View file

@ -1,20 +0,0 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/bin/tsserver
require(absPnpApiPath).setup();
}
}
// Defer to the real typescript/bin/tsserver your application uses
module.exports = absRequire(`typescript/bin/tsserver`);

View file

@ -1,20 +0,0 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/lib/tsc.js
require(absPnpApiPath).setup();
}
}
// Defer to the real typescript/lib/tsc.js your application uses
module.exports = absRequire(`typescript/lib/tsc.js`);

View file

@ -1,223 +0,0 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
const moduleWrapper = tsserver => {
if (!process.versions.pnp) {
return tsserver;
}
const {isAbsolute} = require(`path`);
const pnpApi = require(`pnpapi`);
const isVirtual = str => str.match(/\/(\$\$virtual|__virtual__)\//);
const isPortal = str => str.startsWith("portal:/");
const normalize = str => str.replace(/\\/g, `/`).replace(/^\/?/, `/`);
const dependencyTreeRoots = new Set(pnpApi.getDependencyTreeRoots().map(locator => {
return `${locator.name}@${locator.reference}`;
}));
// VSCode sends the zip paths to TS using the "zip://" prefix, that TS
// doesn't understand. This layer makes sure to remove the protocol
// before forwarding it to TS, and to add it back on all returned paths.
function toEditorPath(str) {
// We add the `zip:` prefix to both `.zip/` paths and virtual paths
if (isAbsolute(str) && !str.match(/^\^?(zip:|\/zip\/)/) && (str.match(/\.zip\//) || isVirtual(str))) {
// We also take the opportunity to turn virtual paths into physical ones;
// this makes it much easier to work with workspaces that list peer
// dependencies, since otherwise Ctrl+Click would bring us to the virtual
// file instances instead of the real ones.
//
// We only do this to modules owned by the the dependency tree roots.
// This avoids breaking the resolution when jumping inside a vendor
// with peer dep (otherwise jumping into react-dom would show resolution
// errors on react).
//
const resolved = isVirtual(str) ? pnpApi.resolveVirtual(str) : str;
if (resolved) {
const locator = pnpApi.findPackageLocator(resolved);
if (locator && (dependencyTreeRoots.has(`${locator.name}@${locator.reference}`) || isPortal(locator.reference))) {
str = resolved;
}
}
str = normalize(str);
if (str.match(/\.zip\//)) {
switch (hostInfo) {
// Absolute VSCode `Uri.fsPath`s need to start with a slash.
// VSCode only adds it automatically for supported schemes,
// so we have to do it manually for the `zip` scheme.
// The path needs to start with a caret otherwise VSCode doesn't handle the protocol
//
// Ref: https://github.com/microsoft/vscode/issues/105014#issuecomment-686760910
//
// 2021-10-08: VSCode changed the format in 1.61.
// Before | ^zip:/c:/foo/bar.zip/package.json
// After | ^/zip//c:/foo/bar.zip/package.json
//
// 2022-04-06: VSCode changed the format in 1.66.
// Before | ^/zip//c:/foo/bar.zip/package.json
// After | ^/zip/c:/foo/bar.zip/package.json
//
// 2022-05-06: VSCode changed the format in 1.68
// Before | ^/zip/c:/foo/bar.zip/package.json
// After | ^/zip//c:/foo/bar.zip/package.json
//
case `vscode <1.61`: {
str = `^zip:${str}`;
} break;
case `vscode <1.66`: {
str = `^/zip/${str}`;
} break;
case `vscode <1.68`: {
str = `^/zip${str}`;
} break;
case `vscode`: {
str = `^/zip/${str}`;
} break;
// To make "go to definition" work,
// We have to resolve the actual file system path from virtual path
// and convert scheme to supported by [vim-rzip](https://github.com/lbrayner/vim-rzip)
case `coc-nvim`: {
str = normalize(resolved).replace(/\.zip\//, `.zip::`);
str = resolve(`zipfile:${str}`);
} break;
// Support neovim native LSP and [typescript-language-server](https://github.com/theia-ide/typescript-language-server)
// We have to resolve the actual file system path from virtual path,
// everything else is up to neovim
case `neovim`: {
str = normalize(resolved).replace(/\.zip\//, `.zip::`);
str = `zipfile://${str}`;
} break;
default: {
str = `zip:${str}`;
} break;
}
}
}
return str;
}
function fromEditorPath(str) {
switch (hostInfo) {
case `coc-nvim`: {
str = str.replace(/\.zip::/, `.zip/`);
// The path for coc-nvim is in format of /<pwd>/zipfile:/<pwd>/.yarn/...
// So in order to convert it back, we use .* to match all the thing
// before `zipfile:`
return process.platform === `win32`
? str.replace(/^.*zipfile:\//, ``)
: str.replace(/^.*zipfile:/, ``);
} break;
case `neovim`: {
str = str.replace(/\.zip::/, `.zip/`);
// The path for neovim is in format of zipfile:///<pwd>/.yarn/...
return str.replace(/^zipfile:\/\//, ``);
} break;
case `vscode`:
default: {
return str.replace(/^\^?(zip:|\/zip(\/ts-nul-authority)?)\/+/, process.platform === `win32` ? `` : `/`)
} break;
}
}
// Force enable 'allowLocalPluginLoads'
// TypeScript tries to resolve plugins using a path relative to itself
// which doesn't work when using the global cache
// https://github.com/microsoft/TypeScript/blob/1b57a0395e0bff191581c9606aab92832001de62/src/server/project.ts#L2238
// VSCode doesn't want to enable 'allowLocalPluginLoads' due to security concerns but
// TypeScript already does local loads and if this code is running the user trusts the workspace
// https://github.com/microsoft/vscode/issues/45856
const ConfiguredProject = tsserver.server.ConfiguredProject;
const {enablePluginsWithOptions: originalEnablePluginsWithOptions} = ConfiguredProject.prototype;
ConfiguredProject.prototype.enablePluginsWithOptions = function() {
this.projectService.allowLocalPluginLoads = true;
return originalEnablePluginsWithOptions.apply(this, arguments);
};
// And here is the point where we hijack the VSCode <-> TS communications
// by adding ourselves in the middle. We locate everything that looks
// like an absolute path of ours and normalize it.
const Session = tsserver.server.Session;
const {onMessage: originalOnMessage, send: originalSend} = Session.prototype;
let hostInfo = `unknown`;
Object.assign(Session.prototype, {
onMessage(/** @type {string | object} */ message) {
const isStringMessage = typeof message === 'string';
const parsedMessage = isStringMessage ? JSON.parse(message) : message;
if (
parsedMessage != null &&
typeof parsedMessage === `object` &&
parsedMessage.arguments &&
typeof parsedMessage.arguments.hostInfo === `string`
) {
hostInfo = parsedMessage.arguments.hostInfo;
if (hostInfo === `vscode` && process.env.VSCODE_IPC_HOOK) {
const [, major, minor] = (process.env.VSCODE_IPC_HOOK.match(
// The RegExp from https://semver.org/ but without the caret at the start
/(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/
) ?? []).map(Number)
if (major === 1) {
if (minor < 61) {
hostInfo += ` <1.61`;
} else if (minor < 66) {
hostInfo += ` <1.66`;
} else if (minor < 68) {
hostInfo += ` <1.68`;
}
}
}
}
const processedMessageJSON = JSON.stringify(parsedMessage, (key, value) => {
return typeof value === 'string' ? fromEditorPath(value) : value;
});
return originalOnMessage.call(
this,
isStringMessage ? processedMessageJSON : JSON.parse(processedMessageJSON)
);
},
send(/** @type {any} */ msg) {
return originalSend.call(this, JSON.parse(JSON.stringify(msg, (key, value) => {
return typeof value === `string` ? toEditorPath(value) : value;
})));
}
});
return tsserver;
};
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/lib/tsserver.js
require(absPnpApiPath).setup();
}
}
// Defer to the real typescript/lib/tsserver.js your application uses
module.exports = moduleWrapper(absRequire(`typescript/lib/tsserver.js`));

View file

@ -1,223 +0,0 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
const moduleWrapper = tsserver => {
if (!process.versions.pnp) {
return tsserver;
}
const {isAbsolute} = require(`path`);
const pnpApi = require(`pnpapi`);
const isVirtual = str => str.match(/\/(\$\$virtual|__virtual__)\//);
const isPortal = str => str.startsWith("portal:/");
const normalize = str => str.replace(/\\/g, `/`).replace(/^\/?/, `/`);
const dependencyTreeRoots = new Set(pnpApi.getDependencyTreeRoots().map(locator => {
return `${locator.name}@${locator.reference}`;
}));
// VSCode sends the zip paths to TS using the "zip://" prefix, that TS
// doesn't understand. This layer makes sure to remove the protocol
// before forwarding it to TS, and to add it back on all returned paths.
function toEditorPath(str) {
// We add the `zip:` prefix to both `.zip/` paths and virtual paths
if (isAbsolute(str) && !str.match(/^\^?(zip:|\/zip\/)/) && (str.match(/\.zip\//) || isVirtual(str))) {
// We also take the opportunity to turn virtual paths into physical ones;
// this makes it much easier to work with workspaces that list peer
// dependencies, since otherwise Ctrl+Click would bring us to the virtual
// file instances instead of the real ones.
//
// We only do this to modules owned by the the dependency tree roots.
// This avoids breaking the resolution when jumping inside a vendor
// with peer dep (otherwise jumping into react-dom would show resolution
// errors on react).
//
const resolved = isVirtual(str) ? pnpApi.resolveVirtual(str) : str;
if (resolved) {
const locator = pnpApi.findPackageLocator(resolved);
if (locator && (dependencyTreeRoots.has(`${locator.name}@${locator.reference}`) || isPortal(locator.reference))) {
str = resolved;
}
}
str = normalize(str);
if (str.match(/\.zip\//)) {
switch (hostInfo) {
// Absolute VSCode `Uri.fsPath`s need to start with a slash.
// VSCode only adds it automatically for supported schemes,
// so we have to do it manually for the `zip` scheme.
// The path needs to start with a caret otherwise VSCode doesn't handle the protocol
//
// Ref: https://github.com/microsoft/vscode/issues/105014#issuecomment-686760910
//
// 2021-10-08: VSCode changed the format in 1.61.
// Before | ^zip:/c:/foo/bar.zip/package.json
// After | ^/zip//c:/foo/bar.zip/package.json
//
// 2022-04-06: VSCode changed the format in 1.66.
// Before | ^/zip//c:/foo/bar.zip/package.json
// After | ^/zip/c:/foo/bar.zip/package.json
//
// 2022-05-06: VSCode changed the format in 1.68
// Before | ^/zip/c:/foo/bar.zip/package.json
// After | ^/zip//c:/foo/bar.zip/package.json
//
case `vscode <1.61`: {
str = `^zip:${str}`;
} break;
case `vscode <1.66`: {
str = `^/zip/${str}`;
} break;
case `vscode <1.68`: {
str = `^/zip${str}`;
} break;
case `vscode`: {
str = `^/zip/${str}`;
} break;
// To make "go to definition" work,
// We have to resolve the actual file system path from virtual path
// and convert scheme to supported by [vim-rzip](https://github.com/lbrayner/vim-rzip)
case `coc-nvim`: {
str = normalize(resolved).replace(/\.zip\//, `.zip::`);
str = resolve(`zipfile:${str}`);
} break;
// Support neovim native LSP and [typescript-language-server](https://github.com/theia-ide/typescript-language-server)
// We have to resolve the actual file system path from virtual path,
// everything else is up to neovim
case `neovim`: {
str = normalize(resolved).replace(/\.zip\//, `.zip::`);
str = `zipfile://${str}`;
} break;
default: {
str = `zip:${str}`;
} break;
}
}
}
return str;
}
function fromEditorPath(str) {
switch (hostInfo) {
case `coc-nvim`: {
str = str.replace(/\.zip::/, `.zip/`);
// The path for coc-nvim is in format of /<pwd>/zipfile:/<pwd>/.yarn/...
// So in order to convert it back, we use .* to match all the thing
// before `zipfile:`
return process.platform === `win32`
? str.replace(/^.*zipfile:\//, ``)
: str.replace(/^.*zipfile:/, ``);
} break;
case `neovim`: {
str = str.replace(/\.zip::/, `.zip/`);
// The path for neovim is in format of zipfile:///<pwd>/.yarn/...
return str.replace(/^zipfile:\/\//, ``);
} break;
case `vscode`:
default: {
return str.replace(/^\^?(zip:|\/zip(\/ts-nul-authority)?)\/+/, process.platform === `win32` ? `` : `/`)
} break;
}
}
// Force enable 'allowLocalPluginLoads'
// TypeScript tries to resolve plugins using a path relative to itself
// which doesn't work when using the global cache
// https://github.com/microsoft/TypeScript/blob/1b57a0395e0bff191581c9606aab92832001de62/src/server/project.ts#L2238
// VSCode doesn't want to enable 'allowLocalPluginLoads' due to security concerns but
// TypeScript already does local loads and if this code is running the user trusts the workspace
// https://github.com/microsoft/vscode/issues/45856
const ConfiguredProject = tsserver.server.ConfiguredProject;
const {enablePluginsWithOptions: originalEnablePluginsWithOptions} = ConfiguredProject.prototype;
ConfiguredProject.prototype.enablePluginsWithOptions = function() {
this.projectService.allowLocalPluginLoads = true;
return originalEnablePluginsWithOptions.apply(this, arguments);
};
// And here is the point where we hijack the VSCode <-> TS communications
// by adding ourselves in the middle. We locate everything that looks
// like an absolute path of ours and normalize it.
const Session = tsserver.server.Session;
const {onMessage: originalOnMessage, send: originalSend} = Session.prototype;
let hostInfo = `unknown`;
Object.assign(Session.prototype, {
onMessage(/** @type {string | object} */ message) {
const isStringMessage = typeof message === 'string';
const parsedMessage = isStringMessage ? JSON.parse(message) : message;
if (
parsedMessage != null &&
typeof parsedMessage === `object` &&
parsedMessage.arguments &&
typeof parsedMessage.arguments.hostInfo === `string`
) {
hostInfo = parsedMessage.arguments.hostInfo;
if (hostInfo === `vscode` && process.env.VSCODE_IPC_HOOK) {
const [, major, minor] = (process.env.VSCODE_IPC_HOOK.match(
// The RegExp from https://semver.org/ but without the caret at the start
/(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/
) ?? []).map(Number)
if (major === 1) {
if (minor < 61) {
hostInfo += ` <1.61`;
} else if (minor < 66) {
hostInfo += ` <1.66`;
} else if (minor < 68) {
hostInfo += ` <1.68`;
}
}
}
}
const processedMessageJSON = JSON.stringify(parsedMessage, (key, value) => {
return typeof value === 'string' ? fromEditorPath(value) : value;
});
return originalOnMessage.call(
this,
isStringMessage ? processedMessageJSON : JSON.parse(processedMessageJSON)
);
},
send(/** @type {any} */ msg) {
return originalSend.call(this, JSON.parse(JSON.stringify(msg, (key, value) => {
return typeof value === `string` ? toEditorPath(value) : value;
})));
}
});
return tsserver;
};
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/lib/tsserverlibrary.js
require(absPnpApiPath).setup();
}
}
// Defer to the real typescript/lib/tsserverlibrary.js your application uses
module.exports = moduleWrapper(absRequire(`typescript/lib/tsserverlibrary.js`));

View file

@ -1,20 +0,0 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/lib/typescript.js
require(absPnpApiPath).setup();
}
}
// Defer to the real typescript/lib/typescript.js your application uses
module.exports = absRequire(`typescript/lib/typescript.js`);

View file

@ -1,6 +0,0 @@
{
"name": "typescript",
"version": "4.7.4-sdk",
"main": "./lib/typescript.js",
"type": "commonjs"
}

8
jsconfig.json Normal file
View file

@ -0,0 +1,8 @@
{
"compilerOptions": {
"module": "es2022",
"target": "ES2022"
},
"exclude": ["node_modules", "dist"],
"include": ["src", "client", "common"]
}

3
jsconfig.json.license Normal file
View file

@ -0,0 +1,3 @@
SPDX-FileCopyrightText: 2022 Johannes Loher
SPDX-License-Identifier: MIT

View file

@ -1,5 +1,5 @@
{ {
"name": "tickwerk", "id": "tickwerk",
"title": "Tickwerk", "title": "Tickwerk",
"description": "A tick based combat system for Foundry Virtual Tabletop", "description": "A tick based combat system for Foundry Virtual Tabletop",
"authors": [ "authors": [
@ -16,8 +16,10 @@
"bugs": "https://git.f3l.de/dungeonslayers/tickwerk/-/issues", "bugs": "https://git.f3l.de/dungeonslayers/tickwerk/-/issues",
"changelog": "https://git.f3l.de/dungeonslayers/tickwerk/-/releases/1.2.1", "changelog": "https://git.f3l.de/dungeonslayers/tickwerk/-/releases/1.2.1",
"version": "1.2.1", "version": "1.2.1",
"minimumCoreVersion": "9", "compatibility": {
"compatibleCoreVersion": "9", "minimum": "10.290",
"verified": "10"
},
"esmodules": ["tickwerk.js"], "esmodules": ["tickwerk.js"],
"styles": ["styles/tickwerk.css"], "styles": ["styles/tickwerk.css"],
"languages": [ "languages": [

View file

@ -27,11 +27,9 @@
"clean": "run-p clean:files clean:link", "clean": "run-p clean:files clean:link",
"clean:files": "rimraf dist", "clean:files": "rimraf dist",
"clean:link": "node ./tools/link-package.js --clean", "clean:link": "node ./tools/link-package.js --clean",
"lint": "eslint --ext .ts,.js,.cjs,.mjs .", "lint": "eslint --ext .js,.cjs,.mjs .",
"lint:fix": "eslint --ext .ts,.js,.cjs,.mjs --fix .", "lint:fix": "eslint --ext .js,.cjs,.mjs --fix .",
"format": "prettier --write \"./**/*.(ts|js|cjs|mjs|json|scss|yml)\"", "format": "prettier --write \"./**/*.(js|cjs|mjs|json|scss|yml)\"",
"typecheck": "tsc --noEmit",
"typecheck:watch": "tsc --noEmit --watch",
"bump-version": "node ./tools/bump-version.js", "bump-version": "node ./tools/bump-version.js",
"postinstall": "husky install", "postinstall": "husky install",
"changelog": "conventional-changelog -p conventionalcommits -o CHANGELOG.md -r 2" "changelog": "conventional-changelog -p conventionalcommits -o CHANGELOG.md -r 2"
@ -40,20 +38,7 @@
"@commitlint/cli": "17.3.0", "@commitlint/cli": "17.3.0",
"@commitlint/config-conventional": "17.3.0", "@commitlint/config-conventional": "17.3.0",
"@guanghechen/rollup-plugin-copy": "2.1.4", "@guanghechen/rollup-plugin-copy": "2.1.4",
"@league-of-foundry-developers/foundry-vtt-types": "9.280.0",
"@pixi/constants": "6.2.1",
"@pixi/core": "6.2.1",
"@pixi/display": "6.2.1",
"@pixi/graphics": "6.2.1",
"@pixi/math": "6.2.1",
"@pixi/runner": "6.2.1",
"@pixi/settings": "6.2.1",
"@pixi/utils": "6.2.1",
"@seald-io/nedb": "3.1.0",
"@swc/core": "1.3.20", "@swc/core": "1.3.20",
"@types/fs-extra": "9.0.13",
"@typescript-eslint/eslint-plugin": "5.44.0",
"@typescript-eslint/parser": "5.44.0",
"conventional-changelog-cli": "2.2.2", "conventional-changelog-cli": "2.2.2",
"conventional-changelog-conventionalcommits": "5.0.0", "conventional-changelog-conventionalcommits": "5.0.0",
"eslint": "8.28.0", "eslint": "8.28.0",
@ -70,13 +55,10 @@
"rollup-plugin-swc3": "0.7.0", "rollup-plugin-swc3": "0.7.0",
"sass": "1.56.1", "sass": "1.56.1",
"semver": "7.3.8", "semver": "7.3.8",
"ts-node": "10.9.1",
"tslib": "2.4.1",
"typescript": "4.7.4",
"yargs": "17.6.2" "yargs": "17.6.2"
}, },
"lint-staged": { "lint-staged": {
"*.(ts|js|cjs|mjs)": "eslint --cache --fix", "*.(js|cjs|mjs)": "eslint --cache --fix",
"*.(json|scss|yml)": "prettier --write" "*.(json|scss|yml)": "prettier --write"
}, },
"packageManager": "yarn@3.2.4" "packageManager": "yarn@3.2.4"

View file

@ -24,7 +24,7 @@ const isProduction = process.env.NODE_ENV === 'production';
* @type {import('rollup').RollupOptions} * @type {import('rollup').RollupOptions}
*/ */
const config = { const config = {
input: { [name]: `${sourceDirectory}/${name}.ts` }, input: { [name]: `${sourceDirectory}/${name}.js` },
output: { output: {
dir: distDirectory, dir: distDirectory,
format: 'es', format: 'es',

View file

@ -3,35 +3,43 @@
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
export const registerCombatTrackerFunctionality = () => { export const registerCombatTrackerFunctionality = () => {
CONFIG.ui.combat = CombatTrackerMixin(CONFIG.ui.combat as typeof CombatTracker); // TODO: improve upstream types CONFIG.ui.combat = CombatTrackerMixin(CONFIG.ui.combat);
}; };
const CombatTrackerMixin = (BaseCombatTracker: typeof CombatTracker) => { /**
* Enhance a combat tracker class with functionality for Tickwerk.
* @param {typeof CombatTracker} BaseCombatTracker The combat tracker class to enhance
* @returns The enhanced combat tracker class
*/
const CombatTrackerMixin = (BaseCombatTracker) => {
return class TickwerkCombatTracker extends BaseCombatTracker { return class TickwerkCombatTracker extends BaseCombatTracker {
static override get defaultOptions(): ApplicationOptions { /** @override */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, { return foundry.utils.mergeObject(super.defaultOptions, {
template: 'modules/tickwerk/templates/combat-tracker.hbs', template: 'modules/tickwerk/templates/combat-tracker.hbs',
}); });
} }
override async getData(options?: Partial<ApplicationOptions>): Promise<CombatTracker.Data> { /** @override */
async getData(options) {
const data = await super.getData(options); const data = await super.getData(options);
return { return {
...data, ...data,
turns: data.turns.map((turn) => ({ ...turn, waiting: this.viewed?.combatants.get(turn.id)?.waiting })), turns: data.turns.map((turn) => ({ ...turn, waiting: this.viewed?.combatants.get(turn.id)?.waiting })),
} as CombatTracker.Data; // TODO: Improve upstream types };
} }
override activateListeners(html: JQuery): void { /** @override */
activateListeners(html) {
super.activateListeners(html); super.activateListeners(html);
html.find('.combatant-control[data-control="toggleWaiting"]').on('click', this._onToggleWaiting.bind(this)); html.find('.combatant-control[data-control="toggleWaiting"]').on('click', this._onToggleWaiting.bind(this));
} }
/** /**
* Handle clicks on the Combatant waiting control button. * Handle clicks on the Combatant waiting control button.
* @param event The originating click event * @param {JQuery.ClickEvent} event The originating click event
*/ */
_onToggleWaiting(event: JQuery.ClickEvent) { _onToggleWaiting(event) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
const button = event.currentTarget; const button = event.currentTarget;

View file

@ -2,4 +2,4 @@
// //
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
export const packageId = 'tickwerk' as const; export const packageId = 'tickwerk';

View file

@ -5,12 +5,12 @@
import { getGame } from '../../helpers'; import { getGame } from '../../helpers';
export const registerActiveEffectFunctionality = () => { export const registerActiveEffectFunctionality = () => {
Hooks.on<Hooks.CreateDocument<typeof ActiveEffect>>('createActiveEffect', onActiveEffectChanged); Hooks.on('createActiveEffect', onActiveEffectChanged);
Hooks.on<Hooks.UpdateDocument<typeof ActiveEffect>>('updateActiveEffect', onActiveEffectChanged); Hooks.on('updateActiveEffect', onActiveEffectChanged);
Hooks.on<Hooks.DeleteDocument<typeof ActiveEffect>>('deleteActiveEffect', onActiveEffectChanged); Hooks.on('deleteActiveEffect', onActiveEffectChanged);
}; };
const onActiveEffectChanged = (activeEffect: ActiveEffect) => { const onActiveEffectChanged = (activeEffect) => {
const game = getGame(); const game = getGame();
const parent = activeEffect.parent; const parent = activeEffect.parent;
const actorId = parent?.id; const actorId = parent?.id;

View file

@ -4,62 +4,76 @@
import { packageId } from '../../constants'; import { packageId } from '../../constants';
import type { TickwerkCombatant } from './combatant';
export const registerCombatFunctionality = () => { export const registerCombatFunctionality = () => {
CONFIG.Combat.documentClass = CombatMixin(CONFIG.Combat.documentClass); CONFIG.Combat.documentClass = CombatMixin(CONFIG.Combat.documentClass);
}; };
const CombatMixin = (BaseCombat: typeof Combat) => { /**
* Enhance a combat class with functionality for the Tickwerk.
* @param {typeof Combat} BaseCombat The combat class to enhance
* @returns A combat class, adapted for the Tickwerk
*/
const CombatMixin = (BaseCombat) => {
return class TickwerkCombat extends BaseCombat { return class TickwerkCombat extends BaseCombat {
override get combatant() { /** @override */
return this.turns[0]; get combatant() {
return this.turns?.[0];
} }
override get round() { /** @override */
return this.tickValue; get nextCombatant() {
return this.turns?.[1];
} }
override get started() { /** @override */
return this.turns.length > 0 && (this.getFlag(packageId, 'started') ?? false); get started() {
} return (this.turns?.length ?? 0) > 0 && (this.getFlag(packageId, 'started') ?? false);
override get turn() {
return 0;
} }
/** /**
* The current tick value of the Combat encounter. * The current tick value of the Combat encounter.
* @type {number}
*/ */
get tickValue(): number { get tickValue() {
const tickValues = this.combatants const tickValues = this.combatants
.filter((combatant) => !combatant.isDefeated && !combatant.waiting) .filter((combatant) => !combatant.isDefeated && !combatant.waiting)
.map((combatant) => combatant.initiative) .map((combatant) => combatant.initiative)
.filter((tickValue): tickValue is number => tickValue !== null); .filter((tickValue) => tickValue !== null);
const tickValue = Math.min(...tickValues); const tickValue = Math.min(...tickValues);
return tickValue === Infinity ? 0 : tickValue; return tickValue === Infinity ? 0 : tickValue;
} }
override async nextRound(): Promise<never> { /** @override */
prepareDerivedData() {
super.prepareDerivedData();
this.turn = this.started ? 0 : null;
}
/** @override */
async nextRound() {
throw new Error('Not implemented!'); throw new Error('Not implemented!');
} }
override async nextTurn() { /** @override */
async nextTurn() {
await this.combatant?.advanceTicksDialog(); await this.combatant?.advanceTicksDialog();
return this; return this;
} }
override previousRound(): Promise<never> { /** @override */
previousRound() {
throw new Error('Not implemented!'); throw new Error('Not implemented!');
} }
override previousTurn(): Promise<never> { /** @override */
previousTurn() {
throw new Error('Not implemented!'); throw new Error('Not implemented!');
} }
override async resetAll() { /** @override */
async resetAll() {
for (const c of this.combatants) { for (const c of this.combatants) {
c.data.update({ initiative: null }); c.updateSource({ initiative: null });
} }
return this.update( return this.update(
{ {
@ -74,7 +88,8 @@ const CombatMixin = (BaseCombat: typeof Combat) => {
); );
} }
override setupTurns(): this['turns'] { /** @override */
setupTurns() {
const turns = this.combatants.contents.sort(this._sortCombatants); const turns = this.combatants.contents.sort(this._sortCombatants);
const c = turns[0]; const c = turns[0];
@ -82,12 +97,13 @@ const CombatMixin = (BaseCombat: typeof Combat) => {
round: this.round, round: this.round,
turn: 0, turn: 0,
combatantId: c?.id ?? null, combatantId: c?.id ?? null,
tokenId: c?.data.tokenId ?? null, tokenId: c?.tokenId ?? null,
}; };
return (this.turns = turns); return (this.turns = turns);
} }
override async startCombat(): Promise<this | undefined> { /** @override */
async startCombat() {
const hasCombatantWithTickValue = this.combatants.find( const hasCombatantWithTickValue = this.combatants.find(
(combatant) => !combatant.isDefeated && combatant.initiative !== null, (combatant) => !combatant.isDefeated && combatant.initiative !== null,
); );
@ -95,10 +111,14 @@ const CombatMixin = (BaseCombat: typeof Combat) => {
ui.notifications?.warn('TICKWERK.WarningCannotStartCombat', { localize: true }); ui.notifications?.warn('TICKWERK.WarningCannotStartCombat', { localize: true });
return this; return this;
} }
return this.setFlag(packageId, 'started', true); this._playCombatSound('startEncounter');
const updateData = { flags: { [packageId]: { started: true } } };
Hooks.callAll('combatStart', this, updateData);
return this.update(updateData);
} }
protected override _sortCombatants(a: TickwerkCombatant, b: TickwerkCombatant): number { /** @override */
_sortCombatants(a, b) {
const da = a.isDefeated ? 1 : 0; const da = a.isDefeated ? 1 : 0;
const db = b.isDefeated ? 1 : 0; const db = b.isDefeated ? 1 : 0;
const cd = da - db; const cd = da - db;
@ -121,15 +141,36 @@ const CombatMixin = (BaseCombat: typeof Combat) => {
return (b.id ?? '') > (a.id ?? '') ? 1 : -1; return (b.id ?? '') > (a.id ?? '') ? 1 : -1;
} }
/** @override */
async _preUpdate(changed, options, user) {
delete changed.round;
delete changed.turn;
options.tickwerk = {
combatantBeforeUpdate: this.combatant?.id,
nextCombatantBeforeUpdate: this.nextCombatant?.id,
};
return super._preUpdate(changed, options, user);
}
/** @override */
_onUpdate(data, options, userId) {
super._onUpdate(data, options, userId);
if (game.user.character) {
const { combatantBeforeUpdate, nextCombatantBeforeUpdate } = options.tickwerk ?? {};
if (
combatantBeforeUpdate !== undefined &&
combatantBeforeUpdate !== this.combatant &&
this.combatant?.actorId === game.user.character._id
) {
this._playCombatSound('yourTurn');
} else if (
nextCombatantBeforeUpdate !== undefined &&
nextCombatantBeforeUpdate !== this.nextCombatant &&
this.nextCombatant?.actorId === game.user.character._id
)
this._playCombatSound('nextUp');
}
}
}; };
}; };
declare global {
interface FlagConfig {
Combat: {
tickwerk?: {
started?: boolean;
};
};
}
}

View file

@ -2,7 +2,6 @@
// //
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
import type { CombatantDataConstructorData } from '@league-of-foundry-developers/foundry-vtt-types/src/foundry/common/data/data.mjs/combatantData';
import { packageId } from '../../constants'; import { packageId } from '../../constants';
import { getGame } from '../../helpers'; import { getGame } from '../../helpers';
@ -10,39 +9,49 @@ export const registerCombatantFunctionality = () => {
CONFIG.Combatant.documentClass = CombatantMixin(CONFIG.Combatant.documentClass); CONFIG.Combatant.documentClass = CombatantMixin(CONFIG.Combatant.documentClass);
}; };
const CombatantMixin = (BaseCombatant: typeof Combatant) => { /**
* Enhance a co,batant class with functionality for the Tickwerk.
* @param {typeof Combatant} BaseCombatant The combat class to enhance
* @returns A combat class, adapted for the Tickwerk
*/
const CombatantMixin = (BaseCombatant) => {
return class TickwerkCombatant extends BaseCombatant { return class TickwerkCombatant extends BaseCombatant {
/** /**
* An temporary property to make changes to the initiative available to other instances in their `_pre…` methods. * An temporary property to make changes to the initiative available to other instances in their `_pre…` methods.
* @type {number|null|undefined}
*/ */
_newInitiative: number | null | undefined; _newInitiative;
/** /**
* An temporary property to make changes to the tiebreaker available to other instances in their `_pre…` methods. * An temporary property to make changes to the tiebreaker available to other instances in their `_pre…` methods.
* @type {number|undefined}
*/ */
_newTiebreaker: number | undefined; _newTiebreaker;
/*** /***
* Is this combatant currently waiting? * Is this combatant currently waiting?
* @type {boolean}
*/ */
get waiting(): boolean { get waiting() {
return this.getFlag(packageId, 'waiting') ?? false; return this.getFlag(packageId, 'waiting') ?? false;
} }
/** /**
* Toggle the waiting state of this combatant. * Toggle the waiting state of this combatant.
* @returns {Promise<this|undefined>} The updated combatant
*/ */
toggleWaiting(): Promise<this | undefined> { toggleWaiting() {
const update: Record<string, unknown> = { [`flags.${packageId}.waiting`]: !this.waiting }; const update = { [`flags.${packageId}.waiting`]: !this.waiting };
if (this.parent?.started && this.waiting) update.initiative = this.parent?.round; if (this.parent?.started && this.waiting) update.initiative = this.parent?.round;
return this.update(update); return this.update(update);
} }
/** /**
* Advance for the given number of ticks. * Advance for the given number of ticks.
* @param ticks The number of ticks to advance for * @param {number} ticks The number of ticks to advance for
* @returns {Promise<void>} A promise that resolves once when the combatant has advanced
*/ */
async advanceTicks(ticks: number): Promise<void> { async advanceTicks(ticks) {
if (this.initiative === null) { if (this.initiative === null) {
ui.notifications?.warn('TICKWERK.WarningCannotAdvanceWithoutTickValue', { localize: true }); ui.notifications?.warn('TICKWERK.WarningCannotAdvanceWithoutTickValue', { localize: true });
return; return;
@ -59,11 +68,15 @@ const CombatantMixin = (BaseCombatant: typeof Combatant) => {
await this.update({ initiative: this.initiative + ticks }); await this.update({ initiative: this.initiative + ticks });
const advanceTime = ticks * CONFIG.time.roundTime; const advanceTime = ticks * CONFIG.time.roundTime;
if (advanceTime !== 0) { if (advanceTime !== 0) {
await this.combat?.update(undefined, { diff: false, advanceTime } as DocumentModificationContext); await this.combat?.update(undefined, { diff: false, advanceTime });
} }
} }
async advanceTicksDialog(): Promise<void> { /**
* Show a dialog for advancing the combatant a certain number of ticks.
* @returns {Promise<void>} A promise that resolves when the dialog has been confirmed and the combatant has advanced
*/
async advanceTicksDialog() {
const game = getGame(); const game = getGame();
const id = foundry.utils.randomID(); const id = foundry.utils.randomID();
@ -76,14 +89,14 @@ const CombatantMixin = (BaseCombatant: typeof Combatant) => {
title: game.i18n.localize('TICKWERK.AdvanceTicks'), title: game.i18n.localize('TICKWERK.AdvanceTicks'),
content: form, content: form,
yes: (html) => { yes: (html) => {
const ticks = html[0]?.querySelector<HTMLInputElement>('input[name="ticks"]')?.value; const ticks = html[0]?.querySelector('input[name="ticks"]')?.value;
const parsedTicks = ticks !== undefined ? parseInt(ticks) : undefined; const parsedTicks = ticks !== undefined ? parseInt(ticks) : undefined;
return Number.isSafeInteger(parsedTicks) ? parsedTicks : undefined; return Number.isSafeInteger(parsedTicks) ? parsedTicks : NaN;
}, },
rejectClose: false, rejectClose: false,
}); });
if (ticks === undefined) { if (Number.isNaN(ticks)) {
ui.notifications?.warn('TICKWERK.WarningInvalidNumberOfTicks', { localize: true }); ui.notifications?.warn('TICKWERK.WarningInvalidNumberOfTicks', { localize: true });
return; return;
} }
@ -95,9 +108,9 @@ const CombatantMixin = (BaseCombatant: typeof Combatant) => {
/** /**
* Update tiebreaker data for a given creation or update. * Update tiebreaker data for a given creation or update.
* @param data The data of the creation / update * @param {object} data The data of the creation / update
*/ */
async #updateTiebreakerData(data: DeepPartial<CombatantDataConstructorData>): Promise<void> { async #updateTiebreakerData(data) {
if ('initiative' in data) { if ('initiative' in data) {
const combatantsWithSameTickValue = const combatantsWithSameTickValue =
this.parent?.combatants.filter((combatant) => { this.parent?.combatants.filter((combatant) => {
@ -106,7 +119,7 @@ const CombatantMixin = (BaseCombatant: typeof Combatant) => {
return otherInitiative === data.initiative; return otherInitiative === data.initiative;
}) ?? []; }) ?? [];
const tiebreaker = await this.#getTiebreaker(combatantsWithSameTickValue); const tiebreaker = await this.#getTiebreaker(combatantsWithSameTickValue);
setProperty(data, `flags.${packageId}.tiebreaker`, tiebreaker); foundry.utils.setProperty(data, `flags.${packageId}.tiebreaker`, tiebreaker);
this._newInitiative = data.initiative; this._newInitiative = data.initiative;
this._newTiebreaker = tiebreaker; this._newTiebreaker = tiebreaker;
} }
@ -114,46 +127,48 @@ const CombatantMixin = (BaseCombatant: typeof Combatant) => {
/** /**
* Get a tiebreaker between this combatant and the given other combatants. * Get a tiebreaker between this combatant and the given other combatants.
* @param combatants The other combatants among which to find a tiebreaker * @param {TickwerkCombatant[]} combatants The other combatants among which to find a tiebreaker
* @returns A promise that resolves to the tiebreaker * @returns {Promise<number>} A promise that resolves to the tiebreaker
*/ */
async #getTiebreaker(combatants: TickwerkCombatant[]): Promise<number> { async #getTiebreaker(combatants) {
const getTiebreaker = CONFIG.tickwerk?.getTiebreaker ?? defaultGetTiebreaker; const getTiebreaker = CONFIG.tickwerk?.getTiebreaker ?? defaultGetTiebreaker;
return getTiebreaker(this, combatants); return getTiebreaker(this, combatants);
} }
override testUserPermission( /** @override */
user: foundry.documents.BaseUser, testUserPermission(user, permission, { exact } = {}) {
permission: keyof typeof foundry.CONST.DOCUMENT_PERMISSION_LEVELS | foundry.CONST.DOCUMENT_PERMISSION_LEVELS,
{ exact }: { exact?: boolean | undefined } = {},
): boolean {
if (user.isGM) return true; if (user.isGM) return true;
return super.testUserPermission(user, permission, { exact }); return super.testUserPermission(user, permission, { exact });
} }
protected override _getInitiativeFormula(): string { /** @override */
_getInitiativeFormula() {
const getInitiativeFormula = CONFIG.tickwerk?.getInitiativeFormula; const getInitiativeFormula = CONFIG.tickwerk?.getInitiativeFormula;
if (getInitiativeFormula) return getInitiativeFormula(this); if (getInitiativeFormula) return getInitiativeFormula(this);
return super._getInitiativeFormula(); return super._getInitiativeFormula();
} }
protected override async _preCreate(...args: Parameters<Combatant['_preCreate']>): Promise<void> { /** @override */
async _preCreate(...args) {
await super._preCreate(...args); await super._preCreate(...args);
await this.#updateTiebreakerData(args[0]); await this.#updateTiebreakerData(args[0]);
} }
protected override async _preUpdate(...args: Parameters<Combatant['_preUpdate']>): Promise<void> { /** @override */
async _preUpdate(...args) {
await super._preUpdate(...args); await super._preUpdate(...args);
await this.#updateTiebreakerData(args[0]); await this.#updateTiebreakerData(args[0]);
} }
protected override _onCreate(...args: Parameters<Combatant['_onCreate']>): void { /** @override */
_onCreate(...args) {
super._onCreate(...args); super._onCreate(...args);
this._newInitiative = undefined; this._newInitiative = undefined;
this._newTiebreaker = undefined; this._newTiebreaker = undefined;
} }
protected override _onUpdate(...args: Parameters<Combatant['_onUpdate']>): void { /** @override */
_onUpdate(...args) {
super._onUpdate(...args); super._onUpdate(...args);
this._newInitiative = undefined; this._newInitiative = undefined;
this._newTiebreaker = undefined; this._newTiebreaker = undefined;
@ -161,7 +176,16 @@ const CombatantMixin = (BaseCombatant: typeof Combatant) => {
}; };
}; };
const defaultGetTiebreaker = async (combatant: TickwerkCombatant, combatants: TickwerkCombatant[]): Promise<number> => { /**
* A function to get a tiebreaker for a combatant
* @typedef {(combatant: TickwerkCombatant, combatants: TickwerkCombatant[]) => Promise<number>} GetTiebreaker
*/
/**
* Default implementation to get a tiebreaker for a combatant.
* @type {GetTiebreaker}
*/
const defaultGetTiebreaker = async (combatant, combatants) => {
if (combatants.length === 0) return 0; if (combatants.length === 0) return 0;
const tiebreakers = combatants.map((combatant) => { const tiebreakers = combatants.map((combatant) => {
return ( return (
@ -172,28 +196,3 @@ const defaultGetTiebreaker = async (combatant: TickwerkCombatant, combatants: Ti
}); });
return Math.max(...tiebreakers) + 1; return Math.max(...tiebreakers) + 1;
}; };
export type TickwerkCombatantConstructor = ReturnType<typeof CombatantMixin>;
export type TickwerkCombatant = InstanceType<TickwerkCombatantConstructor>;
declare global {
interface FlagConfig {
Combatant: {
tickwerk: {
tiebreaker?: number | undefined;
waiting?: boolean | undefined;
};
};
}
interface DocumentClassConfig {
Combatant: TickwerkCombatantConstructor;
}
interface CONFIG {
tickwerk?: {
getTiebreaker?: (combatant: TickwerkCombatant, combatants: TickwerkCombatant[]) => Promise<number>;
getInitiativeFormula?: (combatant: TickwerkCombatant) => string;
};
}
}

View file

@ -2,7 +2,7 @@
// //
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
export const getGame = (): Game => { export const getGame = () => {
if (!(game instanceof Game)) { if (!(game instanceof Game)) {
throw new Error('game is not initialized yet.'); throw new Error('game is not initialized yet.');
} }

View file

@ -5,7 +5,6 @@
import { packageId } from '../constants'; import { packageId } from '../constants';
import { getGame } from '../helpers'; import { getGame } from '../helpers';
import type { TickwerkCombatant } from '../data/documents/combatant';
export const registerDS4SpecificFunctionality = () => { export const registerDS4SpecificFunctionality = () => {
if (CONFIG.tickwerk === undefined) CONFIG.tickwerk = {}; if (CONFIG.tickwerk === undefined) CONFIG.tickwerk = {};
foundry.utils.mergeObject(CONFIG.tickwerk, { getTiebreaker, getInitiativeFormula }); foundry.utils.mergeObject(CONFIG.tickwerk, { getTiebreaker, getInitiativeFormula });
@ -14,20 +13,22 @@ export const registerDS4SpecificFunctionality = () => {
Hooks.on('ds4.rollItem', onRollItem); Hooks.on('ds4.rollItem', onRollItem);
}; };
const getTiebreaker = async (combatant: TickwerkCombatant, combatants: TickwerkCombatant[]): Promise<number> => { /** @type {import("../data/documents/combatant").GetTiebreaker} */
const ds4combatant = combatant as DS4TickwerkCombatant; const getTiebreaker = async (combatant, combatants) => {
const ds4combatants = combatants as DS4TickwerkCombatant[];
if (combatants.length === 0) return 0; if (combatants.length === 0) return 0;
const lowerBounds: number[] = []; /** @type {number[]} */
const upperBounds: number[] = []; const lowerBounds = [];
const equals: number[] = []; /** @type {number[]} */
const upperBounds = [];
/** @type {number[]} */
const equals = [];
for (const combatant of ds4combatants) { for (const other of combatants) {
const tiebreaker = combatant._newTiebreaker ?? combatant.getFlag(packageId, 'tiebreaker') ?? 0; const tiebreaker = other._newTiebreaker ?? other.getFlag(packageId, 'tiebreaker') ?? 0;
if (getInitiative(combatant) > getInitiative(ds4combatant)) { if (getInitiative(other) > getInitiative(combatant)) {
lowerBounds.push(tiebreaker); lowerBounds.push(tiebreaker);
} else if (getInitiative(combatant) < getInitiative(ds4combatant)) { } else if (getInitiative(other) < getInitiative(combatant)) {
upperBounds.push(tiebreaker); upperBounds.push(tiebreaker);
} else { } else {
equals.push(tiebreaker); equals.push(tiebreaker);
@ -62,37 +63,32 @@ const getTiebreaker = async (combatant: TickwerkCombatant, combatants: TickwerkC
} }
}; };
const getInitiativeFormula = (combatant: TickwerkCombatant) => { /**
* Get the initiative formula for a combatant.
* @param {TickwerkCombatant} combatant The combatant for which to get the initiative formula
* @returns {string} The initiative formula
*/
const getInitiativeFormula = (combatant) => {
const started = combatant.combat?.started ?? false; const started = combatant.combat?.started ?? false;
if (!started) return '-@combatValues.initiative.total'; if (!started) return '-@combatValues.initiative.total';
const tickValue = combatant.combat?.round ?? 0; const tickValue = combatant.combat?.round ?? 0;
return `max(${tickValue} + 10 - @combatValues.initiative.total, ${tickValue})`; return `max(${tickValue} + 10 - @combatValues.initiative.total, ${tickValue})`;
}; };
type DS4TickwerkCombatant = TickwerkCombatant & { actor: (Actor & { data: { data: ActorData } }) | null }; /**
* Get the initiative for a combatant.
const getInitiative = (combatant: DS4TickwerkCombatant): number => { * @param {TickwerkCombatant} combatant The combatant for which to get the initiative
return combatant.actor?.data.data.combatValues.initiative.total ?? -Infinity; * @returns {number}
*/
const getInitiative = (combatant) => {
return combatant.actor?.system.combatValues.initiative.total ?? -Infinity;
}; };
interface ActorData { /**
combatValues: { * React to an item roll by prompting the user to advance ticks.
initiative: { * @param {Item} item The item that has been rolled
total: number; */
}; const onRollItem = (item) => {
};
}
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Hooks {
interface StaticCallbacks {
'ds4.rollItem': (item: Item) => void;
}
}
}
const onRollItem = (item: Item) => {
const game = getGame(); const game = getGame();
if (game.settings.get(packageId, 'ds4.reactToRollItemHook')) { if (game.settings.get(packageId, 'ds4.reactToRollItemHook')) {
if (['weapon', 'spell'].includes(item.type) && item.actor?.id) { if (['weapon', 'spell'].includes(item.type) && item.actor?.id) {

View file

@ -1,225 +0,0 @@
{
"Actor": {
"types": ["character", "creature"],
"templates": {
"base": {
"attributes": {
"body": {
"base": 0,
"mod": 0
},
"mobility": {
"base": 0,
"mod": 0
},
"mind": {
"base": 0,
"mod": 0
}
},
"traits": {
"strength": {
"base": 0,
"mod": 0
},
"constitution": {
"base": 0,
"mod": 0
},
"agility": {
"base": 0,
"mod": 0
},
"dexterity": {
"base": 0,
"mod": 0
},
"intellect": {
"base": 0,
"mod": 0
},
"aura": {
"base": 0,
"mod": 0
}
},
"combatValues": {
"hitPoints": {
"mod": 0,
"value": 0
},
"defense": {
"mod": 0
},
"initiative": {
"mod": 0
},
"movement": {
"mod": 0
},
"meleeAttack": {
"mod": 0
},
"rangedAttack": {
"mod": 0
},
"spellcasting": {
"mod": 0
},
"targetedSpellcasting": {
"mod": 0
}
}
}
},
"creature": {
"templates": ["base"],
"baseInfo": {
"loot": "",
"foeFactor": 1,
"creatureType": "humanoid",
"sizeCategory": "normal",
"experiencePoints": 0,
"description": ""
}
},
"character": {
"templates": ["base"],
"baseInfo": {
"race": "",
"class": "",
"heroClass": "",
"culture": ""
},
"progression": {
"level": 0,
"experiencePoints": 0,
"talentPoints": {
"total": 0,
"used": 0
},
"progressPoints": {
"total": 0,
"used": 0
}
},
"profile": {
"biography": "",
"gender": "",
"birthday": "",
"birthplace": "",
"age": 0,
"height": 0,
"hairColor": "",
"weight": 0,
"eyeColor": "",
"specialCharacteristics": ""
},
"currency": {
"gold": 0,
"silver": 0,
"copper": 0
},
"slayerPoints": {
"value": 0
}
}
},
"Item": {
"types": [
"weapon",
"armor",
"shield",
"spell",
"equipment",
"loot",
"talent",
"racialAbility",
"language",
"alphabet",
"specialCreatureAbility"
],
"templates": {
"base": {
"description": ""
},
"physical": {
"quantity": 1,
"price": 0,
"availability": "unset",
"storageLocation": "-"
},
"equipable": {
"equipped": false
},
"protective": {
"armorValue": 0
}
},
"weapon": {
"templates": ["base", "physical", "equipable"],
"attackType": "melee",
"weaponBonus": 0,
"opponentDefense": 0
},
"armor": {
"templates": ["base", "physical", "equipable", "protective"],
"armorMaterialType": "cloth",
"armorType": "body"
},
"shield": {
"templates": ["base", "physical", "equipable", "protective"]
},
"spell": {
"templates": ["base", "equipable"],
"spellType": "spellcasting",
"bonus": "",
"spellCategory": "unset",
"maxDistance": {
"value": "",
"unit": "meter"
},
"effectRadius": {
"value": "",
"unit": "meter"
},
"duration": {
"value": "",
"unit": "custom"
},
"cooldownDuration": "0r",
"minimumLevels": {
"healer": null,
"wizard": null,
"sorcerer": null
}
},
"equipment": {
"templates": ["base", "physical", "equipable"]
},
"loot": {
"templates": ["base", "physical"]
},
"talent": {
"templates": ["base"],
"rank": {
"base": 0,
"max": 0,
"mod": 0
}
},
"racialAbility": {
"templates": ["base"]
},
"language": {
"templates": ["base"]
},
"alphabet": {
"templates": ["base"]
},
"specialCreatureAbility": {
"templates": ["base"],
"experiencePoints": 0
}
}
}

View file

@ -1,6 +0,0 @@
SPDX-FileCopyrightText: 2021 Johannes Loher
SPDX-FileCopyrightText: 2021 Oliver Rümpelein
SPDX-FileCopyrightText: 2021 Gesina Schwalbe
SPDX-FileCopyrightText: 2021 Siegfried Krug
SPDX-License-Identifier: MIT

View file

@ -5,82 +5,89 @@ SPDX-FileCopyrightText: 2022 Johannes Loher
SPDX-License-Identifier: MIT SPDX-License-Identifier: MIT
--}} --}}
<section class="tab sidebar-tab directory flexcol" id="combat" data-tab="combat"> <section class="{{cssClass}} directory flexcol" id="{{cssId}}" data-tab="{{tabName}}">
<header id="combat-round"> <header class="combat-tracker-header">
{{#if user.isGM}} {{#if user.isGM}}
<nav class="encounters flexrow"> <nav class="encounters flexrow" aria-label="COMBAT.NavLabel">
<a class="combat-create" title="{{localize 'COMBAT.Create'}}"> <a class="combat-button combat-create" data-tooltip="COMBAT.Create">
<i class="fas fa-plus"></i> <i class="fas fa-plus"></i>
</a> </a>
{{#if combatCount}} {{#if combatCount}}
<a class="combat-cycle" title="{{localize 'COMBAT.EncounterPrevious'}}" <a class="combat-button combat-cycle" data-tooltip="COMBAT.EncounterPrevious"
{{#if previousId}}data-combat-id="{{previousId}}"{{else}}disabled{{/if}}> {{#if previousId}}data-document-id="{{previousId}}"{{else}}disabled{{/if}}>
<i class="fas fa-caret-left"></i> <i class="fas fa-caret-left"></i>
</a> </a>
<h4 class="encounter">{{localize "COMBAT.Encounter"}} {{currentIndex}} / {{combatCount}}</h4> <h4 class="encounter">{{localize "COMBAT.Encounter"}} {{currentIndex}} / {{combatCount}}</h4>
<a class="combat-cycle" title="{{localize 'COMBAT.EncounterNext'}}" <a class="combat-button combat-cycle" data-tooltip="COMBAT.EncounterNext"
{{#if nextId}}data-combat-id="{{nextId}}"{{else}}disabled{{/if}}> {{#if nextId}}data-document-id="{{nextId}}"{{else}}disabled{{/if}}>
<i class="fas fa-caret-right"></i> <i class="fas fa-caret-right"></i>
</a> </a>
{{/if}} {{/if}}
<a class="combat-control" title="{{localize 'COMBAT.Delete'}}" data-control="endCombat" {{#unless combatCount}}disabled{{/unless}}> <a class="combat-button combat-control" data-tooltip="COMBAT.Delete" data-control="endCombat" {{#unless combatCount}}disabled{{/unless}}>
<i class="fas fa-trash"></i> <i class="fas fa-trash"></i>
</a> </a>
</nav> </nav>
{{/if}} {{/if}}
<nav class="encounters flexrow {{#if hasCombat}}combat{{/if}}"> <div class="encounter-controls flexrow {{#if hasCombat}}combat{{/if}}">
{{#if user.isGM}} {{#if user.isGM}}
<a class="combat-control" title="{{localize 'COMBAT.RollAll'}}" data-control="rollAll" {{#unless turns}}disabled{{/unless}}> <a class="combat-button combat-control" data-tooltip="COMBAT.RollAll" data-control="rollAll" {{#unless turns}}disabled{{/unless}}>
<i class="fas fa-users"></i> <i class="fas fa-users"></i>
</a> </a>
<a class="combat-control" title="{{localize 'COMBAT.RollNPC'}}" data-control="rollNPC" {{#unless turns}}disabled{{/unless}}> <a class="combat-button combat-control" data-tooltip="COMBAT.RollNPC" data-control="rollNPC" {{#unless turns}}disabled{{/unless}}>
<i class="fas fa-users-cog"></i> <i class="fas fa-users-cog"></i>
</a> </a>
{{/if}} {{/if}}
{{#if combatCount}} {{#if combatCount}}
{{#if combat.started}} {{#if combat.started}}
<h3 class="encounter-title">{{localize 'TICKWERK.Tick'}} {{combat.tickValue}}</h3> <h3 class="encounter-title noborder">{{localize 'TICKWERK.Tick'}} {{combat.tickValue}}</h3>
{{else}} {{else}}
<h3 class="encounter-title">{{localize 'COMBAT.NotStarted'}}</h3> <h3 class="encounter-title noborder">{{localize 'COMBAT.NotStarted'}}</h3>
{{/if}} {{/if}}
{{else}} {{else}}
<h3 class="encounter-title">{{localize "COMBAT.None"}}</h3> <h3 class="encounter-title noborder">{{localize "COMBAT.None"}}</h3>
{{/if}} {{/if}}
{{#if user.isGM}} {{#if user.isGM}}
<a class="combat-control" title="{{localize 'COMBAT.InitiativeReset'}}" data-control="resetAll" <a class="combat-button combat-control" data-tooltip="COMBAT.InitiativeReset" data-control="resetAll"
{{#unless hasCombat}}disabled{{/unless}}> {{#unless hasCombat}}disabled{{/unless}}>
<i class="fas fa-undo"></i> <i class="fas fa-undo"></i>
</a> </a>
<a class="combat-control" title="{{labels.scope}}" <a class="combat-button combat-control" data-tooltip="{{labels.scope}}"
data-control="toggleSceneLink" {{#unless hasCombat}}disabled{{/unless}}> data-control="toggleSceneLink" {{#unless hasCombat}}disabled{{/unless}}>
<i class="fas fa-{{#unless linked}}un{{/unless}}link"></i> <i class="fas fa-{{#unless linked}}un{{/unless}}link"></i>
</a> </a>
<a class="combat-settings" title="{{localize 'COMBAT.Settings'}}" data-control="trackerSettings"> <a class="combat-button combat-settings" data-tooltip="COMBAT.Settings" data-control="trackerSettings">
<i class="fas fa-cog"></i> <i class="fas fa-cog"></i>
</a> </a>
{{/if}} {{/if}}
</nav> </div>
</header> </header>
<ol id="combat-tracker" class="directory-list"> <ol id="combat-tracker" class="directory-list">
{{#each turns}} {{#each turns}}
<li class="combatant actor directory-item flexrow {{this.css}}" data-combatant-id="{{this.id}}"> <li class="combatant actor directory-item flexrow {{this.css}}" data-combatant-id="{{this.id}}">
<img class="token-image" data-src="{{this.img}}" title="{{this.name}}"/> <img class="token-image" data-src="{{this.img}}" alt="{{this.name}}"/>
<div class="token-name flexcol"> <div class="token-name flexcol">
<h4>{{this.name}}</h4> <h4>{{this.name}}</h4>
<div class="combatant-controls flexrow"> <div class="combatant-controls flexrow">
{{#if (or ../user.isGM (and ../control this.owner))}} {{#if (or ../user.isGM (and ../control this.owner))}}
<a class="combatant-control" title="{{#if this.waiting}}{{localize 'TICKWERK.StopWaiting'}}{{else}}{{localize 'TICKWERK.Wait'}}{{/if}}" data-control="toggleWaiting"> <a class="combatant-control" data-tooltip="{{#if this.waiting}}TICKWERK.StopWaiting{{else}}TICKWERK.Wait{{/if}}" data-control="toggleWaiting">
<i class="fas {{#if this.waiting}}fa-play-circle{{else}}fa-pause-circle{{/if}}"></i></a> <i class="fas {{#if this.waiting}}fa-play-circle{{else}}fa-pause-circle{{/if}}"></i></a>
{{/if}} {{/if}}
{{#if ../user.isGM}} {{#if ../user.isGM}}
<a class="combatant-control {{#if this.hidden}}active{{/if}}" title="{{localize 'COMBAT.ToggleVis'}}" data-control="toggleHidden"> <a class="combatant-control {{#if this.hidden}}active{{/if}}" data-tooltip="COMBAT.ToggleVis" data-control="toggleHidden">
<i class="fas fa-eye-slash"></i></a> <i class="fas fa-eye-slash"></i>
<a class="combatant-control {{#if this.defeated}}active{{/if}}" title="{{localize 'COMBAT.ToggleDead'}}" data-control="toggleDefeated"> </a>
<i class="fas fa-skull"></i></a> <a class="combatant-control {{#if this.defeated}}active{{/if}}" data-tooltip="COMBAT.ToggleDead" data-control="toggleDefeated">
<i class="fas fa-skull"></i>
</a>
{{/if}}
{{#if this.canPing}}
<a class="combatant-control" data-tooltip="COMBAT.PingCombatant" data-control="pingCombatant">
<i class="fa-solid fa-bullseye-arrow"></i>
</a>
{{/if}} {{/if}}
<div class="token-effects"> <div class="token-effects">
{{#each this.effects}} {{#each this.effects}}
@ -100,25 +107,25 @@ SPDX-License-Identifier: MIT
{{#if this.hasRolled}} {{#if this.hasRolled}}
<span class="initiative">{{#if this.waiting}}{{localize "TICKWERK.Waiting"}}{{else}}{{this.initiative}}{{/if}}</span> <span class="initiative">{{#if this.waiting}}{{localize "TICKWERK.Waiting"}}{{else}}{{this.initiative}}{{/if}}</span>
{{else if this.owner}} {{else if this.owner}}
<a class="combatant-control roll" title="{{localize 'COMBAT.InitiativeRoll'}}" data-control="rollInitiative"></a> <a class="combatant-control roll" data-tooltip="COMBAT.InitiativeRoll" data-control="rollInitiative"></a>
{{/if}} {{/if}}
</div> </div>
</li> </li>
{{/each}} {{/each}}
</ol> </ol>
<nav id="combat-controls" class="directory-footer flexrow"> <nav id="combat-controls" class="directory-footer flexrow" data-tooltip-direction="UP">
{{#if hasCombat}} {{#if hasCombat}}
{{#if user.isGM}} {{#if user.isGM}}
{{#if combat.started}} {{#if combat.started}}
<a class="combat-control center" title="{{localize 'COMBAT.End'}}" data-control="endCombat">{{localize 'COMBAT.End'}}</a> <a class="combat-control center" data-control="endCombat">{{localize 'COMBAT.End'}}</a>
<a class="combat-control" title="{{localize 'COMBAT.TurnNext'}}" data-control="nextTurn"><i class="fas fa-arrow-right"></i></a> <a class="combat-control" data-tooltip="COMBAT.TurnNext" data-control="nextTurn"><i class="fas fa-arrow-right"></i></a>
{{else}} {{else}}
<a class="combat-control center" title="{{localize 'COMBAT.Begin'}}" data-control="startCombat">{{localize 'COMBAT.Begin'}}</a> <a class="combat-control center" data-control="startCombat">{{localize 'COMBAT.Begin'}}</a>
{{/if}} {{/if}}
{{else if (and control combat.started)}} {{else if (and control combat.started)}}
<a class="combat-control center" title="{{localize 'COMBAT.TurnEnd'}}" data-control="nextTurn">{{localize 'COMBAT.TurnEnd'}}</a> <a class="combat-control center" data-control="nextTurn">{{localize 'COMBAT.TurnEnd'}}</a>
<a class="combat-control" title="{{localize 'COMBAT.TurnNext'}}" data-control="nextTurn"><i class="fas fa-arrow-right"></i></a> <a class="combat-control" data-tooltip="COMBAT.TurnNext" data-control="nextTurn"><i class="fas fa-arrow-right"></i></a>
{{/if}} {{/if}}
{{/if}} {{/if}}
</nav> </nav>

View file

@ -1,4 +0,0 @@
{
"extends": "./tsconfig.json",
"include": ["src", "*.js"]
}

View file

@ -1,3 +0,0 @@
SPDX-FileCopyrightText: 2021 Johannes Loher
SPDX-License-Identifier: MIT

View file

@ -1,16 +0,0 @@
{
"compilerOptions": {
"target": "ES2021",
"lib": ["ES2021", "DOM"],
"types": ["@league-of-foundry-developers/foundry-vtt-types"],
"esModuleInterop": true,
"moduleResolution": "node",
"forceConsistentCasingInFileNames": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"resolveJsonModule": true,
"importsNotUsedAsValues": "error"
},
"include": ["src"]
}

1148
yarn.lock

File diff suppressed because it is too large Load diff