Merge branch 'automatic-spell-price-calculation' into 'master'

Automatic spell price calculation

See merge request dungeonslayers/ds4!172
This commit is contained in:
Johannes Loher 2022-02-13 18:57:36 +00:00
commit 18a96a3528
10 changed files with 310 additions and 32 deletions

View file

@ -29,5 +29,13 @@ module.exports = {
"@typescript-eslint/no-var-requires": "off", "@typescript-eslint/no-var-requires": "off",
}, },
}, },
{
files: ["./spec/**/*"],
env: {
browser: false,
},
extends: ["plugin:jest/recommended"],
plugins: ["jest"],
},
], ],
}; };

View file

@ -2,11 +2,15 @@
// //
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
export default { /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
const config = {
preset: "ts-jest", preset: "ts-jest",
globals: { globals: {
"ts-jest": { "ts-jest": {
tsconfig: "<rootDir>/spec/tsconfig.spec.json", tsconfig: "<rootDir>/spec/tsconfig.json",
}, },
}, },
setupFiles: ["<rootDir>/spec/setup.ts"],
}; };
export default config;

View file

@ -0,0 +1,231 @@
// SPDX-FileCopyrightText: 2022 Johannes Loher
//
// SPDX-License-Identifier: MIT
import { DS4SpellDataSourceData, TemporalUnit, UnitData } from "../../../src/item/item-data-source";
import { calculateSpellPrice } from "../../../src/item/type-specific-helpers/spell";
const defaultData: DS4SpellDataSourceData = {
description: "",
equipped: false,
spellType: "spellcasting",
bonus: "",
spellCategory: "unset",
maxDistance: {
value: "",
unit: "meter",
},
effectRadius: {
value: "",
unit: "meter",
},
duration: {
value: "",
unit: "custom",
},
cooldownDuration: {
value: "",
unit: "rounds",
},
minimumLevels: {
healer: null,
wizard: null,
sorcerer: null,
},
};
type TestCase = {
minimumLevel: number | null;
expected: number | null;
};
type CombinedTestCase = {
minimumLevels: DS4SpellDataSourceData["minimumLevels"];
expected: number | null;
};
const testCases: Record<keyof DS4SpellDataSourceData["minimumLevels"], TestCase[]> = {
healer: [
{ minimumLevel: null, expected: null },
{ minimumLevel: 1, expected: 10 },
{ minimumLevel: 2, expected: 45 },
{ minimumLevel: 3, expected: 80 },
{ minimumLevel: 4, expected: 115 },
{ minimumLevel: 5, expected: 150 },
{ minimumLevel: 6, expected: 185 },
{ minimumLevel: 7, expected: 220 },
{ minimumLevel: 8, expected: 255 },
{ minimumLevel: 9, expected: 290 },
{ minimumLevel: 10, expected: 325 },
{ minimumLevel: 11, expected: 360 },
{ minimumLevel: 12, expected: 395 },
{ minimumLevel: 13, expected: 430 },
{ minimumLevel: 14, expected: 465 },
{ minimumLevel: 15, expected: 500 },
{ minimumLevel: 16, expected: 535 },
{ minimumLevel: 17, expected: 570 },
{ minimumLevel: 18, expected: 605 },
{ minimumLevel: 19, expected: 640 },
{ minimumLevel: 20, expected: 675 },
],
sorcerer: [
{ minimumLevel: null, expected: null },
{ minimumLevel: 1, expected: 10 },
{ minimumLevel: 2, expected: 75 },
{ minimumLevel: 3, expected: 140 },
{ minimumLevel: 4, expected: 205 },
{ minimumLevel: 5, expected: 270 },
{ minimumLevel: 6, expected: 335 },
{ minimumLevel: 7, expected: 400 },
{ minimumLevel: 8, expected: 465 },
{ minimumLevel: 9, expected: 530 },
{ minimumLevel: 10, expected: 595 },
{ minimumLevel: 11, expected: 660 },
{ minimumLevel: 12, expected: 725 },
{ minimumLevel: 13, expected: 790 },
{ minimumLevel: 14, expected: 855 },
{ minimumLevel: 15, expected: 920 },
{ minimumLevel: 16, expected: 985 },
{ minimumLevel: 17, expected: 1050 },
{ minimumLevel: 18, expected: 1115 },
{ minimumLevel: 19, expected: 1180 },
{ minimumLevel: 20, expected: 1245 },
],
wizard: [
{ minimumLevel: null, expected: null },
{ minimumLevel: 1, expected: 10 },
{ minimumLevel: 2, expected: 60 },
{ minimumLevel: 3, expected: 110 },
{ minimumLevel: 4, expected: 160 },
{ minimumLevel: 5, expected: 210 },
{ minimumLevel: 6, expected: 260 },
{ minimumLevel: 7, expected: 310 },
{ minimumLevel: 8, expected: 360 },
{ minimumLevel: 9, expected: 410 },
{ minimumLevel: 10, expected: 460 },
{ minimumLevel: 11, expected: 510 },
{ minimumLevel: 12, expected: 560 },
{ minimumLevel: 13, expected: 610 },
{ minimumLevel: 14, expected: 660 },
{ minimumLevel: 15, expected: 710 },
{ minimumLevel: 16, expected: 760 },
{ minimumLevel: 17, expected: 810 },
{ minimumLevel: 18, expected: 860 },
{ minimumLevel: 19, expected: 910 },
{ minimumLevel: 20, expected: 960 },
],
};
function buildCombinedTestCases(): CombinedTestCase[] {
const combinedTestCases: CombinedTestCase[] = [];
// permutation test cases
const isRelevantPermutationTestCase = (t: TestCase) =>
([null, 1, 10, 20] as (number | null)[]).includes(t.minimumLevel);
for (const healerTestCase of testCases.healer.filter(isRelevantPermutationTestCase)) {
for (const sorcererTestCase of testCases.sorcerer.filter(isRelevantPermutationTestCase)) {
for (const wizardTestCase of testCases.wizard.filter(isRelevantPermutationTestCase)) {
const expected =
healerTestCase.expected !== null ||
sorcererTestCase.expected !== null ||
wizardTestCase.expected !== null
? Math.min(
healerTestCase.expected ?? Infinity,
sorcererTestCase.expected ?? Infinity,
wizardTestCase.expected ?? Infinity,
)
: null;
combinedTestCases.push({
minimumLevels: {
healer: healerTestCase.minimumLevel,
sorcerer: sorcererTestCase.minimumLevel,
wizard: wizardTestCase.minimumLevel,
},
expected,
});
}
}
}
// single test cases
const isRelevantSingleTestCase = (t: TestCase) => t.minimumLevel !== null;
for (const spellCasterClass of ["healer", "sorcerer", "wizard"] as const) {
for (const testCase of testCases[spellCasterClass].filter(isRelevantSingleTestCase)) {
combinedTestCases.push({
minimumLevels: {
...defaultData.minimumLevels,
[spellCasterClass]: testCase.minimumLevel,
},
expected: testCase.expected,
});
}
}
return combinedTestCases;
}
describe("calculateSpellPrice", () => {
const cooldownDurations: (UnitData<TemporalUnit> & { factor: number })[] = [
{ value: "", unit: "rounds", factor: 1 },
{ value: "foo", unit: "rounds", factor: 1 },
{ value: "0", unit: "rounds", factor: 1 },
{ value: "1", unit: "rounds", factor: 1 },
{ value: "17279", unit: "rounds", factor: 1 },
{ value: "17280", unit: "rounds", factor: 2 },
{ value: "34559", unit: "rounds", factor: 2 },
{ value: "34560", unit: "rounds", factor: 3 },
{ value: "", unit: "minutes", factor: 1 },
{ value: "foo", unit: "minutes", factor: 1 },
{ value: "0", unit: "minutes", factor: 1 },
{ value: "1", unit: "minutes", factor: 1 },
{ value: "1439", unit: "minutes", factor: 1 },
{ value: "1440", unit: "minutes", factor: 2 },
{ value: "2879", unit: "minutes", factor: 2 },
{ value: "2880", unit: "minutes", factor: 3 },
{ value: "", unit: "hours", factor: 1 },
{ value: "foo", unit: "hours", factor: 1 },
{ value: "0", unit: "hours", factor: 1 },
{ value: "1", unit: "hours", factor: 1 },
{ value: "23", unit: "hours", factor: 1 },
{ value: "24", unit: "hours", factor: 2 },
{ value: "47", unit: "hours", factor: 2 },
{ value: "48", unit: "hours", factor: 3 },
{ value: "", unit: "days", factor: 3 },
{ value: "foo", unit: "days", factor: 3 },
{ value: "0", unit: "days", factor: 1 },
{ value: "1", unit: "days", factor: 2 },
{ value: "2", unit: "days", factor: 3 },
];
describe.each(cooldownDurations)("with cooldown duration set to $value $unit", ({ value, unit, factor }) => {
const dataWithCooldownDuration = {
...defaultData,
cooldownDuration: {
value,
unit,
},
};
it.each(buildCombinedTestCases())(
`returns ${factor} × $expected if the minimum leves are $minimumLevels`,
({ minimumLevels, expected }) => {
// given
const data: DS4SpellDataSourceData = {
...dataWithCooldownDuration,
minimumLevels,
};
// when
const spellPrice = calculateSpellPrice(data);
// then
expect(spellPrice).toBe(expected !== null ? expected * factor : expected);
},
);
});
});

View file

@ -2,14 +2,10 @@
// //
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
import * as fs from "fs-extra"; import en from "../../lang/en.json";
import * as path from "path"; import de from "../../lang/de.json";
describe("English and german localization files", () => { describe("English and german localization files", () => {
const localizationPath = "./lang/";
const en: Record<string, unknown> = fs.readJSONSync(path.join(localizationPath, "en.json"));
const de: Record<string, unknown> = fs.readJSONSync(path.join(localizationPath, "de.json"));
it("should have the same keys.", () => { it("should have the same keys.", () => {
const deKeys = Object.keys(de); const deKeys = Object.keys(de);
const enKeys = Object.keys(en); const enKeys = Object.keys(en);

View file

@ -5,39 +5,21 @@
import evaluateCheck from "../../src/rolls/check-evaluation"; import evaluateCheck from "../../src/rolls/check-evaluation";
class StubGame {
constructor() {
this.i18n = {
localize: (key: string) => key,
};
}
i18n: {
localize: (key: string) => string;
};
}
const game = new StubGame();
Object.defineProperties(globalThis, {
game: { value: game },
Game: { value: StubGame },
});
describe("evaluateCheck with no dice", () => { describe("evaluateCheck with no dice", () => {
it("should throw an error.", () => { it("should throw an error.", () => {
expect(() => evaluateCheck([], 10)).toThrow("DS4.ErrorInvalidNumberOfDice"); expect(() => evaluateCheck([], 10)).toThrow("Invalid number of dice.");
}); });
}); });
describe("evaluateCheck with more dice than required by the checkTargetNumber", () => { describe("evaluateCheck with more dice than required by the checkTargetNumber", () => {
it("should throw an error.", () => { it("should throw an error.", () => {
expect(() => evaluateCheck([10, 10], 10)).toThrow("DS4.ErrorInvalidNumberOfDice"); expect(() => evaluateCheck([10, 10], 10)).toThrow("Invalid number of dice.");
}); });
}); });
describe("evaluateCheck with less dice than required by the checkTargetNumber", () => { describe("evaluateCheck with less dice than required by the checkTargetNumber", () => {
it("should throw an error.", () => { it("should throw an error.", () => {
expect(() => evaluateCheck([10], 21)).toThrow("DS4.ErrorInvalidNumberOfDice"); expect(() => evaluateCheck([10], 21)).toThrow("Invalid number of dice.");
}); });
}); });

57
spec/setup.ts Normal file
View file

@ -0,0 +1,57 @@
// SPDX-FileCopyrightText: 2022 Johannes Loher
//
// SPDX-License-Identifier: MIT
import en from "../lang/en.json";
function setupPrimitives() {
Object.defineProperties(Number, {
isNumeric: {
value: function (n: unknown) {
if (n instanceof Array) return false;
else if (([null, ""] as unknown[]).includes(n)) return false;
// @ts-expect-error Abusing JavaScript a bit here, but it's the implementation from foundry
return +n === +n;
},
},
fromString: {
value: function (str: unknown) {
if (typeof str !== "string" || !str.length) return NaN;
// Remove whitespace.
str = str.replace(/\s+/g, "");
return Number(str);
},
},
});
Object.defineProperties(Math, {
clamped: {
value: function (num: number, min: number, max: number) {
return Math.min(max, Math.max(num, min));
},
},
});
}
function setupStubs() {
class StubGame {
constructor() {
this.i18n = {
localize: (key: string) => (key in en ? en[key as keyof typeof en] : key),
};
}
i18n: {
localize: (key: string) => string;
};
}
const game = new StubGame();
Object.defineProperties(globalThis, {
game: { value: game },
Game: { value: StubGame },
});
}
setupPrimitives();
setupStubs();

View file

@ -106,7 +106,6 @@ SPDX-License-Identifier: MIT
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="data.price-{{data._id}}">{{localize "DS4.SpellPrice"}}</label> <label for="data.price-{{data._id}}">{{localize "DS4.SpellPrice"}}</label>
<input id="data.price-{{data._id}}" data-dtype="Number" type="number" min="0" step="0.01" name="data.price" <span id="data.price-{{data._id}}">{{data.data.price}}</span>
placeholder="0" value="{{data.data.price}}" />
</div> </div>
</div> </div>

View file

@ -7,7 +7,8 @@
"moduleResolution": "node", "moduleResolution": "node",
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"strict": true, "strict": true,
"noUncheckedIndexedAccess": true "noUncheckedIndexedAccess": true,
"resolveJsonModule": true
}, },
"include": ["src"] "include": ["src"]
} }