Merge branch 'master' into 003-add-character-profile

This commit is contained in:
Johannes Loher 2021-01-06 14:33:46 +01:00
commit daeb9fc703
3 changed files with 487 additions and 448 deletions

View file

@ -3,50 +3,89 @@
An implementation of the Dungeonslayers 4 game system for [Foundry Virtual An implementation of the Dungeonslayers 4 game system for [Foundry Virtual
Tabletop](http://foundryvtt.com). Tabletop](http://foundryvtt.com).
## Prerequisites This system provides character sheet support for Actors and Items and mechanical
support for dice and rules necessary to
play games of Dungeponslayers 4.
In order to build this system, a recent version of `npm` is required. ## Installation
## Building To install and use the Dungeonslayers 4 system for Foundry Virtual Tabletop,
simply paste the following URL into the **Install System** dialog on the Setup
menu of the application.
To build the system, first install all required dependencies: https://git.f3l.de/dungeonslayers/ds4/-/raw/master/src/system.json?inline=false
## Development
### Prerequisits
In order to build this system, recent versions of `node` and `npm` are required.
We recommend using the latest lts version of `node`, which is `v14.15.4` at the
time of writing. If you use `nvm` to manage your `node` versions, you can simply
run
```
nvm install
```
in the project's root directory.
You also need to install the the project's dependencies. To do so, run
``` ```
npm install npm install
``` ```
Then build the project by running ### Building
You can build the project by running
``` ```
npm run build npm run build
``` ```
If you'd like the built system to be automatically linked to your local Foundry Alternatively, you can run
VTT installation's data folder, add a file called `foundryconfig.json` to the
project root with the following contents: ```
npm run build:watch
```
to watch for changes and automatically build as necessary.
### Linking the built system to Foundry VTT
In order to provide a fluent development experience, it is recommended to link
the built system to your local Foundry VTT installation's data folder. In order
to do so, first add a file called `foundryconfig.json` to the project root with
the following content:
``` ```
{ {
"dataPath": "/<absolute path to your home>/.local/share/FoundryVTT", "dataPath": "<path to your home directory>/.local/share/FoundryVTT"
"repository": "",
"rawURL": ""
} }
``` ```
On platforms other than Linux you need to adjust the path accordingly.
Then run Then run
``` ```
npm run link npm run link
``` ```
If you want the system to be continuously build upon every saved change, just ### Running the tests
run
You can run the tests with the following command:
``` ```
npm run build:watch npm test
``` ```
# Licensing ## Contributing
Code and content contributions are accepted. Please feel free to submit issues
to the issue tracker or submit merge requests for code changes.
## Licensing
Dungeonslayers (© Christian Kennig) is licensed under [CC BY-NC-SA 3.0](https://creativecommons.org/licenses/by-nc-sa/3.0/de/deed.en). Dungeonslayers (© Christian Kennig) is licensed under [CC BY-NC-SA 3.0](https://creativecommons.org/licenses/by-nc-sa/3.0/de/deed.en).
@ -56,5 +95,5 @@ CC BY-NC-SA 3.0. Hence the modified icons are also published under this
license. A copy of this license can be found under license. A copy of this license can be found under
[src/assets/official/LICENSE](src/assets/official/LICENSE). [src/assets/official/LICENSE](src/assets/official/LICENSE).
The rest of this project is licensed under the MIT License, a copy of which can The software component of this project is licensed under the MIT License, a copy
be found under [LICENSE](LICENSE). of which can be found under [LICENSE](LICENSE).

View file

@ -1,429 +1,429 @@
const gulp = require("gulp"); const gulp = require("gulp");
const fs = require("fs-extra"); const fs = require("fs-extra");
const path = require("path"); const path = require("path");
const chalk = require("chalk"); const chalk = require("chalk");
const archiver = require("archiver"); const archiver = require("archiver");
const stringify = require("json-stringify-pretty-compact"); const stringify = require("json-stringify-pretty-compact");
const typescript = require("typescript"); const typescript = require("typescript");
const ts = require("gulp-typescript"); const ts = require("gulp-typescript");
const less = require("gulp-less"); const less = require("gulp-less");
const sass = require("gulp-sass"); const sass = require("gulp-sass");
const git = require("gulp-git"); const git = require("gulp-git");
const argv = require("yargs").argv; const argv = require("yargs").argv;
sass.compiler = require("sass"); sass.compiler = require("sass");
function getConfig() { function getConfig() {
const configPath = path.resolve(process.cwd(), "foundryconfig.json"); const configPath = path.resolve(process.cwd(), "foundryconfig.json");
let config; let config;
if (fs.existsSync(configPath)) { if (fs.existsSync(configPath)) {
config = fs.readJSONSync(configPath); config = fs.readJSONSync(configPath);
return config; return config;
} else { } else {
return; return;
} }
} }
function getManifest() { function getManifest() {
const json = {}; const json = {};
if (fs.existsSync("src")) { if (fs.existsSync("src")) {
json.root = "src"; json.root = "src";
} else { } else {
json.root = "dist"; json.root = "dist";
} }
const modulePath = path.join(json.root, "module.json"); const modulePath = path.join(json.root, "module.json");
const systemPath = path.join(json.root, "system.json"); const systemPath = path.join(json.root, "system.json");
if (fs.existsSync(modulePath)) { if (fs.existsSync(modulePath)) {
json.file = fs.readJSONSync(modulePath); json.file = fs.readJSONSync(modulePath);
json.name = "module.json"; json.name = "module.json";
} else if (fs.existsSync(systemPath)) { } else if (fs.existsSync(systemPath)) {
json.file = fs.readJSONSync(systemPath); json.file = fs.readJSONSync(systemPath);
json.name = "system.json"; json.name = "system.json";
} else { } else {
return; return;
} }
return json; return json;
} }
/** /**
* TypeScript transformers * TypeScript transformers
* @returns {typescript.TransformerFactory<typescript.SourceFile>} * @returns {typescript.TransformerFactory<typescript.SourceFile>}
*/ */
function createTransformer() { function createTransformer() {
/** /**
* @param {typescript.Node} node * @param {typescript.Node} node
*/ */
function shouldMutateModuleSpecifier(node) { function shouldMutateModuleSpecifier(node) {
if (!typescript.isImportDeclaration(node) && !typescript.isExportDeclaration(node)) return false; if (!typescript.isImportDeclaration(node) && !typescript.isExportDeclaration(node)) return false;
if (node.moduleSpecifier === undefined) return false; if (node.moduleSpecifier === undefined) return false;
if (!typescript.isStringLiteral(node.moduleSpecifier)) return false; if (!typescript.isStringLiteral(node.moduleSpecifier)) return false;
if (!node.moduleSpecifier.text.startsWith("./") && !node.moduleSpecifier.text.startsWith("../")) return false; if (!node.moduleSpecifier.text.startsWith("./") && !node.moduleSpecifier.text.startsWith("../")) return false;
if (path.extname(node.moduleSpecifier.text) !== "") return false; if (path.extname(node.moduleSpecifier.text) !== "") return false;
return true; return true;
} }
/** /**
* Transforms import/export declarations to append `.js` extension * Transforms import/export declarations to append `.js` extension
* @param {typescript.TransformationContext} context * @param {typescript.TransformationContext} context
*/ */
function importTransformer(context) { function importTransformer(context) {
return (node) => { return (node) => {
/** /**
* @param {typescript.Node} node * @param {typescript.Node} node
*/ */
function visitor(node) { function visitor(node) {
if (shouldMutateModuleSpecifier(node)) { if (shouldMutateModuleSpecifier(node)) {
if (typescript.isImportDeclaration(node)) { if (typescript.isImportDeclaration(node)) {
const newModuleSpecifier = typescript.createLiteral(`${node.moduleSpecifier.text}.js`); const newModuleSpecifier = typescript.createLiteral(`${node.moduleSpecifier.text}.js`);
return typescript.updateImportDeclaration( return typescript.updateImportDeclaration(
node, node,
node.decorators, node.decorators,
node.modifiers, node.modifiers,
node.importClause, node.importClause,
newModuleSpecifier newModuleSpecifier,
); );
} else if (typescript.isExportDeclaration(node)) { } else if (typescript.isExportDeclaration(node)) {
const newModuleSpecifier = typescript.createLiteral(`${node.moduleSpecifier.text}.js`); const newModuleSpecifier = typescript.createLiteral(`${node.moduleSpecifier.text}.js`);
return typescript.updateExportDeclaration( return typescript.updateExportDeclaration(
node, node,
node.decorators, node.decorators,
node.modifiers, node.modifiers,
node.exportClause, node.exportClause,
newModuleSpecifier newModuleSpecifier,
); );
} }
} }
return typescript.visitEachChild(node, visitor, context); return typescript.visitEachChild(node, visitor, context);
} }
return typescript.visitNode(node, visitor); return typescript.visitNode(node, visitor);
}; };
} }
return importTransformer; return importTransformer;
} }
const tsConfig = ts.createProject("tsconfig.json", { const tsConfig = ts.createProject("tsconfig.json", {
getCustomTransformers: (_program) => ({ getCustomTransformers: (_program) => ({
after: [createTransformer()], after: [createTransformer()],
}), }),
}); });
/********************/ /********************/
/* BUILD */ /* BUILD */
/********************/ /********************/
/** /**
* Build TypeScript * Build TypeScript
*/ */
function buildTS() { function buildTS() {
return gulp.src("src/**/*.ts").pipe(tsConfig()).pipe(gulp.dest("dist")); return gulp.src("src/**/*.ts").pipe(tsConfig()).pipe(gulp.dest("dist"));
} }
/** /**
* Build Less * Build Less
*/ */
function buildLess() { function buildLess() {
return gulp.src("src/*.less").pipe(less()).pipe(gulp.dest("dist")); return gulp.src("src/*.less").pipe(less()).pipe(gulp.dest("dist"));
} }
/** /**
* Build SASS * Build SASS
*/ */
function buildSASS() { function buildSASS() {
return gulp.src("src/*.scss").pipe(sass().on("error", sass.logError)).pipe(gulp.dest("dist")); return gulp.src("src/*.scss").pipe(sass().on("error", sass.logError)).pipe(gulp.dest("dist"));
} }
/** /**
* Copy static files * Copy static files
*/ */
async function copyFiles() { async function copyFiles() {
const statics = ["lang", "fonts", "assets", "templates", "module.json", "system.json", "template.json"]; const statics = ["lang", "fonts", "assets", "templates", "module.json", "system.json", "template.json"];
try { try {
for (const file of statics) { for (const file of statics) {
if (fs.existsSync(path.join("src", file))) { if (fs.existsSync(path.join("src", file))) {
await fs.copy(path.join("src", file), path.join("dist", file)); await fs.copy(path.join("src", file), path.join("dist", file));
} }
} }
return Promise.resolve(); return Promise.resolve();
} catch (err) { } catch (err) {
Promise.reject(err); Promise.reject(err);
} }
} }
/** /**
* Watch for changes for each build step * Watch for changes for each build step
*/ */
function buildWatch() { function buildWatch() {
gulp.watch("src/**/*.ts", { ignoreInitial: false }, buildTS); gulp.watch("src/**/*.ts", { ignoreInitial: false }, buildTS);
gulp.watch("src/**/*.less", { ignoreInitial: false }, buildLess); gulp.watch("src/**/*.less", { ignoreInitial: false }, buildLess);
gulp.watch("src/**/*.scss", { ignoreInitial: false }, buildSASS); gulp.watch("src/**/*.scss", { ignoreInitial: false }, buildSASS);
gulp.watch(["src/fonts", "src/lang", "src/templates", "src/*.json"], { ignoreInitial: false }, copyFiles); gulp.watch(["src/fonts", "src/lang", "src/templates", "src/*.json"], { ignoreInitial: false }, copyFiles);
} }
/********************/ /********************/
/* CLEAN */ /* CLEAN */
/********************/ /********************/
/** /**
* Remove built files from `dist` folder * Remove built files from `dist` folder
* while ignoring source files * while ignoring source files
*/ */
async function clean() { async function clean() {
const name = path.basename(path.resolve(".")); const name = path.basename(path.resolve("."));
const files = []; const files = [];
// If the project uses TypeScript // If the project uses TypeScript
if (fs.existsSync(path.join("src", `${name}.ts`))) { if (fs.existsSync(path.join("src", `${name}.ts`))) {
files.push( files.push(
"lang", "lang",
"templates", "templates",
"assets", "assets",
"module", "module",
`${name}.js`, `${name}.js`,
"module.json", "module.json",
"system.json", "system.json",
"template.json" "template.json",
); );
} }
// If the project uses Less or SASS // If the project uses Less or SASS
if (fs.existsSync(path.join("src", `${name}.less`)) || fs.existsSync(path.join("src", `${name}.scss`))) { if (fs.existsSync(path.join("src", `${name}.less`)) || fs.existsSync(path.join("src", `${name}.scss`))) {
files.push("fonts", `${name}.css`); files.push("fonts", `${name}.css`);
} }
console.log(" ", chalk.yellow("Files to clean:")); console.log(" ", chalk.yellow("Files to clean:"));
console.log(" ", chalk.blueBright(files.join("\n "))); console.log(" ", chalk.blueBright(files.join("\n ")));
// Attempt to remove the files // Attempt to remove the files
try { try {
for (const filePath of files) { for (const filePath of files) {
await fs.remove(path.join("dist", filePath)); await fs.remove(path.join("dist", filePath));
} }
return Promise.resolve(); return Promise.resolve();
} catch (err) { } catch (err) {
Promise.reject(err); Promise.reject(err);
} }
} }
/********************/ /********************/
/* LINK */ /* LINK */
/********************/ /********************/
/** /**
* Link build to User Data folder * Link build to User Data folder
*/ */
async function linkUserData() { async function linkUserData() {
const name = path.basename(path.resolve(".")); const name = path.basename(path.resolve("."));
const config = fs.readJSONSync("foundryconfig.json"); const config = fs.readJSONSync("foundryconfig.json");
let destDir; let destDir;
try { try {
if ( if (
fs.existsSync(path.resolve(".", "dist", "module.json")) || fs.existsSync(path.resolve(".", "dist", "module.json")) ||
fs.existsSync(path.resolve(".", "src", "module.json")) fs.existsSync(path.resolve(".", "src", "module.json"))
) { ) {
destDir = "modules"; destDir = "modules";
} else if ( } else if (
fs.existsSync(path.resolve(".", "dist", "system.json")) || fs.existsSync(path.resolve(".", "dist", "system.json")) ||
fs.existsSync(path.resolve(".", "src", "system.json")) fs.existsSync(path.resolve(".", "src", "system.json"))
) { ) {
destDir = "systems"; destDir = "systems";
} else { } else {
throw Error(`Could not find ${chalk.blueBright("module.json")} or ${chalk.blueBright("system.json")}`); throw Error(`Could not find ${chalk.blueBright("module.json")} or ${chalk.blueBright("system.json")}`);
} }
let linkDir; let linkDir;
if (config.dataPath) { if (config.dataPath) {
if (!fs.existsSync(path.join(config.dataPath, "Data"))) if (!fs.existsSync(path.join(config.dataPath, "Data")))
throw Error("User Data path invalid, no Data directory found"); throw Error("User Data path invalid, no Data directory found");
linkDir = path.join(config.dataPath, "Data", destDir, name); linkDir = path.join(config.dataPath, "Data", destDir, name);
} else { } else {
throw Error("No User Data path defined in foundryconfig.json"); throw Error("No User Data path defined in foundryconfig.json");
} }
if (argv.clean || argv.c) { if (argv.clean || argv.c) {
console.log(chalk.yellow(`Removing build in ${chalk.blueBright(linkDir)}`)); console.log(chalk.yellow(`Removing build in ${chalk.blueBright(linkDir)}`));
await fs.remove(linkDir); await fs.remove(linkDir);
} else if (!fs.existsSync(linkDir)) { } else if (!fs.existsSync(linkDir)) {
console.log(chalk.green(`Copying build to ${chalk.blueBright(linkDir)}`)); console.log(chalk.green(`Copying build to ${chalk.blueBright(linkDir)}`));
await fs.symlink(path.resolve("./dist"), linkDir); await fs.symlink(path.resolve("./dist"), linkDir);
} }
return Promise.resolve(); return Promise.resolve();
} catch (err) { } catch (err) {
Promise.reject(err); Promise.reject(err);
} }
} }
/*********************/ /*********************/
/* PACKAGE */ /* PACKAGE */
/*********************/ /*********************/
/** /**
* Package build * Package build
*/ */
async function packageBuild() { async function packageBuild() {
const manifest = getManifest(); const manifest = getManifest();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
try { try {
// Remove the package dir without doing anything else // Remove the package dir without doing anything else
if (argv.clean || argv.c) { if (argv.clean || argv.c) {
console.log(chalk.yellow("Removing all packaged files")); console.log(chalk.yellow("Removing all packaged files"));
fs.removeSync("package"); fs.removeSync("package");
return; return;
} }
// Ensure there is a directory to hold all the packaged versions // Ensure there is a directory to hold all the packaged versions
fs.ensureDirSync("package"); fs.ensureDirSync("package");
// Initialize the zip file // Initialize the zip file
const zipName = `${manifest.file.name}-v${manifest.file.version}.zip`; const zipName = `${manifest.file.name}-v${manifest.file.version}.zip`;
const zipFile = fs.createWriteStream(path.join("package", zipName)); const zipFile = fs.createWriteStream(path.join("package", zipName));
const zip = archiver("zip", { zlib: { level: 9 } }); const zip = archiver("zip", { zlib: { level: 9 } });
zipFile.on("close", () => { zipFile.on("close", () => {
console.log(chalk.green(zip.pointer() + " total bytes")); console.log(chalk.green(zip.pointer() + " total bytes"));
console.log(chalk.green(`Zip file ${zipName} has been written`)); console.log(chalk.green(`Zip file ${zipName} has been written`));
return resolve(); return resolve();
}); });
zip.on("error", (err) => { zip.on("error", (err) => {
throw err; throw err;
}); });
zip.pipe(zipFile); zip.pipe(zipFile);
// Add the directory with the final code // Add the directory with the final code
zip.directory("dist/", manifest.file.name); zip.directory("dist/", manifest.file.name);
zip.finalize(); zip.finalize();
} catch (err) { } catch (err) {
return reject(err); return reject(err);
} }
}); });
} }
/*********************/ /*********************/
/* PACKAGE */ /* PACKAGE */
/*********************/ /*********************/
/** /**
* Update version and URLs in the manifest JSON * Update version and URLs in the manifest JSON
*/ */
function updateManifest(cb) { function updateManifest(cb) {
const packageJson = fs.readJSONSync("package.json"); const packageJson = fs.readJSONSync("package.json");
const config = getConfig(), const config = getConfig(),
manifest = getManifest(), manifest = getManifest(),
rawURL = config.rawURL, rawURL = config.rawURL,
repoURL = config.repository, repoURL = config.repository,
manifestRoot = manifest.root; manifestRoot = manifest.root;
if (!config) cb(Error(chalk.red("foundryconfig.json not found"))); if (!config) cb(Error(chalk.red("foundryconfig.json not found")));
if (!manifest) cb(Error(chalk.red("Manifest JSON not found"))); if (!manifest) cb(Error(chalk.red("Manifest JSON not found")));
if (!rawURL || !repoURL) cb(Error(chalk.red("Repository URLs not configured in foundryconfig.json"))); if (!rawURL || !repoURL) cb(Error(chalk.red("Repository URLs not configured in foundryconfig.json")));
try { try {
const version = argv.update || argv.u; const version = argv.update || argv.u;
/* Update version */ /* Update version */
const versionMatch = /^(\d{1,}).(\d{1,}).(\d{1,})$/; const versionMatch = /^(\d{1,}).(\d{1,}).(\d{1,})$/;
const currentVersion = manifest.file.version; const currentVersion = manifest.file.version;
let targetVersion = ""; let targetVersion = "";
if (!version) { if (!version) {
cb(Error("Missing version number")); cb(Error("Missing version number"));
} }
if (versionMatch.test(version)) { if (versionMatch.test(version)) {
targetVersion = version; targetVersion = version;
} else { } else {
targetVersion = currentVersion.replace(versionMatch, (substring, major, minor, patch) => { targetVersion = currentVersion.replace(versionMatch, (substring, major, minor, patch) => {
console.log(substring, Number(major) + 1, Number(minor) + 1, Number(patch) + 1); console.log(substring, Number(major) + 1, Number(minor) + 1, Number(patch) + 1);
if (version === "major") { if (version === "major") {
return `${Number(major) + 1}.0.0`; return `${Number(major) + 1}.0.0`;
} else if (version === "minor") { } else if (version === "minor") {
return `${major}.${Number(minor) + 1}.0`; return `${major}.${Number(minor) + 1}.0`;
} else if (version === "patch") { } else if (version === "patch") {
return `${major}.${minor}.${Number(patch) + 1}`; return `${major}.${minor}.${Number(patch) + 1}`;
} else { } else {
return ""; return "";
} }
}); });
} }
if (targetVersion === "") { if (targetVersion === "") {
return cb(Error(chalk.red("Error: Incorrect version arguments."))); return cb(Error(chalk.red("Error: Incorrect version arguments.")));
} }
if (targetVersion === currentVersion) { if (targetVersion === currentVersion) {
return cb(Error(chalk.red("Error: Target version is identical to current version."))); return cb(Error(chalk.red("Error: Target version is identical to current version.")));
} }
console.log(`Updating version number to '${targetVersion}'`); console.log(`Updating version number to '${targetVersion}'`);
packageJson.version = targetVersion; packageJson.version = targetVersion;
manifest.file.version = targetVersion; manifest.file.version = targetVersion;
/* Update URLs */ /* Update URLs */
const result = `${rawURL}/v${manifest.file.version}/package/${manifest.file.name}-v${manifest.file.version}.zip`; const result = `${rawURL}/v${manifest.file.version}/package/${manifest.file.name}-v${manifest.file.version}.zip`;
manifest.file.url = repoURL; manifest.file.url = repoURL;
manifest.file.manifest = `${rawURL}/master/${manifestRoot}/${manifest.name}`; manifest.file.manifest = `${rawURL}/master/${manifestRoot}/${manifest.name}`;
manifest.file.download = result; manifest.file.download = result;
const prettyProjectJson = stringify(manifest.file, { const prettyProjectJson = stringify(manifest.file, {
maxLength: 35, maxLength: 35,
indent: "\t", indent: "\t",
}); });
fs.writeJSONSync("package.json", packageJson, { spaces: "\t" }); fs.writeJSONSync("package.json", packageJson, { spaces: "\t" });
fs.writeFileSync(path.join(manifest.root, manifest.name), prettyProjectJson, "utf8"); fs.writeFileSync(path.join(manifest.root, manifest.name), prettyProjectJson, "utf8");
return cb(); return cb();
} catch (err) { } catch (err) {
cb(err); cb(err);
} }
} }
function gitAdd() { function gitAdd() {
return gulp.src("package").pipe(git.add({ args: "--no-all" })); return gulp.src("package").pipe(git.add({ args: "--no-all" }));
} }
function gitCommit() { function gitCommit() {
return gulp.src("./*").pipe( return gulp.src("./*").pipe(
git.commit(`v${getManifest().file.version}`, { git.commit(`v${getManifest().file.version}`, {
args: "-a", args: "-a",
disableAppendPaths: true, disableAppendPaths: true,
}) }),
); );
} }
function gitTag() { function gitTag() {
const manifest = getManifest(); const manifest = getManifest();
return git.tag(`v${manifest.file.version}`, `Updated to ${manifest.file.version}`, (err) => { return git.tag(`v${manifest.file.version}`, `Updated to ${manifest.file.version}`, (err) => {
if (err) throw err; if (err) throw err;
}); });
} }
const execGit = gulp.series(gitAdd, gitCommit, gitTag); const execGit = gulp.series(gitAdd, gitCommit, gitTag);
const execBuild = gulp.parallel(buildTS, buildLess, buildSASS, copyFiles); const execBuild = gulp.parallel(buildTS, buildLess, buildSASS, copyFiles);
exports.build = gulp.series(clean, execBuild); exports.build = gulp.series(clean, execBuild);
exports.watch = buildWatch; exports.watch = buildWatch;
exports.clean = clean; exports.clean = clean;
exports.link = linkUserData; exports.link = linkUserData;
exports.package = packageBuild; exports.package = packageBuild;
exports.update = updateManifest; exports.update = updateManifest;
exports.publish = gulp.series(clean, updateManifest, execBuild, packageBuild, execGit); exports.publish = gulp.series(clean, updateManifest, execBuild, packageBuild, execGit);

View file

@ -22,7 +22,7 @@
"gridUnits": "m", "gridUnits": "m",
"primaryTokenAttribute": "combatValues.hitPoints.current", "primaryTokenAttribute": "combatValues.hitPoints.current",
"url": "https://git.f3l.de/dungeonslayers/ds4", "url": "https://git.f3l.de/dungeonslayers/ds4",
"manifest": "https://git.f3l.de/dungeonslayers/ds4/-/raw/latest/src/system.json?inline=false", "manifest": "https://git.f3l.de/dungeonslayers/ds4/-/raw/master/src/system.json?inline=false",
"download": "https://git.f3l.de/dungeonslayers/ds4/-/jobs/artifacts/latest/download?job=build", "download": "https://git.f3l.de/dungeonslayers/ds4/-/jobs/artifacts/0.1.0/download?job=build",
"license": "MIT" "license": "MIT"
} }