diff --git a/.gitignore b/.gitignore index 0ea99857..6646792d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ /coverage/ /dist /.idea/watcherTasks.xml +/test/__snapshots__/ diff --git a/.idea/fabricate.iml b/.idea/fabricate.iml index 76042920..19bc430b 100644 --- a/.idea/fabricate.iml +++ b/.idea/fabricate.iml @@ -10,6 +10,9 @@ + + + diff --git a/docs/api.md b/docs/api.md index e597a53b..22078bfb 100644 --- a/docs/api.md +++ b/docs/api.md @@ -176,7 +176,7 @@ interface CraftingSystemDetailsJson { } interface PartDictionaryJson { - components: Record; + components: Record; recipes: Record; essences: Record; } @@ -210,16 +210,16 @@ console.log(`Created crafting system with ID "${craftingSystem.id}"`); // <-- Yo ## Add a component to a crafting system -A crafting component is specified by the `CraftingComponentJson` interface. +A crafting component is specified by the `ComponentJson` interface.
-CraftingComponentJson Interface +ComponentJson Interface ```typescript -interface CraftingComponentJson { - +interface ComponentJson { + /** * The UUID of the Item document for this component * */ @@ -326,18 +326,18 @@ CraftingSystem#mutateComponent ```typescript /** - * Modifies an existing component by applying the mutations defined in the supplied `CraftingComponentJson` - * - * @param id The ID of the component to modify - * @param the complete target state of the component to apply. Anything you omit is deleted - * @return a Promise that resolves to the updated crafting component - * - * @throws an Error if the ID is not for an existing component - * @throws an Error if the the component dictionary has not been loaded - * @throws an Error if the mutation contains an invalid item UUID - * @throws an Error if the mutation references essences or components that do not exist - * */ -mutateComponent(id: string, mutation: CraftingComponentJson): Promise; + * Modifies an existing component by applying the mutations defined in the supplied `ComponentJson` + * + * @param id The ID of the component to modify + * @param the complete target state of the component to apply. Anything you omit is deleted + * @return a Promise that resolves to the updated crafting component + * + * @throws an Error if the ID is not for an existing component + * @throws an Error if the the component dictionary has not been loaded + * @throws an Error if the mutation contains an invalid item UUID + * @throws an Error if the mutation references essences or components that do not exist + * */ + mutateComponent(id: string, mutation: ComponentJson): Promise; ```
@@ -354,7 +354,7 @@ console.log(`Before modification: ${myComponent}`); const componentData = myComponent.toJson(); componentData.salvageOptions["My new Salvage Option"] = { "rLP3cTCTnQsxddDt": 1 } // Adds a new salvage option componentData.essences["bGfx37pYjqlf812"] = 3; // Adds an essence with a quantity of 3 -// perform any other modifications you want here. You can alter any of the fields in the `CraftingComponentJson`, +// perform any other modifications you want here. You can alter any of the fields in the `ComponentJson`, // including the item UUID. The new values meet the requirements specified in the interface. const updatedComponent = await craftingSystem.mutateComponent(myComponent.id, componentData); await game.fabricate.SystemRegistry.saveCraftingSystem(craftingSystem); diff --git a/package-lock.json b/package-lock.json index dbdf6c52..faf0205a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,19 @@ { "name": "fabricate", - "version": "0.8.3", + "version": "0.8.9", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "fabricate", - "version": "0.8.3", + "version": "0.8.9", "license": "MIT", "dependencies": { "@types/node": "^18.7.3" }, "devDependencies": { "@league-of-foundry-developers/foundry-vtt-types": "^9.280.0", + "@rollup/plugin-typescript": "^11.1.2", "@sveltejs/vite-plugin-svelte": "^2.0.2", "@tsconfig/svelte": "^3.0.0", "@types/jest": "^28.1.6", @@ -29,7 +30,7 @@ "ts-loader": "^9.3.1", "ts-node": "^10.9.1", "typescript": "^4.9.3", - "vite": "^4.1.1" + "vite": "^4.4.7" } }, "node_modules/@ampproject/remapping": { @@ -618,9 +619,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.16.17.tgz", - "integrity": "sha512-N9x1CMXVhtWEAMS7pNNONyA14f71VPQN9Cnavj1XQh6T7bskqiLLrSca4O0Vr8Wdcga943eThxnVp3JLnBMYtw==", + "version": "0.18.17", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.17.tgz", + "integrity": "sha512-wHsmJG/dnL3OkpAcwbgoBTTMHVi4Uyou3F5mf58ZtmUyIKfcdA7TROav/6tCzET4A3QW2Q2FC+eFneMU+iyOxg==", "cpu": [ "arm" ], @@ -634,9 +635,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.16.17.tgz", - "integrity": "sha512-MIGl6p5sc3RDTLLkYL1MyL8BMRN4tLMRCn+yRJJmEDvYZ2M7tmAf80hx1kbNEUX2KJ50RRtxZ4JHLvCfuB6kBg==", + "version": "0.18.17", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.17.tgz", + "integrity": "sha512-9np+YYdNDed5+Jgr1TdWBsozZ85U1Oa3xW0c7TWqH0y2aGghXtZsuT8nYRbzOMcl0bXZXjOGbksoTtVOlWrRZg==", "cpu": [ "arm64" ], @@ -650,9 +651,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.16.17.tgz", - "integrity": "sha512-a3kTv3m0Ghh4z1DaFEuEDfz3OLONKuFvI4Xqczqx4BqLyuFaFkuaG4j2MtA6fuWEFeC5x9IvqnX7drmRq/fyAQ==", + "version": "0.18.17", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.17.tgz", + "integrity": "sha512-O+FeWB/+xya0aLg23hHEM2E3hbfwZzjqumKMSIqcHbNvDa+dza2D0yLuymRBQQnC34CWrsJUXyH2MG5VnLd6uw==", "cpu": [ "x64" ], @@ -666,9 +667,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.16.17.tgz", - "integrity": "sha512-/2agbUEfmxWHi9ARTX6OQ/KgXnOWfsNlTeLcoV7HSuSTv63E4DqtAc+2XqGw1KHxKMHGZgbVCZge7HXWX9Vn+w==", + "version": "0.18.17", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.17.tgz", + "integrity": "sha512-M9uJ9VSB1oli2BE/dJs3zVr9kcCBBsE883prage1NWz6pBS++1oNn/7soPNS3+1DGj0FrkSvnED4Bmlu1VAE9g==", "cpu": [ "arm64" ], @@ -682,9 +683,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.16.17.tgz", - "integrity": "sha512-2By45OBHulkd9Svy5IOCZt376Aa2oOkiE9QWUK9fe6Tb+WDr8hXL3dpqi+DeLiMed8tVXspzsTAvd0jUl96wmg==", + "version": "0.18.17", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.17.tgz", + "integrity": "sha512-XDre+J5YeIJDMfp3n0279DFNrGCXlxOuGsWIkRb1NThMZ0BsrWXoTg23Jer7fEXQ9Ye5QjrvXpxnhzl3bHtk0g==", "cpu": [ "x64" ], @@ -698,9 +699,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.16.17.tgz", - "integrity": "sha512-mt+cxZe1tVx489VTb4mBAOo2aKSnJ33L9fr25JXpqQqzbUIw/yzIzi+NHwAXK2qYV1lEFp4OoVeThGjUbmWmdw==", + "version": "0.18.17", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.17.tgz", + "integrity": "sha512-cjTzGa3QlNfERa0+ptykyxs5A6FEUQQF0MuilYXYBGdBxD3vxJcKnzDlhDCa1VAJCmAxed6mYhA2KaJIbtiNuQ==", "cpu": [ "arm64" ], @@ -714,9 +715,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.16.17.tgz", - "integrity": "sha512-8ScTdNJl5idAKjH8zGAsN7RuWcyHG3BAvMNpKOBaqqR7EbUhhVHOqXRdL7oZvz8WNHL2pr5+eIT5c65kA6NHug==", + "version": "0.18.17", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.17.tgz", + "integrity": "sha512-sOxEvR8d7V7Kw8QqzxWc7bFfnWnGdaFBut1dRUYtu+EIRXefBc/eIsiUiShnW0hM3FmQ5Zf27suDuHsKgZ5QrA==", "cpu": [ "x64" ], @@ -730,9 +731,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.16.17.tgz", - "integrity": "sha512-iihzrWbD4gIT7j3caMzKb/RsFFHCwqqbrbH9SqUSRrdXkXaygSZCZg1FybsZz57Ju7N/SHEgPyaR0LZ8Zbe9gQ==", + "version": "0.18.17", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.17.tgz", + "integrity": "sha512-2d3Lw6wkwgSLC2fIvXKoMNGVaeY8qdN0IC3rfuVxJp89CRfA3e3VqWifGDfuakPmp90+ZirmTfye1n4ncjv2lg==", "cpu": [ "arm" ], @@ -746,9 +747,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.16.17.tgz", - "integrity": "sha512-7S8gJnSlqKGVJunnMCrXHU9Q8Q/tQIxk/xL8BqAP64wchPCTzuM6W3Ra8cIa1HIflAvDnNOt2jaL17vaW+1V0g==", + "version": "0.18.17", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.17.tgz", + "integrity": "sha512-c9w3tE7qA3CYWjT+M3BMbwMt+0JYOp3vCMKgVBrCl1nwjAlOMYzEo+gG7QaZ9AtqZFj5MbUc885wuBBmu6aADQ==", "cpu": [ "arm64" ], @@ -762,9 +763,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.16.17.tgz", - "integrity": "sha512-kiX69+wcPAdgl3Lonh1VI7MBr16nktEvOfViszBSxygRQqSpzv7BffMKRPMFwzeJGPxcio0pdD3kYQGpqQ2SSg==", + "version": "0.18.17", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.17.tgz", + "integrity": "sha512-1DS9F966pn5pPnqXYz16dQqWIB0dmDfAQZd6jSSpiT9eX1NzKh07J6VKR3AoXXXEk6CqZMojiVDSZi1SlmKVdg==", "cpu": [ "ia32" ], @@ -778,9 +779,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.16.17.tgz", - "integrity": "sha512-dTzNnQwembNDhd654cA4QhbS9uDdXC3TKqMJjgOWsC0yNCbpzfWoXdZvp0mY7HU6nzk5E0zpRGGx3qoQg8T2DQ==", + "version": "0.18.17", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.17.tgz", + "integrity": "sha512-EvLsxCk6ZF0fpCB6w6eOI2Fc8KW5N6sHlIovNe8uOFObL2O+Mr0bflPHyHwLT6rwMg9r77WOAWb2FqCQrVnwFg==", "cpu": [ "loong64" ], @@ -794,9 +795,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.16.17.tgz", - "integrity": "sha512-ezbDkp2nDl0PfIUn0CsQ30kxfcLTlcx4Foz2kYv8qdC6ia2oX5Q3E/8m6lq84Dj/6b0FrkgD582fJMIfHhJfSw==", + "version": "0.18.17", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.17.tgz", + "integrity": "sha512-e0bIdHA5p6l+lwqTE36NAW5hHtw2tNRmHlGBygZC14QObsA3bD4C6sXLJjvnDIjSKhW1/0S3eDy+QmX/uZWEYQ==", "cpu": [ "mips64el" ], @@ -810,9 +811,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.16.17.tgz", - "integrity": "sha512-dzS678gYD1lJsW73zrFhDApLVdM3cUF2MvAa1D8K8KtcSKdLBPP4zZSLy6LFZ0jYqQdQ29bjAHJDgz0rVbLB3g==", + "version": "0.18.17", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.17.tgz", + "integrity": "sha512-BAAilJ0M5O2uMxHYGjFKn4nJKF6fNCdP1E0o5t5fvMYYzeIqy2JdAP88Az5LHt9qBoUa4tDaRpfWt21ep5/WqQ==", "cpu": [ "ppc64" ], @@ -826,9 +827,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.16.17.tgz", - "integrity": "sha512-ylNlVsxuFjZK8DQtNUwiMskh6nT0vI7kYl/4fZgV1llP5d6+HIeL/vmmm3jpuoo8+NuXjQVZxmKuhDApK0/cKw==", + "version": "0.18.17", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.17.tgz", + "integrity": "sha512-Wh/HW2MPnC3b8BqRSIme/9Zhab36PPH+3zam5pqGRH4pE+4xTrVLx2+XdGp6fVS3L2x+DrsIcsbMleex8fbE6g==", "cpu": [ "riscv64" ], @@ -842,9 +843,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.16.17.tgz", - "integrity": "sha512-gzy7nUTO4UA4oZ2wAMXPNBGTzZFP7mss3aKR2hH+/4UUkCOyqmjXiKpzGrY2TlEUhbbejzXVKKGazYcQTZWA/w==", + "version": "0.18.17", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.17.tgz", + "integrity": "sha512-j/34jAl3ul3PNcK3pfI0NSlBANduT2UO5kZ7FCaK33XFv3chDhICLY8wJJWIhiQ+YNdQ9dxqQctRg2bvrMlYgg==", "cpu": [ "s390x" ], @@ -858,9 +859,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.16.17.tgz", - "integrity": "sha512-mdPjPxfnmoqhgpiEArqi4egmBAMYvaObgn4poorpUaqmvzzbvqbowRllQ+ZgzGVMGKaPkqUmPDOOFQRUFDmeUw==", + "version": "0.18.17", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.17.tgz", + "integrity": "sha512-QM50vJ/y+8I60qEmFxMoxIx4de03pGo2HwxdBeFd4nMh364X6TIBZ6VQ5UQmPbQWUVWHWws5MmJXlHAXvJEmpQ==", "cpu": [ "x64" ], @@ -874,9 +875,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.16.17.tgz", - "integrity": "sha512-/PzmzD/zyAeTUsduZa32bn0ORug+Jd1EGGAUJvqfeixoEISYpGnAezN6lnJoskauoai0Jrs+XSyvDhppCPoKOA==", + "version": "0.18.17", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.17.tgz", + "integrity": "sha512-/jGlhWR7Sj9JPZHzXyyMZ1RFMkNPjC6QIAan0sDOtIo2TYk3tZn5UDrkE0XgsTQCxWTTOcMPf9p6Rh2hXtl5TQ==", "cpu": [ "x64" ], @@ -890,9 +891,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.16.17.tgz", - "integrity": "sha512-2yaWJhvxGEz2RiftSk0UObqJa/b+rIAjnODJgv2GbGGpRwAfpgzyrg1WLK8rqA24mfZa9GvpjLcBBg8JHkoodg==", + "version": "0.18.17", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.17.tgz", + "integrity": "sha512-rSEeYaGgyGGf4qZM2NonMhMOP/5EHp4u9ehFiBrg7stH6BYEEjlkVREuDEcQ0LfIl53OXLxNbfuIj7mr5m29TA==", "cpu": [ "x64" ], @@ -906,9 +907,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.16.17.tgz", - "integrity": "sha512-xtVUiev38tN0R3g8VhRfN7Zl42YCJvyBhRKw1RJjwE1d2emWTVToPLNEQj/5Qxc6lVFATDiy6LjVHYhIPrLxzw==", + "version": "0.18.17", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.17.tgz", + "integrity": "sha512-Y7ZBbkLqlSgn4+zot4KUNYst0bFoO68tRgI6mY2FIM+b7ZbyNVtNbDP5y8qlu4/knZZ73fgJDlXID+ohY5zt5g==", "cpu": [ "x64" ], @@ -922,9 +923,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.16.17.tgz", - "integrity": "sha512-ga8+JqBDHY4b6fQAmOgtJJue36scANy4l/rL97W+0wYmijhxKetzZdKOJI7olaBaMhWt8Pac2McJdZLxXWUEQw==", + "version": "0.18.17", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.17.tgz", + "integrity": "sha512-bwPmTJsEQcbZk26oYpc4c/8PvTY3J5/QK8jM19DVlEsAB41M39aWovWoHtNm78sd6ip6prilxeHosPADXtEJFw==", "cpu": [ "arm64" ], @@ -938,9 +939,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.16.17.tgz", - "integrity": "sha512-WnsKaf46uSSF/sZhwnqE4L/F89AYNMiD4YtEcYekBt9Q7nj0DiId2XH2Ng2PHM54qi5oPrQ8luuzGszqi/veig==", + "version": "0.18.17", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.17.tgz", + "integrity": "sha512-H/XaPtPKli2MhW+3CQueo6Ni3Avggi6hP/YvgkEe1aSaxw+AeO8MFjq8DlgfTd9Iz4Yih3QCZI6YLMoyccnPRg==", "cpu": [ "ia32" ], @@ -954,9 +955,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.16.17.tgz", - "integrity": "sha512-y+EHuSchhL7FjHgvQL/0fnnFmO4T1bhvWANX6gcnqTjtnKWbTvUMCpGnv2+t+31d7RzyEAYAd4u2fnIhHL6N/Q==", + "version": "0.18.17", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.17.tgz", + "integrity": "sha512-fGEb8f2BSA3CW7riJVurug65ACLuQAzKq0SSqkY2b2yHHH0MzDfbLyKIGzHwOI/gkHcxM/leuSW6D5w/LMNitA==", "cpu": [ "x64" ], @@ -3524,6 +3525,60 @@ "@pixi/settings": "6.5.8" } }, + "node_modules/@rollup/plugin-typescript": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-11.1.2.tgz", + "integrity": "sha512-0ghSOCMcA7fl1JM+0gYRf+Q/HWyg+zg7/gDSc+fRLmlJWcW5K1I+CLRzaRhXf4Y3DRyPnnDo4M2ktw+a6JcDEg==", + "dev": true, + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.14.0||^3.0.0", + "tslib": "*", + "typescript": ">=3.7.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + }, + "tslib": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.0.2.tgz", + "integrity": "sha512-pTd9rIsP92h+B6wWwFbW8RkZv4hiR/xKsqre4SIuAOaOEQRxi0lqLke9k2/7WegC85GgUs9pjmOjCUi3In4vwA==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils/node_modules/@types/estree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.1.tgz", + "integrity": "sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==", + "dev": true + }, "node_modules/@sinclair/typebox": { "version": "0.24.51", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz", @@ -4753,9 +4808,9 @@ "dev": true }, "node_modules/esbuild": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.16.17.tgz", - "integrity": "sha512-G8LEkV0XzDMNwXKgM0Jwu3nY3lSTwSGY6XbxM9cr9+s0T/qSV1q1JVPBGzm3dcjhCic9+emZDmMffkwgPeOeLg==", + "version": "0.18.17", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.17.tgz", + "integrity": "sha512-1GJtYnUxsJreHYA0Y+iQz2UEykonY66HNWOb0yXYZi9/kNrORUEHVg87eQsCtqh59PEJ5YVZJO98JHznMJSWjg==", "dev": true, "hasInstallScript": true, "bin": { @@ -4765,28 +4820,28 @@ "node": ">=12" }, "optionalDependencies": { - "@esbuild/android-arm": "0.16.17", - "@esbuild/android-arm64": "0.16.17", - "@esbuild/android-x64": "0.16.17", - "@esbuild/darwin-arm64": "0.16.17", - "@esbuild/darwin-x64": "0.16.17", - "@esbuild/freebsd-arm64": "0.16.17", - "@esbuild/freebsd-x64": "0.16.17", - "@esbuild/linux-arm": "0.16.17", - "@esbuild/linux-arm64": "0.16.17", - "@esbuild/linux-ia32": "0.16.17", - "@esbuild/linux-loong64": "0.16.17", - "@esbuild/linux-mips64el": "0.16.17", - "@esbuild/linux-ppc64": "0.16.17", - "@esbuild/linux-riscv64": "0.16.17", - "@esbuild/linux-s390x": "0.16.17", - "@esbuild/linux-x64": "0.16.17", - "@esbuild/netbsd-x64": "0.16.17", - "@esbuild/openbsd-x64": "0.16.17", - "@esbuild/sunos-x64": "0.16.17", - "@esbuild/win32-arm64": "0.16.17", - "@esbuild/win32-ia32": "0.16.17", - "@esbuild/win32-x64": "0.16.17" + "@esbuild/android-arm": "0.18.17", + "@esbuild/android-arm64": "0.18.17", + "@esbuild/android-x64": "0.18.17", + "@esbuild/darwin-arm64": "0.18.17", + "@esbuild/darwin-x64": "0.18.17", + "@esbuild/freebsd-arm64": "0.18.17", + "@esbuild/freebsd-x64": "0.18.17", + "@esbuild/linux-arm": "0.18.17", + "@esbuild/linux-arm64": "0.18.17", + "@esbuild/linux-ia32": "0.18.17", + "@esbuild/linux-loong64": "0.18.17", + "@esbuild/linux-mips64el": "0.18.17", + "@esbuild/linux-ppc64": "0.18.17", + "@esbuild/linux-riscv64": "0.18.17", + "@esbuild/linux-s390x": "0.18.17", + "@esbuild/linux-x64": "0.18.17", + "@esbuild/netbsd-x64": "0.18.17", + "@esbuild/openbsd-x64": "0.18.17", + "@esbuild/sunos-x64": "0.18.17", + "@esbuild/win32-arm64": "0.18.17", + "@esbuild/win32-ia32": "0.18.17", + "@esbuild/win32-x64": "0.18.17" } }, "node_modules/escalade": { @@ -4867,6 +4922,12 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true + }, "node_modules/eventemitter3": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz", @@ -6423,10 +6484,16 @@ "dev": true }, "node_modules/nanoid": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz", - "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==", + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", + "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -6944,9 +7011,9 @@ } }, "node_modules/postcss": { - "version": "8.4.21", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz", - "integrity": "sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==", + "version": "8.4.27", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.27.tgz", + "integrity": "sha512-gY/ACJtJPSmUFPDCHtX78+01fHa64FaU4zaaWfuh1MhGJISufJAH4cun6k/8fwsHYeK4UQmENQK+tRLCFJE8JQ==", "dev": true, "funding": [ { @@ -6956,10 +7023,14 @@ { "type": "tidelift", "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" } ], "dependencies": { - "nanoid": "^3.3.4", + "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" }, @@ -7170,9 +7241,9 @@ } }, "node_modules/rollup": { - "version": "3.14.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.14.0.tgz", - "integrity": "sha512-o23sdgCLcLSe3zIplT9nQ1+r97okuaiR+vmAPZPTDYB7/f3tgWIYNyiQveMsZwshBT0is4eGax/HH83Q7CG+/Q==", + "version": "3.26.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.26.3.tgz", + "integrity": "sha512-7Tin0C8l86TkpcMtXvQu6saWH93nhG3dGQ1/+l5V2TDMceTxO7kDiK6GzbfLWNNxqJXm591PcEZUozZm51ogwQ==", "dev": true, "bin": { "rollup": "dist/bin/rollup" @@ -8152,15 +8223,14 @@ } }, "node_modules/vite": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-4.1.1.tgz", - "integrity": "sha512-LM9WWea8vsxhr782r9ntg+bhSFS06FJgCvvB0+8hf8UWtvaiDagKYWXndjfX6kGl74keHJUcpzrQliDXZlF5yg==", + "version": "4.4.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-4.4.7.tgz", + "integrity": "sha512-6pYf9QJ1mHylfVh39HpuSfMPojPSKVxZvnclX1K1FyZ1PXDOcLBibdq5t1qxJSnL63ca8Wf4zts6mD8u8oc9Fw==", "dev": true, "dependencies": { - "esbuild": "^0.16.14", - "postcss": "^8.4.21", - "resolve": "^1.22.1", - "rollup": "^3.10.0" + "esbuild": "^0.18.10", + "postcss": "^8.4.26", + "rollup": "^3.25.2" }, "bin": { "vite": "bin/vite.js" @@ -8168,12 +8238,16 @@ "engines": { "node": "^14.18.0 || >=16.0.0" }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@types/node": ">= 14", "less": "*", + "lightningcss": "^1.21.0", "sass": "*", "stylus": "*", "sugarss": "*", @@ -8186,6 +8260,9 @@ "less": { "optional": true }, + "lightningcss": { + "optional": true + }, "sass": { "optional": true }, @@ -8900,156 +8977,156 @@ } }, "@esbuild/android-arm": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.16.17.tgz", - "integrity": "sha512-N9x1CMXVhtWEAMS7pNNONyA14f71VPQN9Cnavj1XQh6T7bskqiLLrSca4O0Vr8Wdcga943eThxnVp3JLnBMYtw==", + "version": "0.18.17", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.17.tgz", + "integrity": "sha512-wHsmJG/dnL3OkpAcwbgoBTTMHVi4Uyou3F5mf58ZtmUyIKfcdA7TROav/6tCzET4A3QW2Q2FC+eFneMU+iyOxg==", "dev": true, "optional": true }, "@esbuild/android-arm64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.16.17.tgz", - "integrity": "sha512-MIGl6p5sc3RDTLLkYL1MyL8BMRN4tLMRCn+yRJJmEDvYZ2M7tmAf80hx1kbNEUX2KJ50RRtxZ4JHLvCfuB6kBg==", + "version": "0.18.17", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.17.tgz", + "integrity": "sha512-9np+YYdNDed5+Jgr1TdWBsozZ85U1Oa3xW0c7TWqH0y2aGghXtZsuT8nYRbzOMcl0bXZXjOGbksoTtVOlWrRZg==", "dev": true, "optional": true }, "@esbuild/android-x64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.16.17.tgz", - "integrity": "sha512-a3kTv3m0Ghh4z1DaFEuEDfz3OLONKuFvI4Xqczqx4BqLyuFaFkuaG4j2MtA6fuWEFeC5x9IvqnX7drmRq/fyAQ==", + "version": "0.18.17", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.17.tgz", + "integrity": "sha512-O+FeWB/+xya0aLg23hHEM2E3hbfwZzjqumKMSIqcHbNvDa+dza2D0yLuymRBQQnC34CWrsJUXyH2MG5VnLd6uw==", "dev": true, "optional": true }, "@esbuild/darwin-arm64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.16.17.tgz", - "integrity": "sha512-/2agbUEfmxWHi9ARTX6OQ/KgXnOWfsNlTeLcoV7HSuSTv63E4DqtAc+2XqGw1KHxKMHGZgbVCZge7HXWX9Vn+w==", + "version": "0.18.17", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.17.tgz", + "integrity": "sha512-M9uJ9VSB1oli2BE/dJs3zVr9kcCBBsE883prage1NWz6pBS++1oNn/7soPNS3+1DGj0FrkSvnED4Bmlu1VAE9g==", "dev": true, "optional": true }, "@esbuild/darwin-x64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.16.17.tgz", - "integrity": "sha512-2By45OBHulkd9Svy5IOCZt376Aa2oOkiE9QWUK9fe6Tb+WDr8hXL3dpqi+DeLiMed8tVXspzsTAvd0jUl96wmg==", + "version": "0.18.17", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.17.tgz", + "integrity": "sha512-XDre+J5YeIJDMfp3n0279DFNrGCXlxOuGsWIkRb1NThMZ0BsrWXoTg23Jer7fEXQ9Ye5QjrvXpxnhzl3bHtk0g==", "dev": true, "optional": true }, "@esbuild/freebsd-arm64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.16.17.tgz", - "integrity": "sha512-mt+cxZe1tVx489VTb4mBAOo2aKSnJ33L9fr25JXpqQqzbUIw/yzIzi+NHwAXK2qYV1lEFp4OoVeThGjUbmWmdw==", + "version": "0.18.17", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.17.tgz", + "integrity": "sha512-cjTzGa3QlNfERa0+ptykyxs5A6FEUQQF0MuilYXYBGdBxD3vxJcKnzDlhDCa1VAJCmAxed6mYhA2KaJIbtiNuQ==", "dev": true, "optional": true }, "@esbuild/freebsd-x64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.16.17.tgz", - "integrity": "sha512-8ScTdNJl5idAKjH8zGAsN7RuWcyHG3BAvMNpKOBaqqR7EbUhhVHOqXRdL7oZvz8WNHL2pr5+eIT5c65kA6NHug==", + "version": "0.18.17", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.17.tgz", + "integrity": "sha512-sOxEvR8d7V7Kw8QqzxWc7bFfnWnGdaFBut1dRUYtu+EIRXefBc/eIsiUiShnW0hM3FmQ5Zf27suDuHsKgZ5QrA==", "dev": true, "optional": true }, "@esbuild/linux-arm": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.16.17.tgz", - "integrity": "sha512-iihzrWbD4gIT7j3caMzKb/RsFFHCwqqbrbH9SqUSRrdXkXaygSZCZg1FybsZz57Ju7N/SHEgPyaR0LZ8Zbe9gQ==", + "version": "0.18.17", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.17.tgz", + "integrity": "sha512-2d3Lw6wkwgSLC2fIvXKoMNGVaeY8qdN0IC3rfuVxJp89CRfA3e3VqWifGDfuakPmp90+ZirmTfye1n4ncjv2lg==", "dev": true, "optional": true }, "@esbuild/linux-arm64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.16.17.tgz", - "integrity": "sha512-7S8gJnSlqKGVJunnMCrXHU9Q8Q/tQIxk/xL8BqAP64wchPCTzuM6W3Ra8cIa1HIflAvDnNOt2jaL17vaW+1V0g==", + "version": "0.18.17", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.17.tgz", + "integrity": "sha512-c9w3tE7qA3CYWjT+M3BMbwMt+0JYOp3vCMKgVBrCl1nwjAlOMYzEo+gG7QaZ9AtqZFj5MbUc885wuBBmu6aADQ==", "dev": true, "optional": true }, "@esbuild/linux-ia32": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.16.17.tgz", - "integrity": "sha512-kiX69+wcPAdgl3Lonh1VI7MBr16nktEvOfViszBSxygRQqSpzv7BffMKRPMFwzeJGPxcio0pdD3kYQGpqQ2SSg==", + "version": "0.18.17", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.17.tgz", + "integrity": "sha512-1DS9F966pn5pPnqXYz16dQqWIB0dmDfAQZd6jSSpiT9eX1NzKh07J6VKR3AoXXXEk6CqZMojiVDSZi1SlmKVdg==", "dev": true, "optional": true }, "@esbuild/linux-loong64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.16.17.tgz", - "integrity": "sha512-dTzNnQwembNDhd654cA4QhbS9uDdXC3TKqMJjgOWsC0yNCbpzfWoXdZvp0mY7HU6nzk5E0zpRGGx3qoQg8T2DQ==", + "version": "0.18.17", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.17.tgz", + "integrity": "sha512-EvLsxCk6ZF0fpCB6w6eOI2Fc8KW5N6sHlIovNe8uOFObL2O+Mr0bflPHyHwLT6rwMg9r77WOAWb2FqCQrVnwFg==", "dev": true, "optional": true }, "@esbuild/linux-mips64el": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.16.17.tgz", - "integrity": "sha512-ezbDkp2nDl0PfIUn0CsQ30kxfcLTlcx4Foz2kYv8qdC6ia2oX5Q3E/8m6lq84Dj/6b0FrkgD582fJMIfHhJfSw==", + "version": "0.18.17", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.17.tgz", + "integrity": "sha512-e0bIdHA5p6l+lwqTE36NAW5hHtw2tNRmHlGBygZC14QObsA3bD4C6sXLJjvnDIjSKhW1/0S3eDy+QmX/uZWEYQ==", "dev": true, "optional": true }, "@esbuild/linux-ppc64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.16.17.tgz", - "integrity": "sha512-dzS678gYD1lJsW73zrFhDApLVdM3cUF2MvAa1D8K8KtcSKdLBPP4zZSLy6LFZ0jYqQdQ29bjAHJDgz0rVbLB3g==", + "version": "0.18.17", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.17.tgz", + "integrity": "sha512-BAAilJ0M5O2uMxHYGjFKn4nJKF6fNCdP1E0o5t5fvMYYzeIqy2JdAP88Az5LHt9qBoUa4tDaRpfWt21ep5/WqQ==", "dev": true, "optional": true }, "@esbuild/linux-riscv64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.16.17.tgz", - "integrity": "sha512-ylNlVsxuFjZK8DQtNUwiMskh6nT0vI7kYl/4fZgV1llP5d6+HIeL/vmmm3jpuoo8+NuXjQVZxmKuhDApK0/cKw==", + "version": "0.18.17", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.17.tgz", + "integrity": "sha512-Wh/HW2MPnC3b8BqRSIme/9Zhab36PPH+3zam5pqGRH4pE+4xTrVLx2+XdGp6fVS3L2x+DrsIcsbMleex8fbE6g==", "dev": true, "optional": true }, "@esbuild/linux-s390x": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.16.17.tgz", - "integrity": "sha512-gzy7nUTO4UA4oZ2wAMXPNBGTzZFP7mss3aKR2hH+/4UUkCOyqmjXiKpzGrY2TlEUhbbejzXVKKGazYcQTZWA/w==", + "version": "0.18.17", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.17.tgz", + "integrity": "sha512-j/34jAl3ul3PNcK3pfI0NSlBANduT2UO5kZ7FCaK33XFv3chDhICLY8wJJWIhiQ+YNdQ9dxqQctRg2bvrMlYgg==", "dev": true, "optional": true }, "@esbuild/linux-x64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.16.17.tgz", - "integrity": "sha512-mdPjPxfnmoqhgpiEArqi4egmBAMYvaObgn4poorpUaqmvzzbvqbowRllQ+ZgzGVMGKaPkqUmPDOOFQRUFDmeUw==", + "version": "0.18.17", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.17.tgz", + "integrity": "sha512-QM50vJ/y+8I60qEmFxMoxIx4de03pGo2HwxdBeFd4nMh364X6TIBZ6VQ5UQmPbQWUVWHWws5MmJXlHAXvJEmpQ==", "dev": true, "optional": true }, "@esbuild/netbsd-x64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.16.17.tgz", - "integrity": "sha512-/PzmzD/zyAeTUsduZa32bn0ORug+Jd1EGGAUJvqfeixoEISYpGnAezN6lnJoskauoai0Jrs+XSyvDhppCPoKOA==", + "version": "0.18.17", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.17.tgz", + "integrity": "sha512-/jGlhWR7Sj9JPZHzXyyMZ1RFMkNPjC6QIAan0sDOtIo2TYk3tZn5UDrkE0XgsTQCxWTTOcMPf9p6Rh2hXtl5TQ==", "dev": true, "optional": true }, "@esbuild/openbsd-x64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.16.17.tgz", - "integrity": "sha512-2yaWJhvxGEz2RiftSk0UObqJa/b+rIAjnODJgv2GbGGpRwAfpgzyrg1WLK8rqA24mfZa9GvpjLcBBg8JHkoodg==", + "version": "0.18.17", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.17.tgz", + "integrity": "sha512-rSEeYaGgyGGf4qZM2NonMhMOP/5EHp4u9ehFiBrg7stH6BYEEjlkVREuDEcQ0LfIl53OXLxNbfuIj7mr5m29TA==", "dev": true, "optional": true }, "@esbuild/sunos-x64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.16.17.tgz", - "integrity": "sha512-xtVUiev38tN0R3g8VhRfN7Zl42YCJvyBhRKw1RJjwE1d2emWTVToPLNEQj/5Qxc6lVFATDiy6LjVHYhIPrLxzw==", + "version": "0.18.17", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.17.tgz", + "integrity": "sha512-Y7ZBbkLqlSgn4+zot4KUNYst0bFoO68tRgI6mY2FIM+b7ZbyNVtNbDP5y8qlu4/knZZ73fgJDlXID+ohY5zt5g==", "dev": true, "optional": true }, "@esbuild/win32-arm64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.16.17.tgz", - "integrity": "sha512-ga8+JqBDHY4b6fQAmOgtJJue36scANy4l/rL97W+0wYmijhxKetzZdKOJI7olaBaMhWt8Pac2McJdZLxXWUEQw==", + "version": "0.18.17", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.17.tgz", + "integrity": "sha512-bwPmTJsEQcbZk26oYpc4c/8PvTY3J5/QK8jM19DVlEsAB41M39aWovWoHtNm78sd6ip6prilxeHosPADXtEJFw==", "dev": true, "optional": true }, "@esbuild/win32-ia32": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.16.17.tgz", - "integrity": "sha512-WnsKaf46uSSF/sZhwnqE4L/F89AYNMiD4YtEcYekBt9Q7nj0DiId2XH2Ng2PHM54qi5oPrQ8luuzGszqi/veig==", + "version": "0.18.17", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.17.tgz", + "integrity": "sha512-H/XaPtPKli2MhW+3CQueo6Ni3Avggi6hP/YvgkEe1aSaxw+AeO8MFjq8DlgfTd9Iz4Yih3QCZI6YLMoyccnPRg==", "dev": true, "optional": true }, "@esbuild/win32-x64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.16.17.tgz", - "integrity": "sha512-y+EHuSchhL7FjHgvQL/0fnnFmO4T1bhvWANX6gcnqTjtnKWbTvUMCpGnv2+t+31d7RzyEAYAd4u2fnIhHL6N/Q==", + "version": "0.18.17", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.17.tgz", + "integrity": "sha512-fGEb8f2BSA3CW7riJVurug65ACLuQAzKq0SSqkY2b2yHHH0MzDfbLyKIGzHwOI/gkHcxM/leuSW6D5w/LMNitA==", "dev": true, "optional": true }, @@ -11435,6 +11512,35 @@ "url": "^0.11.0" } }, + "@rollup/plugin-typescript": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-11.1.2.tgz", + "integrity": "sha512-0ghSOCMcA7fl1JM+0gYRf+Q/HWyg+zg7/gDSc+fRLmlJWcW5K1I+CLRzaRhXf4Y3DRyPnnDo4M2ktw+a6JcDEg==", + "dev": true, + "requires": { + "@rollup/pluginutils": "^5.0.1", + "resolve": "^1.22.1" + } + }, + "@rollup/pluginutils": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.0.2.tgz", + "integrity": "sha512-pTd9rIsP92h+B6wWwFbW8RkZv4hiR/xKsqre4SIuAOaOEQRxi0lqLke9k2/7WegC85GgUs9pjmOjCUi3In4vwA==", + "dev": true, + "requires": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^2.3.1" + }, + "dependencies": { + "@types/estree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.1.tgz", + "integrity": "sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==", + "dev": true + } + } + }, "@sinclair/typebox": { "version": "0.24.51", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz", @@ -12478,33 +12584,33 @@ "dev": true }, "esbuild": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.16.17.tgz", - "integrity": "sha512-G8LEkV0XzDMNwXKgM0Jwu3nY3lSTwSGY6XbxM9cr9+s0T/qSV1q1JVPBGzm3dcjhCic9+emZDmMffkwgPeOeLg==", - "dev": true, - "requires": { - "@esbuild/android-arm": "0.16.17", - "@esbuild/android-arm64": "0.16.17", - "@esbuild/android-x64": "0.16.17", - "@esbuild/darwin-arm64": "0.16.17", - "@esbuild/darwin-x64": "0.16.17", - "@esbuild/freebsd-arm64": "0.16.17", - "@esbuild/freebsd-x64": "0.16.17", - "@esbuild/linux-arm": "0.16.17", - "@esbuild/linux-arm64": "0.16.17", - "@esbuild/linux-ia32": "0.16.17", - "@esbuild/linux-loong64": "0.16.17", - "@esbuild/linux-mips64el": "0.16.17", - "@esbuild/linux-ppc64": "0.16.17", - "@esbuild/linux-riscv64": "0.16.17", - "@esbuild/linux-s390x": "0.16.17", - "@esbuild/linux-x64": "0.16.17", - "@esbuild/netbsd-x64": "0.16.17", - "@esbuild/openbsd-x64": "0.16.17", - "@esbuild/sunos-x64": "0.16.17", - "@esbuild/win32-arm64": "0.16.17", - "@esbuild/win32-ia32": "0.16.17", - "@esbuild/win32-x64": "0.16.17" + "version": "0.18.17", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.17.tgz", + "integrity": "sha512-1GJtYnUxsJreHYA0Y+iQz2UEykonY66HNWOb0yXYZi9/kNrORUEHVg87eQsCtqh59PEJ5YVZJO98JHznMJSWjg==", + "dev": true, + "requires": { + "@esbuild/android-arm": "0.18.17", + "@esbuild/android-arm64": "0.18.17", + "@esbuild/android-x64": "0.18.17", + "@esbuild/darwin-arm64": "0.18.17", + "@esbuild/darwin-x64": "0.18.17", + "@esbuild/freebsd-arm64": "0.18.17", + "@esbuild/freebsd-x64": "0.18.17", + "@esbuild/linux-arm": "0.18.17", + "@esbuild/linux-arm64": "0.18.17", + "@esbuild/linux-ia32": "0.18.17", + "@esbuild/linux-loong64": "0.18.17", + "@esbuild/linux-mips64el": "0.18.17", + "@esbuild/linux-ppc64": "0.18.17", + "@esbuild/linux-riscv64": "0.18.17", + "@esbuild/linux-s390x": "0.18.17", + "@esbuild/linux-x64": "0.18.17", + "@esbuild/netbsd-x64": "0.18.17", + "@esbuild/openbsd-x64": "0.18.17", + "@esbuild/sunos-x64": "0.18.17", + "@esbuild/win32-arm64": "0.18.17", + "@esbuild/win32-ia32": "0.18.17", + "@esbuild/win32-x64": "0.18.17" } }, "escalade": { @@ -12562,6 +12668,12 @@ "dev": true, "peer": true }, + "estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true + }, "eventemitter3": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz", @@ -13748,9 +13860,9 @@ "dev": true }, "nanoid": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz", - "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==", + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", + "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", "dev": true }, "natural-compare": { @@ -14185,12 +14297,12 @@ } }, "postcss": { - "version": "8.4.21", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz", - "integrity": "sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==", + "version": "8.4.27", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.27.tgz", + "integrity": "sha512-gY/ACJtJPSmUFPDCHtX78+01fHa64FaU4zaaWfuh1MhGJISufJAH4cun6k/8fwsHYeK4UQmENQK+tRLCFJE8JQ==", "dev": true, "requires": { - "nanoid": "^3.3.4", + "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } @@ -14339,9 +14451,9 @@ } }, "rollup": { - "version": "3.14.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.14.0.tgz", - "integrity": "sha512-o23sdgCLcLSe3zIplT9nQ1+r97okuaiR+vmAPZPTDYB7/f3tgWIYNyiQveMsZwshBT0is4eGax/HH83Q7CG+/Q==", + "version": "3.26.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.26.3.tgz", + "integrity": "sha512-7Tin0C8l86TkpcMtXvQu6saWH93nhG3dGQ1/+l5V2TDMceTxO7kDiK6GzbfLWNNxqJXm591PcEZUozZm51ogwQ==", "dev": true, "requires": { "fsevents": "~2.3.2" @@ -14984,16 +15096,15 @@ } }, "vite": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-4.1.1.tgz", - "integrity": "sha512-LM9WWea8vsxhr782r9ntg+bhSFS06FJgCvvB0+8hf8UWtvaiDagKYWXndjfX6kGl74keHJUcpzrQliDXZlF5yg==", + "version": "4.4.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-4.4.7.tgz", + "integrity": "sha512-6pYf9QJ1mHylfVh39HpuSfMPojPSKVxZvnclX1K1FyZ1PXDOcLBibdq5t1qxJSnL63ca8Wf4zts6mD8u8oc9Fw==", "dev": true, "requires": { - "esbuild": "^0.16.14", + "esbuild": "^0.18.10", "fsevents": "~2.3.2", - "postcss": "^8.4.21", - "resolve": "^1.22.1", - "rollup": "^3.10.0" + "postcss": "^8.4.26", + "rollup": "^3.25.2" } }, "vitefu": { diff --git a/package.json b/package.json index 62e64b45..73739cde 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fabricate", - "version": "0.8.9", + "version": "0.9.0", "description": "A system-agnostic, flexible crafting module for FoundryVT", "main": "index.js", "type": "module", @@ -17,6 +17,7 @@ "license": "MIT", "devDependencies": { "@league-of-foundry-developers/foundry-vtt-types": "^9.280.0", + "@rollup/plugin-typescript": "^11.1.2", "@sveltejs/vite-plugin-svelte": "^2.0.2", "@tsconfig/svelte": "^3.0.0", "@types/jest": "^28.1.6", @@ -33,7 +34,7 @@ "ts-loader": "^9.3.1", "ts-node": "^10.9.1", "typescript": "^4.9.3", - "vite": "^4.1.1" + "vite": "^4.4.7" }, "dependencies": { "@types/node": "^18.7.3" diff --git a/src/applications/CraftingSystemManagerAppFactory.ts b/src/applications/CraftingSystemManagerAppFactory.ts index 8504baa3..e2986e17 100644 --- a/src/applications/CraftingSystemManagerAppFactory.ts +++ b/src/applications/CraftingSystemManagerAppFactory.ts @@ -1,19 +1,29 @@ import CraftingSystemManager from "./craftingSystemManagerApp/CraftingSystemEditor.svelte" import Properties from "../scripts/Properties"; import {SvelteApplication} from "./SvelteApplication"; -import {DefaultGameProvider} from "../scripts/foundry/GameProvider"; -import {DefaultSystemRegistry} from "../scripts/registries/SystemRegistry"; -import {DefaultLocalizationService} from "./common/LocalizationService"; +import {LocalizationService} from "./common/LocalizationService"; +import {FabricateAPI} from "../scripts/api/FabricateAPI"; class CraftingSystemManagerAppFactory { - public static async make(systemRegistry: DefaultSystemRegistry): Promise { + private readonly localizationService: LocalizationService; + private readonly fabricateAPI: FabricateAPI; - const gameProvider = new DefaultGameProvider(); - const GAME = gameProvider.get(); + constructor({ + fabricateAPI, + localizationService, + }: { + fabricateAPI: FabricateAPI; + localizationService: LocalizationService; + }) { + this.fabricateAPI = fabricateAPI; + this.localizationService = localizationService; + } + + public make(): SvelteApplication { const applicationOptions = { - title: GAME.i18n.localize(`${Properties.module.id}.CraftingSystemManagerApp.title`), + title: this.localizationService.localize(`${Properties.module.id}.CraftingSystemManagerApp.title`), id: Properties.ui.apps.craftingSystemManager.id, resizable: true, width: 1020, @@ -22,12 +32,14 @@ class CraftingSystemManagerAppFactory { return new SvelteApplication({ applicationOptions, + onClose: () => { + this.fabricateAPI.activateNotifications(); + }, svelteConfig: { options: { props: { - localization: new DefaultLocalizationService(gameProvider), - systemRegistry, - gameProvider + localization: this.localizationService, + fabricateAPI: this.fabricateAPI } }, componentType: CraftingSystemManager diff --git a/src/applications/SvelteApplication.ts b/src/applications/SvelteApplication.ts index 424bf34b..d1f80fef 100644 --- a/src/applications/SvelteApplication.ts +++ b/src/applications/SvelteApplication.ts @@ -18,20 +18,24 @@ class SvelteApplication extends Application { private static readonly _defaultClasses = ["fab-application-window", "fab-fabricate-theme"]; private readonly _svelteConfig: SvelteComponentConfig; + private readonly _onClose: () => void; private _component: SvelteComponent; constructor({ applicationOptions, - svelteConfig + svelteConfig, + onClose = () => {}, }: { applicationOptions: Partial; svelteConfig: SvelteComponentConfig; + onClose?: () => void; }) { applicationOptions.template = SvelteApplication._template; applicationOptions.classes = applicationOptions.classes ? applicationOptions.classes.concat(SvelteApplication._defaultClasses) : SvelteApplication._defaultClasses; super(applicationOptions); this._svelteConfig = svelteConfig; + this._onClose = onClose; } activateListeners(html: JQuery) { @@ -48,6 +52,7 @@ class SvelteApplication extends Application { async close(): Promise { await super.close(); this._component.$destroy(); + this._onClose(); console.log(`Fabricate | Destroyed Svelte component: ${this.options.id}`); } diff --git a/src/applications/common/ComponentSalvageCarousel.svelte b/src/applications/common/ComponentSalvageCarousel.svelte new file mode 100644 index 00000000..fdbc90f9 --- /dev/null +++ b/src/applications/common/ComponentSalvageCarousel.svelte @@ -0,0 +1,46 @@ + + + +{#if selectedSalvageAttempt} + +{/if} \ No newline at end of file diff --git a/src/applications/common/CraftingComponentCarousel.svelte b/src/applications/common/CraftingComponentCarousel.svelte deleted file mode 100644 index 7f2f84c4..00000000 --- a/src/applications/common/CraftingComponentCarousel.svelte +++ /dev/null @@ -1,43 +0,0 @@ - - - - \ No newline at end of file diff --git a/src/applications/common/CraftingComponentGrid.svelte b/src/applications/common/CraftingComponentGrid.svelte index 4e6267e3..ab106ed3 100644 --- a/src/applications/common/CraftingComponentGrid.svelte +++ b/src/applications/common/CraftingComponentGrid.svelte @@ -9,18 +9,22 @@
{#each componentCombination.units as unit} -
-
-

{truncate(unit.part.name, nameLength)}

-
-
-
- {unit.part.name} - {#if unit.quantity > 1} - {unit.quantity} - {/if} + {#await unit.element.load()} + {:then nothing} +
+
+

{truncate(unit.element.name, nameLength)}

+
+
+
+ {unit.element.name} + {#if unit.quantity > 1} + {unit.quantity} + {/if} +
+
-
-
+ {:catch error} + {/await} {/each}
\ No newline at end of file diff --git a/src/applications/common/DropEventParser.ts b/src/applications/common/DropEventParser.ts index 0dfaef29..c4d5864d 100644 --- a/src/applications/common/DropEventParser.ts +++ b/src/applications/common/DropEventParser.ts @@ -1,18 +1,18 @@ import {DocumentManager, FabricateItemData} from "../../scripts/foundry/DocumentManager"; import Properties from "../../scripts/Properties"; -import {CraftingComponent} from "../../scripts/common/CraftingComponent"; +import {Component} from "../../scripts/crafting/component/Component"; import {LocalizationService} from "./LocalizationService"; class DropData { private readonly _itemData: FabricateItemData; - private readonly _component: CraftingComponent; + private readonly _component: Component; constructor({ itemData, component }: { itemData?: FabricateItemData; - component?: CraftingComponent; + component?: Component; }) { this._itemData = itemData; this._component = component; @@ -22,7 +22,7 @@ class DropData { return this._itemData; } - get component(): CraftingComponent { + get component(): Component { return this._component; } @@ -42,8 +42,8 @@ class DropEventParser { private readonly _partType: string; private readonly _strict: boolean; private readonly _documentManager: DocumentManager; - private readonly _allowedCraftingComponentsById: Map; - private readonly _allowedCraftingComponentsByItemUuid: Map; + private readonly _allowedCraftingComponentsById: Map; + private readonly _allowedCraftingComponentsByItemUuid: Map; constructor({ localizationService, @@ -54,7 +54,7 @@ class DropEventParser { }: { localizationService: LocalizationService; partType: string; - allowedCraftingComponents?: CraftingComponent[]; + allowedCraftingComponents?: Component[]; documentManager: DocumentManager; strict?: boolean; }) { @@ -86,7 +86,7 @@ class DropEventParser { ui.notifications.warn(message); return new DropData({}); } - const itemData = await this._documentManager.getDocumentByUuid(dropData.uuid); + const itemData = await this._documentManager.loadItemDataByDocumentUuid(dropData.uuid); if (this._strict && ! this._allowedCraftingComponentsByItemUuid.has(itemData.uuid)) { const message = this._localizationService.format( `${Properties.module.id}.DropEventParser.errors.unrecognisedComponent`, @@ -145,7 +145,7 @@ class DropEventParser { return new DropData({}); } - public static serialiseComponentData(component: CraftingComponent): string { + public static serialiseComponentData(component: Component): string { return JSON.stringify({ componentId: component.id }); } diff --git a/src/applications/common/EventBus.ts b/src/applications/common/EventBus.ts index 54827963..c5e7bb5c 100644 --- a/src/applications/common/EventBus.ts +++ b/src/applications/common/EventBus.ts @@ -1,6 +1,6 @@ import {CraftingSystem} from "../../scripts/system/CraftingSystem"; -import {CraftingComponent} from "../../scripts/common/CraftingComponent"; -import {Recipe} from "../../scripts/common/Recipe"; +import {Component} from "../../scripts/crafting/component/Component"; +import {Recipe} from "../../scripts/crafting/recipe/Recipe"; const registeredNodes: Map = new Map(); const eventBus = function(node: any, eventTypes: string[] | string) { @@ -43,7 +43,7 @@ function craftingSystemUpdated(craftingSystem: CraftingSystem) { dispatch(eventType, event); } -function componentUpdated(craftingComponent: CraftingComponent) { +function componentUpdated(craftingComponent: Component) { const eventType = "componentUpdated"; const event = new CustomEvent(eventType, { bubbles: true, detail: craftingComponent }); dispatch(eventType, event); diff --git a/src/applications/common/LocalizationService.ts b/src/applications/common/LocalizationService.ts index dac2d118..2b924068 100644 --- a/src/applications/common/LocalizationService.ts +++ b/src/applications/common/LocalizationService.ts @@ -4,9 +4,9 @@ interface LocalizationService { localizeAll(basePath: string, childPaths: string[], lineBreak: boolean): string; - localize(path: string): any; + localize(path: string): string; - format(path: string, params: {}): any; + format(path: string, params: {}): string; } diff --git a/src/applications/common/OpenItemSheet.ts b/src/applications/common/OpenItemSheet.ts index f31531f5..df78a6a2 100644 --- a/src/applications/common/OpenItemSheet.ts +++ b/src/applications/common/OpenItemSheet.ts @@ -2,7 +2,7 @@ import {DefaultDocumentManager} from "../../scripts/foundry/DocumentManager"; const openItemSheet = function openItemSheet(node: any, itemUuid: string) { node.onclick = async () => { - const document = await new DefaultDocumentManager().getDocumentByUuid(itemUuid); + const document = await new DefaultDocumentManager().loadItemDataByDocumentUuid(itemUuid); await document.sourceDocument.sheet.render(true); }; return { diff --git a/src/applications/common/Tabs.svelte b/src/applications/common/Tabs.svelte index 16e871eb..cc3e57b3 100644 --- a/src/applications/common/Tabs.svelte +++ b/src/applications/common/Tabs.svelte @@ -4,7 +4,7 @@ diff --git a/src/applications/common/TrackedCraftingComponentGrid.svelte b/src/applications/common/TrackedCraftingComponentGrid.svelte new file mode 100644 index 00000000..63192f76 --- /dev/null +++ b/src/applications/common/TrackedCraftingComponentGrid.svelte @@ -0,0 +1,32 @@ + + +
+ {#each trackedCombination.units as unit} +
+
+

{truncate(unit.target.element.name, nameLength)}

+
+
+
+ {unit.target.element.name} +
+
+
+

{formatQuantity(unit.actual.quantity, unit.target.quantity)}

+
+
+ {/each} +
\ No newline at end of file diff --git a/src/applications/componentSalvageApp/ComponentSalvageApp.svelte b/src/applications/componentSalvageApp/ComponentSalvageApp.svelte index 49aff381..a0247035 100644 --- a/src/applications/componentSalvageApp/ComponentSalvageApp.svelte +++ b/src/applications/componentSalvageApp/ComponentSalvageApp.svelte @@ -8,132 +8,161 @@ import eventBus from "../common/EventBus"; import { localizationKey } from "../common/LocalizationService"; import Properties from "../../scripts/Properties"; - import CraftingComponentCarousel from "../common/CraftingComponentCarousel.svelte"; - import {SuccessfulSalvageResult} from "../../scripts/crafting/result/SalvageResult"; - import {Combination} from "../../scripts/common/Combination"; + import ComponentSalvageCarousel from "../common/ComponentSalvageCarousel.svelte"; + import CraftingAttemptCarousel from "../recipeCraftingApp/CraftingAttemptCarousel.svelte"; + import TrackedCraftingComponentGrid from "../common/TrackedCraftingComponentGrid.svelte"; const localizationPath = `${Properties.module.id}.ComponentSalvageApp`; - export let craftingComponent; - export let inventory; - export let ownedComponentsOfType; + export let componentSalvageManager; + export let localization; export let closeHook; - let selectedOptionName = craftingComponent.firstOptionName; - if (selectedOptionName) { - craftingComponent.selectSalvageOption(selectedOptionName); + let salvageAttempts = []; + let selectedSalvageAttempt; + + async function loadAppData() { + salvageAttempts = await componentSalvageManager.loadSalvageAttempts(); + if (!selectedSalvageAttempt && salvageAttempts.length > 0) { + selectedSalvageAttempt = salvageAttempts[0]; + } + if (selectedSalvageAttempt && salvageAttempts.length > 0) { + selectedSalvageAttempt = salvageAttempts.find((attempt) => attempt.optionId === selectedSalvageAttempt.optionId); + } } + onMount(loadAppData); + + function selectNextSalvageOption() { + const currentIndex = salvageAttempts.findIndex((attempt) => attempt.optionId === selectedSalvageAttempt.optionId); + if (currentIndex === salvageAttempts.length - 1) { + selectedSalvageAttempt = salvageAttempts[0]; + } else { + selectedSalvageAttempt = salvageAttempts[currentIndex + 1]; + } + } + + function selectPreviousSalvageOption() { + const currentIndex = salvageAttempts.findIndex((attempt) => attempt.optionId === selectedSalvageAttempt.optionId); + if (currentIndex === 0) { + selectedSalvageAttempt = salvageAttempts[salvageAttempts.length - 1]; + } else { + selectedSalvageAttempt = salvageAttempts[currentIndex - 1]; + } + } + + setContext(localizationKey, { localization, }); - onMount(async () => { - return reIndex(); - }); - async function doSalvage(event) { const skipDialog = event.detail.skipDialog; if (skipDialog) { - return salvageComponent(craftingComponent); + return salvageComponent(); } let confirm = false; await Dialog.confirm({ title: localization.localize(`${localizationPath}.dialog.doSalvage.title`), - content: `

${localization.format(`${localizationPath}.dialog.doSalvage.content`, { componentName: craftingComponent.name})}

`, + content: `

${localization.format(`${localizationPath}.dialog.doSalvage.content`, { componentName: componentSalvageManager.componentToSalvage.name })}

`, yes: async () => { confirm = true; } }); if (confirm) { - return salvageComponent(craftingComponent); + return salvageComponent(); } } - async function salvageComponent(craftingComponent) { - const salvageResult = new SuccessfulSalvageResult({ - created: craftingComponent.selectedSalvage, - consumed: Combination.of(craftingComponent, 1) - }); - await inventory.acceptSalvageResult(salvageResult); + async function salvageComponent() { + await componentSalvageManager.doSalvage(selectedSalvageAttempt.optionId); + await loadAppData(); } - function handleComponentUpdated(event) { + async function handleComponentUpdated(event) { const updatedComponent = event.detail; - if (updatedComponent.id !== craftingComponent.id) { + if (updatedComponent.id !== componentSalvageManager.componentToSalvage.id) { return; } - craftingComponent = updatedComponent; - selectedOptionName = craftingComponent.firstOptionName; - craftingComponent.selectSalvageOption(selectedOptionName); - if (!craftingComponent.isSalvageable) { + if (!updatedComponent.isSalvageable) { closeHook(); return; } + await loadAppData(); } - async function handleItemUpdated(event) { - const { sourceId, actor } = event.detail; - // If the modified item is not owned, not owned by the actor who owns this crafting component, or is not associated with this component - if (!actor || !actor.id === inventory.actor.id || sourceId !== craftingComponent.itemUuid) { - // do nothing + async function reloadApplicableInventoryEvents(event) { + const actor = event.detail.actor; + const sourceId = event.detail.sourceId; + if (!actor) { + throw new Error("No actor provided in event detail"); + } + if (!sourceId) { + throw new Error("No sourceId provided in event detail"); + } + // If the modified item is not owned, or not owned by the actor who owns this crafting component + if (actor.id !== componentSalvageManager.actor.id) { return; } // if it is, we need to re-index the inventory - return reIndex(); + return loadAppData(); } - async function handleItemCreated(event) { - const {sourceId, actor} = event.detail; - if (!actor?.id === inventory.actor.id || sourceId !== craftingComponent.itemUuid) { - return; - } - return reIndex(); + async function handleItemUpdated(event) { + await reloadApplicableInventoryEvents(event); } - async function handleItemDeleted(event) { - const {sourceId, actor} = event.detail; - if (!actor?.id === inventory.actor.id || sourceId !== craftingComponent.itemUuid) { - return; - } - await reIndex(); - if (ownedComponentsOfType.isEmpty()) { - closeHook(); - } + async function handleItemCreated(event) { + await reloadApplicableInventoryEvents(event); } - async function reIndex() { - await inventory.index(); - const ownedComponents = inventory.ownedComponents; - ownedComponentsOfType = ownedComponents.just(craftingComponent); + async function handleItemDeleted(event) { + await reloadApplicableInventoryEvents(event); } -
-
handleComponentUpdated(e)} - on:itemUpdated={(e) => handleItemUpdated(e)} - on:itemCreated={(e) => handleItemCreated(e)} - on:itemDeleted={(e) => handleItemDeleted(e)}> - doSalvage(e)} /> -
- {#if craftingComponent.salvageOptions.length === 1} -

{localization.localize(`${localizationPath}.hints.doSalvage`)}:

-
- -
- {:else if craftingComponent.salvageOptions.length > 1} - +{#if selectedSalvageAttempt} +
+
handleComponentUpdated(e)} + on:itemUpdated={(e) => handleItemUpdated(e)} + on:itemCreated={(e) => handleItemCreated(e)} + on:itemDeleted={(e) => handleItemDeleted(e)}> + doSalvage(e)} /> +
+ {#if salvageAttempts.length === 1} +

{localization.localize(`${localizationPath}.hints.doSalvage`)}:

+
+ + {#if selectedSalvageAttempt.requiresCatalysts} +

{localization.localize(`${localizationPath}.hints.requiresCatalysts`)}

+ + {/if} +
+ {:else if salvageAttempts.length > 1} +

{localization.localize(`${localizationPath}.hints.doSalvage`)}:

- - {:else} -
-

{localization.localize(`${localizationPath}.errors.notSalvageable`)}

-
- {/if} +
+ {:else} +
+

{localization.localize(`${localizationPath}.errors.notSalvageable`)}

+
+ {/if} +
+
+
+{:else} +
+
+
+

{localization.localize(`${localizationPath}.errors.notSalvageable`)}

+
-
\ No newline at end of file +{/if} diff --git a/src/applications/componentSalvageApp/ComponentSalvageAppCatalog.ts b/src/applications/componentSalvageApp/ComponentSalvageAppCatalog.ts index 5fc65cc8..52bb3460 100644 --- a/src/applications/componentSalvageApp/ComponentSalvageAppCatalog.ts +++ b/src/applications/componentSalvageApp/ComponentSalvageAppCatalog.ts @@ -1,52 +1,48 @@ import {ComponentSalvageAppFactory} from "./ComponentSalvageAppFactory"; -import {SystemRegistry} from "../../scripts/registries/SystemRegistry"; -import {CraftingSystem} from "../../scripts/system/CraftingSystem"; -import {CraftingComponent} from "../../scripts/common/CraftingComponent"; import {SvelteApplication} from "../SvelteApplication"; +import {Component} from "../../scripts/crafting/component/Component"; interface ComponentSalvageAppCatalog { - load(craftingComponent: CraftingComponent, craftingSystem: CraftingSystem, actor: Actor): Promise; + + load(actor: Actor, component: Component): Promise; + } class DefaultComponentSalvageAppCatalog implements ComponentSalvageAppCatalog { - private readonly _componentSalvageAppFactory: ComponentSalvageAppFactory; - private readonly _systemRegistry: SystemRegistry; - private readonly _appIndex: Map = new Map(); + private readonly _componentSalvageAppFactory: ComponentSalvageAppFactory; constructor({ + appIndex = new Map(), componentSalvageAppFactory, - systemRegistry, - appIndex = new Map() }: { - componentSalvageAppFactory: ComponentSalvageAppFactory; - systemRegistry: SystemRegistry; appIndex?: Map; + componentSalvageAppFactory: ComponentSalvageAppFactory; }) { - this._componentSalvageAppFactory = componentSalvageAppFactory; - this._systemRegistry = systemRegistry; this._appIndex = appIndex; + this._componentSalvageAppFactory = componentSalvageAppFactory; } - async load(craftingComponent: CraftingComponent, craftingSystem: CraftingSystem, actor: Actor): Promise { - const appId = `fabricate-component-salvage-app-${craftingComponent.id}`; + async load(actor: Actor, component: Component): Promise { + const appId = this.getAppId(actor, component); if (this._appIndex.has(appId)) { const svelteApplication = this._appIndex.get(appId); if (svelteApplication.rendered) { await svelteApplication.close(); } this._appIndex.delete(appId); - craftingSystem = await this._systemRegistry.getCraftingSystemById(craftingSystem.id); - await craftingSystem.loadPartDictionary(); - craftingComponent = craftingSystem.getComponentById(craftingComponent.id); } - - const app = this._componentSalvageAppFactory.make(craftingComponent, craftingSystem, actor, appId); + const app = this._componentSalvageAppFactory.make(component, actor, appId); this._appIndex.set(appId, app); return app; } + private getAppId(actor: Actor, component: Component) { + //@ts-ignore + const actorId = actor.id; + return `fabricate-component-salvage-app-${component.id}-${actorId}`; + } } export { ComponentSalvageAppCatalog, DefaultComponentSalvageAppCatalog } \ No newline at end of file diff --git a/src/applications/componentSalvageApp/ComponentSalvageAppFactory.ts b/src/applications/componentSalvageApp/ComponentSalvageAppFactory.ts index cd9ce458..370dea1f 100644 --- a/src/applications/componentSalvageApp/ComponentSalvageAppFactory.ts +++ b/src/applications/componentSalvageApp/ComponentSalvageAppFactory.ts @@ -1,45 +1,63 @@ import {SvelteApplication} from "../SvelteApplication"; -import {CraftingSystem} from "../../scripts/system/CraftingSystem"; -import {CraftingComponent} from "../../scripts/common/CraftingComponent"; -import {DefaultGameProvider} from "../../scripts/foundry/GameProvider"; +import {Component} from "../../scripts/crafting/component/Component"; import Properties from "../../scripts/Properties"; import ComponentSalvageApp from "./ComponentSalvageApp.svelte"; -import {Combination} from "../../scripts/common/Combination"; -import {DefaultLocalizationService} from "../common/LocalizationService"; -import {DefaultInventoryFactory} from "../../scripts/actor/InventoryFactory"; +import {LocalizationService} from "../common/LocalizationService"; +import {CraftingAPI} from "../../scripts/api/CraftingAPI"; +import {ComponentAPI} from "../../scripts/api/ComponentAPI"; +import {DefaultComponentSalvageManager} from "./ComponentSalvageManager"; interface ComponentSalvageAppFactory { - make(craftingComponent: CraftingComponent, craftingSystem: CraftingSystem, actor: Actor, appId: string): SvelteApplication; + make(component: Component, actor: any, appId: string): SvelteApplication; } class DefaultComponentSalvageAppFactory implements ComponentSalvageAppFactory { - make(craftingComponent: CraftingComponent, craftingSystem: CraftingSystem, actor: any, appId: string): SvelteApplication { + private readonly localizationService: LocalizationService; + private readonly craftingAPI: CraftingAPI; + private readonly componentAPI: ComponentAPI; - const gameProvider = new DefaultGameProvider(); - const GAME = gameProvider.get(); + constructor({ + craftingAPI, + componentAPI, + localizationService, + }: { + craftingAPI: CraftingAPI; + componentAPI: ComponentAPI; + localizationService: LocalizationService; + }) { + this.craftingAPI = craftingAPI; + this.componentAPI = componentAPI; + this.localizationService = localizationService; + } + + make(component: Component, actor: any, appId: string): SvelteApplication { const applicationOptions = { - title: GAME.i18n.format(`${Properties.module.id}.ComponentSalvageApp.title`, { actorName: actor.name }), + title: this.localizationService.format(`${Properties.module.id}.ComponentSalvageApp.title`, { actorName: actor.name }), id: appId, resizable: false, width: 540, height: 514 - } + }; - const inventory = new DefaultInventoryFactory(gameProvider).make(actor, craftingSystem); + const componentSalvageManager = new DefaultComponentSalvageManager({ + actor, + craftingAPI: this.craftingAPI, + componentAPI: this.componentAPI, + componentToSalvage: component, + }); return new SvelteApplication({ applicationOptions, svelteConfig: { + componentType: ComponentSalvageApp, options: { props: { - craftingComponent, - inventory, - localization: new DefaultLocalizationService(gameProvider), - ownedComponentsOfType: Combination.EMPTY(), + componentSalvageManager, + localization: this.localizationService, closeHook: async () => { const svelteApplication: SvelteApplication = Object.values(ui.windows) .find(w => w.id == appId); @@ -47,7 +65,6 @@ class DefaultComponentSalvageAppFactory implements ComponentSalvageAppFactory { } } }, - componentType: ComponentSalvageApp } }); } diff --git a/src/applications/componentSalvageApp/ComponentSalvageManager.ts b/src/applications/componentSalvageApp/ComponentSalvageManager.ts new file mode 100644 index 00000000..1c4f9e02 --- /dev/null +++ b/src/applications/componentSalvageApp/ComponentSalvageManager.ts @@ -0,0 +1,198 @@ +import {CraftingAPI} from "../../scripts/api/CraftingAPI"; +import {BaseActor} from "@league-of-foundry-developers/foundry-vtt-types/src/foundry/common/documents.mjs"; +import {ComponentAPI} from "../../scripts/api/ComponentAPI"; +import {Component} from "../../scripts/crafting/component/Component"; +import {TrackedCombination} from "../../scripts/common/TrackedCombination"; +import {Combination} from "../../scripts/common/Combination"; +import {SalvageOption} from "../../scripts/crafting/component/SalvageOption"; +import {SalvageResult} from "../../scripts/crafting/result/SalvageResult"; + +interface SalvageAttempt { + + readonly optionId: string; + readonly optionName: string; + readonly amountOwned: number; + readonly isPossible: boolean; + readonly requiredCatalysts: TrackedCombination; + readonly requiresCatalysts: boolean; + readonly componentToSalvage: Component; + readonly producedComponents: Combination; + +} + +export { SalvageAttempt }; + +class DefaultSalvageAttempt implements SalvageAttempt { + + private readonly _optionId: string; + private readonly _optionName: string; + private readonly _amountOwned: number; + private readonly _possible: boolean; + private readonly _requiredCatalysts: TrackedCombination; + private readonly _componentToSalvage: Component; + private readonly _producedComponents: Combination; + + constructor({ + optionId, + optionName, + amountOwned, + isPossible, + requiredCatalysts, + componentToSalvage, + producedComponents, + }: { + optionId: string; + optionName: string; + amountOwned: number; + isPossible: boolean; + requiredCatalysts: TrackedCombination; + componentToSalvage: Component; + producedComponents: Combination; + } ) { + this._optionId = optionId; + this._optionName = optionName; + this._amountOwned = amountOwned; + this._possible = isPossible; + this._requiredCatalysts = requiredCatalysts; + this._componentToSalvage = componentToSalvage; + this._producedComponents = producedComponents; + } + + get amountOwned(): number { + return this._amountOwned; + } + + get isPossible(): boolean { + return this._possible; + } + + get requiredCatalysts(): TrackedCombination { + return this._requiredCatalysts; + } + + get requiresCatalysts(): boolean { + return !this._requiredCatalysts.isEmpty + } + + get producedComponents(): Combination { + return this._producedComponents; + } + + get optionId(): string { + return this._optionId; + } + + get componentToSalvage(): Component { + return this._componentToSalvage; + } + + get optionName(): string { + return this._optionName; + } + +} + +interface ComponentSalvageManager { + + readonly actor: BaseActor; + + readonly componentToSalvage: Component; + + loadSalvageAttempts(): Promise; + + doSalvage(salvageOptionId: string): Promise; + +} + +export { ComponentSalvageManager }; + +class DefaultComponentSalvageManager implements ComponentSalvageManager { + + private readonly _actor: BaseActor; + private readonly _craftingAPI: CraftingAPI; + private readonly _componentAPI: ComponentAPI; + private readonly _componentToSalvage: Component; + + constructor({ + actor, + craftingAPI, + componentAPI, + componentToSalvage, + }: { + actor: BaseActor; + craftingAPI: CraftingAPI; + componentAPI: ComponentAPI; + componentToSalvage: Component; + }) { + this._actor = actor; + this._craftingAPI = craftingAPI; + this._componentAPI = componentAPI; + this._componentToSalvage = componentToSalvage; + } + + get actor(): BaseActor { + return this._actor; + } + + async loadSalvageAttempts(): Promise { + const amountOwned = await this._craftingAPI.countOwnedComponentsOfType(this._actor.id, this._componentToSalvage.id); + const ownedComponents = await this._craftingAPI.getOwnedComponentsForCraftingSystem(this._actor.id, this._componentToSalvage.craftingSystemId); + const includedComponentIds = this._componentToSalvage.getUniqueReferencedComponents().map(referenceUnit => referenceUnit.id); + const includedComponentsById = await this._componentAPI.getAllById(includedComponentIds); + await Promise.all(Array.from(includedComponentsById.values()) + .map(component => component.load()) + .concat(this._componentToSalvage.load()) + ); + return this._componentToSalvage.salvageOptions.all.map((option) => this.buildSalvageAttempt(this._componentToSalvage, option, amountOwned, ownedComponents, includedComponentsById)); + } + + private buildSalvageAttempt( + componentToSalvage: Component, + option: SalvageOption, + amountOwned: number, + ownedComponents: Combination, + includedComponentsById: Map, + ): SalvageAttempt { + + let requiredCatalysts: TrackedCombination = TrackedCombination.EMPTY(); + if (option.requiresCatalysts) { + const targetCatalysts = option.catalysts.convertElements(reference => includedComponentsById.get(reference.id)); + const actualCatalysts = ownedComponents.units + .filter(unit => targetCatalysts.has(unit.element)) + .reduce((combination, unit) => combination.addUnit(unit), Combination.EMPTY()); + requiredCatalysts = new TrackedCombination({ + target: targetCatalysts, + actual: actualCatalysts + }); + } + + const isSalvageable = requiredCatalysts.isSufficient; + const producedComponents = option.results.convertElements(reference => includedComponentsById.get(reference.id)); + + return new DefaultSalvageAttempt({ + optionId: option.id, + optionName: option.name, + amountOwned, + isPossible: isSalvageable, + requiredCatalysts, + componentToSalvage, + producedComponents + }); + + } + + async doSalvage(salvageOptionId: string): Promise { + return this._craftingAPI.salvageComponent({ + salvageOptionId, + sourceActorId: this._actor.id, + componentId: this._componentToSalvage.id, + }); + } + + get componentToSalvage(): Component { + return this._componentToSalvage; + } + +} + +export { DefaultComponentSalvageManager } \ No newline at end of file diff --git a/src/applications/componentSalvageApp/SalvageHeader.svelte b/src/applications/componentSalvageApp/SalvageHeader.svelte index 8828a208..e26f5358 100644 --- a/src/applications/componentSalvageApp/SalvageHeader.svelte +++ b/src/applications/componentSalvageApp/SalvageHeader.svelte @@ -9,7 +9,7 @@ const dispatch = createEventDispatcher(); export let component; - export let ownedComponentsOfType; + export let amountOwned; const { localization } = getContext(localizationKey); function salvageComponent(event) { @@ -22,6 +22,6 @@
-

{component.name} ({ownedComponentsOfType.size})

+

{component.name} ({amountOwned})

\ No newline at end of file diff --git a/src/applications/craftingSystemManagerApp/CraftingSystemEditor.svelte b/src/applications/craftingSystemManagerApp/CraftingSystemEditor.svelte index 3b98e2dc..3bccab3d 100644 --- a/src/applications/craftingSystemManagerApp/CraftingSystemEditor.svelte +++ b/src/applications/craftingSystemManagerApp/CraftingSystemEditor.svelte @@ -1,79 +1,80 @@ + handleItemDeleted(e)} use:eventBus='{"itemDeleted"}'> @@ -104,7 +105,7 @@ - + diff --git a/src/applications/craftingSystemManagerApp/CraftingSystemEditor.ts b/src/applications/craftingSystemManagerApp/CraftingSystemEditor.ts index 91f3743b..b08e1dc8 100644 --- a/src/applications/craftingSystemManagerApp/CraftingSystemEditor.ts +++ b/src/applications/craftingSystemManagerApp/CraftingSystemEditor.ts @@ -1,78 +1,60 @@ import {Writable} from "svelte/store"; -import {CraftingSystem, CraftingSystemJson} from "../../scripts/system/CraftingSystem"; -import {SystemRegistry} from "../../scripts/registries/SystemRegistry"; +import {CraftingSystem} from "../../scripts/system/CraftingSystem"; import Properties from "../../scripts/Properties"; -import FabricateApplication from "../../scripts/interface/FabricateApplication"; import {LocalizationService} from "../common/LocalizationService"; +import {FabricateAPI} from "../../scripts/api/FabricateAPI"; +import {FabricateExportModel} from "../../scripts/repository/import/FabricateExportModel"; +import {Component} from "../../scripts/crafting/component/Component"; class CraftingSystemEditor { private readonly _craftingSystems: Writable; - private readonly _systemRegistry: SystemRegistry; + private readonly _components: Writable; private readonly _localization: LocalizationService; - private readonly _game: Game; + private readonly _fabricateAPI: FabricateAPI; private static readonly _dialogLocalizationPath = `${Properties.module.id}.CraftingSystemManagerApp.dialog`; constructor({ craftingSystems, - systemRegistry, + components, localization, - game + fabricateAPI, }: { craftingSystems: Writable; - systemRegistry: SystemRegistry; + components: Writable; localization: LocalizationService; - game: Game; + fabricateAPI: FabricateAPI; }) { + this._fabricateAPI = fabricateAPI; + this._components = components; this._craftingSystems = craftingSystems; - this._systemRegistry = systemRegistry; this._localization = localization; - this._game = game; } public async createNewCraftingSystem(): Promise { - const systemJson: CraftingSystemJson = { - parts: { - recipes: {}, - components: {}, - essences: {} - }, - locked: false, - details: { - name: "(New!) My New Crafting System", - author: this._game.user.name, - summary: "A brand new Crafting System created with Fabricate", - description: "" - }, - enabled: true, - id: randomID() - }; - const createdSystem = await this._systemRegistry.createCraftingSystem(systemJson); + const result = await this._fabricateAPI.systems.create(); this._craftingSystems.update((craftingSystems) => { - craftingSystems.push(createdSystem); + craftingSystems.push(result); return craftingSystems; }); - return createdSystem; + return result; } async deleteCraftingSystem(craftingSystemToDelete: CraftingSystem) { await Dialog.confirm({ title: this._localization.localize(`${CraftingSystemEditor._dialogLocalizationPath}.deleteSystemConfirm.title`), - content: `

${this._localization.format(`${CraftingSystemEditor._dialogLocalizationPath}.deleteSystemConfirm.content`, {systemName: craftingSystemToDelete.name})}

`, + content: `

${this._localization.format(`${CraftingSystemEditor._dialogLocalizationPath}.deleteSystemConfirm.content`, {systemName: craftingSystemToDelete.details.name})}

`, yes: async () => { - await this._systemRegistry.deleteCraftingSystemById(craftingSystemToDelete.id); - const message = this._localization.format(`${CraftingSystemEditor._dialogLocalizationPath}.deleteCraftingSystem.success`, { systemName: craftingSystemToDelete.name}); + await this._fabricateAPI.deleteAllByCraftingSystemId(craftingSystemToDelete.id); this._craftingSystems.update((craftingSystems) => { - const filtered = craftingSystems.filter(craftingSystem => craftingSystem.id !== craftingSystemToDelete.id); - ui.notifications.info(message); - return filtered; + return craftingSystems.filter(craftingSystem => craftingSystem.id !== craftingSystemToDelete.id); }); } }); } - async importCraftingSystem(onSuccess?: (craftingSystem: CraftingSystem) => void, targetSystem?: CraftingSystem): Promise { + async importCraftingSystem(targetCraftingSystem?: CraftingSystem): Promise { const craftingSystemTypeName = this._localization.localize(`${Properties.module.id}.typeNames.craftingSystem.singular`); const importActionHint = this._localization.localize(`${CraftingSystemEditor._dialogLocalizationPath}.importCraftingSystem.hint`); const content = await renderTemplate("templates/apps/import-data.html", { @@ -88,6 +70,7 @@ class CraftingSystemEditor { icon: '', label: this._localization.localize(`${CraftingSystemEditor._dialogLocalizationPath}.importCraftingSystem.buttons.import`), callback: async (html) => { + // @ts-ignore const form = html.find("form")[0]; if (!form.data.files.length) { @@ -96,21 +79,41 @@ class CraftingSystemEditor { throw new Error(message); } const fileData = await readTextFromFile(form.data.files[0]); - let craftingSystemJson: CraftingSystemJson; + + let dataToImport: FabricateExportModel; try { - craftingSystemJson = JSON.parse(fileData); + dataToImport = JSON.parse(fileData); } catch (e: any) { const message = this._localization.localize(`${CraftingSystemEditor._dialogLocalizationPath}.importCraftingSystem.errors.couldNotParseFile`); ui.notifications.error(message); throw new Error(message); } - if (targetSystem) { - const updated = await this.overwriteCraftingSystem(craftingSystemJson, targetSystem); - onSuccess(updated); - } else { - const created = await this.importNewCraftingSystem(craftingSystemJson); - onSuccess(created); + + if (targetCraftingSystem && (targetCraftingSystem.id !== dataToImport.craftingSystem.id)) { + const message = this._localization.format(`${CraftingSystemEditor._dialogLocalizationPath}.importCraftingSystem.errors.importIdMismatch`, { + systemName: targetCraftingSystem.details.name, + expectedId: targetCraftingSystem.id, + actualId: dataToImport.craftingSystem.id, + }) + ui.notifications.error(message); + throw new Error(message); + } + + const importResult = await this._fabricateAPI.import(dataToImport); + if (!importResult) { + return; } + this._craftingSystems.update((craftingSystems) => { + const found = craftingSystems.find(craftingSystem => craftingSystem.id === importResult.craftingSystem.id); + if (!found) { + craftingSystems.push(importResult.craftingSystem); + return craftingSystems; + } + return craftingSystems + .filter(craftingSystem => craftingSystem.id !== importResult.craftingSystem.id) + .concat(importResult.craftingSystem); + }); + } }, no: { @@ -123,80 +126,41 @@ class CraftingSystemEditor { }).render(true); } - private async importNewCraftingSystem(craftingSystemJson: CraftingSystemJson): Promise { - if (!craftingSystemJson.id) { - craftingSystemJson.id = randomID(); - } - const importedSystem = await FabricateApplication.systemRegistry.createCraftingSystem(craftingSystemJson); + public async exportCraftingSystem(craftingSystem: CraftingSystem) { + const exportData = await this._fabricateAPI.export(craftingSystem.id); + const fileContents = JSON.stringify(exportData, null, 2); + const fileName = `fabricate-crafting-system-${craftingSystem.details.name.slugify()}.json`; + saveDataToFile(fileContents, "application/json", fileName); + const message = this._localization.format(`${CraftingSystemEditor._dialogLocalizationPath}.exportCraftingSystem.success`, { systemName: craftingSystem.details.name, fileName }); + ui.notifications.info(message); + } + + async duplicateCraftingSystem(sourceCraftingSystem: CraftingSystem): Promise { + + let duplicatedCraftingSystemData = await this._fabricateAPI.duplicateCraftingSystem(sourceCraftingSystem.id); + const message = this._localization.format( - `${CraftingSystemEditor._dialogLocalizationPath}.importCraftingSystem.success`, - { systemName: importedSystem.name} + `${CraftingSystemEditor._dialogLocalizationPath}.duplicateCraftingSystem.complete`, + { + sourceSystemName: sourceCraftingSystem.details.name, + duplicatedSystemName: duplicatedCraftingSystemData?.craftingSystem?.details?.name + } ); - this._craftingSystems.update((craftingSystems) => { - craftingSystems.push(importedSystem); - return craftingSystems; - }); ui.notifications.info(message); - return importedSystem; - } - private async overwriteCraftingSystem(craftingSystemJson: CraftingSystemJson, targetSystem: CraftingSystem): Promise { - const systemFound = await this._systemRegistry.hasCraftingSystem(targetSystem.id); - if (!systemFound) { - const message = this._localization.format( - `${CraftingSystemEditor._dialogLocalizationPath}.importCraftingSystem.errors.targetSystemNotFound`, - { systemName: targetSystem.name }); - ui.notifications.error(message); - throw new Error(message); + if (duplicatedCraftingSystemData?.craftingSystem) { + this._craftingSystems.update((craftingSystems) => { + craftingSystems.push(duplicatedCraftingSystemData.craftingSystem); + return craftingSystems; + }); } - if (targetSystem.id !== craftingSystemJson.id) { - const message = this._localization.format(`${CraftingSystemEditor._dialogLocalizationPath}.importCraftingSystem.errors.importIdMismatch`, { - systemName: targetSystem.name, - expectedId: targetSystem.id, - actualId: craftingSystemJson.id, - }) - ui.notifications.error(message); - throw new Error(message); - } - const updatedCraftingSystem = await FabricateApplication.systemRegistry.createCraftingSystem(craftingSystemJson); - const message = this._localization.format(`${CraftingSystemEditor._dialogLocalizationPath}.importCraftingSystem.success`, { systemName: updatedCraftingSystem.name}); - this._craftingSystems.update((craftingSystems) => { - const filtered = craftingSystems.filter(craftingSystem => craftingSystem.id !== updatedCraftingSystem.id); - filtered.push(updatedCraftingSystem); - ui.notifications.info(message); - return filtered; - }); - return updatedCraftingSystem; - } - public exportCraftingSystem(craftingSystem: CraftingSystem) { - const exportData = JSON.stringify(craftingSystem.toJson(), null, 2); - const fileName = `fabricate-crafting-system-${craftingSystem.name.slugify()}.json`; - saveDataToFile(exportData, "application/json", fileName); - const message = this._localization.format(`${CraftingSystemEditor._dialogLocalizationPath}.exportCraftingSystem.success`, { systemName: craftingSystem.name, fileName }); - ui.notifications.info(message); - } + return duplicatedCraftingSystemData.craftingSystem; - async duplicateCraftingSystem(sourceCraftingSystem: CraftingSystem): Promise { - const clonedCraftingSystem = sourceCraftingSystem.clone({ - id: randomID(), - name: `${sourceCraftingSystem.name} (copy)`, - locked: false - }); - const duplicationResult = await FabricateApplication.systemRegistry.saveCraftingSystem(clonedCraftingSystem); - ui.notifications.info(this._localization.format(`${CraftingSystemEditor._dialogLocalizationPath}.duplicateCraftingSystem.success`, { - sourceSystemName: sourceCraftingSystem.name, - duplicatedSystemName: duplicationResult.name - })); - this._craftingSystems.update((craftingSystems) => { - craftingSystems.push(duplicationResult); - return craftingSystems; - }); - return duplicationResult; } async saveCraftingSystem(craftingSystem: CraftingSystem): Promise { - const updatedCraftingSystem = await this._systemRegistry.saveCraftingSystem(craftingSystem); + const updatedCraftingSystem = await this._fabricateAPI.systems.save(craftingSystem); this._craftingSystems.update((craftingSystems) => { const filtered = craftingSystems.filter(craftingSystem => craftingSystem.id !== updatedCraftingSystem.id); filtered.push(updatedCraftingSystem); @@ -205,6 +169,19 @@ class CraftingSystemEditor { return updatedCraftingSystem; } + async deleteComponent(component: Component): Promise { + return this._fabricateAPI.components.deleteById(component.id); + } + + async saveComponent(craftingComponent: Component) { + const updatedComponent = await this._fabricateAPI.components.save(craftingComponent); + this._components.update((components) => { + const filtered = components.filter(component => component.id !== updatedComponent.id); + filtered.push(updatedComponent); + return filtered; + }); + return updatedComponent; + } } export { CraftingSystemEditor } \ No newline at end of file diff --git a/src/applications/craftingSystemManagerApp/CraftingSystemNavbar.svelte b/src/applications/craftingSystemManagerApp/CraftingSystemNavbar.svelte index 9704d039..10630b60 100644 --- a/src/applications/craftingSystemManagerApp/CraftingSystemNavbar.svelte +++ b/src/applications/craftingSystemManagerApp/CraftingSystemNavbar.svelte @@ -20,12 +20,8 @@ $loading = false; } - async function importCraftingSystem() { - $loading = true; - await craftingSystemEditor.importCraftingSystem((craftingSystem) => { - $selectedCraftingSystem = craftingSystem; - }); - $loading = false; + async function importCraftingSystem(targetCraftingSystemId) { + await craftingSystemEditor.importCraftingSystem(targetCraftingSystemId); } diff --git a/src/applications/craftingSystemManagerApp/CraftingSystemNavbarItem.svelte b/src/applications/craftingSystemManagerApp/CraftingSystemNavbarItem.svelte index a786415d..6d269381 100644 --- a/src/applications/craftingSystemManagerApp/CraftingSystemNavbarItem.svelte +++ b/src/applications/craftingSystemManagerApp/CraftingSystemNavbarItem.svelte @@ -7,8 +7,7 @@ const { craftingSystemEditor, selectedCraftingSystem, - localization, - loading + localization } = getContext(key); const localizationPath = `${Properties.module.id}.CraftingSystemManagerApp.navbar`; @@ -17,29 +16,19 @@ } async function deleteSystem() { - $loading = true; await craftingSystemEditor.deleteCraftingSystem(craftingSystem); - $loading = false; } - async function importCraftingSystem() { - $loading = true; - await craftingSystemEditor.importCraftingSystem((craftingSystem) => { - $selectedCraftingSystem = craftingSystem; - }, craftingSystem); - $loading = false; + async function importCraftingSystem(craftingSystem) { + await craftingSystemEditor.importCraftingSystem(craftingSystem); } - function exportSystem() { - $loading = true; - craftingSystemEditor.exportCraftingSystem(craftingSystem); - $loading = false; + async function exportSystem() { + await craftingSystemEditor.exportCraftingSystem(craftingSystem); } async function duplicateSystem() { - $loading = true; $selectedCraftingSystem = await craftingSystemEditor.duplicateCraftingSystem(craftingSystem); - $loading = false; } @@ -47,15 +36,15 @@
-

{craftingSystem.name} {#if craftingSystem.isLocked}{/if}

{#if craftingSystem.hasErrors}{/if} +

{craftingSystem.details.name} {#if craftingSystem.embedded}{/if}

{#if craftingSystem.hasErrors}{/if}
{#if $selectedCraftingSystem !== craftingSystem}
{/if} {#if $selectedCraftingSystem === craftingSystem}
-

{craftingSystem.summary}

+

{craftingSystem.details.summary}


- {#if !craftingSystem.isLocked} + {#if !craftingSystem.embedded} @@ -66,7 +55,7 @@ - {#if !craftingSystem.isLocked} + {#if !craftingSystem.embedded} diff --git a/src/applications/craftingSystemManagerApp/componentManager/ComponentEditor.svelte b/src/applications/craftingSystemManagerApp/componentManager/ComponentEditor.svelte index e9b277da..244c8ca2 100644 --- a/src/applications/craftingSystemManagerApp/componentManager/ComponentEditor.svelte +++ b/src/applications/craftingSystemManagerApp/componentManager/ComponentEditor.svelte @@ -3,94 +3,71 @@ import Properties from "../../../scripts/Properties.js"; import {DropEventParser} from "../../common/DropEventParser"; import {DefaultDocumentManager} from "../../../scripts/foundry/DocumentManager"; - import {Combination, Unit} from "../../../scripts/common/Combination"; + import {Combination} from "../../../scripts/common/Combination"; import truncate from "../../common/Truncate"; - import {SalvageOption} from "../../../scripts/common/CraftingComponent"; import {Tab, Tabs} from "../../common/FabricateTabs.js"; import TabList from "../../common/TabList.svelte"; import TabPanel from "../../common/TabPanel.svelte"; - import {componentUpdated} from "../../common/EventBus"; + import {componentUpdated, recipeUpdated} from "../../common/EventBus"; import {getContext, onDestroy} from "svelte"; import {SalvageSearchStore} from "../../stores/SalvageSearchStore"; import {ComponentEssenceStore} from "../../stores/ComponentEssenceStore"; + import {Unit} from "../../../scripts/common/Unit"; + import {SalvageOption} from "../../../scripts/crafting/component/SalvageOption"; const localizationPath = `${Properties.module.id}.CraftingSystemManagerApp.tabs.components`; - let selectPreviousTab; + let selectSalvageTab; const { localization, - loading, selectedComponent, - craftingComponents, + components, selectedCraftingSystem, - craftingComponentEditor + componentEditor, + essences } = getContext(key); - const salvageSearchResults = new SalvageSearchStore({ selectedComponent, availableComponents: craftingComponents }); + const salvageSearchResults = new SalvageSearchStore({ selectedComponent, components }); const searchTerms = salvageSearchResults.searchTerms; - const componentEssences = new ComponentEssenceStore({selectedCraftingSystem, selectedComponent}); + const componentEssences = new ComponentEssenceStore({ allEssences: essences, selectedComponent }); function deselectComponent() { $selectedComponent = null; } + function selectLastSalvageOption() { + if ($selectedComponent.salvageOptions.all.length > 1) { + selectSalvageTab(length => length - 1); + } + } + async function replaceItem(event) { - $loading = true; - await craftingComponentEditor.replaceItem(event, $selectedCraftingSystem, $selectedComponent); - $loading = false; + await componentEditor.replaceItem(event,$selectedComponent); } async function incrementEssence(essence) { - $loading = true; - $selectedComponent.essences = $selectedComponent.essences.add(new Unit(essence, 1)); - await craftingComponentEditor.saveComponent($selectedComponent, $selectedCraftingSystem); - $loading = false; + $selectedComponent.essences = $selectedComponent.essences.addUnit(new Unit(essence.toReference(), 1)); + await componentEditor.saveComponent($selectedComponent, $selectedCraftingSystem); } async function decrementEssence(essence) { - $loading = true; - $selectedComponent.essences = $selectedComponent.essences.minus(new Unit(essence, 1)); - await craftingComponentEditor.saveComponent($selectedComponent, $selectedCraftingSystem); - $loading = false; + $selectedComponent.essences = $selectedComponent.essences.subtractUnit(new Unit(essence.toReference(), 1)); + await componentEditor.saveComponent($selectedComponent, $selectedCraftingSystem); } function clearSearch() { salvageSearchResults.clear(); } - async function addSalvageOption(event) { - $loading = true; - const dropEventParser = new DropEventParser({ - localizationService: localization, - documentManager: new DefaultDocumentManager(), - partType: localization.localize(`${Properties.module.id}.typeNames.component.singular`), - allowedCraftingComponents: $craftingComponents - }); - const component = (await dropEventParser.parse(event)).component; - const name = generateOptionName($selectedComponent); - const salvageOption = new SalvageOption({name, salvage: Combination.of(component, 1)}); - $selectedComponent.addSalvageOption(salvageOption); - await craftingComponentEditor.saveComponent($selectedComponent, $selectedCraftingSystem); - if ($selectedComponent.salvageOptions.length > 1) { - selectPreviousTab(); + async function addSalvageOption(event, addAsCatalyst = false) { + if (addAsCatalyst) { + await componentEditor.addSalvageOptionComponentAsCatalyst(event, $selectedComponent); + } else { + await componentEditor.addSalvageOptionComponentAsSalvageResult(event, $selectedComponent); } - $loading = false; + selectLastSalvageOption(); componentUpdated($selectedComponent); } - - function generateOptionName(component) { - if (!component.isSalvageable) { - return localization.format(`${Properties.module.id}.typeNames.component.salvageOption.name`, { number: 1 }); - } - const existingNames = component.salvageOptions.map(salvageOption => salvageOption.name); - let nextOptionNumber = 2; - let nextOptionName; - do { - nextOptionName = localization.format(`${Properties.module.id}.typeNames.component.salvageOption.name`, { number: nextOptionNumber }); - nextOptionNumber++; - } while (existingNames.includes(nextOptionName)); - return nextOptionName; - } function dragStart(event, component) { event.dataTransfer.setData('application/json', DropEventParser.serialiseComponentData(component)); @@ -100,59 +77,102 @@ function scheduleSave() { clearTimeout(scheduledSave); scheduledSave = setTimeout(async () => { - $loading = true; - await craftingComponentEditor.saveComponent($selectedComponent, $selectedCraftingSystem); - $loading = false; + await componentEditor.saveComponent($selectedComponent, $selectedCraftingSystem); }, 1000); } async function deleteSalvageOption(optionToDelete) { - $loading = true; - $selectedComponent.deleteSalvageOptionByName(optionToDelete.name); - await craftingComponentEditor.saveComponent($selectedComponent, $selectedCraftingSystem); - $loading = false; + $selectedComponent.deleteSalvageOptionById(optionToDelete.id); + await componentEditor.saveComponent($selectedComponent, $selectedCraftingSystem); componentUpdated($selectedComponent); } - async function addComponentToSalvageOption(event, salvageOption) { - $loading = true; + async function addComponentToSalvageOption(event, salvageOption, addAsCatalyst = false) { const dropEventParser = new DropEventParser({ localizationService: localization, documentManager: new DefaultDocumentManager(), partType: localization.localize(`${Properties.module.id}.typeNames.component.singular`), - allowedCraftingComponents: $craftingComponents + allowedCraftingComponents: $components }); - const component = (await dropEventParser.parse(event)).component; - salvageOption.add(component); - await craftingComponentEditor.saveComponent($selectedComponent, $selectedCraftingSystem); - $loading = false; - componentUpdated($selectedComponent); + const dropData = await dropEventParser.parse(event); + if (dropData.hasCraftingComponent) { + await addExistingComponentToSalvageOption(salvageOption, dropData.component, addAsCatalyst); + componentUpdated($selectedComponent); + return; + } + if (dropData.hasItemData) { + await importNewComponent(dropData.itemData, salvageOption, addAsCatalyst); + componentUpdated($selectedComponent); + return; + } + throw new Error("Something went wrong adding a component to an Ingredient option. "); } - async function decrementSalvageOptionComponent(salvageOption, component) { - $loading = true; - salvageOption.subtract(component); + async function importNewComponent(itemData, salvageOption, addAsCatalyst) { + const doImport = await Dialog.confirm({ + title: localization.format( + `${localizationPath}.prompts.importItemAsComponent.title`, + { + componentName: itemData.name + } + ), + content: localization.format( + `${localizationPath}.prompts.importItemAsComponent.content`, + { + componentName: itemData.name, + systemName: $selectedCraftingSystem.details.name + } + ) + }); + if (doImport) { + const component = await componentEditor.createComponent(itemData, $selectedCraftingSystem); + await addExistingComponentToSalvageOption(salvageOption, component, addAsCatalyst); + } + } + + async function addExistingComponentToSalvageOption(salvageOption, component, addAsCatalyst) { + if (addAsCatalyst) { + salvageOption.addCatalyst(component.id); + } else { + salvageOption.addResult(component.id); + } + $selectedComponent.saveSalvageOption(salvageOption); + await componentEditor.saveComponent($selectedComponent); + } + + async function decrementSalvageOptionComponent(salvageOption, component, asCatalyst = false) { + if (asCatalyst) { + salvageOption.subtractCatalyst(component.id); + } else { + salvageOption.subtractResult(component.id); + } if (salvageOption.isEmpty) { return deleteSalvageOption(salvageOption); } - await craftingComponentEditor.saveComponent($selectedComponent, $selectedCraftingSystem); - $loading = false; + await componentEditor.saveComponent($selectedComponent); componentUpdated($selectedComponent); } - async function incrementSalvageOptionComponent(salvageOption, component, event) { + async function incrementSalvageOptionComponent(salvageOption, component, event, asCatalyst = false) { if (event && event.shiftKey) { - return decrementSalvageOptionComponent(salvageOption, component); + return decrementSalvageOptionComponent(salvageOption, component, asCatalyst); } - $loading = true; - salvageOption.add(component); - await craftingComponentEditor.saveComponent($selectedComponent, $selectedCraftingSystem); - $loading = false; + if (asCatalyst) { + salvageOption.addCatalyst(component.id); + } else { + salvageOption.addResult(component.id); + } + await componentEditor.saveComponent($selectedComponent); componentUpdated($selectedComponent); } - function sortByName(salvageOption) { - return salvageOption.sort((left, right) => left.name.localeCompare(right.name)); + function dereferenceComponentCombination(componentReferenceCombination) { + return componentReferenceCombination + .map(componentReferenceUnit => new Unit(dereferenceComponent(componentReferenceUnit.element), componentReferenceUnit.quantity)); + } + + function dereferenceComponent(componentReference) { + return $components.find(component => component.id === componentReference.id); } onDestroy(() => { @@ -185,14 +205,14 @@
{#if $selectedComponent.isSalvageable}
- + - {#each $selectedComponent.salvageOptions as salvageOption} + {#each $selectedComponent.salvageOptions.all as salvageOption} {salvageOption.name} {/each} {localization.localize(`${localizationPath}.component.labels.newSalvageOption`)} - {#each $selectedComponent.salvageOptions as salvageOption} + {#each $selectedComponent.salvageOptions.all as salvageOption}
@@ -202,35 +222,91 @@
-
addComponentToSalvageOption(e, salvageOption)}> - {#each salvageOption.salvage.units as salvageUnit} -
incrementSalvageOptionComponent(salvageOption, salvageUnit.part, e)} on:auxclick={decrementSalvageOptionComponent(salvageOption, salvageUnit.part)}> -
-

{truncate(salvageUnit.part.name, 9)}

-
-
-
- {salvageUnit.part.name} - {#if salvageUnit.quantity > 1} - {salvageUnit.quantity} - {/if} -
-
-
- {/each} -
+

{localization.localize(`${localizationPath}.component.labels.salvageHeading`)}

+ {#if salvageOption.hasResults} +
addComponentToSalvageOption(e, salvageOption)}> + {#each dereferenceComponentCombination(salvageOption.results) as resultUnit} + {#await resultUnit.element.load()} + {:then nothing} +
incrementSalvageOptionComponent(salvageOption, resultUnit.element, e)} on:auxclick={decrementSalvageOptionComponent(salvageOption, resultUnit.element)}> +
+

{truncate(resultUnit.element.name, 9)}

+
+
+
+ {resultUnit.element.name} + {#if resultUnit.quantity > 1} + {resultUnit.quantity} + {/if} +
+
+
+ {:catch error} + {/await} + {/each} +
+ {:else} +
addComponentToSalvageOption(e, salvageOption, false)}> + +
+ {/if} +

{localization.localize(`${localizationPath}.component.labels.catalystsHeading`)}

+ {#if salvageOption.requiresCatalysts} +
addComponentToSalvageOption(e, salvageOption, true)}> + {#each dereferenceComponentCombination(salvageOption.catalysts) as catalystUnit} + {#await catalystUnit.element.load()} + {:then nothing} +
incrementSalvageOptionComponent(salvageOption, catalystUnit.element, e)} on:auxclick={decrementSalvageOptionComponent(salvageOption, catalystUnit.element, true)}> +
+

{truncate(catalystUnit.element.name, 9)}

+
+
+
+ {catalystUnit.element.name} + {#if catalystUnit.quantity > 1} + {catalystUnit.quantity} + {/if} +
+
+
+ {:catch error} + {/await} + {/each} +
+ {:else} +
addComponentToSalvageOption(e, salvageOption, true)}> + +
+ {/if}
{/each} -
addSalvageOption(e)}> - +
+
+

{localization.localize(`${localizationPath}.component.labels.resultsHeading`)}

+
+
addSalvageOption(e, false)}> + +
+
+

{localization.localize(`${localizationPath}.component.labels.catalystsHeading`)}

+
+
addSalvageOption(e, true)}> + +
{:else} -
addSalvageOption(e)}> +
addSalvageOption(e, false)}> + +
+
+

{localization.localize(`${localizationPath}.component.labels.catalystsHeading`)}

+
+
addSalvageOption(e, true)}>
{/if} @@ -265,7 +341,7 @@ {:else if $searchTerms.name}

{localization.localize(`${localizationPath}.component.info.noMatchingSalvage`)}

{:else} -

{localization.format(`${localizationPath}.component.info.noAvailableSalvage`, { systemName: $selectedCraftingSystem.name, componentName: selectedComponent.name })}

+

{localization.format(`${localizationPath}.component.info.noAvailableSalvage`, { systemName: $selectedCraftingSystem.details.name, componentName: selectedComponent.name })}

{/if}
@@ -276,26 +352,26 @@
- {#if $selectedCraftingSystem.hasEssences} + {#if $essences.length > 0} {#each $componentEssences as essenceUnit}
- +
{essenceUnit.quantity} - + - {essenceUnit.part.name} + {essenceUnit.element.name}
- +
{/each} {:else} -

{localization.format(`${localizationPath}.component.info.noAvailableEssences`, { systemName: $selectedCraftingSystem.name, componentName: selectedComponent.name })}

+

{localization.format(`${localizationPath}.component.info.noAvailableEssences`, { systemName: $selectedCraftingSystem.details.name, componentName: $selectedComponent.name })}

{/if}
diff --git a/src/applications/craftingSystemManagerApp/componentManager/ComponentsBrowser.svelte b/src/applications/craftingSystemManagerApp/componentManager/ComponentsBrowser.svelte index 2b76493c..6ca827ed 100644 --- a/src/applications/craftingSystemManagerApp/componentManager/ComponentsBrowser.svelte +++ b/src/applications/craftingSystemManagerApp/componentManager/ComponentsBrowser.svelte @@ -6,16 +6,16 @@ import {ComponentSearchStore} from "../../stores/ComponentSearchStore"; const localizationPath = `${Properties.module.id}.CraftingSystemManagerApp.tabs.components`; + const { selectedCraftingSystem, - craftingComponents, + components, localization, - craftingComponentEditor, - selectedComponent, - loading + componentEditor, + selectedComponent } = getContext(key); - const componentSearchResults = new ComponentSearchStore({availableComponents: craftingComponents}); + const componentSearchResults = new ComponentSearchStore({ components }); const searchTerms = componentSearchResults.searchTerms; function clearSearch() { @@ -23,9 +23,7 @@ } async function importComponent(event) { - $loading = true; - await craftingComponentEditor.importComponent(event, $selectedCraftingSystem); - $loading = false; + await componentEditor.importComponent(event, $selectedCraftingSystem); } function selectComponent(component) { @@ -33,15 +31,12 @@ } async function deleteComponent(event, component) { - $loading = true; - await craftingComponentEditor.deleteComponent(event, component, $selectedCraftingSystem); - $loading = false; + await componentEditor.deleteComponent(event, component, $selectedCraftingSystem); } async function disableComponent(component) { component.isDisabled = true; - $loading = true; - await craftingComponentEditor.saveComponent(component, $selectedCraftingSystem); + await componentEditor.saveComponent(component, $selectedCraftingSystem); const message = localization.format( `${localizationPath}.component.disabled`, { @@ -49,13 +44,11 @@ } ); ui.notifications.info(message); - $loading = false; } async function enableComponent(component) { component.isDisabled = false; - $loading = true; - await craftingComponentEditor.saveComponent(component, $selectedCraftingSystem); + await componentEditor.saveComponent(component, $selectedCraftingSystem); const message = localization.format( `${localizationPath}.component.enabled`, { @@ -63,7 +56,6 @@ } ); ui.notifications.info(message); - $loading = false; } async function toggleComponentDisabled(component) { @@ -71,13 +63,11 @@ } async function duplicateComponent(component) { - $loading = true; - await craftingComponentEditor.duplicateComponent(component, $selectedCraftingSystem); - $loading = false; + await componentEditor.duplicateComponent(component); } async function openItemSheet(component) { - const document = await new DefaultDocumentManager().getDocumentByUuid(component.itemUuid); + const document = await new DefaultDocumentManager().loadItemDataByDocumentUuid(component.itemUuid); await document.sourceDocument.sheet.render(true); } @@ -85,18 +75,18 @@ {#if $selectedCraftingSystem}
- + An Artificer's workbench
- {#if !$selectedCraftingSystem.isLocked} + {#if !$selectedCraftingSystem.isEmbedded}
-

{localization.format(`${localizationPath}.addNew`, { systemName: $selectedCraftingSystem?.name })}

+

{localization.format(`${localizationPath}.addNew`, { systemName: $selectedCraftingSystem?.details.name })}

{/if}
-

{localization.format(`${localizationPath}.search.title`, { systemName: $selectedCraftingSystem?.name })}

+

{localization.format(`${localizationPath}.search.title`, { systemName: $selectedCraftingSystem?.details.name })}

- {#if $craftingComponents.length > 0} + {#if $components.length > 0}
{#each $componentSearchResults as component}
-
-

{component.name}

- {#if component.hasErrors}{/if} -
-
- {#if !component.hasErrors} -
- {component.name} + {#await component.load()} + {:then nothing} +
+

{component.name}

+
+
+
+ {component.name} +
+ {#if !$selectedCraftingSystem.isEmbedded} +
+ + + + +
+ {/if}
- {:else} -
- {component.name} + {:catch error} +
+

{component.name}

+
- {/if} - {#if !$selectedCraftingSystem.isLocked} -
- - - - +
+
+ {component.name} +
+ {#if !$selectedCraftingSystem.isEmbedded} +
+ + + + +
+ {/if}
- {/if} -
+ {/await}
{/each}
@@ -151,7 +155,7 @@ {:else}

{localization.localize(`${localizationPath}.noneFound`)}

- {#if !$selectedCraftingSystem.isLocked}

{localization.localize(`${localizationPath}.create`)}

{/if} + {#if !$selectedCraftingSystem.isEmbedded}

{localization.localize(`${localizationPath}.create`)}

{/if}
{/if}
diff --git a/src/applications/craftingSystemManagerApp/componentManager/CraftingComponentEditor.ts b/src/applications/craftingSystemManagerApp/componentManager/CraftingComponentEditor.ts new file mode 100644 index 00000000..efebf2c5 --- /dev/null +++ b/src/applications/craftingSystemManagerApp/componentManager/CraftingComponentEditor.ts @@ -0,0 +1,159 @@ +import {DropEventParser} from "../../common/DropEventParser"; +import {DefaultDocumentManager, FabricateItemData} from "../../../scripts/foundry/DocumentManager"; +import Properties from "../../../scripts/Properties"; +import {Component} from "../../../scripts/crafting/component/Component"; +import {LocalizationService} from "../../common/LocalizationService"; +import {CraftingSystem} from "../../../scripts/system/CraftingSystem"; +import {FabricateAPI} from "../../../scripts/api/FabricateAPI"; +import {ComponentsStore} from "../../stores/ComponentsStore"; + +class CraftingComponentEditor { + + private readonly _fabricateAPI: FabricateAPI; + private readonly _components: ComponentsStore; + private readonly _localization: LocalizationService; + private readonly _localizationPath = `${Properties.module.id}.CraftingSystemManagerApp.tabs.components`; + + constructor({ + localization, + fabricateAPI, + components + }: { + localization: LocalizationService; + fabricateAPI: FabricateAPI; + components: ComponentsStore; + }) { + this._localization = localization; + this._fabricateAPI = fabricateAPI; + this._components = components; + } + + public async importComponent(event: any, selectedSystem: CraftingSystem) { + const dropEventParser = new DropEventParser({ + localizationService: this._localization, + documentManager: new DefaultDocumentManager(), + partType: this._localization.localize(`${Properties.module.id}.typeNames.component.singular`) + }) + const dropData = await dropEventParser.parse(event); + if (!dropData.hasItemData) { + return; + } + await this.createComponent(dropData.itemData, selectedSystem); + } + + public async createComponent(itemData: FabricateItemData, selectedSystem: CraftingSystem): Promise { + const component = await this._fabricateAPI.components.create({ + craftingSystemId: selectedSystem.id, + itemUuid: itemData.uuid + }); + this._components.insert(component); + return component; + } + + public async deleteComponent(event: any, component: Component, selectedSystem: CraftingSystem): Promise { + let doDelete; + if (event.shiftKey) { + doDelete = true; + } else { + doDelete = await Dialog.confirm({ + title: this._localization.format( + `${this._localizationPath}.prompts.delete.title`, + { + componentName: component.name + } + ), + content: this._localization.format( + `${this._localizationPath}.prompts.delete.content`, + { + componentName: component.name, + systemName: selectedSystem.details.name + } + ) + }); + } + if (!doDelete) { + return undefined; + } + const deletedComponent = await this._fabricateAPI.components.deleteById(component.id); + this._components.remove(deletedComponent); + } + + public async saveComponent(craftingComponent: Component): Promise { + const savedComponent = await this._fabricateAPI.components.save(craftingComponent); + this._components.insert(savedComponent); + return savedComponent; + } + + public async duplicateComponent(craftingComponent: Component): Promise { + const duplicatedComponent = await this._fabricateAPI.components.cloneById(craftingComponent.id); + this._components.insert(duplicatedComponent); + return duplicatedComponent; + } + + public async replaceItem(event: any, selectedComponent: Component): Promise { + const dropEventParser = new DropEventParser({ + localizationService: this._localization, + documentManager: new DefaultDocumentManager(), + partType: this._localization.localize(`${Properties.module.id}.typeNames.component.singular`) + }) + const dropData = await dropEventParser.parse(event); + selectedComponent.itemData = dropData.itemData; + const updatedComponent = await this._fabricateAPI.components.save(selectedComponent); + this._components.insert(updatedComponent); + return updatedComponent; + } + + public async addSalvageOptionComponentAsCatalyst(event: any, selectedComponent: Component): Promise { + const component = await this.getComponentFromDropEvent(event); + selectedComponent.setSalvageOption({ + name: this.generateOptionName(selectedComponent), + catalysts: { [ component.id ]: 1 }, + results: {} + }); + await this.saveComponent(selectedComponent); + return selectedComponent; + } + + public async addSalvageOptionComponentAsSalvageResult(event: any, selectedComponent: Component): Promise { + const component = await this.getComponentFromDropEvent(event); + selectedComponent.setSalvageOption({ + name: this.generateOptionName(selectedComponent), + catalysts: {}, + results: { [ component.id ]: 1 } + }); + await this.saveComponent(selectedComponent); + return selectedComponent; + } + + // todo: prompt to import unknown items as components + private async getComponentFromDropEvent(event: any): Promise { + const dropEventParser = new DropEventParser({ + localizationService: this._localization, + documentManager: new DefaultDocumentManager(), + partType: this._localization.localize(`${Properties.module.id}.typeNames.component.singular`), + allowedCraftingComponents: this._components.get(), + }); + const component = (await dropEventParser.parse(event)).component; + if (!component) { + throw new Error("No component found in drop data."); + } + return component; + } + + private generateOptionName(component: Component) { + if (!component.isSalvageable) { + return this._localization.format(`${Properties.module.id}.typeNames.component.salvageOption.name`, { number: 1 }); + } + const existingNames = component.salvageOptions.all.map(salvageOption => salvageOption.name); + let nextOptionNumber = 2; + let nextOptionName; + do { + nextOptionName = this._localization.format(`${Properties.module.id}.typeNames.component.salvageOption.name`, { number: nextOptionNumber }); + nextOptionNumber++; + } while (existingNames.includes(nextOptionName)); + return nextOptionName; + } + +} + +export { CraftingComponentEditor } \ No newline at end of file diff --git a/src/applications/craftingSystemManagerApp/componentManager/CraftingComponentManager.ts b/src/applications/craftingSystemManagerApp/componentManager/CraftingComponentManager.ts deleted file mode 100644 index dbf51bd4..00000000 --- a/src/applications/craftingSystemManagerApp/componentManager/CraftingComponentManager.ts +++ /dev/null @@ -1,175 +0,0 @@ -import {CraftingSystemEditor} from "../CraftingSystemEditor"; -import {DropEventParser} from "../../common/DropEventParser"; -import {DefaultDocumentManager, FabricateItemData} from "../../../scripts/foundry/DocumentManager"; -import Properties from "../../../scripts/Properties"; -import {CraftingComponent} from "../../../scripts/common/CraftingComponent"; -import {LocalizationService} from "../../common/LocalizationService"; -import {CraftingSystem} from "../../../scripts/system/CraftingSystem"; - -class CraftingComponentManager { - - private readonly _craftingSystemEditor: CraftingSystemEditor; - private readonly _localization: LocalizationService; - private readonly _localizationPath = `${Properties.module.id}.CraftingSystemManagerApp.tabs.components`; - - constructor({ - craftingSystemEditor, - localization - }: { - craftingSystemEditor: CraftingSystemEditor; - localization: LocalizationService; - }) { - this._craftingSystemEditor = craftingSystemEditor; - this._localization = localization; - } - - public async importComponent(event: any, selectedSystem: CraftingSystem) { - const dropEventParser = new DropEventParser({ - localizationService: this._localization, - documentManager: new DefaultDocumentManager(), - partType: this._localization.localize(`${Properties.module.id}.typeNames.component.singular`) - }) - const dropData = await dropEventParser.parse(event); - if (!dropData.hasItemData) { - return; - } - await this.createComponent(dropData.itemData, selectedSystem); - } - - public async createComponent(itemData: FabricateItemData, selectedSystem: CraftingSystem): Promise { - if (selectedSystem.includesComponentByItemUuid(itemData.uuid)) { - const existingComponent = selectedSystem.getComponentByItemUuid(itemData.uuid); - const message = this._localization.format( - `${this._localizationPath}.errors.import.itemAlreadyIncluded`, - { - itemUuid: itemData.uuid, - componentName: existingComponent.name, - systemName: selectedSystem.name - } - ); - ui.notifications.warn(message); - return; - } - const craftingComponent = new CraftingComponent({ - id: randomID(), - itemData: itemData - }); - selectedSystem.editComponent(craftingComponent); - await this._craftingSystemEditor.saveCraftingSystem(selectedSystem); - const message = this._localization.format( - `${this._localizationPath}.component.imported`, - { - componentName: craftingComponent.name, - systemName: selectedSystem.name - } - ); - ui.notifications.info(message); - return craftingComponent; - } - - public async deleteComponent(event: any, component: CraftingComponent, selectedSystem: CraftingSystem) { - let doDelete; - if (event.shiftKey) { - doDelete = true; - } else { - doDelete = await Dialog.confirm({ - title: this._localization.format( - `${this._localizationPath}.prompts.delete.title`, - { - componentName: component.name - } - ), - content: this._localization.format( - `${this._localizationPath}.prompts.delete.content`, - { - componentName: component.name, - systemName: selectedSystem.name - } - ) - }); - } - if (!doDelete) { - return; - } - selectedSystem.deleteComponentById(component.id); - await this._craftingSystemEditor.saveCraftingSystem(selectedSystem); - const message = this._localization.format( - `${this._localizationPath}.component.deleted`, - { - componentName: component.name, - systemName: selectedSystem.name - } - ); - ui.notifications.info(message); - } - - public async saveComponent(craftingComponent: CraftingComponent, selectedSystem: CraftingSystem) { - if (this.validateOptionNames(craftingComponent)) { - selectedSystem.editComponent(craftingComponent); - await this._craftingSystemEditor.saveCraftingSystem(selectedSystem); - return; - } - const message = this._localization.format(`${this._localizationPath}.component.errors.optionNotUnique`, { componentName: craftingComponent.name }); - ui.notifications.error(message); - } - - private validateOptionNames(component: CraftingComponent) { - let valid = true; - component.salvageOptions - .map(salvageOption => salvageOption.name) - .forEach((value, index, array) => { - if (array.indexOf(value) !== index) { - valid = false; - console.error(`The salvage option name ${value} is not unique.`); - } - }); - return valid; - } - - public async duplicateComponent(craftingComponent: CraftingComponent, selectedSystem: CraftingSystem): Promise { - const clonedComponent = craftingComponent.clone(randomID()); - selectedSystem.editComponent(clonedComponent); - await this._craftingSystemEditor.saveCraftingSystem(selectedSystem); - return clonedComponent; - } - - public async replaceItem(event: any, selectedSystem: CraftingSystem, selectedComponent: CraftingComponent) { - const dropEventParser = new DropEventParser({ - localizationService: this._localization, - documentManager: new DefaultDocumentManager(), - partType: this._localization.localize(`${Properties.module.id}.typeNames.component.singular`) - }) - const dropData = await dropEventParser.parse(event); - const itemData = dropData.itemData; - if (selectedSystem.includesComponentByItemUuid(itemData.uuid)) { - const existingComponent = selectedSystem.getComponentByItemUuid(itemData.uuid); - const message = this._localization.format( - `${this._localizationPath}.errors.import.itemAlreadyIncluded`, - { - itemUuid: itemData.uuid, - componentName: existingComponent.name, - systemName: selectedSystem.name - } - ); - ui.notifications.error(message); - return; - } - const previousItemName = selectedComponent.name; - selectedComponent.itemData = itemData; - selectedSystem.editComponent(selectedComponent); - await this._craftingSystemEditor.saveCraftingSystem(selectedSystem); - const message = this._localization.format( - `${this._localizationPath}.component.replaced`, - { - previousItemName, - itemName: selectedComponent.name, - systemName: selectedSystem.name - } - ); - ui.notifications.info(message); - return; - } - -} - -export { CraftingComponentManager } \ No newline at end of file diff --git a/src/applications/craftingSystemManagerApp/detailsManager/CraftingSystemDetails.svelte b/src/applications/craftingSystemManagerApp/detailsManager/CraftingSystemDetails.svelte index b838b7b3..9b157a5f 100644 --- a/src/applications/craftingSystemManagerApp/detailsManager/CraftingSystemDetails.svelte +++ b/src/applications/craftingSystemManagerApp/detailsManager/CraftingSystemDetails.svelte @@ -8,17 +8,14 @@ const { selectedCraftingSystem, craftingSystemEditor, - localization, - loading + localization } = getContext(key); let scheduledSave; function scheduleSave() { clearTimeout(scheduledSave); scheduledSave = setTimeout(async () => { - $loading = true; await craftingSystemEditor.saveCraftingSystem($selectedCraftingSystem); - $loading = false; }, 1000); } @@ -29,36 +26,36 @@
-

{localization.format(`${localizationPath}.title`, { systemName: $selectedCraftingSystem?.name })}

+

{localization.format(`${localizationPath}.title`, { systemName: $selectedCraftingSystem?.details.name })}

{localization.localize(`${localizationPath}.labels.name`)}:

- {#if !$selectedCraftingSystem?.isLocked} + {#if !$selectedCraftingSystem?.isEmbedded}
- {$selectedCraftingSystem.name} + {$selectedCraftingSystem.details.name}
{:else} -
{$selectedCraftingSystem.name}
+
{$selectedCraftingSystem.details.name}
{/if}

{localization.localize(`${localizationPath}.labels.author`)}:

- {#if !$selectedCraftingSystem?.isLocked} + {#if !$selectedCraftingSystem?.isEmbedded}
- {$selectedCraftingSystem.author} + {$selectedCraftingSystem.details.author}
{:else} -
{$selectedCraftingSystem.author}
+
{$selectedCraftingSystem.details.author}
{/if}
@@ -67,42 +64,42 @@

{localization.localize(`${localizationPath}.labels.summary`)}:

- {#if !$selectedCraftingSystem?.isLocked} + {#if !$selectedCraftingSystem?.isEmbedded}
- {$selectedCraftingSystem.summary} + {$selectedCraftingSystem.details.summary}
{:else} -
{$selectedCraftingSystem.summary}
+
{$selectedCraftingSystem.details.summary}
{/if}

{localization.localize(`${localizationPath}.labels.description`)}:

- {#if !$selectedCraftingSystem?.isLocked} + {#if !$selectedCraftingSystem?.isEmbedded}
- {$selectedCraftingSystem.description} + {$selectedCraftingSystem.details.description}
{:else} -
{$selectedCraftingSystem.description}
+
{$selectedCraftingSystem.details.description}
{/if}

{localization.localize(`${localizationPath}.settings.title`)}

-

{localization.localize(`${localizationPath}.settings.enabled.label`)}:

+

{localization.localize(`${localizationPath}.settings.disabled.label`)}:

-

{localization.localize(`${localizationPath}.settings.enabled.description`)}

+

{localization.localize(`${localizationPath}.settings.disabled.description`)}

\ No newline at end of file diff --git a/src/applications/craftingSystemManagerApp/essenceManager/EssenceEditor.ts b/src/applications/craftingSystemManagerApp/essenceManager/EssenceEditor.ts new file mode 100644 index 00000000..79e5766b --- /dev/null +++ b/src/applications/craftingSystemManagerApp/essenceManager/EssenceEditor.ts @@ -0,0 +1,96 @@ +import {LocalizationService} from "../../common/LocalizationService"; +import {Essence} from "../../../scripts/crafting/essence/Essence"; +import {CraftingSystem} from "../../../scripts/system/CraftingSystem"; +import {DropEventParser} from "../../common/DropEventParser"; +import {DefaultDocumentManager} from "../../../scripts/foundry/DocumentManager"; +import Properties from "../../../scripts/Properties"; +import {FabricateAPI} from "../../../scripts/api/FabricateAPI"; +import {EssencesStore} from "../../stores/EssenceStore"; + +class EssenceEditor { + + private readonly _essences: EssencesStore; + private readonly _fabricateAPI: FabricateAPI; + private readonly _localization: LocalizationService; + + private readonly _localizationPath = `${Properties.module.id}.CraftingSystemManagerApp.tabs.essences`; + + constructor({ + essences, + fabricateAPI, + localization, + }: { + essences: EssencesStore; + fabricateAPI: FabricateAPI; + localization: LocalizationService; + }) { + this._essences = essences; + this._fabricateAPI = fabricateAPI; + this._localization = localization; + } + + public async create(selectedCraftingSystem: CraftingSystem) { + const essence = await this._fabricateAPI.essences.create({ + craftingSystemId: selectedCraftingSystem.id + }); + this._essences.insert(essence); + } + + public async deleteEssence(event: any, essence: Essence, selectedCraftingSystem: CraftingSystem) { + let doDelete; + if (event.shiftKey) { + doDelete = true; + } else { + doDelete = await Dialog.confirm({ + title: this._localization.format( + `${this._localizationPath}.prompts.delete.title`, + { + essenceName: essence.name + } + ), + content: this._localization.format( + `${this._localizationPath}.prompts.delete.content`, + { + essenceName: essence.name, + systemName: selectedCraftingSystem.details.name + } + ) + }); + } + if (!doDelete) { + return; + } + await this._fabricateAPI.essences.deleteById(essence.id); + this._essences.remove(essence); + } + + public async setActiveEffectSource(event: any, essence: Essence) { + const dropEventParser = new DropEventParser({ + localizationService: this._localization, + documentManager: new DefaultDocumentManager(), + partType: this._localization.localize(`${Properties.module.id}.typeNames.activeEffectSource.singular`) + }) + const dropData = await dropEventParser.parse(event); + essence.activeEffectSource = dropData.itemData; + await this._fabricateAPI.essences.save(essence); + this._essences.insert(essence); + return essence; + } + + public async removeActiveEffectSource(essence: Essence) { + essence.activeEffectSource = null; + await this._fabricateAPI.essences.save(essence); + this._essences.insert(essence); + return essence; + } + + public async setIconCode(essence: Essence, iconCode: string) { + essence.iconCode = iconCode; + await this._fabricateAPI.essences.save(essence); + this._essences.insert(essence); + return essence; + } + +} + +export { EssenceEditor } \ No newline at end of file diff --git a/src/applications/craftingSystemManagerApp/essenceManager/EssenceEditor.svelte b/src/applications/craftingSystemManagerApp/essenceManager/EssenceEditorComponent.svelte similarity index 75% rename from src/applications/craftingSystemManagerApp/essenceManager/EssenceEditor.svelte rename to src/applications/craftingSystemManagerApp/essenceManager/EssenceEditorComponent.svelte index 3065675b..df0ef197 100644 --- a/src/applications/craftingSystemManagerApp/essenceManager/EssenceEditor.svelte +++ b/src/applications/craftingSystemManagerApp/essenceManager/EssenceEditorComponent.svelte @@ -1,74 +1,52 @@ - + {#if $selectedRecipe}
- + A crafting recipe book
@@ -270,73 +274,130 @@
-

{localization.localize(`${localizationPath}.recipe.labels.requirementsHeading`)}

+

{localization.localize(`${localizationPath}.recipe.labels.ingredientsHeading`)}

- {#if $selectedRecipe.hasIngredients} - + {#if $selectedRecipe.hasRequirements} + - {#each $selectedRecipe.ingredientOptions as ingredientOption} + {#each $selectedRecipe.requirementOptions.all as ingredientOption} {ingredientOption.name} {/each} {localization.localize(`${localizationPath}.recipe.labels.newIngredientOption`)} - - {#each $selectedRecipe.ingredientOptions as ingredientOption} + {#each $selectedRecipe.requirementOptions.all as requirementOption}

{localization.localize(`${localizationPath}.recipe.labels.ingredientOptionName`)}

-
{ingredientOption.name}
+
{requirementOption.name}
- +

{localization.localize(`${localizationPath}.recipe.labels.ingredientsHeading`)}

- {#if ingredientOption.requiresIngredients} -
addComponentToIngredientOption(e, ingredientOption, false)}> - {#each ingredientOption.ingredients.units as ingredientUnit} -
incrementIngredientOptionComponent(ingredientOption, ingredientUnit.part, e, false)} on:auxclick={decrementIngredientOptionComponent(ingredientOption, ingredientUnit.part, false)}> -
-

{truncate(ingredientUnit.part.name, 9)}

-
-
-
- {ingredientUnit.part.name} - {#if ingredientUnit.quantity > 1} - {ingredientUnit.quantity} - {/if} -
-
-
- {/each} -
+ {#if requirementOption.requiresIngredients} +
addComponentToRequirementOption(e, requirementOption, false)}> + {#each dereferenceComponentCombination(requirementOption.ingredients) as ingredientUnit} + {#await ingredientUnit.element.load()} + {:then nothing} +
incrementRequirementOptionComponent(requirementOption, ingredientUnit.element, e, false)} + on:auxclick={decrementRequirementOptionComponent(requirementOption, ingredientUnit.element, false)}> +
+

{truncate(ingredientUnit.element.name, 9)}

+
+
+
+ {ingredientUnit.element.name} + {#if ingredientUnit.quantity > 1} + {ingredientUnit.quantity} + {/if} +
+
+
+ {:catch error} + {/await} + {/each} +
{:else} -
addComponentToIngredientOption(e, ingredientOption, false)}> +
addComponentToRequirementOption(e, requirementOption, false)}>
{/if}

{localization.localize(`${localizationPath}.recipe.labels.catalystsHeading`)}

- {#if ingredientOption.requiresCatalysts} -
addComponentToIngredientOption(e, ingredientOption, true)}> - {#each ingredientOption.catalysts.units as catalystUnit} -
incrementIngredientOptionComponent(ingredientOption, catalystUnit.part, e, true)} on:auxclick={decrementIngredientOptionComponent(ingredientOption, catalystUnit.part, true)}> -
-

{truncate(catalystUnit.part.name, 9)}

-
-
-
- {catalystUnit.part.name} - {#if catalystUnit.quantity > 1} - {catalystUnit.quantity} - {/if} + {#if requirementOption.requiresCatalysts} +
addComponentToRequirementOption(e, requirementOption, true)}> + {#each dereferenceComponentCombination(requirementOption.catalysts) as catalystUnit} + {#await catalystUnit.element.load()} + {:then nothing} +
incrementRequirementOptionComponent(requirementOption, catalystUnit.element, e, true)} on:auxclick={decrementRequirementOptionComponent(requirementOption, catalystUnit.element, true)}> +
+

{truncate(catalystUnit.element.name, 9)}

+
+
+
+ {catalystUnit.element.name} + {#if catalystUnit.quantity > 1} + {catalystUnit.quantity} + {/if} +
+
+ {:catch error} + {/await} + {/each} +
+ {:else} +
addComponentToRequirementOption(e, requirementOption, true)}> + +
+ {/if} +

{localization.localize(`${localizationPath}.recipe.labels.essencesHeading`)}

+ {#if requirementOption.requiresEssences} +
+ {#each $requirementOptionEssences.get(requirementOption.id) as essenceUnit} +
+ +
+ + {essenceUnit.quantity} + + + + + + {essenceUnit.element.name} +
+
{/each}
{:else} -
addComponentToIngredientOption(e, ingredientOption, true)}> - +
+
+ {#each $essences as essence} +
+ +
+ 0 + + + + + {essence.name} + +
+
+ {/each} +
{/if}
@@ -348,29 +409,81 @@

{localization.localize(`${localizationPath}.recipe.labels.ingredientsHeading`)}

-
addIngredientOption(e, false)}> +
addRequirementOption(e, false)}>

{localization.localize(`${localizationPath}.recipe.labels.catalystsHeading`)}

-
addIngredientOption(e, true)}> +
addRequirementOption(e, true)}>
+
+

{localization.localize(`${localizationPath}.recipe.labels.essencesHeading`)}

+
+
+
+ {#each $essences as essence} +
+ +
+ 0 + + + + + {essence.name} + +
+
+ {/each} +
+
{:else} -
addIngredientOption(e, false)}> +
addRequirementOption(e, false)}>

{localization.localize(`${localizationPath}.recipe.labels.catalystsHeading`)}

-
addIngredientOption(e, true)}> +
addRequirementOption(e, true)}>
+ {#if $essences.length > 0} +
+

{localization.localize(`${localizationPath}.recipe.labels.essencesHeading`)}

+
+
+
+ {#each $essences as essence} +
+ +
+ 0 + + + + + {essence.name} + +
+
+ {/each} +
+
+ {:else} +

{localization.format(`${localizationPath}.recipe.info.noAvailableEssences`, { systemName: $selectedCraftingSystem.details.name, recipeName: selectedRecipe.name })}

+ {/if} {/if}
@@ -388,58 +501,30 @@ {#if $componentSearchResults.length > 0}
{#each $componentSearchResults as component} -
dragStart(event, component)}> -
-

{truncate(component.name, 9)}

-
-
-
- {component.name} + {#await component.load()} + {:then nothing} +
dragStart(event, component)}> +
+

{truncate(component.name, 9)}

+
+
+
+ {component.name} +
+
-
-
+ {:catch error} + {/await} {/each}
{:else if $searchTerms.name}

{localization.localize(`${localizationPath}.recipe.info.noMatchingComponents`)}

{:else} -

{localization.format(`${localizationPath}.recipe.info.noAvailableComponents`, { systemName: $selectedCraftingSystem.name, recipeName: $selectedRecipe.name })}

+

{localization.format(`${localizationPath}.recipe.info.noAvailableComponents`, { systemName: $selectedCraftingSystem.details.name, recipeName: $selectedRecipe.name })}

{/if}
-
-
-
-

{localization.localize(`${localizationPath}.recipe.labels.essencesHeading`)}

-
-
-
- {#if $selectedCraftingSystem.hasEssences} - {#each $recipeEssences as essenceUnit} -
- -
- - {essenceUnit.quantity} - - - - - - {essenceUnit.part.name} - -
- -
- {/each} - {:else} -

{localization.format(`${localizationPath}.recipe.info.noAvailableEssences`, { systemName: $selectedCraftingSystem.name, recipeName: selectedRecipe.name })}

- {/if} -
-
-
-
@@ -451,38 +536,52 @@

{localization.localize(`${localizationPath}.recipe.labels.resultsHeading`)}

{#if $selectedRecipe.hasResults} - + - {#each $selectedRecipe.resultOptions as resultOption} + {#each $selectedRecipe.resultOptions.all as resultOption} {resultOption.name} {/each} {localization.localize(`${localizationPath}.recipe.labels.newResultOption`)} - {#each $selectedRecipe.resultOptions as resultOption} + {#each $selectedRecipe.resultOptions.all as resultOption}

{localization.localize(`${localizationPath}.recipe.labels.resultOptionName`)}

-
{resultOption.name}
+
+ {resultOption.name} +
- +
-
addComponentToResultOption(e, resultOption)}> - {#each resultOption.results.units as resultUnit} -
incrementResultOptionComponent(resultOption, resultUnit.part, e)} on:auxclick={decrementResultOptionComponent(resultOption, resultUnit.part)}> -
-

{truncate(resultUnit.part.name, 9)}

-
-
-
- {resultUnit.part.name} - {#if resultUnit.quantity > 1} - {resultUnit.quantity} - {/if} +
addComponentToResultOption(e, resultOption)}> + {#each dereferenceComponentCombination(resultOption.results) as resultUnit} + {#await resultUnit.element.load()} + {:then nothing} +
incrementResultOptionComponent(resultOption, resultUnit.element, e)} + on:auxclick={decrementResultOptionComponent(resultOption, resultUnit.element)}> +
+

{truncate(resultUnit.element.name, 9)}

+
+
+
+ {resultUnit.element.name} + {#if resultUnit.quantity > 1} + {resultUnit.quantity} + {/if} +
+
-
-
+ {:catch error} + {/await} {/each}
@@ -530,7 +629,7 @@ {:else if $searchTerms.name}

{localization.localize(`${localizationPath}.recipe.info.noMatchingComponents`)}

{:else} -

{localization.format(`${localizationPath}.recipe.info.noAvailableComponents`, { systemName: $selectedCraftingSystem.name, recipeName: $selectedRecipe.name })}

+

{localization.format(`${localizationPath}.recipe.info.noAvailableComponents`, { systemName: $selectedCraftingSystem.details.name, recipeName: $selectedRecipe.name })}

{/if}
diff --git a/src/applications/craftingSystemManagerApp/recipeManager/RecipeEditor.ts b/src/applications/craftingSystemManagerApp/recipeManager/RecipeEditor.ts new file mode 100644 index 00000000..a8b33944 --- /dev/null +++ b/src/applications/craftingSystemManagerApp/recipeManager/RecipeEditor.ts @@ -0,0 +1,188 @@ +import {DropEventParser} from "../../common/DropEventParser"; +import {DefaultDocumentManager, FabricateItemData} from "../../../scripts/foundry/DocumentManager"; +import Properties from "../../../scripts/Properties"; +import {LocalizationService} from "../../common/LocalizationService"; +import {CraftingSystem} from "../../../scripts/system/CraftingSystem"; +import {Recipe} from "../../../scripts/crafting/recipe/Recipe"; +import {FabricateAPI} from "../../../scripts/api/FabricateAPI"; +import {RecipesStore} from "../../stores/RecipesStore"; +import {ComponentsStore} from "../../stores/ComponentsStore"; +import {RequirementOption} from "../../../scripts/crafting/recipe/RequirementOption"; +import {ResultOption} from "../../../scripts/crafting/recipe/ResultOption"; +import {Essence} from "../../../scripts/crafting/essence/Essence"; + +class RecipeEditor { + + private readonly _recipes: RecipesStore; + private readonly _components: ComponentsStore; + private readonly _fabricateAPI: FabricateAPI; + private readonly _localization: LocalizationService; + + private readonly _localizationPath = `${Properties.module.id}.CraftingSystemManagerApp.tabs.recipes`; + + constructor({ + recipes, + components, + fabricateAPI, + localization, + }: { + recipes: RecipesStore; + components: ComponentsStore; + fabricateAPI: FabricateAPI; + localization: LocalizationService; + }) { + this._recipes = recipes; + this._components = components; + this._fabricateAPI = fabricateAPI; + this._localization = localization; + } + + public async importRecipe(event: any, selectedSystem: CraftingSystem) { + const dropEventParser = new DropEventParser({ + localizationService: this._localization, + documentManager: new DefaultDocumentManager(), + partType: this._localization.localize(`${Properties.module.id}.typeNames.recipe.singular`) + }) + const dropData = await dropEventParser.parse(event); + if (!dropData.hasItemData) { + return; + } + await this.createRecipe(dropData.itemData, selectedSystem); + } + + public async createRecipe(itemData: FabricateItemData, selectedSystem: CraftingSystem): Promise { + const recipe = await this._fabricateAPI.recipes.create({ + craftingSystemId: selectedSystem.id, + itemUuid: itemData.uuid + }); + this._recipes.insert(recipe); + return recipe; + } + + public async deleteRecipe(event: any, recipe: Recipe, selectedSystem: CraftingSystem) { + let doDelete; + if (event.shiftKey) { + doDelete = true; + } else { + doDelete = await Dialog.confirm({ + title: this._localization.format( + `${this._localizationPath}.prompts.delete.title`, + { + recipeName: recipe.name + } + ), + content: this._localization.format( + `${this._localizationPath}.prompts.delete.content`, + { + recipeName: recipe.name, + systemName: selectedSystem.details.name + } + ) + }); + } + if (!doDelete) { + return; + } + const deletedRecipe = await this._fabricateAPI.recipes.deleteById(recipe.id); + this._recipes.remove(deletedRecipe); + } + + public async saveRecipe(recipe: Recipe) { + const savedRecipe = await this._fabricateAPI.recipes.save(recipe); + this._recipes.insert(savedRecipe); + return savedRecipe; + } + + public async duplicateRecipe(recipe: Recipe): Promise { + const duplicatedRecipe = await this._fabricateAPI.recipes.cloneById(recipe.id); + this._recipes.insert(duplicatedRecipe); + return duplicatedRecipe; + } + + public async replaceItem(event: any, selectedRecipe: Recipe) { + const dropEventParser = new DropEventParser({ + localizationService: this._localization, + documentManager: new DefaultDocumentManager(), + partType: this._localization.localize(`${Properties.module.id}.typeNames.recipe.singular`) + }) + const dropData = await dropEventParser.parse(event); + selectedRecipe.itemData = dropData.itemData; + return this.saveRecipe(selectedRecipe); + } + + public async addRequirementOptionComponentAsCatalyst(event: any, selectedRecipe: Recipe) { + const component = await this.getComponentFromDropEvent(event); + selectedRecipe.setRequirementOption({ + name: this.generateOptionName(selectedRecipe.requirementOptions.all.map(requirementOption => requirementOption.name)), + catalysts: { [ component.id ]: 1 } + }); + await this.saveRecipe(selectedRecipe); + } + + public async addRequirementOptionComponentAsIngredient(event: any, selectedRecipe: Recipe) { + const component = await this.getComponentFromDropEvent(event); + selectedRecipe.setRequirementOption({ + name: this.generateOptionName(selectedRecipe.requirementOptions.all.map(requirementOption => requirementOption.name)), + ingredients: { [ component.id ]: 1 } + }); + await this.saveRecipe(selectedRecipe); + } + + public async addResultOptionComponent(event: any, selectedRecipe: Recipe) { + const component = await this.getComponentFromDropEvent(event); + selectedRecipe.setResultOption({ + name: this.generateOptionName(selectedRecipe.resultOptions.all.map(resultOption => resultOption.name)), + results: { [ component.id ]: 1 } + }); + await this.saveRecipe(selectedRecipe); + } + + // todo: prompt to import unknown items as components + private async getComponentFromDropEvent(event: any, rejectUnknownItems: boolean = true) { + const dropEventParser = new DropEventParser({ + strict: rejectUnknownItems, + localizationService: this._localization, + documentManager: new DefaultDocumentManager(), + partType: this._localization.localize(`${Properties.module.id}.typeNames.component.singular`), + allowedCraftingComponents: this._components.get(), + }); + const component = (await dropEventParser.parse(event)).component; + if (!component) { + throw new Error("No component found in drop data."); + } + return component; + } + + private generateOptionName(existingNames: string[]) { + let nextOptionNumber = 1; + let nextOptionName; + do { + nextOptionName = this._localization.format(`${Properties.module.id}.typeNames.recipe.option.name`, { number: nextOptionNumber }); + nextOptionNumber++; + } while (existingNames.includes(nextOptionName)); + return nextOptionName; + } + + public async deleteRequirementOption(selectedRecipe: Recipe, requirementOption: RequirementOption) { + selectedRecipe.deleteRequirementOptionById(requirementOption.id); + const updatedRecipe = await this._fabricateAPI.recipes.save(selectedRecipe); + this._recipes.insert(updatedRecipe); + return updatedRecipe; + } + + public async deleteResultOption(selectedRecipe: Recipe, resultOption: ResultOption) { + selectedRecipe.deleteResultOptionById(resultOption.id); + await this.saveRecipe(selectedRecipe); + } + + public async createEssenceRequirementOption(selectedRecipe: Recipe, essence: Essence) { + selectedRecipe.setRequirementOption({ + name: this.generateOptionName(selectedRecipe.requirementOptions.all.map(requirementOption => requirementOption.name)), + essences: { [ essence.id ]: 1 } + }); + await this.saveRecipe(selectedRecipe); + } + +} + +export { RecipeEditor } \ No newline at end of file diff --git a/src/applications/craftingSystemManagerApp/recipeManager/RecipeManager.ts b/src/applications/craftingSystemManagerApp/recipeManager/RecipeManager.ts deleted file mode 100644 index 0f5608f1..00000000 --- a/src/applications/craftingSystemManagerApp/recipeManager/RecipeManager.ts +++ /dev/null @@ -1,251 +0,0 @@ -import {CraftingSystemEditor} from "../CraftingSystemEditor"; -import {DropEventParser} from "../../common/DropEventParser"; -import {DefaultDocumentManager} from "../../../scripts/foundry/DocumentManager"; -import Properties from "../../../scripts/Properties"; -import {LocalizationService} from "../../common/LocalizationService"; -import {CraftingSystem} from "../../../scripts/system/CraftingSystem"; -import {RequirementOption, Recipe, ResultOption} from "../../../scripts/common/Recipe"; -import {Combination} from "../../../scripts/common/Combination"; - -class RecipeManager { - - private readonly _craftingSystemEditor: CraftingSystemEditor; - private readonly _localization: LocalizationService; - private readonly _localizationPath = `${Properties.module.id}.CraftingSystemManagerApp.tabs.recipes`; - - constructor({ - craftingSystemEditor, - localization - }: { - craftingSystemEditor: CraftingSystemEditor; - localization: LocalizationService; - }) { - this._craftingSystemEditor = craftingSystemEditor; - this._localization = localization; - } - - public async importRecipe(event: any, selectedSystem: CraftingSystem) { - const dropEventParser = new DropEventParser({ - localizationService: this._localization, - documentManager: new DefaultDocumentManager(), - partType: this._localization.localize(`${Properties.module.id}.typeNames.recipe.singular`) - }) - const dropData = await dropEventParser.parse(event); - if (!dropData.hasItemData) { - return; - } - const itemData = dropData.itemData; - if (selectedSystem.includesRecipeByItemUuid(itemData.uuid)) { - const existingRecipe = selectedSystem.getRecipeByItemUuid(itemData.uuid); - const message = this._localization.format( - `${this._localizationPath}.errors.import.itemAlreadyIncluded`, - { - itemUuid: itemData.uuid, - recipeName: existingRecipe.name, - systemName: selectedSystem.name - } - ); - ui.notifications.warn(message); - return; - } - const recipe = new Recipe({ - id: randomID(), - itemData: itemData - }); - selectedSystem.editRecipe(recipe); - await this._craftingSystemEditor.saveCraftingSystem(selectedSystem); - const message = this._localization.format( - `${this._localizationPath}.recipe.imported`, - { - recipeName: recipe.name, - systemName: selectedSystem.name - } - ); - ui.notifications.info(message); - } - - public async deleteRecipe(event: any, recipe: Recipe, selectedSystem: CraftingSystem) { - let doDelete; - if (event.shiftKey) { - doDelete = true; - } else { - doDelete = await Dialog.confirm({ - title: this._localization.format( - `${this._localizationPath}.prompts.delete.title`, - { - recipeName: recipe.name - } - ), - content: this._localization.format( - `${this._localizationPath}.prompts.delete.content`, - { - recipeName: recipe.name, - systemName: selectedSystem.name - } - ) - }); - } - if (!doDelete) { - return; - } - selectedSystem.deleteRecipeById(recipe.id); - await this._craftingSystemEditor.saveCraftingSystem(selectedSystem); - const message = this._localization.format( - `${this._localizationPath}.recipe.deleted`, - { - recipeName: recipe.name, - systemName: selectedSystem.name - } - ); - ui.notifications.info(message); - } - - public async saveRecipe(recipe: Recipe, selectedSystem: CraftingSystem) { - if (this.validateOptionNames(recipe)) { - selectedSystem.editRecipe(recipe); - await this._craftingSystemEditor.saveCraftingSystem(selectedSystem); - return; - } - const message = this._localization.format(`${this._localizationPath}.recipe.errors.optionNotUnique`, { recipeName: recipe.name }); - ui.notifications.error(message); - } - - private validateOptionNames(recipe: Recipe) { - let valid = true; - recipe.ingredientOptions - .map(ingredientOption => ingredientOption.name) - .forEach((value, index, array) => { - if (array.indexOf(value) !== index) { - valid = false; - console.error(`The ingredient option name ${value} is not unique.`); - } - }); - recipe.resultOptions - .map(ingredientOption => ingredientOption.name) - .forEach((value, index, array) => { - if (array.indexOf(value) !== index) { - valid = false; - console.error(`The result option name ${value} is not unique.`); - } - }); - return valid; - } - - public async duplicateRecipe(recipe: Recipe, selectedSystem: CraftingSystem): Promise { - const clonedRecipe = recipe.clone(randomID()); - selectedSystem.editRecipe(clonedRecipe); - await this._craftingSystemEditor.saveCraftingSystem(selectedSystem); - return clonedRecipe; - } - - public async replaceItem(event: any, selectedSystem: CraftingSystem, selectedRecipe: Recipe) { - const dropEventParser = new DropEventParser({ - localizationService: this._localization, - documentManager: new DefaultDocumentManager(), - partType: this._localization.localize(`${Properties.module.id}.typeNames.recipe.singular`) - }) - const dropData = await dropEventParser.parse(event); - const itemData = dropData.itemData; - if (selectedSystem.includesRecipeByItemUuid(itemData.uuid)) { - const existingRecipe = selectedSystem.getRecipeByItemUuid(itemData.uuid); - const message = this._localization.format( - `${this._localizationPath}.errors.import.itemAlreadyIncluded`, - { - itemUuid: itemData.uuid, - recipeName: existingRecipe.name, - systemName: selectedSystem.name - } - ); - ui.notifications.error(message); - return; - } - const previousItemName = selectedRecipe.name; - selectedRecipe.itemData = itemData; - selectedSystem.editRecipe(selectedRecipe); - await this._craftingSystemEditor.saveCraftingSystem(selectedSystem); - const message = this._localization.format( - `${this._localizationPath}.recipe.replaced`, - { - previousItemName, - itemName: selectedRecipe.name, - systemName: selectedSystem.name - } - ); - ui.notifications.info(message); - return; - } - - public async addIngredientOption(event: any, addAsCatalyst: boolean, selectedRecipe: Recipe, selectedCraftingSystem: CraftingSystem) { - const dropEventParser = new DropEventParser({ - strict: true, - localizationService: this._localization, - documentManager: new DefaultDocumentManager(), - partType: this._localization.localize(`${Properties.module.id}.typeNames.component.singular`), - allowedCraftingComponents: selectedCraftingSystem.craftingComponents - }); - const component = (await dropEventParser.parse(event)).component; - if (!component) { - return; - } - const name = this.generateIngredientOptionName(selectedRecipe); - let ingredientOption; - if (addAsCatalyst) { - ingredientOption = new RequirementOption({name, catalysts: Combination.of(component, 1)}); - } else { - ingredientOption = new RequirementOption({name, ingredients: Combination.of(component, 1)}); - } - selectedRecipe.addIngredientOption(ingredientOption); - selectedCraftingSystem.editRecipe(selectedRecipe); - await this._craftingSystemEditor.saveCraftingSystem(selectedCraftingSystem); - } - - public async addResultOption(event: any, selectedRecipe: Recipe, selectedCraftingSystem: CraftingSystem) { - const dropEventParser = new DropEventParser({ - strict: true, - localizationService: this._localization, - documentManager: new DefaultDocumentManager(), - partType: this._localization.localize(`${Properties.module.id}.typeNames.component.singular`), - allowedCraftingComponents: selectedCraftingSystem.craftingComponents - }); - const component = (await dropEventParser.parse(event)).component; - if (!component) { - return; - } - const name = this.generateResultOptionName(selectedRecipe); - let resultOption = new ResultOption({name, results: Combination.of(component, 1)}); - selectedRecipe.addResultOption(resultOption); - selectedCraftingSystem.editRecipe(selectedRecipe); - await this._craftingSystemEditor.saveCraftingSystem(selectedCraftingSystem); - } - - public generateIngredientOptionName(recipe: Recipe) { - if (!recipe.hasIngredients) { - return this._localization.format(`${Properties.module.id}.typeNames.recipe.ingredientOption.name`, { number: 1 }); - } - const existingNames = recipe.ingredientOptions.map(ingredientOption => ingredientOption.name); - let nextOptionNumber = 2; - let nextOptionName; - do { - nextOptionName = this._localization.format(`${Properties.module.id}.typeNames.recipe.ingredientOption.name`, { number: nextOptionNumber }); - nextOptionNumber++; - } while (existingNames.includes(nextOptionName)); - return nextOptionName; - } - - public generateResultOptionName(recipe: Recipe) { - if (!recipe.hasResults) { - return this._localization.format(`${Properties.module.id}.typeNames.recipe.resultOption.name`, { number: 1 }); - } - const existingNames = recipe.resultOptions.map(ingredientOption => ingredientOption.name); - let nextOptionNumber = 2; - let nextOptionName; - do { - nextOptionName = this._localization.format(`${Properties.module.id}.typeNames.recipe.resultOption.name`, { number: nextOptionNumber }); - nextOptionNumber++; - } while (existingNames.includes(nextOptionName)); - return nextOptionName; - } - -} - -export { RecipeManager } \ No newline at end of file diff --git a/src/applications/recipeCraftingApp/CraftingAttemptCarousel.svelte b/src/applications/recipeCraftingApp/CraftingAttemptCarousel.svelte index d19f7971..0247f309 100644 --- a/src/applications/recipeCraftingApp/CraftingAttemptCarousel.svelte +++ b/src/applications/recipeCraftingApp/CraftingAttemptCarousel.svelte @@ -12,14 +12,13 @@ export let columns; export let craftingAttempt; - export let selectedIngredientOptionName; function selectNextOption() { - dispatch("nextIngredientOptionSelected", {}); + dispatch("nextRequirementOptionSelected", {}); } function selectPreviousOption() { - dispatch("previousIngredientOptionSelected", {}); + dispatch("previousRequirementOptionSelected", {}); } @@ -28,14 +27,14 @@
\ No newline at end of file diff --git a/src/applications/recipeCraftingApp/CraftingAttemptGrid.svelte b/src/applications/recipeCraftingApp/CraftingAttemptGrid.svelte index 10450cfa..afef6db0 100644 --- a/src/applications/recipeCraftingApp/CraftingAttemptGrid.svelte +++ b/src/applications/recipeCraftingApp/CraftingAttemptGrid.svelte @@ -26,19 +26,23 @@

{localization.localize(`${Properties.module.id}.typeNames.ingredient.plural`)}

{#each ingredients.units as unit} -
-
-

{truncate(unit.target.part.name, nameLength)}

-
-
-
- {unit.target.part.name} + {#await Promise.all([unit.target.element.load(), unit.actual.element.load()])} + {:then nothing} +
+
+

{truncate(unit.target.element.name, nameLength)}

+
+
+
+ {unit.target.element.name} +
+
+
+

{formatQuantity(unit.actual.quantity, unit.target.quantity)}

+
-
-
-

{formatQuantity(unit.actual.quantity, unit.target.quantity)}

-
-
+ {:catch error} + {/await} {/each}
{/if} @@ -46,19 +50,23 @@

{localization.localize(`${Properties.module.id}.typeNames.catalyst.plural`)}

{#each catalysts.units as unit} -
-
-

{truncate(unit.target.part.name, nameLength)}

-
-
-
- {unit.target.part.name} + {#await Promise.all([unit.target.element.load(), unit.actual.element.load()])} + {:then nothing} +
+
+

{truncate(unit.target.element.name, nameLength)}

+
+
+
+ {unit.target.element.name} +
+
+
+

{formatQuantity(unit.actual.quantity, unit.target.quantity)}

+
-
-
-

{formatQuantity(unit.actual.quantity, unit.target.quantity)}

-
-
+ {:catch error} + {/await} {/each}
{/if} @@ -66,19 +74,23 @@

{localization.localize(`${Properties.module.id}.typeNames.essence.plural`)}

{#each essences.units as unit} -
-
- - {formatQuantity(unit.actual.quantity, unit.target.quantity)} - - - - - - {unit.target.part.name} - -
-
+ {#await Promise.all([unit.target.element.load(), unit.actual.element.load()])} + {:then nothing} +
+
+ + {formatQuantity(unit.actual.quantity, unit.target.quantity)} + + + + + + {unit.target.element.name} + +
+
+ {:catch error} + {/await} {/each}
{/if} \ No newline at end of file diff --git a/src/applications/recipeCraftingApp/CraftingResultCarousel.svelte b/src/applications/recipeCraftingApp/CraftingResultCarousel.svelte index f6716f84..eaaba326 100644 --- a/src/applications/recipeCraftingApp/CraftingResultCarousel.svelte +++ b/src/applications/recipeCraftingApp/CraftingResultCarousel.svelte @@ -1,43 +1,37 @@ - + \ No newline at end of file diff --git a/src/applications/recipeCraftingApp/RecipeCraftingApp.svelte b/src/applications/recipeCraftingApp/RecipeCraftingApp.svelte index 68fbc47a..d254ffc5 100644 --- a/src/applications/recipeCraftingApp/RecipeCraftingApp.svelte +++ b/src/applications/recipeCraftingApp/RecipeCraftingApp.svelte @@ -1,4 +1,4 @@ - +[ -
handleRecipeUpdated(e)} - on:itemUpdated={(e) => handleItemUpdated(e)} - on:itemCreated={(e) => handleItemCreated(e)} - on:itemDeleted={(e) => handleItemDeleted(e)}> -
- doCraftRecipe(e)} /> - {#if loaded} +{#if craftingAttempt} +
handleRecipeUpdated(e)} + on:itemUpdated={(e) => handleItemUpdated(e)} + on:itemCreated={(e) => handleItemCreated(e)} + on:itemDeleted={(e) => handleItemDeleted(e)}> +
+ doCraftRecipe(e)} />

{localization.localize(`${localizationPath}.hints.doCrafting`)}

- {#if !craftingPrep.isSingleton} + {#if recipeCraftingManager.hasRequirementChoices} + on:nextIngredientOptionSelected={selectNextRequirementOption()} + on:previousIngredientOptionSelected={selectPreviousRequirementOption()} /> {:else}
+ ingredients={craftingAttempt.selectedComponents.ingredients} + catalysts={craftingAttempt.selectedComponents.catalysts} + essences={craftingAttempt.selectedComponents.essences} />
{/if}
- {#if recipe.hasResultOptions} - + {#if recipeCraftingManager.hasResultChoices} +

{localization.localize(`${Properties.module.id}.typeNames.result.plural`)}

{:else}

Results

- +
{/if}
- {/if} +
-
+{/if} \ No newline at end of file diff --git a/src/applications/recipeCraftingApp/RecipeCraftingAppCatalog.ts b/src/applications/recipeCraftingApp/RecipeCraftingAppCatalog.ts index a1079571..b3688bdd 100644 --- a/src/applications/recipeCraftingApp/RecipeCraftingAppCatalog.ts +++ b/src/applications/recipeCraftingApp/RecipeCraftingAppCatalog.ts @@ -1,51 +1,50 @@ import {RecipeCraftingAppFactory} from "./RecipeCraftingAppFactory"; -import {SystemRegistry} from "../../scripts/registries/SystemRegistry"; -import {CraftingSystem} from "../../scripts/system/CraftingSystem"; import {SvelteApplication} from "../SvelteApplication"; -import {Recipe} from "../../scripts/common/Recipe"; +import {Recipe} from "../../scripts/crafting/recipe/Recipe"; interface RecipeCraftingAppCatalog { - load(recipe: Recipe, craftingSystem: CraftingSystem, actor: Actor): Promise; + + load(recipe: Recipe, actor: Actor): Promise; + } class DefaultRecipeCraftingAppCatalog implements RecipeCraftingAppCatalog { private readonly _recipeCraftingAppFactory: RecipeCraftingAppFactory; - private readonly _systemRegistry: SystemRegistry; private readonly _appIndex: Map = new Map(); constructor({ recipeCraftingAppFactory, - systemRegistry, appIndex = new Map() }: { recipeCraftingAppFactory: RecipeCraftingAppFactory; - systemRegistry: SystemRegistry; appIndex?: Map; }) { this._recipeCraftingAppFactory = recipeCraftingAppFactory; - this._systemRegistry = systemRegistry; this._appIndex = appIndex; } - async load(recipe: Recipe, craftingSystem: CraftingSystem, actor: Actor): Promise { - const appId = `fabricate-recipe-crafting-app-${recipe.id}`; + async load(recipe: Recipe, actor: Actor): Promise { + const appId = this.getAppId(actor, recipe); if (this._appIndex.has(appId)) { const svelteApplication = this._appIndex.get(appId); if (svelteApplication.rendered) { await svelteApplication.close(); } this._appIndex.delete(appId); - craftingSystem = await this._systemRegistry.getCraftingSystemById(craftingSystem.id); - await craftingSystem.loadPartDictionary(); - recipe = craftingSystem.getRecipeById(recipe.id); + await recipe.load(); } - const app = this._recipeCraftingAppFactory.make(recipe, craftingSystem, actor, appId); + const app = await this._recipeCraftingAppFactory.make(recipe, actor, appId); this._appIndex.set(appId, app); return app; } + private getAppId(actor: Actor, recipe: Recipe) { + // @ts-ignore + const actorId = actor.id; + return `fabricate-recipe-crafting-app-${recipe.id}-${actorId}`; + } } export { RecipeCraftingAppCatalog, DefaultRecipeCraftingAppCatalog } \ No newline at end of file diff --git a/src/applications/recipeCraftingApp/RecipeCraftingAppFactory.ts b/src/applications/recipeCraftingApp/RecipeCraftingAppFactory.ts index 64f8884f..93b9d926 100644 --- a/src/applications/recipeCraftingApp/RecipeCraftingAppFactory.ts +++ b/src/applications/recipeCraftingApp/RecipeCraftingAppFactory.ts @@ -1,43 +1,65 @@ import {SvelteApplication} from "../SvelteApplication"; -import {CraftingSystem} from "../../scripts/system/CraftingSystem"; -import {DefaultGameProvider} from "../../scripts/foundry/GameProvider"; import Properties from "../../scripts/Properties"; import {DefaultLocalizationService} from "../common/LocalizationService"; import RecipeCraftingApp from "./RecipeCraftingApp.svelte"; -import {Recipe} from "../../scripts/common/Recipe"; -import {DefaultInventoryFactory} from "../../scripts/actor/InventoryFactory"; +import {Recipe} from "../../scripts/crafting/recipe/Recipe"; +import {CraftingAPI} from "../../scripts/api/CraftingAPI"; +import {DefaultRecipeCraftingManager} from "./RecipeCraftingManager"; +import {ComponentAPI} from "../../scripts/api/ComponentAPI"; interface RecipeCraftingAppFactory { - make(recipe: Recipe, craftingSystem: CraftingSystem, actor: Actor, appId: string): SvelteApplication; + make(recipe: Recipe, actor: Actor, appId: string): Promise; } class DefaultRecipeCraftingAppFactory implements RecipeCraftingAppFactory { - make(recipe: Recipe, craftingSystem: CraftingSystem, actor: any, appId: string): SvelteApplication { + private readonly localizationService: DefaultLocalizationService; + private readonly craftingAPI: CraftingAPI; + private readonly _componentAPI: ComponentAPI; - const gameProvider = new DefaultGameProvider(); - const GAME = gameProvider.get(); + constructor({ + craftingAPI, + componentAPI, + localizationService, + }: { + craftingAPI: CraftingAPI; + componentAPI: ComponentAPI; + localizationService: DefaultLocalizationService; + }) { + this.craftingAPI = craftingAPI; + this._componentAPI = componentAPI; + this.localizationService = localizationService; + } + + async make(recipe: Recipe, actor: any, appId: string): Promise { const applicationOptions = { - title: GAME.i18n.format(`${Properties.module.id}.RecipeCraftingApp.title`, { actorName: actor.name }), + title: this.localizationService.format(`${Properties.module.id}.RecipeCraftingApp.title`, { actorName: actor.name }), id: appId, resizable: false, width: 680, height: 620 } - const inventory = new DefaultInventoryFactory(gameProvider).make(actor, craftingSystem); + const allCraftingSystemComponentsById = await this._componentAPI.getAllByCraftingSystemId(recipe.craftingSystemId); + + const recipeCraftingManager = new DefaultRecipeCraftingManager({ + recipeToCraft: recipe, + craftingAPI: this.craftingAPI, + sourceActor: actor, + targetActor: actor, + allCraftingSystemComponentsById + }); return new SvelteApplication({ applicationOptions, svelteConfig: { options: { props: { - recipe, - inventory, - localization: new DefaultLocalizationService(gameProvider), + recipeCraftingManager, + localization: this.localizationService, closeHook: async () => { const svelteApplication: SvelteApplication = Object.values(ui.windows) .find(w => w.id == appId); diff --git a/src/applications/recipeCraftingApp/RecipeCraftingManager.ts b/src/applications/recipeCraftingApp/RecipeCraftingManager.ts new file mode 100644 index 00000000..2b5444dd --- /dev/null +++ b/src/applications/recipeCraftingApp/RecipeCraftingManager.ts @@ -0,0 +1,232 @@ +import {BaseActor} from "@league-of-foundry-developers/foundry-vtt-types/src/foundry/common/documents.mjs"; +import {Recipe} from "../../scripts/crafting/recipe/Recipe"; +import {CraftingResult} from "../../scripts/crafting/result/CraftingResult"; +import {Component} from "../../scripts/crafting/component/Component"; +import {Combination} from "../../scripts/common/Combination"; +import {ComponentSelection} from "../../scripts/component/ComponentSelection"; +import {CraftingAPI} from "../../scripts/api/CraftingAPI"; + +interface OptionDetails { + name: string; + id: string; +} + +export { OptionDetails }; + +interface CraftingAttempt { + + readonly requirementOption: OptionDetails; + readonly resultOption: OptionDetails; + readonly isPossible: boolean; + readonly recipeToCraft: Recipe; + readonly requiresCatalysts: boolean; + readonly requiresEssences: boolean; + readonly requiresIngredients: boolean; + readonly producedComponents: Combination; + readonly selectedComponents: ComponentSelection; + +} + +export { CraftingAttempt }; + +class DefaultCraftingAttempt implements CraftingAttempt { + + private readonly _resultOption: OptionDetails; + private readonly _recipeToCraft: Recipe; + private readonly _requirementOption: OptionDetails; + private readonly _producedComponents: Combination; + private readonly _selectedComponents: ComponentSelection; + + constructor({ + resultOption, + recipeToCraft, + requirementOption, + producedComponents, + selectedComponents, + } : { + resultOption: OptionDetails; + recipeToCraft: Recipe; + requirementOption: OptionDetails; + producedComponents: Combination; + selectedComponents: ComponentSelection; + }) { + this._resultOption = resultOption; + this._recipeToCraft = recipeToCraft; + this._requirementOption = requirementOption; + this._producedComponents = producedComponents; + this._selectedComponents = selectedComponents; + } + + get resultOption(): OptionDetails { + return this._resultOption; + } + + get recipeToCraft(): Recipe { + return this._recipeToCraft; + } + + get requirementOption(): OptionDetails { + return this._requirementOption; + } + + get producedComponents(): Combination { + return this._producedComponents; + } + + get selectedComponents(): ComponentSelection { + return this._selectedComponents; + } + + get isPossible(): boolean { + return this._selectedComponents.isSufficient; + } + + get requiresCatalysts(): boolean { + return !this._selectedComponents.catalysts.target.isEmpty(); + } + + get requiresEssences(): boolean { + return !this._selectedComponents.essences.target.isEmpty(); + } + + get requiresIngredients(): boolean { + return !this._selectedComponents.ingredients.target.isEmpty(); + } + +} + +export { DefaultCraftingAttempt } + +interface RecipeCraftingManager { + + readonly recipeToCraft: Recipe; + + readonly sourceActor: BaseActor; + + readonly targetActor: BaseActor; + + readonly hasRequirementChoices: boolean; + + readonly hasResultChoices: boolean; + + selectNextRequirementOption(): Promise; + + selectNextResultOption(): Promise; + + selectPreviousRequirementOption(): Promise; + + selectPreviousResultOption(): Promise; + + getCraftingAttempt(): Promise; + + doCrafting(craftingAttempt: CraftingAttempt): Promise; + +} + +export { RecipeCraftingManager }; + +class DefaultRecipeCraftingManager implements RecipeCraftingManager { + + private readonly _sourceActor: BaseActor; + private readonly _targetActor: BaseActor; + private readonly _craftingAPI: CraftingAPI; + private readonly _recipeToCraft: Recipe; + private readonly _allCraftingSystemComponentsById: Map; + + constructor({ + sourceActor, + targetActor, + craftingAPI, + recipeToCraft, + allCraftingSystemComponentsById, + }: { + sourceActor: BaseActor; + targetActor: BaseActor; + craftingAPI: CraftingAPI; + recipeToCraft: Recipe; + allCraftingSystemComponentsById: Map; + }) { + this._sourceActor = sourceActor; + this._targetActor = targetActor; + this._craftingAPI = craftingAPI; + this._recipeToCraft = recipeToCraft; + this._allCraftingSystemComponentsById = allCraftingSystemComponentsById; + } + + get recipeToCraft(): Recipe { + return this._recipeToCraft; + } + + get sourceActor(): BaseActor { + return this._sourceActor; + } + + get targetActor(): BaseActor { + return this._targetActor; + } + + get hasRequirementChoices(): boolean { + return this._recipeToCraft.hasRequirementChoices; + } + + get hasResultChoices(): boolean { + return this._recipeToCraft.hasResultChoices; + } + + async doCrafting(craftingAttempt: CraftingAttempt): Promise { + return this._craftingAPI.craftRecipe({ + recipeId: this._recipeToCraft.id, + sourceActorId: this._sourceActor.id, + targetActorId: this._targetActor.id, + resultOptionId: this.recipeToCraft.selectedResultOption.id, + requirementOptionId: this.recipeToCraft.selectedRequirementOption.id, + userSelectedComponents: { + catalysts: craftingAttempt.selectedComponents.catalysts.actual.toJson(), + essenceSources: craftingAttempt.selectedComponents.essenceSources.toJson(), + ingredients: craftingAttempt.selectedComponents.ingredients.actual.toJson(), + } + }); + } + + async getCraftingAttempt(): Promise { + const producedComponents = this._recipeToCraft.selectedResultOption.results + .convertElements(reference => this._allCraftingSystemComponentsById.get(reference.id)); + const selectedComponents = await this._craftingAPI.selectComponents({ + recipeId: this._recipeToCraft.id, + sourceActorId: this._sourceActor.id, + requirementOptionId: this._recipeToCraft.selectedRequirementOption.id, + }); + return new DefaultCraftingAttempt({ + resultOption: { + id: this._recipeToCraft.selectedResultOption.id, + name: this._recipeToCraft.selectedResultOption.name, + }, + requirementOption: { + id: this._recipeToCraft.selectedRequirementOption.id, + name: this._recipeToCraft.selectedRequirementOption.name, + }, + recipeToCraft: this._recipeToCraft, + producedComponents, + selectedComponents, + }); + } + + selectNextRequirementOption(): Promise { + return Promise.resolve(undefined); + } + + selectNextResultOption(): Promise { + return Promise.resolve(undefined); + } + + selectPreviousRequirementOption(): Promise { + return Promise.resolve(undefined); + } + + selectPreviousResultOption(): Promise { + return Promise.resolve(undefined); + } + +} + +export { DefaultRecipeCraftingManager }; \ No newline at end of file diff --git a/src/applications/stores/ComponentEssenceStore.ts b/src/applications/stores/ComponentEssenceStore.ts index d7617b53..f59ea012 100644 --- a/src/applications/stores/ComponentEssenceStore.ts +++ b/src/applications/stores/ComponentEssenceStore.ts @@ -1,37 +1,47 @@ import {derived, Readable, Subscriber} from "svelte/store"; -import {Essence} from "../../scripts/common/Essence"; -import {Unit} from "../../scripts/common/Combination"; -import {CraftingSystem} from "../../scripts/system/CraftingSystem"; -import {CraftingComponent} from "../../scripts/common/CraftingComponent"; +import {Essence} from "../../scripts/crafting/essence/Essence"; +import {Component} from "../../scripts/crafting/component/Component"; +import {Unit} from "../../scripts/common/Unit"; -class ComponentEssenceStore { +/** + * This store provides the current and available essences for the selected component by combining the essences of the + * selected crafting system with the essences of the selected component. + */ +class ComponentEssenceStore implements Readable[]> { - private readonly _essences: Readable[]>; + /** + * The essences for the selected component, including essences that are available in the selected crafting system + * but not yet included in the selected component. + * @private + */ + private readonly _componentEssences: Readable[]>; constructor({ - selectedCraftingSystem, + allEssences, selectedComponent }: { - selectedCraftingSystem: Readable; - selectedComponent: Readable; + allEssences: Readable; + selectedComponent: Readable; }) { - this._essences = derived( - [selectedCraftingSystem, selectedComponent], - ([$selectedSystem, $selectedCraftingComponent], set) => { - if (!$selectedSystem || !$selectedSystem.hasEssences || !$selectedCraftingComponent) { + this._componentEssences = derived( + [allEssences, selectedComponent], + ([$allEssences, $selectedComponent], set) => { + if ($allEssences.length === 0 || !$selectedComponent) { set([]); return; } - const essences = $selectedSystem.getEssences().map(essence => { - return new Unit(essence, $selectedCraftingComponent.essences.amountFor(essence.id)); - }); + const essences = $allEssences + .map(essence => { + return new Unit(essence, $selectedComponent.essences.amountFor(essence.id)); + }); set(essences); }); } public subscribe(subscriber: Subscriber[]>) { - return this._essences.subscribe(subscriber); + return this._componentEssences.subscribe(subscriber); } + } export {ComponentEssenceStore } \ No newline at end of file diff --git a/src/applications/stores/ComponentSearchStore.ts b/src/applications/stores/ComponentSearchStore.ts index bb3e4c9d..2a6a03a7 100644 --- a/src/applications/stores/ComponentSearchStore.ts +++ b/src/applications/stores/ComponentSearchStore.ts @@ -1,5 +1,5 @@ import {writable, Writable, Readable, derived, Subscriber} from "svelte/store"; -import {CraftingComponent} from "../../scripts/common/CraftingComponent"; +import {Component} from "../../scripts/crafting/component/Component"; interface ComponentSearchTerms { name?: string; @@ -9,18 +9,18 @@ interface ComponentSearchTerms { class ComponentSearchStore { - private readonly _availableComponents: Readable; - private readonly _searchResults: Readable; + private readonly _availableComponents: Readable; + private readonly _searchResults: Readable; private readonly _searchTerms: Writable; constructor({ - availableComponents, + components, searchTerms = {} }: { - availableComponents: Readable; + components: Readable; searchTerms?: ComponentSearchTerms; }) { - this._availableComponents = availableComponents; + this._availableComponents = components; this._searchTerms = writable(searchTerms); this._searchResults = derived( [this._availableComponents, this._searchTerms], @@ -31,11 +31,11 @@ class ComponentSearchStore { this.clearOnSystemSelection(this._availableComponents); } - private clearOnSystemSelection(availableComponents: Readable) { + private clearOnSystemSelection(availableComponents: Readable) { availableComponents.subscribe(() => this.clear()); } - private searchComponents(craftingComponents: CraftingComponent[], searchTerms: ComponentSearchTerms) { + private searchComponents(craftingComponents: Component[], searchTerms: ComponentSearchTerms) { return craftingComponents.filter((component) => { if (searchTerms.hasEssences && !component.hasEssences) { return false; @@ -50,7 +50,7 @@ class ComponentSearchStore { }); } - public subscribe(subscriber: Subscriber) { + public subscribe(subscriber: Subscriber) { return this._searchResults.subscribe(subscriber); } diff --git a/src/applications/stores/ComponentsStore.ts b/src/applications/stores/ComponentsStore.ts new file mode 100644 index 00000000..b0f352f6 --- /dev/null +++ b/src/applications/stores/ComponentsStore.ts @@ -0,0 +1,79 @@ +import {CraftingSystem} from "../../scripts/system/CraftingSystem"; +import {Readable, Subscriber, Updater, writable, Writable, get} from "svelte/store"; +import {Component} from "../../scripts/crafting/component/Component"; +import {FabricateAPI} from "../../scripts/api/FabricateAPI"; + +class ComponentsStore implements Writable { + + private readonly _craftingComponents: Writable; + private readonly _fabricateAPI: FabricateAPI; + + constructor({ + fabricateAPI, + selectedCraftingSystem, + initialValue = [], + }: { + fabricateAPI: FabricateAPI; + selectedCraftingSystem: Readable; + initialValue?: Component[]; + }) { + this._fabricateAPI = fabricateAPI; + this._craftingComponents = writable(initialValue); + this.watchSelectedCraftingSystem(selectedCraftingSystem); + } + + private watchSelectedCraftingSystem(selectedCraftingSystem: Readable) { + selectedCraftingSystem.subscribe((craftingSystem) => { + if (!craftingSystem) { + this._craftingComponents.set([]); + return; + } + this._fabricateAPI.components.getAllByCraftingSystemId(craftingSystem.id) + .then((components) => { + this._craftingComponents.set(Array.from(components.values())); + }); + }); + } + + public get(): Component[] { + return get(this._craftingComponents); + } + + public subscribe(subscriber: Subscriber) { + return this._craftingComponents.subscribe(subscriber); + } + + set(value: Component[]): void { + this._craftingComponents.set(value); + } + + update(updater: Updater): void { + this._craftingComponents.update(updater); + } + + insert(component: Component) { + this._craftingComponents.update((components) => { + const index = components.findIndex((candidate) => candidate.id === component.id); + if (index === -1) { + components.push(component); + return components; + } + components[index] = component; + return components; + }); + } + + remove(component: Component) { + this._craftingComponents.update((components) => { + const index = components.findIndex((candidate) => candidate.id === component.id); + if (index === -1) { + return components; + } + components.splice(index, 1); + return components; + }); + } + +} + +export { ComponentsStore } \ No newline at end of file diff --git a/src/applications/stores/CraftingComponentsStore.ts b/src/applications/stores/CraftingComponentsStore.ts deleted file mode 100644 index 1b1e2743..00000000 --- a/src/applications/stores/CraftingComponentsStore.ts +++ /dev/null @@ -1,26 +0,0 @@ -import {CraftingSystem} from "../../scripts/system/CraftingSystem"; -import {derived, Readable, Subscriber} from "svelte/store"; -import {CraftingComponent} from "../../scripts/common/CraftingComponent"; - -class CraftingComponentsStore { - - private readonly _craftingComponents: Readable; - - constructor({ - selectedCraftingSystem - }: { - selectedCraftingSystem: Readable; - }) { - this._craftingComponents = derived( - selectedCraftingSystem, - ($selectedCraftingSystem, set) => set($selectedCraftingSystem?.craftingComponents ?? []) - ); - } - - public subscribe(subscriber: Subscriber) { - return this._craftingComponents.subscribe(subscriber); - } - -} - -export { CraftingComponentsStore } \ No newline at end of file diff --git a/src/applications/stores/CraftingSystemsStore.ts b/src/applications/stores/CraftingSystemsStore.ts index 5dda3499..1becc807 100644 --- a/src/applications/stores/CraftingSystemsStore.ts +++ b/src/applications/stores/CraftingSystemsStore.ts @@ -33,13 +33,13 @@ class CraftingSystemsStore { private sort(craftingSystems: CraftingSystem[]): CraftingSystem[] { return craftingSystems.sort((left, right) => { - if (left.isLocked && !right.isLocked) { + if (left.isEmbedded && !right.isEmbedded) { return -1; } - if (right.isLocked && !left.isLocked) { + if (right.isEmbedded && !left.isEmbedded) { return 1; } - return left.name.localeCompare(right.name); + return left.details.name.localeCompare(right.details.name); }); } diff --git a/src/applications/stores/EssenceStore.ts b/src/applications/stores/EssenceStore.ts new file mode 100644 index 00000000..3a4e007f --- /dev/null +++ b/src/applications/stores/EssenceStore.ts @@ -0,0 +1,79 @@ +import {CraftingSystem} from "../../scripts/system/CraftingSystem"; +import {Readable, Subscriber, Updater, writable, Writable, get} from "svelte/store"; +import {FabricateAPI} from "../../scripts/api/FabricateAPI"; +import {Essence} from "../../scripts/crafting/essence/Essence"; + +class EssencesStore implements Writable { + + private readonly _essences: Writable; + private readonly _fabricateAPI: FabricateAPI; + + constructor({ + fabricateAPI, + selectedCraftingSystem, + initialValue = [], + }: { + fabricateAPI: FabricateAPI; + selectedCraftingSystem: Readable; + initialValue?: Essence[]; + }) { + this._fabricateAPI = fabricateAPI; + this._essences = writable(initialValue); + this.watchSelectedCraftingSystem(selectedCraftingSystem); + } + + private watchSelectedCraftingSystem(selectedCraftingSystem: Readable) { + selectedCraftingSystem.subscribe((craftingSystem) => { + if (!craftingSystem) { + this._essences.set([]); + return; + } + this._fabricateAPI.essences.getAllByCraftingSystemId(craftingSystem.id) + .then((essences) => { + this._essences.set(Array.from(essences.values())); + }); + }); + } + + public get(): Essence[] { + return get(this._essences); + } + + public subscribe(subscriber: Subscriber) { + return this._essences.subscribe(subscriber); + } + + set(value: Essence[]): void { + this._essences.set(value); + } + + update(updater: Updater): void { + this._essences.update(updater); + } + + insert(essence: Essence) { + this._essences.update((essences) => { + const index = essences.findIndex((candidate) => candidate.id === essence.id); + if (index === -1) { + essences.push(essence); + return essences; + } + essences[index] = essence; + return essences; + }); + } + + remove(essence: Essence) { + this._essences.update((components) => { + const index = components.findIndex((candidate) => candidate.id === essence.id); + if (index === -1) { + return components; + } + components.splice(index, 1); + return components; + }); + } + +} + +export { EssencesStore } \ No newline at end of file diff --git a/src/applications/stores/RecipeEssenceStore.ts b/src/applications/stores/RecipeEssenceStore.ts deleted file mode 100644 index f90b0d37..00000000 --- a/src/applications/stores/RecipeEssenceStore.ts +++ /dev/null @@ -1,37 +0,0 @@ -import {derived, Readable, Subscriber} from "svelte/store"; -import {Essence} from "../../scripts/common/Essence"; -import {Unit} from "../../scripts/common/Combination"; -import {CraftingSystem} from "../../scripts/system/CraftingSystem"; -import {Recipe} from "../../scripts/common/Recipe"; - -class RecipeEssenceStore { - - private readonly _essences: Readable[]>; - - constructor({ - selectedCraftingSystem, - selectedRecipe - }: { - selectedCraftingSystem: Readable; - selectedRecipe: Readable; - }) { - this._essences = derived( - [selectedCraftingSystem, selectedRecipe], - ([$selectedSystem, $selectedCraftingComponent], set) => { - if (!$selectedSystem || !$selectedSystem.hasEssences || !$selectedCraftingComponent) { - set([]); - return; - } - const essences = $selectedSystem.getEssences().map(essence => { - return new Unit(essence, $selectedCraftingComponent.essences.amountFor(essence.id)); - }); - set(essences); - }); - } - - public subscribe(subscriber: Subscriber[]>) { - return this._essences.subscribe(subscriber); - } -} - -export {RecipeEssenceStore } \ No newline at end of file diff --git a/src/applications/stores/RecipeRequirementOptionEssenceStore.ts b/src/applications/stores/RecipeRequirementOptionEssenceStore.ts new file mode 100644 index 00000000..7899bd82 --- /dev/null +++ b/src/applications/stores/RecipeRequirementOptionEssenceStore.ts @@ -0,0 +1,58 @@ +import {derived, Readable, Subscriber} from "svelte/store"; +import {Essence} from "../../scripts/crafting/essence/Essence"; +import {Unit} from "../../scripts/common/Unit"; +import {Recipe} from "../../scripts/crafting/recipe/Recipe"; + +/** + * This store provides the current and available essences for each requirement option by combining the essences of the + * selected crafting system with the essences of the selected recipe's requirement options. + */ +class RecipeRequirementOptionEssenceStore implements Readable[]>> { + + /** + * The essences for each requirement option by id, including essences that are available in the selected crafting + * system but not yet included in the selected component. + * @private + */ + private readonly _recipeRequirementOptionEssences: Readable[]>>; + + constructor({ + allEssences, + selectedRecipe, + }: { + allEssences: Readable; + selectedRecipe: Readable; + }) { + this._recipeRequirementOptionEssences = derived( + [allEssences, selectedRecipe], + ([$allEssences, $selectedRecipe], set) => { + if ($allEssences.length === 0 || !$selectedRecipe) { + set(new Map()); + return; + } + const result = $selectedRecipe.requirementOptions.all + .map(requirementOption => { + const essences = $allEssences + .map(essence => { + return new Unit(essence, requirementOption.essences.amountFor(essence.id)); + }); + return { + requirementOption, + essences + } + }) + .reduce((map, {requirementOption, essences}) => { + map.set(requirementOption.id, essences); + return map; + }, new Map[]>()); + set(result); + }); + } + + public subscribe(subscriber: Subscriber[]>>) { + return this._recipeRequirementOptionEssences.subscribe(subscriber); + } + +} + +export {RecipeRequirementOptionEssenceStore } \ No newline at end of file diff --git a/src/applications/stores/RecipeSearchStore.ts b/src/applications/stores/RecipeSearchStore.ts index 2d821326..1e0ffc92 100644 --- a/src/applications/stores/RecipeSearchStore.ts +++ b/src/applications/stores/RecipeSearchStore.ts @@ -1,5 +1,5 @@ import {writable, Writable, Readable, derived, Subscriber} from "svelte/store"; -import {Recipe} from "../../scripts/common/Recipe"; +import {Recipe} from "../../scripts/crafting/recipe/Recipe"; interface RecipeSearchTerms { name?: string; @@ -37,10 +37,10 @@ class RecipeSearchStore { private searchRecipes(recipes: Recipe[], searchTerms: RecipeSearchTerms) { return recipes.filter((recipe) => { - if (searchTerms.requiresEssences && !recipe.requiresEssences) { + if (searchTerms.requiresEssences && !recipe.hasEssenceRequirementOption()) { return false; } - if (searchTerms.requiresNamedIngredients && !recipe.hasIngredients) { + if (searchTerms.requiresNamedIngredients && !recipe.hasRequirements) { return false; } if (!searchTerms.name) { diff --git a/src/applications/stores/RecipesStore.ts b/src/applications/stores/RecipesStore.ts index e7216a67..a4afa5e2 100644 --- a/src/applications/stores/RecipesStore.ts +++ b/src/applications/stores/RecipesStore.ts @@ -1,26 +1,75 @@ +import {get, Readable, Subscriber, Updater, writable, Writable} from "svelte/store"; +import {Recipe} from "../../scripts/crafting/recipe/Recipe"; +import {FabricateAPI} from "../../scripts/api/FabricateAPI"; import {CraftingSystem} from "../../scripts/system/CraftingSystem"; -import {derived, Readable, Subscriber} from "svelte/store"; -import {Recipe} from "../../scripts/common/Recipe"; class RecipesStore { - private readonly _recipes: Readable; + private readonly _recipes: Writable; + private readonly _fabricateAPI: FabricateAPI; constructor({ - selectedCraftingSystem + fabricateAPI, + selectedCraftingSystem, + initialValue = [], }: { + fabricateAPI: FabricateAPI; selectedCraftingSystem: Readable; + initialValue?: Recipe[]; }) { - this._recipes = derived( - selectedCraftingSystem, - ($selectedCraftingSystem, set) => set($selectedCraftingSystem?.recipes ?? []) - ); + this._fabricateAPI = fabricateAPI; + this._recipes = writable(initialValue); + this.watchSelectedCraftingSystem(selectedCraftingSystem); + } + + private watchSelectedCraftingSystem(selectedCraftingSystem: Readable) { + selectedCraftingSystem.subscribe((craftingSystem) => { + if (!craftingSystem) { + this._recipes.set([]); + return; + } + this._fabricateAPI.recipes.getAllByCraftingSystemId(craftingSystem.id) + .then((recipes) => { + this._recipes.set(Array.from(recipes.values())); + }); + }); + } + + public get(): Recipe[] { + return get(this._recipes); } public subscribe(subscriber: Subscriber) { return this._recipes.subscribe(subscriber); } + update(updater: Updater): void { + this._recipes.update(updater); + } + + insert(recipe: Recipe) { + this._recipes.update((recipes) => { + const index = recipes.findIndex((candidate) => candidate.id === recipe.id); + if (index === -1) { + recipes.push(recipe); + return recipes; + } + recipes.splice(index, 1, recipe); + return recipes; + }); + } + + remove(recipe: Recipe) { + this._recipes.update((recipes) => { + const index = recipes.findIndex((candidate) => candidate.id === recipe.id); + if (index === -1) { + return recipes; + } + recipes.splice(index, 1); + return recipes; + }); + } + } export { RecipesStore } \ No newline at end of file diff --git a/src/applications/stores/SalvageSearchStore.ts b/src/applications/stores/SalvageSearchStore.ts index a134f621..48c63231 100644 --- a/src/applications/stores/SalvageSearchStore.ts +++ b/src/applications/stores/SalvageSearchStore.ts @@ -1,5 +1,5 @@ import {writable, Writable, Readable, derived, Subscriber} from "svelte/store"; -import {CraftingComponent} from "../../scripts/common/CraftingComponent"; +import {Component} from "../../scripts/crafting/component/Component"; interface SalvageSearchTerms { name?: string; @@ -7,21 +7,21 @@ interface SalvageSearchTerms { class SalvageSearchStore { - private readonly _availableComponents: Readable; - private readonly _selectedComponent: Readable; - private readonly _searchResults: Readable; + private readonly _availableComponents: Readable; + private readonly _selectedComponent: Readable; + private readonly _searchResults: Readable; private readonly _searchTerms: Writable; constructor({ - availableComponents, + components, selectedComponent, searchTerms = {} }: { - availableComponents: Readable; - selectedComponent: Readable; + components: Readable; + selectedComponent: Readable; searchTerms?: SalvageSearchTerms; }) { - this._availableComponents = availableComponents; + this._availableComponents = components; this._selectedComponent = selectedComponent; this._searchTerms = writable(searchTerms); this._searchResults = derived( @@ -34,15 +34,15 @@ class SalvageSearchStore { this.clearOnComponentSelection(this._selectedComponent); } - private clearOnSystemSelection(availableComponents: Readable) { + private clearOnSystemSelection(availableComponents: Readable) { availableComponents.subscribe(() => this.clear()); } - private clearOnComponentSelection(selectedComponent: Readable) { + private clearOnComponentSelection(selectedComponent: Readable) { selectedComponent.subscribe(() => this.clear()); } - private searchComponents(craftingComponents: CraftingComponent[], selectedComponent: CraftingComponent, searchTerms: SalvageSearchTerms) { + private searchComponents(craftingComponents: Component[], selectedComponent: Component, searchTerms: SalvageSearchTerms) { return craftingComponents .filter(component => component !== selectedComponent) .filter((component) => { @@ -53,7 +53,7 @@ class SalvageSearchStore { }); } - public subscribe(subscriber: Subscriber) { + public subscribe(subscriber: Subscriber) { return this._searchResults.subscribe(subscriber); } diff --git a/src/applications/stores/SelectedCraftingComponentStore.ts b/src/applications/stores/SelectedCraftingComponentStore.ts index 1be311d9..9d603cee 100644 --- a/src/applications/stores/SelectedCraftingComponentStore.ts +++ b/src/applications/stores/SelectedCraftingComponentStore.ts @@ -1,22 +1,22 @@ import {get, Readable, Subscriber, Updater, writable, Writable} from "svelte/store"; -import {CraftingComponent} from "../../scripts/common/CraftingComponent"; +import {Component} from "../../scripts/crafting/component/Component"; class SelectedCraftingComponentStore { - private readonly _selectedCraftingComponent: Writable; + private readonly _selectedCraftingComponent: Writable; constructor({ craftingComponents, selectedComponent }: { - craftingComponents: Readable; - selectedComponent?: CraftingComponent; + craftingComponents: Readable; + selectedComponent?: Component; }) { this._selectedCraftingComponent = writable(selectedComponent); this.deselectOrUpdateWhenAvailableComponentsChange(craftingComponents); } - private deselectOrUpdateWhenAvailableComponentsChange(craftingComponents: Readable) { + private deselectOrUpdateWhenAvailableComponentsChange(craftingComponents: Readable) { craftingComponents.subscribe(value => { if (!value) { throw new Error("Components may not be null"); @@ -33,15 +33,15 @@ class SelectedCraftingComponentStore { }); } - public subscribe(subscriber: Subscriber) { + public subscribe(subscriber: Subscriber) { return this._selectedCraftingComponent.subscribe(subscriber); } - public set(value: CraftingComponent) { + public set(value: Component) { return this._selectedCraftingComponent.set(value); } - public update(updater: Updater) { + public update(updater: Updater) { this._selectedCraftingComponent.update(updater); } diff --git a/src/applications/stores/SelectedCraftingSystemStore.ts b/src/applications/stores/SelectedCraftingSystemStore.ts index f286b3b5..cc8f03fb 100644 --- a/src/applications/stores/SelectedCraftingSystemStore.ts +++ b/src/applications/stores/SelectedCraftingSystemStore.ts @@ -13,20 +13,9 @@ class SelectedCraftingSystemStore { selectedSystem?: CraftingSystem; }) { this._selectedCraftingSystem = writable(selectedSystem); - this.loadWhenSelected(this._selectedCraftingSystem); this.shadowCraftingSystemUpdates(craftingSystems); } - public async reload() { - const selectedSystem = get(this._selectedCraftingSystem); - if (!selectedSystem) { - return; - } - await selectedSystem.reload(); - this._selectedCraftingSystem.set(selectedSystem); - return selectedSystem; - } - private shadowCraftingSystemUpdates(craftingSystems: Readable) { craftingSystems.subscribe(value => { if (!value) { @@ -36,26 +25,12 @@ class SelectedCraftingSystemStore { this._selectedCraftingSystem.set(null); } const selectedSystem = get(this._selectedCraftingSystem); - const found = value.find(system => system === selectedSystem); + const found = value.find(system => system.id === selectedSystem?.id); if (!found) { this._selectedCraftingSystem.set(value[0]); return; } - Promise.resolve(this.reload()) - .catch(e => console.error(`Unable to reload crafting system. ${e instanceof Error ? e.stack : e}`)); - }); - } - - private loadWhenSelected(selectedCraftingSystem: Writable) { - selectedCraftingSystem.subscribe((value) => { - if (!value) { - return; - } - if (!value.isLoaded) { - Promise - .resolve(value.loadPartDictionary()) - .then(() => selectedCraftingSystem.set(value)); - } + this._selectedCraftingSystem.set(found); }); } diff --git a/src/applications/stores/SelectedRecipeStore.ts b/src/applications/stores/SelectedRecipeStore.ts index 48f54ec9..42c33e84 100644 --- a/src/applications/stores/SelectedRecipeStore.ts +++ b/src/applications/stores/SelectedRecipeStore.ts @@ -1,5 +1,5 @@ import {get, Readable, Subscriber, Updater, writable, Writable} from "svelte/store"; -import {Recipe} from "../../scripts/common/Recipe"; +import {Recipe} from "../../scripts/crafting/recipe/Recipe"; class SelectedRecipeStore { diff --git a/src/public/lang/en.json b/src/public/lang/en.json index ac7a1eb0..bd42a68a 100644 --- a/src/public/lang/en.json +++ b/src/public/lang/en.json @@ -8,10 +8,7 @@ "recipe": { "singular": "Recipe", "plural": "Recipes", - "ingredientOption": { - "name": "Option {number}" - }, - "resultOption": { + "option": { "name": "Option {number}" } }, @@ -43,12 +40,138 @@ "plural": "Active Effect Sources" } }, + "settings": { + "settingDeleted": "The setting {settingPath} was deleted. ", + "craftingSystem": { + "deleted": "Deleted crafting system \"{systemName}\"", + "created": "Created crafting system \"{systemName}\"", + "updated": "Updated crafting system \"{systemName}\"", + "import": { + "invalidData": { + "summary": "The provided crafting system import data is not valid. Correct the following errors and try again: {errors}", + "invalidJson": "Invalid JSON. ", + "noData": "No data was provided to import. ", + "noVersion": "Missing required property \"version: string\". ", + "noSystem": "Missing required property \"system: CraftingSystemExportModel\". ", + "noEssences": "Missing required property \"essences: EssenceExportModel[]\". ", + "noComponents": "Missing required property \"components: ComponentExportModel[]\". ", + "noRecipes": "Missing required property \"recipes: RecipeExportModel[]\". " + }, + "failure": "Failed to import crafting system \"{systemName}\"", + "success": "Successfully imported crafting system \"{systemName}\", with {essenceCount} essences, {componentCount} components, and {recipeCount} recipes. " + }, + "duplicate": { + "failure": "Failed to duplicate crafting system with ID \"{systemId}\". Caused by: {cause}", + "success": "Successfully duplicated crafting system with \"{systemId}\". The clone's ID is \"{cloneId}\". " + }, + "export": { + "craftingSystemNotFound": "The crafting system with ID {craftingSystemId} does not exist. " + } + }, + "errors": { + "invalidRead": "The stored value for the setting {settingPath} is not valid, and could not be read. {errors}", + "invalidWrite": "The provided value for the setting {settingPath} is not valid, and could not be saved. {errors}", + "craftingSystem": { + "doesNotExist": "The crafting system with ID {craftingSystemId} does not exist. ", + "invalid": "The crafting system with ID {craftingSystemId} is not valid. Errors: {errors}. ", + "missingCraftingSystems": "The crafting systems with the IDs {craftingSystemIds} could not be found. ", + "cannotModifyEmbedded": "The crafting system {craftingSystemName} is an embedded system and cannot be modified. ", + "cannotDeleteEmbedded": "The crafting system {craftingSystemName} is an embedded system and cannot be deleted. " + }, + "document": { + "notFound": "The document with UUID {documentUuid} could not be found. " + }, + "recipe": { + "doesNotExist": "The recipe with ID {recipeId} does not exist. ", + "invalid": "The recipe with ID {recipeId} is not valid. Errors: {errors}. ", + "missingRecipes": "The recipes with the IDs {recipeIds} could not be found. ", + "noneSaved": "A problem occurred saving one or more recipes. No recipes were saved. " + }, + "component": { + "doesNotExist": "The component with ID {componentId} does not exist. ", + "invalid": "The component with ID {componentId} is not valid. Errors: {errors}. ", + "missingComponents": "The components with the IDs {componentIds} could not be found. ", + "noneSaved": "A problem occurred saving one or more components. No components were saved." + }, + "essence": { + "doesNotExist": "The essence with ID {essenceId} does not exist. ", + "invalid": "The essence with ID {essenceId} is not valid. Errors: {errors}. ", + "missingEssences": "The essences with the IDs {essenceIds} could not be found. ", + "noneSaved": "A problem occurred saving one or more essences. No essences were saved." + } + }, + "migration": { + "notNeeded": "Fabricate settings are already up to date and don't not need to be migrated. ", + "started": "Fabricate is migrating your crafting system settings. Please do close your browser or shut down the game world. ", + "failed": "Fabricate was unable to migrate your crafting system settings. Please report this error to the module author. ", + "success": "Fabricate has finished migrating your crafting system settings!" + }, + "recipe": { + "updated": "Updated recipe \"{recipeName}\"", + "created": "Created recipe \"{recipeName}\"", + "savedAll": "Saved {count} recipes" + }, + "component": { + "updated": "Updated component \"{componentName}\"", + "created": "Created component \"{componentName}\"", + "savedAll": "Saved {count} components" + }, + "essence": { + "updated": "Updated essence \"{essenceName}\"", + "created": "Created essence \"{essenceName}\"", + "savedAll": "Saved {count} essences" + } + }, + "crafting": { + "actorNotFound": "The Actor with id {actorId} could not be found. ", + "disabledCraftingSystem": "The component {componentName} belongs to the crafting system {craftingSystemName}, which is disabled and cannot be used.", + "salvage": { + "componentNotFound": "The component with id {componentId} could not be found. ", + "componentNotOwned": "The component with name {componentName} is not owned by the Actor {actorName}. ", + "disabledComponent": "The component {componentName} is disabled and cannot be salvaged.", + "unsalvageableComponent": "The component {componentName} has no usable salvage options.", + "invalidItemData": "The component with ID \"{componentId}\" has a problem with its item data. Caused by: {cause}", + "multipleCraftingSystems": "This Salvage Result has components from multiple Crafting Systems. This is not supported, and possibly a bug. The crafting system IDs are: {craftingSystemIds}. ", + "success": "Salvaged {componentName} in {craftingSystemName}.", + "salvageOptionIdRequired": "The component {componentName} has {salvageOptionCount} salvage options, but no salvage option ID was provided. The available salvage option ids are: {salvageOptionIds}. ", + "missingCatalysts": "Unable to salvage the component {componentName}. Missing required catalysts: {missingCatalystNames}. " + }, + "recipe": { + "recipeNotFound": "The recipe with id {recipeId} could not be found. ", + "invalidItemData": "The recipe with ID \"{recipeId}\" has a problem with its item data. Caused by: {cause}", + "disabledRecipe": "The recipe {recipeName} is disabled and cannot be crafted.", + "requirementOptionIdRequired": "The recipe {recipeName} has {requirementOptionCount} requirement options, but no requirement option ID was provided. The available requirement option ids are: {requirementOptionIds}. ", + "resultOptionIdRequired": "The recipe {recipeName} has {resultOptionCount} result options, but no result option ID was provided. The available result option ids are: {resultOptionIds}. ", + "multipleCraftingSystems": "The recipe {recipeName} has components from multiple Crafting Systems. This is not supported, and possibly a bug. The crafting system IDs are: {craftingSystemIds}. ", + "invalidComponentItemData": "The recipe {recipeName} has a problem with its component item data. The invalid component IDs are {invalidComponentIds}. ", + "multipleEssenceCraftingSystems": "The recipe {recipeName} has essences from multiple Crafting Systems. This is not supported, and possibly a bug. The crafting system IDs are: {craftingSystemIds}. ", + "invalidEssenceItemData": "The recipe {recipeName} has a problem with its essence item data. The invalid essence IDs are {essenceIds}. ", + "insufficientUserComponents": "The selected components for crafting {recipeName} are not all owned by the Actor {actorName}. The missing components are {missingComponentNames}. ", + "insufficientComponents": "The Actor {actorName} does not have enough of the required components to craft {recipeName}. ", + "success": "Crafted {recipeName} in {craftingSystemName}." + }, + "inventory": { + "error": { + "invalidActor": "The Actor this inventory was configured for is not valid.", + "componentNotFound": "The component with ID {componentId} could not be found." + } + } + }, "CraftingComponentCarousel": { "buttons": { "next": "Next option", "previous": "Previous option" } }, + "ComponentSalvageCarousel": { + "buttons": { + "next": "Next option", + "previous": "Previous option" + }, + "hints": { + "requiresCatalysts": "This component requires these catalysts to salvaged." + } + }, "CraftingResultCarousel": { "buttons": { "next": "Next", @@ -80,7 +203,8 @@ "notSalvageable": "Something has gone wrong, and this component has zero salvage options." }, "hints": { - "doSalvage": "Salvaging this component will add these items to your inventory" + "doSalvage": "Salvaging this component will add these items to your inventory", + "requiresCatalysts": "This component requires these catalysts to salvaged." }, "dialog": { "doSalvage": { @@ -124,8 +248,8 @@ }, "settings": { "title": "Settings", - "enabled": { - "label": "Enabled", + "disabled": { + "label": "Disabled", "description": "Disabled systems cannot be used. Their components cannot be salvaged. Their recipes cannot be crafted." } } @@ -174,6 +298,8 @@ "labels": { "salvageName": "Option name", "salvageHeading": "Salvage", + "resultsHeading": "Results", + "catalystsHeading": "Catalysts", "availableComponentsHeading": "Available Components", "essencesHeading": "Essences", "replaceItem": "Drop an item to replace", @@ -235,7 +361,7 @@ "catalystsHeading": "Catalysts", "resultsHeading": "Results", "availableComponentsHeading": "Available Components", - "essencesHeading": "Required essences", + "essencesHeading": "Essences", "replaceItem": "Drop an item to replace", "newIngredientOption": "New", "newResultOption": "New", @@ -306,9 +432,6 @@ "title": "Delete Crafting System", "content": "Are you sure you want to delete {systemName}? This action cannot be undone. " }, - "deleteCraftingSystem": { - "success": "The Crafting System {systemName} was successfully deleted." - }, "saveCraftingSystem": { "success": "The Crafting System {systemName} was successfully updated." }, @@ -316,7 +439,7 @@ "success": "The Crafting System {systemName} was successfully exported to {fileName}." }, "duplicateCraftingSystem": { - "success": "Duplicated {sourceSystemName} as {duplicatedSystemName}." + "complete": "Duplicated {sourceSystemName} as {duplicatedSystemName}." }, "importCraftingSystem": { "title": "Import Crafting System", @@ -349,24 +472,6 @@ } }, "ui": { - "notifications": { - "submissionError": { - "prefix": "There were some problems with your form" - }, - "settings": { - "migration": { - "started": "Fabricate is migrating your crafting system settings. Please do close your browser or shut down the game world. ", - "finished": "Fabricate has finished migrating your crafting system settings!" - }, - "errors": { - "summary": "Fabricate was unable to read the setting for the key {settingKey}. Caused by:", - "noValue": "Expected a non-null, non-empty setting value.", - "noVersion": "Expected a non-null, non-empty setting version.", - "notFound": "No Setting was registered for the provided setting key.", - "migration": "Unable to migrate the setting for the key {settingKey}." - } - } - }, "sidebar": { "buttons": { "openCraftingSystemManager": "Crafting Systems" diff --git a/src/public/module.json b/src/public/module.json index e88c1507..0d6ae5ff 100644 --- a/src/public/module.json +++ b/src/public/module.json @@ -1,7 +1,7 @@ { "id": "fabricate", "title": "Fabricate", - "version": "0.8.9", + "version": "0.9.0", "description": "A system-agnostic, flexible crafting module for FoundryVTT", "authors": [ { diff --git a/src/scripts/5e/RollProvider5E.ts b/src/scripts/5e/RollProvider5E.ts index b8924c81..98eb49dc 100644 --- a/src/scripts/5e/RollProvider5E.ts +++ b/src/scripts/5e/RollProvider5E.ts @@ -5,7 +5,7 @@ import PatchActor5e = PatchTypes5e.PatchActor5e; import CharacterAbility = PatchTypes5e.CharacterAbility; import OwnedItemPatch5e = PatchTypes5e.OwnedItemPatch5e; import PatchAttributes5e = PatchTypes5e.PatchAttributes5e; -import {DnD5ECraftingCheckSpec} from "../system/bundled/DnD5e"; +import {DnD5ECraftingCheckSpec} from "../system/embedded/DnD5e"; class ToolProficiencyModifierCalculator implements ModifierCalculator { diff --git a/src/scripts/Properties.ts b/src/scripts/Properties.ts index 79f357ba..511c4b79 100644 --- a/src/scripts/Properties.ts +++ b/src/scripts/Properties.ts @@ -1,3 +1,5 @@ +import {SettingVersion} from "./repository/migration/SettingVersion"; + const Properties = { module: { id: "fabricate", @@ -7,15 +9,31 @@ const Properties = { }, documents: { supportedTypes: ["Item"] - } + }, }, ui: { defaults: { - essenceIconCode: "fa-solid fa-mortar-pestle", + essence: { + name: "My new essence", + tooltip: "A new essence", + iconCode: "fa-solid fa-mortar-pestle", + description: "A magical essence that can be used to craft items", + }, itemImageUrl: "icons/containers/bags/pack-simple-leather-tan.webp", noItemImageUrl: "modules/fabricate/assets/no-item-icon-4.webp", erroredItemImageUrl: "modules/fabricate/assets/item-loading-error-icon.webp", - recipeImageUrl: "icons/sundries/scrolls/scroll-runed-brown-black.webp" + recipeImageUrl: "icons/sundries/scrolls/scroll-runed-brown-black.webp", + craftingSystem: { + name: "My New Crafting System", + description: "This crafting system is a collection of recipes and components that can be used to craft items.", + author: (user?: string) => { + if (!user) { + return "Author"; + } + return user + }, + summary: "Summary" + } }, banners: { componentEditor: "modules/fabricate/assets/components-hero-banner.webp", @@ -36,24 +54,30 @@ const Properties = { } }, flags: { - keys: { - actor: { - hopperForSystem: (systemId: string) => `craftingSystems.${systemId}.hopper`, - knownRecipesForSystem: (systemId: string) => `craftingSystems.${systemId}.knownRecipes`, - }, - item: { - id: "id", - type: (systemId: string) => `craftingSystemData.${systemId}.type`, - recipe: (systemId: string) => `craftingSystemData.${systemId}.recipeData`, - componentData: (systemId: string) => `craftingSystemData.${systemId}.componentData`, - } - } + }, settings: { + collectionNames: { + craftingSystem: "CraftingSystem", + item: "Item", + gameSystem: "Game System" + }, craftingSystems: { key: "craftingSystems", - targetVersion: "2" - } + }, + essences: { + key: "essences", + }, + components: { + key: "components", + }, + recipes: { + key: "recipes", + }, + modelVersion: { + key: "modelVersion", + targetValue: SettingVersion.V3 + }, } }; diff --git a/src/scripts/actor/ComponentCombinationGenerator.ts b/src/scripts/actor/ComponentCombinationGenerator.ts index a89cd95b..9d14b182 100644 --- a/src/scripts/actor/ComponentCombinationGenerator.ts +++ b/src/scripts/actor/ComponentCombinationGenerator.ts @@ -1,12 +1,13 @@ import {ComponentCombinationNode} from "./ComponentCombinationNode"; -import {Combination, Unit} from "../common/Combination"; -import {CraftingComponent} from "../common/CraftingComponent"; -import {Essence} from "../common/Essence"; +import {Combination} from "../common/Combination"; +import {Component} from "../crafting/component/Component"; +import {Unit} from "../common/Unit"; +import {EssenceReference} from "../crafting/essence/EssenceReference"; interface ComponentEssenceCombination { - components: Combination; - essences: Combination; - isSufficientFor(requiredEssences: Combination): boolean; + components: Combination; + essences: Combination; + isSufficientFor(requiredEssences: Combination): boolean; } interface CombinationGenerationResult { @@ -69,23 +70,23 @@ class SuccessfulCombinationGenerationResult implements CombinationGenerationResu class DefaultComponentEssenceCombination implements ComponentEssenceCombination { - private readonly _components: Combination; - private readonly _essences: Combination; + private readonly _components: Combination; + private readonly _essences: Combination; - constructor(components: Combination, essences: Combination) { + constructor(components: Combination, essences: Combination) { this._components = components; this._essences = essences; } - get components(): Combination { + get components(): Combination { return this._components; } - get essences(): Combination { + get essences(): Combination { return this._essences; } - public isSufficientFor(requiredEssences: Combination): boolean { + public isSufficientFor(requiredEssences: Combination): boolean { return this._essences.size >= requiredEssences.size && requiredEssences.isIn(this._essences); } @@ -95,9 +96,9 @@ class DefaultComponentEssenceCombination implements ComponentEssenceCombination class ComponentCombinationGenerator { private readonly _roots: ComponentCombinationNode[]; - private readonly _requiredEssences: Combination; + private readonly _requiredEssences: Combination; - constructor(availableComponents: Combination, requiredEssences: Combination) { + constructor(availableComponents: Combination, requiredEssences: Combination) { this._requiredEssences = requiredEssences; this._roots = availableComponents.members .map((component) => Combination.ofUnit(new Unit(component, 1))) diff --git a/src/scripts/actor/ComponentCombinationNode.ts b/src/scripts/actor/ComponentCombinationNode.ts index d3a54073..802325b5 100644 --- a/src/scripts/actor/ComponentCombinationNode.ts +++ b/src/scripts/actor/ComponentCombinationNode.ts @@ -1,40 +1,41 @@ -import {Combination, Unit} from "../common/Combination"; -import {CraftingComponent} from "../common/CraftingComponent"; -import {Essence} from "../common/Essence"; +import {Combination} from "../common/Combination"; +import {Component} from "../crafting/component/Component"; +import {Unit} from "../common/Unit"; +import {EssenceReference} from "../crafting/essence/EssenceReference"; export class ComponentCombinationNode { - private readonly _requiredEssences: Combination; - private readonly _componentCombination: Combination; - private readonly _essenceCombination: Combination; - private readonly _remainingPicks: Combination; + private readonly _requiredEssences: Combination; + private readonly _componentCombination: Combination; + private readonly _essenceCombination: Combination; + private readonly _remainingPicks: Combination; private _children: ComponentCombinationNode[]; - constructor(requiredEssences: Combination, nodeCombination: Combination, remainingPicks: Combination) { + constructor(requiredEssences: Combination, nodeCombination: Combination, remainingPicks: Combination) { this._requiredEssences = requiredEssences; this._componentCombination = nodeCombination; this._remainingPicks = remainingPicks; - this._essenceCombination = nodeCombination.explode((component: CraftingComponent) => component.essences); + this._essenceCombination = nodeCombination.explode((component: Component) => component.essences); } public populate(): void { if (this._requiredEssences.isIn(this._essenceCombination)) { return; } - this._children = this._remainingPicks.members.map((component: CraftingComponent) => { + this._children = this._remainingPicks.members.map((component: Component) => { const deltaUnit = new Unit(component, 1); - const childComponentCombination: Combination = this._componentCombination.add(deltaUnit); - const remainingPicksForChild: Combination = this._remainingPicks.subtract(Combination.ofUnit(deltaUnit)); + const childComponentCombination: Combination = this._componentCombination.addUnit(deltaUnit); + const remainingPicksForChild: Combination = this._remainingPicks.subtract(Combination.ofUnit(deltaUnit)); return new ComponentCombinationNode(this._requiredEssences, childComponentCombination, remainingPicksForChild); }); this._children.forEach((child: ComponentCombinationNode) => child.populate()); } - get componentCombination(): Combination { + get componentCombination(): Combination { return this._componentCombination; } - get essenceCombination(): Combination { + get essenceCombination(): Combination { return this._essenceCombination; } diff --git a/src/scripts/actor/EssenceSelection.ts b/src/scripts/actor/EssenceSelection.ts index 478c4d4c..d8b50be7 100644 --- a/src/scripts/actor/EssenceSelection.ts +++ b/src/scripts/actor/EssenceSelection.ts @@ -1,23 +1,23 @@ import {Combination} from "../common/Combination"; -import {CraftingComponent} from "../common/CraftingComponent"; +import {Component} from "../crafting/component/Component"; import {ComponentCombinationGenerator, ComponentEssenceCombination} from "./ComponentCombinationGenerator"; -import {Essence} from "../common/Essence"; import {TrackedCombination} from "../common/TrackedCombination"; +import {EssenceReference} from "../crafting/essence/EssenceReference"; export class EssenceSelection { - private readonly _essences: Combination; + private readonly _essences: Combination; - constructor(essences: Combination) { + constructor(essences: Combination) { this._essences = essences; } - perform(contents: Combination): Combination { + perform(contents: Combination): Combination { if (this._essences.isEmpty()) { return Combination.EMPTY(); } let availableComponents = contents.clone(); - contents.members.forEach(((thisComponent: CraftingComponent) => { + contents.members.forEach(((thisComponent: Component) => { if (thisComponent.essences.isEmpty() || !thisComponent.essences.intersects(this._essences)) { availableComponents = availableComponents.subtract(availableComponents.just(thisComponent)); } @@ -30,7 +30,7 @@ export class EssenceSelection { return this.selectClosestMatch(generationResult.insufficientCombinations, this._essences); } - private selectBestMatch(combinations: ComponentEssenceCombination[]): Combination { + private selectBestMatch(combinations: ComponentEssenceCombination[]): Combination { if (combinations.length === 0) { return Combination.EMPTY(); } @@ -45,7 +45,7 @@ export class EssenceSelection { return sortedCombinations[0].components; } - private selectClosestMatch(combinations: ComponentEssenceCombination[], requiredEssences: Combination): Combination { + private selectClosestMatch(combinations: ComponentEssenceCombination[], requiredEssences: Combination): Combination { if (combinations.length === 0) { return Combination.EMPTY(); } diff --git a/src/scripts/actor/Inventory.ts b/src/scripts/actor/Inventory.ts index b095e97d..b818104f 100644 --- a/src/scripts/actor/Inventory.ts +++ b/src/scripts/actor/Inventory.ts @@ -1,258 +1,193 @@ -import {CraftingComponent} from "../common/CraftingComponent"; -import {Combination, Unit} from "../common/Combination"; -import {ObjectUtility} from "../foundry/ObjectUtility"; -import {CraftingResult} from "../crafting/result/CraftingResult"; -import {InventoryContentsNotFoundError} from "../error/InventoryContentsNotFoundError"; -import {AlchemyResult} from "../crafting/alchemy/AlchemyResult"; -import {GameProvider} from "../foundry/GameProvider"; -import {DocumentManager, FabricateItemData} from "../foundry/DocumentManager"; +import {Component} from "../crafting/component/Component"; +import {Combination} from "../common/Combination"; +import {Unit} from "../common/Unit"; +import {InventoryAction} from "./InventoryAction"; import EmbeddedCollection from "@league-of-foundry-developers/foundry-vtt-types/src/foundry/common/abstract/embedded-collection.mjs"; -import {BaseItem} from "@league-of-foundry-developers/foundry-vtt-types/src/foundry/common/documents.mjs"; +import {BaseActor, BaseItem} from "@league-of-foundry-developers/foundry-vtt-types/src/foundry/common/documents.mjs"; import {ActorData} from "@league-of-foundry-developers/foundry-vtt-types/src/foundry/common/data/data.mjs"; -import { - AlwaysOneItemQuantityReader, - ItemQuantityReader, - ItemQuantityWriter, - NoItemQuantityWriter -} from "./ItemQuantity"; -import {SalvageResult} from "../crafting/result/SalvageResult"; +import {LocalizationService} from "../../applications/common/LocalizationService"; +import Properties from "../Properties"; +import {Essence} from "../crafting/essence/Essence"; +import {ItemDataManager, SingletonItemDataManager} from "./ItemDataManager"; interface Inventory { - actor: any; - ownedComponents: Combination; - acceptCraftingResult(craftingResult: CraftingResult): Promise; - acceptSalvageResult(salvageResult: SalvageResult): Promise; - acceptAlchemyResult(alchemyResult: AlchemyResult): Promise; - index(): Promise; - contains(craftingComponent: CraftingComponent, quantity: number): boolean; - size: number - amountFor(craftingComponent: CraftingComponent): number; -} -interface InventoryActions { - additions: Combination; - removals: Combination; -} + /** + * The actor to which this inventory belongs. + */ + readonly actor: BaseActor; + + /** + * The components in this inventory. + * + * @returns A Promise that resolves with the components in this inventory. + * @throws Error if the actor does not exist. + */ + getContents(): Combination; + + /** + * Perform the specified action on this inventory, adding and removing components as necessary. Additions and + * removals are performed separately. If one fails, the other will be rolled back, or aborted. They are reconciled + * before being applied. + * + * @param action - The action to perform. + * @returns A Promise that resolves with the new contents of this inventory. + * @throws Error if the inventory does not contain the required components, or if the actor does not exist. + */ + perform(action: InventoryAction): Promise>; + + /** + * Determines whether this inventory contains the specified component in the specified quantity. + * + * @param craftingComponent - The component to check for. + * @param quantity - The quantity of the component to check for. Defaults to 1. + * @returns A Promise that resolves with true if the inventory contains the specified component in the specified + * quantity, false otherwise. + * @throws Error if the actor does not exist. + */ + contains(craftingComponent: Component, quantity?: number): boolean; -interface InventoryRecord { - item: any; - quantity: number; } class CraftingInventory implements Inventory { - private readonly _documentManager: DocumentManager; - private readonly _objectUtils: ObjectUtility; - private readonly _actor: any; - private readonly _knownComponentsByItemUuid: Map; - private _managedItems: Map; - private readonly _itemQuantityReader: ItemQuantityReader; - private readonly _itemQuantityWriter: ItemQuantityWriter; + private readonly _actor: BaseActor; + private readonly _localization: LocalizationService; + private readonly _itemDataManager: ItemDataManager; + + /** + * A Map of source item UUIDs to the Component that uses it. An inventory expects to be initialised with a complete + * set of components for a single crafting system. Item UUIDs must therefore map to a single component. + * @private + */ + private readonly _knownComponentsByItemUuid: Map; + private readonly _knownComponentsById: Map; constructor({ actor, - documentManager, - objectUtils, - managedItems = new Map(), - knownComponentsByItemUuid = new Map(), - itemQuantityReader = new AlwaysOneItemQuantityReader(), - itemQuantityWriter = new NoItemQuantityWriter() + localization, + itemDataManager = new SingletonItemDataManager(), + knownComponents = [], }: { - actor: any; - gameProvider: GameProvider; - documentManager: DocumentManager; - objectUtils: ObjectUtility; - knownComponentsByItemUuid?: Map; - ownedComponents?: Combination; - managedItems?: Map; - itemQuantityReader?: ItemQuantityReader; - itemQuantityWriter?: ItemQuantityWriter; + actor: BaseActor; + localization: LocalizationService; + knownEssencesById?: Map; + itemDataManager?: ItemDataManager; + knownComponents?: Component[]; }) { this._actor = actor; - this._knownComponentsByItemUuid = knownComponentsByItemUuid; - this._documentManager = documentManager; - this._objectUtils = objectUtils; - this._managedItems = managedItems; - this._itemQuantityReader = itemQuantityReader; - this._itemQuantityWriter = itemQuantityWriter; + this._localization = localization; + this._itemDataManager = itemDataManager; - } + this._knownComponentsByItemUuid = new Map(); + this._knownComponentsById = new Map(); - get actor(): any { - return this._actor; + knownComponents.forEach(component => { + this._knownComponentsByItemUuid.set(component.itemUuid, component); + this._knownComponentsById.set(component.id, component); + }); } - contains(craftingComponent: CraftingComponent, quantity: number = 1): boolean { - if (!this._managedItems.has(craftingComponent)) { - return false; + get actor(): BaseActor { + if (!this._actor) { + throw new Error(this._localization.localize(`${Properties.module.id}.inventory.error.invalidActor`)); } - return quantity <= this.amountFor(craftingComponent); + return this._actor; } - amountFor(craftingComponent: CraftingComponent) { - if (!this._managedItems.has(craftingComponent)) { - return 0; - } - return this._managedItems.get(craftingComponent).reduce((previousValue, currentValue) => { - return previousValue + currentValue.quantity; - }, 0); + contains(craftingComponent: Component, quantity: number = 1): boolean { + const contents = this.getContents(); + return contents.has(craftingComponent, quantity); } - async removeAll(components: Combination): Promise { - const updates: any[] = []; - const deletes: any[] = []; - for (const unit of components.units) { - const craftingComponent: CraftingComponent = unit.part; - if (!this.contains(craftingComponent)) { - throw new InventoryContentsNotFoundError(unit, 0, this._actor.id); - } - const amountOwned = this.amountFor(craftingComponent); - if (unit.quantity > amountOwned) { - throw new InventoryContentsNotFoundError(unit, amountOwned, this._actor.id); - } - const records = this._managedItems.get(craftingComponent) - .sort((left, right) => left.quantity - right.quantity); - let outstandingRemovalAmount: number = unit.quantity; - let currentRecordIndex: number = 0; - while (outstandingRemovalAmount > 0) { - const currentRecord = records[currentRecordIndex]; - const quantity: number = currentRecord.quantity; - const itemData: any = currentRecord.item; - if (quantity <= outstandingRemovalAmount) { - deletes.push(itemData); - outstandingRemovalAmount -= quantity; - } else { - const remainingQuantity = quantity - outstandingRemovalAmount; - const itemData = await this.prepareItemUpdate(currentRecord, remainingQuantity); - updates.push(itemData); - outstandingRemovalAmount = 0; + getContents(): Combination { + // @ts-ignore + const contentsWithSourceItems = this.getContentsWithSourceItems(); + return Array.from(contentsWithSourceItems.entries()) + .flatMap(([componentId, items]) => { + return items.map((item: any) => { + const quantity = this._itemDataManager.count(item); + return new Unit(this._knownComponentsById.get(componentId), quantity); + }); + }) + .reduce((contents, unit) => contents.addUnit(unit), Combination.EMPTY()); + } + + private getContentsWithSourceItems(): Map { + const actor = this.actor; + // @ts-ignore + const ownedItems: EmbeddedCollection = actor.items; + return Array.from(ownedItems.values()) + .filter((item: any) => this._knownComponentsByItemUuid.has(item.getFlag("core", "sourceId"))) + .flatMap((item: BaseItem) => { + const sourceItemUuid: string = item.getFlag("core", "sourceId"); + const component = this._knownComponentsByItemUuid.get(sourceItemUuid); + return { component, item }; + }) + .reduce((contents, entry) => { + if (!contents.has(entry.component.id)) { + contents.set(entry.component.id, []); } - currentRecordIndex++; - } - } - const results: any[] = []; - if (updates.length > 0) { - const updatedItems: any[] = await this.updateOwnedItems(this._actor, updates); - results.push(...updatedItems); - } - if (deletes.length > 0) { - const createdItems: any[] = await this.deleteOwnedItems(this._actor, deletes); - results.push(...createdItems); - } + contents.get(entry.component.id) + .push(entry.item); + return contents; + }, new Map()); + } + async perform(action: InventoryAction): Promise> { + const rationalisedAction = action.rationalise(); + if (rationalisedAction.additions.isEmpty() && rationalisedAction.removals.isEmpty()) { + return this.getContents(); + } + await this.apply(action); + return this.getContents(); } - async addAll(components: Combination, activeEffects: ActiveEffect[]): Promise { + async removeAll(components: Combination): Promise { - const creates: any[] = await Promise.all(components.units - .filter(unit => !this.contains(unit.part)) - .map(unit => { - return this.buildItemData(unit, activeEffects); - }) - ); - - const updates: any[] = await Promise.all(components.units - .filter(unit => this.contains(unit.part)) - .map(unit => { - const records = this._managedItems.get(unit.part) - .sort((left, right) => right.quantity - left.quantity); - const inventoryRecord: InventoryRecord = records[0]; - const targetQuantity = unit.quantity + inventoryRecord.quantity; - return this.prepareItemUpdate(inventoryRecord, targetQuantity); - }) - ); + const sourceItemsByComponentId = this.getContentsWithSourceItems(); + + const itemData = this._itemDataManager.prepareRemovals(components, sourceItemsByComponentId); - if (updates.length > 0) { - await this.updateOwnedItems(this._actor, updates); + if (itemData.updates.length > 0) { + await this.updateOwnedItems(this._actor, itemData.updates); } - if (creates.length > 0) { - await this.createOwnedItems(this._actor, creates); + if (itemData.deletes.length > 0) { + await this.deleteOwnedItems(this._actor, itemData.deletes); } - console.log(`Fabricate | Created ${creates.length} items and updated ${updates.length} items for actor "${this._actor.name}". `); } - private async prepareItemUpdate(inventoryRecord: InventoryRecord, targetQuantity: number): Promise { - const newItemData: any = this._objectUtils.duplicate(inventoryRecord.item); - await this._itemQuantityWriter.write(targetQuantity, newItemData); - return newItemData; - } + async addAll(components: Combination, activeEffects: ActiveEffect[]): Promise { - private async buildItemData(unit: Unit, activeEffects: ActiveEffect[]): Promise { - const sourceData: FabricateItemData = unit.part.itemData; - const itemData: any = this._objectUtils.duplicate(sourceData.sourceDocument); - itemData.effects = [...itemData.effects, ...activeEffects]; - itemData.flags.core = {sourceId: sourceData.uuid}; - await this._itemQuantityWriter.write(unit.quantity, itemData); - return itemData; - } + const sourceItemsByComponentId = this.getContentsWithSourceItems(); - async acceptCraftingResult(craftingResult: CraftingResult): Promise { - return this.acceptResult(craftingResult.created, craftingResult.consumed); - } + const itemData = this._itemDataManager.prepareAdditions(components, activeEffects, sourceItemsByComponentId); - async acceptSalvageResult(salvageResult: SalvageResult): Promise { - return this.acceptResult(salvageResult.created, salvageResult.consumed); - } + if (itemData.updates.length > 0) { + await this.updateOwnedItems(this._actor, itemData.updates); + } + if (itemData.creates.length > 0) { + await this.createOwnedItems(this._actor, itemData.creates); + } - private async acceptResult(created: Combination, consumed: Combination): Promise { - await this.index(); - - const activeEffects = consumed - .explode(craftingComponent => craftingComponent.essences) - .members - .filter(essence => essence.hasActiveEffectSource) - .flatMap(essence => essence.activeEffectSource.sourceDocument.effects.contents) - .map(activeEffect => this._objectUtils.duplicate(activeEffect)); - - const inventoryActions: InventoryActions = this.rationalise(created, consumed); - await Promise.all([ - this.addAll(inventoryActions.additions, activeEffects), - this.removeAll(inventoryActions.removals) - ]); - await this.index(); + // @ts-ignore - TODO: FVTT Types are wrong and `actor.name` is missing + console.log(`Fabricate | Created ${itemData.creates.length} items and updated ${itemData.updates.length} items for actor "${this._actor.name}". `); } - async acceptAlchemyResult(alchemyResult: AlchemyResult): Promise { - const baseItem = await this._documentManager.getDocumentByUuid(alchemyResult.baseComponent.itemUuid) - const baseItemData = this._objectUtils.duplicate(baseItem); - // todo: implement once types and process are known - // @ts-ignore - const createdItemData: Entity.Data = await this._actor.createEmbeddedEntity('OwnedItem', baseItemData); - return null; + private async apply(action: InventoryAction): Promise { + await this.addAll(action.additions, action.activeEffects); + await this.removeAll(action.removals); } - private rationalise(created: Combination, consumed: Combination): InventoryActions { - if (!consumed.intersects(created)) { - return ({ - additions: created, - removals: consumed - }); - } - let rationalisedCreated: Combination = created; - let rationalisedConsumed: Combination = Combination.EMPTY(); - consumed.units.forEach(consumedUnit => { - if (!created.has(consumedUnit.part)) { - rationalisedConsumed = rationalisedCreated.add(consumedUnit); - } - if (created.containsAll(consumedUnit)) { - rationalisedCreated = rationalisedCreated.minus(consumedUnit); - } else { - const updatedConsumedUnit: Unit = rationalisedCreated.amounts.get(consumedUnit.part.id) - .minus(consumedUnit.quantity); - rationalisedCreated = rationalisedCreated.minus(consumedUnit); - rationalisedConsumed = rationalisedConsumed.add(updatedConsumedUnit.invert()); + async deleteOwnedItems(actor: any, itemsData: any[]): Promise { + const ids: string[] = itemsData.map((itemData: any) => { + if (!itemData.id) { + throw new Error("Cannot delete item without ID. "); } + return itemData.id; }); - return ({ - additions: rationalisedCreated, - removals: rationalisedConsumed - }); - } - - async deleteOwnedItems(actor: any, itemsData: any[]): Promise { - const ids: string[] = itemsData.map((itemData: any) => itemData._id); return actor.deleteEmbeddedDocuments("Item", ids); } @@ -264,37 +199,6 @@ class CraftingInventory implements Inventory { return actor.updateEmbeddedDocuments("Item", itemsData); } - async index(): Promise { - const actor: any = this.actor; - const ownedItems: EmbeddedCollection = actor.items; - const itemsByComponentType: Map = new Map(); - await Promise.all(Array.from(ownedItems.values()) - .filter((item: any) => this._knownComponentsByItemUuid.has(item.getFlag("core", "sourceId"))) - .map(async (item: BaseItem) => { - const sourceItemUuid: string = item.getFlag("core", "sourceId"); - const component = this._knownComponentsByItemUuid.get(sourceItemUuid); - const quantity = await this._itemQuantityReader.read(item); - if (itemsByComponentType.has(component)) { - itemsByComponentType.get(component).push({ item, quantity }); - } else { - itemsByComponentType.set(component, [{ item, quantity }]); - } - })) - this._managedItems = itemsByComponentType; - } - - get size(): number { - return Array.from(this._managedItems.keys()) - .map(component => this.amountFor(component)) - .reduce((previousValue, currentValue) => previousValue + currentValue, 0) - } - - get ownedComponents(): Combination { - return Array.from(this._managedItems.keys()) - .flatMap(component => this._managedItems.get(component).map(itemRecord => Combination.of(component, itemRecord.quantity))) - .reduce((previousValue, currentValue) => previousValue.combineWith(currentValue), Combination.EMPTY()); - } - } export {Inventory, CraftingInventory} \ No newline at end of file diff --git a/src/scripts/actor/InventoryAction.ts b/src/scripts/actor/InventoryAction.ts new file mode 100644 index 00000000..bff41d43 --- /dev/null +++ b/src/scripts/actor/InventoryAction.ts @@ -0,0 +1,95 @@ +import {Combination} from "../common/Combination"; +import {Component} from "../crafting/component/Component"; + +interface InventoryAction { + + /** + * The components to add to the inventory. + */ + readonly additions: Combination; + + /** + * The components to remove from the inventory. + */ + readonly removals: Combination; + + /** + * The active effects to apply to the added components. + */ + readonly activeEffects: ActiveEffect[]; + + /** + * Rationalises the action to remove any components that are added and removed in the same action. + */ + rationalise(): InventoryAction; + + withoutAdditions(): InventoryAction; + + withoutRemovals(): InventoryAction; + +} + +export { InventoryAction } + +class SimpleInventoryAction implements InventoryAction { + + private readonly _additions: Combination; + private readonly _removals: Combination; + private readonly _activeEffects: ActiveEffect[]; + + constructor({ + additions = Combination.EMPTY(), + removals = Combination.EMPTY(), + activeEffects = [], + }: { + additions?: Combination; + removals?: Combination; + activeEffects?: ActiveEffect[]; + }) { + this._additions = additions; + this._removals = removals; + this._activeEffects = activeEffects; + } + + get additions(): Combination { + return this._additions; + } + + get removals(): Combination { + return this._removals; + } + + get activeEffects(): ActiveEffect[] { + return this._activeEffects; + } + + rationalise(): InventoryAction { + const consumedInCreated = this._additions.intersectionWith(this._removals); + const rationalisedRemovals = this._removals.subtract(consumedInCreated); + const rationalisedAdditions = this._additions.subtract(consumedInCreated); + return new SimpleInventoryAction({ + additions: rationalisedAdditions, + removals: rationalisedRemovals, + activeEffects: this._activeEffects + }); + } + + withoutAdditions(): InventoryAction { + return new SimpleInventoryAction({ + additions: Combination.EMPTY(), + removals: this._removals, + activeEffects: this._activeEffects + }); + } + + withoutRemovals(): InventoryAction { + return new SimpleInventoryAction({ + additions: this._additions, + removals: Combination.EMPTY(), + activeEffects: this._activeEffects + }); + } + +} + +export { SimpleInventoryAction }; diff --git a/src/scripts/actor/InventoryFactory.ts b/src/scripts/actor/InventoryFactory.ts index 26590f76..101d36f6 100644 --- a/src/scripts/actor/InventoryFactory.ts +++ b/src/scripts/actor/InventoryFactory.ts @@ -1,66 +1,68 @@ import {CraftingInventory, Inventory} from "./Inventory"; -import { - AlwaysOneItemQuantityReader, - DnD5EItemQuantityReader, - DnD5EItemQuantityWriter, - ItemQuantityReader, - ItemQuantityWriter, NoItemQuantityWriter -} from "./ItemQuantity"; -import {DefaultDocumentManager} from "../foundry/DocumentManager"; -import {DefaultObjectUtility} from "../foundry/ObjectUtility"; -import {GameProvider} from "../foundry/GameProvider"; -import {CraftingSystem} from "../system/CraftingSystem"; +import {DefaultObjectUtility, ObjectUtility} from "../foundry/ObjectUtility"; +import {Component} from "../crafting/component/Component"; +import {ItemDataManager, PropertyPathAwareItemDataManager, SingletonItemDataManager} from "./ItemDataManager"; +import {LocalizationService} from "../../applications/common/LocalizationService"; +import {BaseActor} from "@league-of-foundry-developers/foundry-vtt-types/src/foundry/common/documents.mjs"; interface InventoryFactory { - make(actor: any, craftingSystem: CraftingSystem): Inventory; + make(gameSystemId: string, + actor: BaseActor, + knownComponents: Component[], + ): Inventory; } + class DefaultInventoryFactory implements InventoryFactory { - private static readonly _itemQuantityIoByGameSystem: Map = new Map([ - ["dnd5e", { - reader: new DnD5EItemQuantityReader(), - writer: new DnD5EItemQuantityWriter() - }] + private static readonly _KNOWN_GAME_SYSTEM_ITEM_QUANTITY_PROPERTY_PATHS: Map = new Map([ + ["dnd5e", "system.quantity"] ]); - private readonly _gameProvider: GameProvider; - - constructor(gameProvider: GameProvider) { - this._gameProvider = gameProvider; - } + private readonly _localizationService: LocalizationService; + private readonly _objectUtility: ObjectUtility; + private readonly _gameSystemItemQuantityPropertyPaths: Map; - public static registerItemReader(gameSystem: string, itemQuantityReader: ItemQuantityReader) { - if (!DefaultInventoryFactory._itemQuantityIoByGameSystem.has(gameSystem)) { - DefaultInventoryFactory._itemQuantityIoByGameSystem.set(gameSystem, { writer: null, reader: null }); - } - DefaultInventoryFactory._itemQuantityIoByGameSystem.get(gameSystem).reader = itemQuantityReader; + constructor({ + localizationService, + objectUtility = new DefaultObjectUtility(), + gameSystemItemQuantityPropertyPaths = DefaultInventoryFactory._KNOWN_GAME_SYSTEM_ITEM_QUANTITY_PROPERTY_PATHS, + }: { + localizationService: LocalizationService; + objectUtility?: ObjectUtility; + gameSystemItemQuantityPropertyPaths?: Map; + }) { + this._localizationService = localizationService; + this._objectUtility = objectUtility; + this._gameSystemItemQuantityPropertyPaths = gameSystemItemQuantityPropertyPaths; } - public static registerItemWriter(gameSystem: string, itemQuantityWriter: ItemQuantityWriter) { - if (!DefaultInventoryFactory._itemQuantityIoByGameSystem.has(gameSystem)) { - DefaultInventoryFactory._itemQuantityIoByGameSystem.set(gameSystem, { writer: null, reader: null }); - } - DefaultInventoryFactory._itemQuantityIoByGameSystem.get(gameSystem).writer = itemQuantityWriter; + public registerGameSystemItemQuantityPropertyPath(gameSystem: string, propertyPath: string) { + this._gameSystemItemQuantityPropertyPaths.set(gameSystem, propertyPath); } - make(actor: any, craftingSystem: CraftingSystem): Inventory { - const GAME = this._gameProvider.get(); + make(gameSystemId: string, + actor: BaseActor, + knownComponents: Component[], + ): Inventory { - const itemQuantityIo = DefaultInventoryFactory._itemQuantityIoByGameSystem.get(GAME.system.id); - const itemQuantityReader = itemQuantityIo ? itemQuantityIo.reader : new AlwaysOneItemQuantityReader(); - const itemQuantityWriter = itemQuantityIo ? itemQuantityIo.writer : new NoItemQuantityWriter(); + let itemDataManager: ItemDataManager; + if (this._gameSystemItemQuantityPropertyPaths.has(gameSystemId)) { + itemDataManager = new PropertyPathAwareItemDataManager({ + objectUtils: this._objectUtility, + propertyPath: this._gameSystemItemQuantityPropertyPaths.get(gameSystemId), + }); + } else { + itemDataManager = new SingletonItemDataManager({ objectUtils: this._objectUtility } ); + } - return new CraftingInventory({ + return new CraftingInventory({ actor, - documentManager: new DefaultDocumentManager(), - objectUtils: new DefaultObjectUtility(), - gameProvider: this._gameProvider, - knownComponentsByItemUuid: craftingSystem.craftingComponentsByItemUuid, - itemQuantityReader, - itemQuantityWriter - }); + localization: this._localizationService, + itemDataManager, + knownComponents, + }); } diff --git a/src/scripts/actor/ItemDataManager.ts b/src/scripts/actor/ItemDataManager.ts new file mode 100644 index 00000000..0857ddb9 --- /dev/null +++ b/src/scripts/actor/ItemDataManager.ts @@ -0,0 +1,213 @@ +import {DefaultObjectUtility, ObjectUtility} from "../foundry/ObjectUtility"; +import {Combination} from "../common/Combination"; +import {Component} from "../crafting/component/Component"; +import {Unit} from "../common/Unit"; +import {FabricateItemData} from "../foundry/DocumentManager"; +import {InventoryContentsNotFoundError} from "../error/InventoryContentsNotFoundError"; + +interface ItemDataManager { + + /** + * Counts the quantity of the specified item. + * + * @param item - The item to count the quantity of. + * @returns The quantity of the specified item. + */ + count(item: any): number; + + /** + * Prepares the additions to be made to the inventory as item update or create operations. + * + * @param components - The components to add. + * @param activeEffects - The active effects to apply to the added items. + * @param ownedItemsByComponentId - The owned items for each component. + * @returns The updates and creates to be applied to the inventory. + */ + prepareAdditions(components: Combination, + activeEffects: ActiveEffect[], + ownedItemsByComponentId: Map + ): { updates: any[], creates: any[] }; + + /** + * Prepares the removals to be made from the inventory as item update or delete operations. + * + * @param components - The components to remove. + * @param ownedItemsByComponentId - The owned items for each component. + * @returns The updates and deletes to be applied to the inventory. + */ + prepareRemovals(components: Combination, ownedItemsByComponentId: Map): { updates: any[], deletes: any[] }; + +} + +class SingletonItemDataManager implements ItemDataManager { + + private readonly _objectUtils: ObjectUtility; + + constructor({ + objectUtils = new DefaultObjectUtility(), + }: { + objectUtils?: ObjectUtility; + } = {}) { + this._objectUtils = objectUtils; + } + + count(): number { + return 1; + } + + prepareAdditions(components: Combination, activeEffects: ActiveEffect[]): { + updates: any[], + creates: any[] + } { + const creates = components + .units + .map(unit => this.buildItemData(unit, activeEffects)); + return {updates: [], creates}; + } + + private buildItemData(unit: Unit, activeEffects: ActiveEffect[]): any { + const sourceData: FabricateItemData = unit.element.itemData; + const itemData: any = this._objectUtils.duplicate(sourceData.sourceDocument); + itemData.effects = [...itemData.effects, ...activeEffects]; + itemData.flags.core = { sourceId: sourceData.uuid }; + return itemData; + } + + prepareRemovals(components: Combination, ownedItemsByComponentId: Map): { + updates: any[]; + deletes: any[] + } { + const deletes = components.units + .map(unit => { + const craftingComponent: Component = unit.element; + if (!ownedItemsByComponentId.has(craftingComponent.id)) { + throw new InventoryContentsNotFoundError(unit, 0); + } + const ownedItemsForComponent = ownedItemsByComponentId.get(craftingComponent.id); + if (unit.quantity > ownedItemsForComponent.length) { + throw new InventoryContentsNotFoundError(unit, ownedItemsForComponent.length); + } + return ownedItemsForComponent.splice(0, unit.quantity); + }) + .reduce((allItems, items) => [...allItems, ...items], []); + return { updates: [], deletes }; + } + +} + +class PropertyPathAwareItemDataManager implements ItemDataManager { + + private readonly _objectUtils: ObjectUtility; + private readonly _propertyPath: string; + + constructor({ + objectUtils = new DefaultObjectUtility(), + propertyPath, + }: { + objectUtils?: ObjectUtility; + propertyPath: string; + }) { + this._objectUtils = objectUtils; + this._propertyPath = propertyPath; + } + + count(item: any): number { + const quantity = this._objectUtils.getPropertyValue(this._propertyPath, item); + if (typeof quantity !== "number") { + throw new Error(`Expected a number, but found ${quantity}`); + } + return quantity; + } + + prepareAdditions(components: Combination, + activeEffects: ActiveEffect[], + ownedItemsByComponentId: Map + ): { updates: any[]; creates: any[] } { + + const creates = components.units + .filter(unit => !ownedItemsByComponentId.has(unit.element.id)) + .map(unit => this.buildItemData(unit, activeEffects)); + + const updates: any[] = components.units + .filter(unit => ownedItemsByComponentId.has(unit.element.id)) + .map(unit => { + const items = ownedItemsByComponentId.get(unit.element.id) + .sort((left, right) => this.count(right) - this.count(left)); + const highestQuantityItem = items[0]; + const targetQuantity = unit.quantity + this.count(highestQuantityItem); + return this.prepareItemUpdate(highestQuantityItem, targetQuantity); + }); + + return {creates, updates}; + + } + + private prepareItemUpdate(item: any, targetQuantity: number): any { + const newItemData: any = this._objectUtils.duplicate(item); + this._objectUtils.setPropertyValue(this._propertyPath, newItemData, targetQuantity); + return newItemData; + } + + private buildItemData(unit: Unit, activeEffects: ActiveEffect[]): any { + const sourceData: FabricateItemData = unit.element.itemData; + const itemData: any = this._objectUtils.duplicate(sourceData.sourceDocument); + itemData.effects = [...itemData.effects, ...activeEffects]; + itemData.flags.core = { sourceId: sourceData.uuid }; + this._objectUtils.setPropertyValue(this._propertyPath, itemData, unit.quantity); + return itemData; + } + + prepareRemovals(components: Combination, sourceItemsByComponentId: Map): { + updates: any[]; + deletes: any[] + } { + return components.units + .map(unit => { + + const craftingComponent: Component = unit.element; + if (!sourceItemsByComponentId.has(craftingComponent.id)) { + throw new InventoryContentsNotFoundError(unit, 0); + } + + const ownedItemsForComponent = sourceItemsByComponentId.get(craftingComponent.id); + const ownedQuantity = ownedItemsForComponent + .reduce((total, item) => total + this.count(item), 0); + const amountToRemove = unit.quantity; + + if (amountToRemove > ownedQuantity) { + throw new InventoryContentsNotFoundError(unit, ownedItemsForComponent.length); + } + + if (amountToRemove === ownedQuantity) { + return { deletes: ownedItemsForComponent, updates: [] }; + } + + const ownedItemsSortedByLowestQuantity = ownedItemsForComponent + .sort((left, right) => this.count(left) - this.count(right)); + let remainingAmountToRemove = amountToRemove; + const deletes: any[] = []; + const updates: any[] = []; + + for (const item of ownedItemsSortedByLowestQuantity) { + const thisItemQuantity = this.count(item); + if (thisItemQuantity > remainingAmountToRemove) { + updates.push(this.prepareItemUpdate(item, thisItemQuantity - remainingAmountToRemove)); + break; + } + deletes.push(item); + remainingAmountToRemove -= thisItemQuantity; + } + + return { deletes, updates }; + + }) + .reduce((allItems, items) => { + return { deletes: [...allItems.deletes, ...items.deletes], updates: [...allItems.updates, ...items.updates] }; + }, { deletes: [], updates: [] }); + } + + + +} + +export { ItemDataManager, SingletonItemDataManager, PropertyPathAwareItemDataManager } \ No newline at end of file diff --git a/src/scripts/actor/ItemQuantity.ts b/src/scripts/actor/ItemQuantity.ts deleted file mode 100644 index 6755c8fd..00000000 --- a/src/scripts/actor/ItemQuantity.ts +++ /dev/null @@ -1,100 +0,0 @@ -interface ItemQuantityReader { - - read(item: any): Promise; - -} - -interface ItemQuantityWriter { - - write(quantity: number, item: any): Promise; - -} - -class DnD5EItemQuantityReader implements ItemQuantityReader { - - async read(item: any): Promise { - return item.system.quantity; - } - -} - -class DnD5EItemQuantityWriter implements ItemQuantityWriter { - - async write(quantity: number, item: any): Promise { - item.system.quantity = quantity; - } - -} - -class AlwaysOneItemQuantityReader implements ItemQuantityReader { - - async read(): Promise { - return 1; - } - -} - -class NoItemQuantityWriter implements ItemQuantityWriter { - - async write(): Promise { - return; - } - -} - -class MacroItemQuantityReader implements ItemQuantityReader { - - private _macro: Macro; - - constructor(macro: Macro) { - this._macro = macro; - } - - async read(item: any): Promise { - const AsyncFunction = Object.getPrototypeOf(async function() {}).constructor; - // @ts-ignore - // todo: Macro Types are wrong in FVTT Types - const script: string = this._macro.command; - const fn = new AsyncFunction("item", script); - try { - return await fn(item); - } catch(e) { - throw new Error("There was an error in the supplied macro syntax. "); - } - } - -} - -class MacroItemQuantityWriter implements ItemQuantityWriter { - - private _macro: Macro; - - constructor(macro: Macro) { - this._macro = macro; - } - - async write(quantity: number, item: any): Promise { - const AsyncFunction = Object.getPrototypeOf(async function() {}).constructor; - // @ts-ignore - // todo: Macro Types are wrong in FVTT Types - const script: string = this._macro.command; - const fn = new AsyncFunction("quantity", "item", script); - try { - return await fn(quantity, item); - } catch(e) { - throw new Error("There was an error in the supplied macro syntax. "); - } - } - -} - -export { - ItemQuantityReader, - ItemQuantityWriter, - AlwaysOneItemQuantityReader, - NoItemQuantityWriter, - DnD5EItemQuantityReader, - DnD5EItemQuantityWriter, - MacroItemQuantityReader, - MacroItemQuantityWriter -} \ No newline at end of file diff --git a/src/scripts/api/ComponentAPI.ts b/src/scripts/api/ComponentAPI.ts new file mode 100644 index 00000000..25824f72 --- /dev/null +++ b/src/scripts/api/ComponentAPI.ts @@ -0,0 +1,643 @@ +import {Component, ComponentJson} from "../crafting/component/Component"; +import Properties from "../Properties"; +import {LocalizationService} from "../../applications/common/LocalizationService"; +import {EntityDataStore} from "../repository/EntityDataStore"; +import {IdentityFactory} from "../foundry/IdentityFactory"; +import {EntityValidationResult} from "./EntityValidator"; +import {ComponentValidator} from "../crafting/component/ComponentValidator"; +import {NotificationService} from "../foundry/NotificationService"; +import {SalvageOptionJson} from "../crafting/component/SalvageOption"; +import {ComponentExportModel} from "../repository/import/FabricateExportModel"; + +/** + * A value object representing an option for salvaging a component + * + * @interface + * */ +interface SalvageOptionValue { + + /** + * The name of the salvage option. + */ + name: string; + + /** + * The salvage that will be produced when the option is used to salvage the component. + */ + results: Record; + + /** + * The additional components that must be present to salvage this component + */ + catalysts: Record; + +} + +/** + * Options for creating a new component. + * + * @interface + */ +interface ComponentCreationOptions { + + /** + * The UUID of the item associated with the component. + * */ + itemUuid: string; + + /** + * The ID of the crafting system that the component belongs to. + * */ + craftingSystemId: string; + + /** + * Optional dictionary of the essences contained by the component. The dictionary is keyed on the essence ID and + * with the values representing the required quantities. + * */ + essences?: Record; + + /** + * Whether the component is disabled. Defaults to false. + * */ + disabled?: boolean; + + /** + * Optional array of salvage options for the component. + * */ + salvageOptions?: SalvageOptionValue[]; + +} + +export { ComponentCreationOptions }; + +/** + * A service for managing components. + * + * @interface + */ +interface ComponentAPI { + + /** + * Creates a new component with the given options. + * + * @async + * @param {ComponentCreationOptions} componentOptions - The options for the component. + * @returns {Promise} - A promise that resolves with the newly created component. As document data is loaded + * during validation, the created component is returned with item data loaded. + * @throws {Error} - If there is an error creating the component. + */ + create(componentOptions: ComponentCreationOptions): Promise; + + /** + * Returns all components. + * + * @async + * @returns {Promise>} A promise that resolves to a Map of component instances, where the keys are + * the component IDs, or rejects with an Error if the settings cannot be read. + */ + getAll(): Promise>; + + /** + * Retrieves the component with the specified ID. + * + * @async + * @param {string} id - The ID of the component to retrieve. + * @returns {Promise} A Promise that resolves with the component, or undefined if it does not + * exist. + */ + getById(id: string): Promise; + + /** + * Retrieves all components with the specified IDs. + * + * @async + * @param {string} componentIds - An array of component IDs to retrieve. + * @returns {Promise} A Promise that resolves to a Map of component instances, where the keys are + * the component IDs. Values are undefined if the component with the corresponding ID does not exist + */ + getAllById(componentIds: string[]): Promise>; + + /** + * Returns all components for a given crafting system ID. + * + * @async + * @param {string} craftingSystemId - The ID of the crafting system. + * @returns {Promise>} A Promise that resolves to a Map of component instances for the given + * crafting system, where the keys are the component IDs, or rejects with an Error if the settings cannot be read. + */ + getAllByCraftingSystemId(craftingSystemId: string): Promise>; + + /** + * Returns all components in a map keyed on component ID, for a given item UUID. + * + * @async + * @param {string} itemUuid - The UUID of the item. + * @returns {Promise>} A Promise that resolves to a Map of component instances, where the keys + * are the component IDs, or rejects with an Error if the settings cannot be read. + */ + getAllByItemUuid(itemUuid: string): Promise>; + + /** + * Saves a component. + * + * @async + * @param {Component} component - The component to save. + * @returns {Promise} A Promise that resolves with the saved component, or rejects with an error if the component + * is not valid, or cannot be saved. As document data is loaded during validation, the created component is returned + * with item data loaded. + */ + save(component: Component): Promise; + + /** + * Deletes a component by ID. + * + * @async + * @param {string} componentId - The ID of the component to delete. + * @returns {Promise} A Promise that resolves to the deleted component or undefined if the component + * with the given ID does not exist. + */ + deleteById(componentId: string): Promise; + + /** + * Deletes all components associated with a given item UUID. + * + * @async + * @param {string} componentId - The UUID of the item to delete components for. + * @returns {Promise} A Promise that resolves to the deleted component(s) or an empty array if no + * components were associated with the given item UUID. Rejects with an Error if the components could not be deleted. + */ + deleteByItemUuid(componentId: string): Promise; + + /** + * Deletes all components associated with a given crafting system. + * + * @async + * @param {string} craftingSystemId - The ID of the crafting system to delete components for. + * @returns {Promise} A Promise that resolves to the deleted component(s) or an empty array if no + * components were associated with the given crafting system. Rejects with an Error if the components could not be + * deleted. + */ + deleteByCraftingSystemId(craftingSystemId: string): Promise; + + /** + * Removes all references to the specified essence from all components within the specified crafting system. + * + * @async + * @param {string} essenceId - The ID of the essence to remove references to. + * @param {string} craftingSystemId - The ID of the crafting system containing the components to modify. + * @returns {Promise} A Promise that resolves with an array of all modified components that contain + * references to the removed essence, or an empty array if no modifications were made. If the specified + * crafting system has no components, the Promise will reject with an Error. + */ + removeEssenceReferences(essenceId: string, craftingSystemId: string): Promise; + + /** + * Removes all references to the specified salvage from all components within the specified crafting system. + * + * @param {string} componentId + * @param {string} craftingSystemId + * @returns {Promise} A Promise that resolves with an array of all modified components that contain + * references to the removed salvage, or an empty array if no modifications were made. If the specified + * crafting system has no components, the Promise will reject with an Error. + */ + removeSalvageReferences(componentId: string, craftingSystemId: string): Promise; + + /** + * Clones a component by ID. + * + * @async + * @param {string} componentId - The ID of the component to clone. + * @returns {Promise} A Promise that resolves with the newly cloned component, or rejects with an Error if the + * component is not valid or cannot be cloned. + */ + cloneById(componentId: string): Promise; + + /** + * The Notification service used by this API. If `notifications.isSuppressed` is true, all notification messages + * will print only to the console. If false, notification messages will be displayed in both the console and the UI. + * */ + notifications: NotificationService; + + /** + * Creates or overwrites a component with the given details. This operation is intended to be used when importing a + * crafting system and its components from a JSON file. Most users should use `create` or `save` components instead. + * + * @async + * @param componentData - The component data to insert + * @returns {Promise} A Promise that resolves with the saved component, or rejects with an error if + * the component is not valid, or cannot be saved. + */ + insert(componentData: ComponentExportModel): Promise; + + /** + * Creates or overwrites multiple components with the given details. This operation is intended to be used when + * importing a crafting system and its components from a JSON file. Most users should use `create` or `save` + * components instead. + * + * @async + * @param componentData - The component data to insert + * @returns {Promise} A Promise that resolves with the saved components, or rejects with an error + * if any of the components are not valid, or cannot be saved. + */ + insertMany(componentData: ComponentExportModel[]): Promise; + + + /** + * Clones all provided Components to a target Crafting System, optionally substituting each Component's essences with + * new IDs. Components are cloned by value and the copies will be assigned new IDs. The cloned Components will be + * assigned to the Crafting System with the given target Crafting System ID. This operation is not idempotent and + * will produce duplicate Components with distinct IDs if called multiple times with the same source Components + * and target Crafting System ID. As only one Component can be associated with a given game item within a single + * Crafting system, Components cloned into the same Crafting system will have their associated items removed. + * + * @async + * @param sourceComponents - The Components to clone + * @param targetCraftingSystemId - The ID of the Crafting System to clone the Components to. Defaults to the source + * component's Crafting System ID. + * @param substituteEssenceIds - An optional Map of Essence IDs to substitute with new IDs. If a Component + * references an Essence ID in this Map, the Component will be cloned with the new Essence ID in place of the + * original ID. + */ + cloneAll(sourceComponents: Component[], targetCraftingSystemId?: string, substituteEssenceIds?: Map): Promise<{ components: Component[], idLinks: Map }>; + + /** + * Saves all provided components. + * + * @async + * @param components - The components to save. + * @returns {Promise} A Promise that resolves with the saved components, or rejects with an error if + * any of the components are not valid, or cannot be saved. + */ + saveAll(components: Component[]): Promise; + +} + +export { ComponentAPI }; + +class DefaultComponentAPI implements ComponentAPI { + + private static readonly _LOCALIZATION_PATH: string = `${Properties.module.id}.settings` + + private readonly componentValidator: ComponentValidator; + private readonly notificationService: NotificationService; + private readonly localizationService: LocalizationService; + private readonly componentStore: EntityDataStore; + private readonly identityFactory: IdentityFactory; + + constructor({ + componentValidator, + notificationService, + localizationService, + componentStore, + identityFactory + }: { + componentValidator: ComponentValidator; + notificationService: NotificationService; + localizationService: LocalizationService; + componentStore: EntityDataStore; + identityFactory: IdentityFactory; + }) { + this.componentValidator = componentValidator; + this.notificationService = notificationService; + this.localizationService = localizationService; + this.componentStore = componentStore; + this.identityFactory = identityFactory; + } + + get notifications(): NotificationService { + return this.notificationService; + } + + async cloneById(componentId: string): Promise { + const source = await this.componentStore.getById(componentId); + if (!source) { + const message = this.localizationService.format( + `${DefaultComponentAPI._LOCALIZATION_PATH}.errors.component.cloneTargetNotFound`, + { componentId } + ); + this.notificationService.error(message); + throw new Error(message); + } + const assignedIds = await this.componentStore.listAllEntityIds(); + const clone = source.clone({ + id: this.identityFactory.make(assignedIds) + }); + return this.save(clone); + } + + async create({ + essences = {}, + itemUuid, + disabled = false, + craftingSystemId, + salvageOptions = [] + }: ComponentCreationOptions): Promise { + + const assignedIds = await this.componentStore.listAllEntityIds(); + const id = this.identityFactory.make(assignedIds); + + const mappedSalvageOptions = salvageOptions.reduce((result, salvageOption) => { + const optionId = this.identityFactory.make(); + result[optionId] = { + id: optionId, + ...salvageOption + }; + return result; + }, >{}); + + const entityJson: ComponentJson = { + id, + embedded: false, + craftingSystemId, + itemUuid, + essences, + disabled, + salvageOptions: mappedSalvageOptions + }; + + const component = await this.componentStore.buildEntity(entityJson); + return this.save(component); + + } + + async deleteByCraftingSystemId(craftingSystemId: string): Promise { + const components = await this.componentStore.getCollection(craftingSystemId, Properties.settings.collectionNames.craftingSystem); + components.forEach(component => this.rejectDeletingEmbeddedComponent(component)); + await this.componentStore.deleteCollection(craftingSystemId, Properties.settings.collectionNames.craftingSystem); + return components; + } + + async deleteById(componentId: string): Promise { + const deletedComponent = await this.componentStore.getById(componentId); + this.rejectDeletingEmbeddedComponent(deletedComponent); + if (!deletedComponent) { + const message = this.localizationService.format( + `${DefaultComponentAPI._LOCALIZATION_PATH}.errors.component.doesNotExist`, + { componentId } + ); + this.notificationService.error(message); + return undefined; + } + await this.componentStore.deleteById(componentId); + return deletedComponent; + } + + async deleteByItemUuid(itemUuid: string): Promise { + const components = await this.componentStore.getCollection(itemUuid, Properties.settings.collectionNames.item); + components.forEach(component => this.rejectDeletingEmbeddedComponent(component)); + await this.componentStore.deleteCollection(itemUuid, Properties.settings.collectionNames.item); + return components; + } + + async getAll(): Promise> { + const components = await this.componentStore.getAllEntities(); + return new Map(components.map(component => [component.id, component])); + } + + async getAllByCraftingSystemId(craftingSystemId: string): Promise> { + const components = await this.componentStore.getCollection(craftingSystemId, Properties.settings.collectionNames.craftingSystem); + components.forEach(component => this.rejectDeletingEmbeddedComponent(component)); + return new Map(components.map(component => [component.id, component])); + } + + async getAllById(componentIds: string[]): Promise> { + const components = await this.componentStore.getAllById(componentIds); + const result = new Map(components.map(component => [ component.id, component ])); + const missingValues = componentIds.filter(id => !result.has(id)); + if (missingValues.length > 0) { + const message = this.localizationService.format( + `${DefaultComponentAPI._LOCALIZATION_PATH}.errors.component.missingComponents`, + { componentsIds: missingValues.join(", ") } + ); + this.notificationService.error(message); + missingValues.forEach(id => result.set(id, undefined)); + } + return result; + } + + async getAllByItemUuid(itemUuid: string): Promise> { + const components = await this.componentStore.getCollection(itemUuid, Properties.settings.collectionNames.item); + return new Map(components.map(component => [component.id, component])); + } + + async getById(id: string): Promise { + const component = await this.componentStore.getById(id); + if (!component) { + const message = this.localizationService.format( + `${DefaultComponentAPI._LOCALIZATION_PATH}.errors.component.doesNotExist`, + { componentId: id } + ); + + this.notificationService.error(message); + return undefined; + } + return component; + } + + async insert({ + id, + disabled = false, + essences = {}, + craftingSystemId, + itemUuid, + salvageOptions = [] + }: ComponentExportModel): Promise { + const salvageOptionsRecord = salvageOptions + .reduce((result, salvageOption) => { + result[salvageOption.id] = { + ...salvageOption + }; + return result; + }, >{}); + const componentJson = { + id, + craftingSystemId, + itemUuid, + disabled, + essences, + embedded: false, + salvageOptions: salvageOptionsRecord, + } + const component = await this.componentStore.buildEntity(componentJson); + return this.save(component); + } + + async insertMany(componentImportData: ComponentExportModel[]): Promise { + return Promise.all(componentImportData.map(component => this.insert(component))); + } + + async cloneAll(sourceComponents: Component[], + targetCraftingSystemId?: string, + substituteEssenceIds: Map = new Map() + ): Promise<{ components: Component[]; idLinks: Map }> { + + const assignedComponentIds = await this.componentStore.listAllEntityIds(); + const newComponentIdsBySourceComponentId = sourceComponents + .map(sourceComponent => { + const newId = this.identityFactory.make(assignedComponentIds); + return [sourceComponent.id, newId] + }) + .reduce((result, [sourceId, newId]) => { + result.set(sourceId, newId); + return result; + }, new Map()); + + const cloneData = sourceComponents + .map(sourceComponent => { + const newId = newComponentIdsBySourceComponentId.get(sourceComponent.id); + if (!newId) { + throw new Error(`Failed to find new id for source component id ${sourceComponent.id}`); + } + const clonedComponent = sourceComponent.clone({ + id: newId, + craftingSystemId: targetCraftingSystemId, + substituteEssenceIds + }); + return { + component: clonedComponent, + sourceId: sourceComponent.id, + } + }) + .reduce( + (result, currentValue) => { + result.ids.set(currentValue.sourceId, currentValue.component.id); + result.components.push(currentValue.component); + return result; + }, + { + ids: new Map, + components: [], + } + ); + + const savedClones = await this.saveAll(cloneData.components); + + return { + components: savedClones, + idLinks: cloneData.ids, + }; + } + + async removeEssenceReferences(essenceIdToDelete: string, craftingSystemId: string): Promise { + const componentsForCraftingSystem = await this.componentStore.getCollection(craftingSystemId, Properties.settings.collectionNames.craftingSystem); + const componentsWithEssence = componentsForCraftingSystem.filter(component => component.essences.has(essenceIdToDelete)); + const componentsWithEssenceRemoved = componentsWithEssence.map(component => { + component.removeEssence(essenceIdToDelete); + return component; + }); + await this.componentStore.updateAll(componentsWithEssenceRemoved); + return componentsWithEssenceRemoved; + } + + async removeSalvageReferences(componentId: string, craftingSystemId: string): Promise { + const componentsForCraftingSystem = await this.componentStore.getCollection(craftingSystemId, Properties.settings.collectionNames.craftingSystem); + const componentsWithSalvage = componentsForCraftingSystem + .filter(component => { + if (!component.isSalvageable) { + return false; + } + const firstMatchingSalvage = component.salvageOptions.all + .map(salvageOption => salvageOption.results) + .find(salvage => salvage.has(componentId)); + return !!firstMatchingSalvage; + }); + const componentsWithSalvageRemoved = componentsWithSalvage.map(component => { + component.removeComponentFromSalvageOptions(componentId); + return component; + }); + await this.componentStore.updateAll(componentsWithSalvageRemoved); + return componentsWithSalvageRemoved; + } + + async save(component: Component): Promise { + const existing = await this.componentStore.getById(component.id); + this.rejectModifyingEmbeddedComponent(existing); + + await this.rejectSavingInvalidComponent(component); + + await this.componentStore.insert(component); + + const activityName = existing ? "updated" : "created"; + const message = this.localizationService.format( + `${DefaultComponentAPI._LOCALIZATION_PATH}.component.${activityName}`, + { componentName: component.name } + ); + this.notificationService.info(message); + + return component; + } + + private async rejectSavingInvalidComponent(component: Component): Promise> { + const existingComponentIds = await this.componentStore.listAllEntityIds(); + const existingComponentsForCraftingSystem = await this.getAllByCraftingSystemId(component.craftingSystemId); + const existingComponentsIdsForItem = Array.from(existingComponentsForCraftingSystem.values()) + .filter(other => other.itemUuid === component.itemUuid) + .map(other => other.id); + const validationResult = await this.componentValidator.validate(component, existingComponentIds, existingComponentsIdsForItem); + if (validationResult.successful) { + return validationResult; + } + const message = this.localizationService.format( + `${DefaultComponentAPI._LOCALIZATION_PATH}.errors.component.invalid`, + { + errors: validationResult.errors.join(", "), + componentId: component.id + } + ); + this.notificationService.error(message); + throw new Error(message); + } + + private rejectModifyingEmbeddedComponent(component: Component): void { + if (!component?.embedded) { + return; + } + const message = this.localizationService.format( + `${DefaultComponentAPI._LOCALIZATION_PATH}.errors.component.cannotModifyEmbedded`, + { componentName: component.name } + ); + this.notificationService.error(message); + throw new Error(message); + } + + private rejectDeletingEmbeddedComponent(componentToDelete: Component): void { + if (!componentToDelete?.embedded) { + return; + } + const message = this.localizationService.format( + `${DefaultComponentAPI._LOCALIZATION_PATH}.errors.component.cannotDeleteEmbedded`, + { componentName: componentToDelete.name } + ); + this.notificationService.error(message); + throw new Error(message); + } + + async saveAll(components: Component[]) { + + const existing = await this.componentStore.getAllById(components.map(component => component.id)); + existing.forEach(existingComponent => this.rejectModifyingEmbeddedComponent(existingComponent)); + + const validations = components.map(component => this.rejectSavingInvalidComponent(component)); + await Promise.all(validations) + .catch(() => { + const message = this.localizationService.localize( + `${DefaultComponentAPI._LOCALIZATION_PATH}.errors.component.noneSaved` + ); + this.notificationService.error(message); + throw new Error(message); + }); + + await this.componentStore.insertAll(components); + + const message = this.localizationService.format( + `${DefaultComponentAPI._LOCALIZATION_PATH}.component.savedAll`, + {count: components.length} + ); + this.notificationService.info(message); + + return components; + + } +} + +export { DefaultComponentAPI }; diff --git a/src/scripts/api/CraftingAPI.ts b/src/scripts/api/CraftingAPI.ts new file mode 100644 index 00000000..b9fc4f25 --- /dev/null +++ b/src/scripts/api/CraftingAPI.ts @@ -0,0 +1,1078 @@ +import {LocalizationService} from "../../applications/common/LocalizationService"; +import {EssenceAPI} from "./EssenceAPI"; +import {ComponentAPI} from "./ComponentAPI"; +import {RecipeAPI} from "./RecipeAPI"; +import {CraftingSystemAPI} from "./CraftingSystemAPI"; +import {NoSalvageResult, SalvageResult, SuccessfulSalvageResult} from "../crafting/result/SalvageResult"; +import Properties from "../Properties"; +import {Combination} from "../common/Combination"; +import {Component} from "../crafting/component/Component"; +import {GameProvider} from "../foundry/GameProvider"; +import {InventoryFactory} from "../actor/InventoryFactory"; +import {Inventory} from "../actor/Inventory"; +import {NotificationService} from "../foundry/NotificationService"; +import {SimpleInventoryAction} from "../actor/InventoryAction"; +import {SalvageOption} from "../crafting/component/SalvageOption"; +import {CraftingResult, NoCraftingResult, SuccessfulCraftingResult} from "../crafting/result/CraftingResult"; +import {RequirementOption} from "../crafting/recipe/RequirementOption"; +import {ResultOption} from "../crafting/recipe/ResultOption"; +import {ComponentSelectionStrategy} from "../crafting/selection/ComponentSelectionStrategy"; +import {ComponentSelection, DefaultComponentSelection, EmptyComponentSelection} from "../component/ComponentSelection"; +import {TrackedCombination} from "../common/TrackedCombination"; +import {Unit} from "../common/Unit"; +import {Essence} from "../crafting/essence/Essence"; + +/** + * Options used when salvaging a component using the Crafting API. + */ +interface ComponentSalvageOptions { + + /** + * The ID of the component to salvage. + */ + componentId: string; + + /** + * The ID of the Actor from which the component should be removed. + */ + sourceActorId: string; + + /** + * The optional ID of the Actor to which any produced components should be added. If not specified, the + * sourceActorId is used. Specify a different targetActorId when salvaging from a container or shared inventory to + * another character. + */ + targetActorId?: string; + + /** + * The optional ID of the Salvage Option to use. Not required if the component has only one Salvage Option. If the + * component has multiple Salvage Options this must be specified. + */ + salvageOptionId?: string; + +} + +/** + * Options used when explicitly selecting components for crafting recipes + */ +interface UserSelectedComponents { + + /** + * The IDs and quantities of the catalysts to use when crafting the recipe. + */ + catalysts: Record; + + /** + * The IDs and quantities of the ingredients to use when crafting the recipe. + */ + ingredients: Record; + + /** + * The IDs and quantities of the components to use as essence sources when crafting the recipe. + */ + essenceSources: Record; + +} + +interface RecipeCraftingOptions { + + /** + * The ID of the recipe to attempt. + */ + recipeId: string; + + /** + * The ID of the Actor from which the components should be removed. + */ + sourceActorId: string; + + /** + * The optional ID of the Actor to which any produced components should be added. If not specified, the + * sourceActorId is used. Specify a different targetActorId when crafting from a container or shared inventory to + * another character. + */ + targetActorId?: string; + + /** + * The optional ID of the Requirement Option to use. Not required if the recipe has only one Requirement Option. If + * the recipe has multiple Requirement Options this must be specified. + */ + requirementOptionId?: string; + + /** + * The optional ID of the Result Option to use. Not required if the recipe has only one Result Option. If the recipe + * has multiple Result Options this must be specified. + */ + resultOptionId?: string; + + /** + * The optional IDs and quantities of the components to use when crafting the recipe. If not specified, the + * components and amounts will be selected automatically for the least wasteful essence sources (if any are + * required). This is useful when customising component selection for essences. However, if the Recipe also requires + * catalysts and named ingredients be sure to include them in the component selection. If an insufficient + * combination is specified crafting will not be attempted. + */ + userSelectedComponents?: UserSelectedComponents; + +} + +interface ComponentSelectionOptions { + + /** + * The ID of the Actor whose inventory you want to select components from. + */ + sourceActorId: string; + + /** + * The optional ID of the Recipe Requirement Option to select components for. Not required if the recipe has only + * one Result Option. If the recipe has multiple requirement options this must be specified. + */ + requirementOptionId?: string; + + /** + * The ID of the Recipe to select components for. + */ + recipeId: string; +} + +interface CraftingAPI { + + /** + * Counts the number of components of a given type owned by the specified actor. + * + * @async + * @param actorId - The ID of the actor to check. + * @param componentId - The ID of the component to count. + * @returns A Promise that resolves with the number of components of this type owned by the actor. + */ + countOwnedComponentsOfType(actorId: string, componentId: string): Promise; + + /** + * Gets the components owned by the specified actor for the specified crafting system. + * + * @async + * @param actorId - The ID of the actor whose inventory you want to search. + * @param craftingSystemId - The ID of the crafting system to limit component matches to. + * @returns A Promise that resolves with the components owned by the actor for the specified crafting system. + */ + getOwnedComponentsForCraftingSystem(actorId: string, craftingSystemId: string): Promise>; + + /** + * Attempts to salvage the specified component. + * + * @async + * @param componentSalvageOptions - The options to use when salvaging the component. + * @returns Promise A Promise that resolves with the Salvage Result + */ + salvageComponent(componentSalvageOptions: ComponentSalvageOptions): Promise; + + /** + * Attempts to craft the specified recipe. + * + * @async + * @param recipeCraftingOptions - The options to use when crafting the recipe. + * @returns Promise A Promise that resolves with the prepared Crafting Result. + */ + craftRecipe(recipeCraftingOptions: RecipeCraftingOptions): Promise; + + /** + * Selects components from the specified source Actor for the specified recipe requirement option. The Component + * Selection will be insufficient if the source Actor's inventory does not contain enough components to satisfy the + * requirement option. + * + * @async + * @param componentSelectionOptions - The options to use when selecting components. + * @returns Promise A Promise that resolves with the selected components. + */ + selectComponents(componentSelectionOptions: ComponentSelectionOptions): Promise; + +} + +export { CraftingAPI }; + +class DefaultCraftingAPI implements CraftingAPI { + + private static readonly _LOCALIZATION_PATH = `${Properties.module.id}.crafting`; + + private readonly recipeAPI: RecipeAPI; + private readonly essenceAPI: EssenceAPI; + private readonly gameProvider: GameProvider; + private readonly componentAPI: ComponentAPI; + private readonly inventoryFactory: InventoryFactory; + private readonly craftingSystemAPI: CraftingSystemAPI; + private readonly notificationService: NotificationService; + private readonly localizationService: LocalizationService; + private readonly componentSelectionStrategy: ComponentSelectionStrategy; + + constructor({ + recipeAPI, + essenceAPI, + gameProvider, + componentAPI, + inventoryFactory, + craftingSystemAPI, + notificationService, + localizationService, + componentSelectionStrategy, + }: { + recipeAPI: RecipeAPI; + essenceAPI: EssenceAPI; + gameProvider: GameProvider; + componentAPI: ComponentAPI; + inventoryFactory: InventoryFactory; + craftingSystemAPI: CraftingSystemAPI; + notificationService: NotificationService; + localizationService: LocalizationService; + componentSelectionStrategy: ComponentSelectionStrategy; + }) { + this.recipeAPI = recipeAPI; + this.essenceAPI = essenceAPI; + this.gameProvider = gameProvider; + this.componentAPI = componentAPI; + this.inventoryFactory = inventoryFactory; + this.craftingSystemAPI = craftingSystemAPI; + this.notificationService = notificationService; + this.localizationService = localizationService; + this.componentSelectionStrategy = componentSelectionStrategy; + } + + async countOwnedComponentsOfType(actorId: string, componentId: string): Promise { + const component = await this.componentAPI.getById(componentId); + if (!component) { + const message = this.localizationService.format(`${DefaultCraftingAPI._LOCALIZATION_PATH}.inventory.error.componentNotFound`, { componentId }); + this.notificationService.error(message); + return; + } + const ownedComponents = await this.getOwnedComponentsForCraftingSystem(actorId, component.craftingSystemId); + return ownedComponents.amountFor(component); + } + + async getOwnedComponentsForCraftingSystem(actorId: string, craftingSystemId: string): Promise> { + const inventory = await this.getInventory(actorId, craftingSystemId); + return inventory.getContents(); + } + + private async getInventory(actorId: string, craftingSystemId: string): Promise { + const actor = await this.gameProvider.loadActor(actorId); + const gameSystemId = this.gameProvider.getGameSystemId(); + const componentsForCraftingSystem = await this.componentAPI.getAllByCraftingSystemId(craftingSystemId); + const knownComponents = Array.from(componentsForCraftingSystem.values()); + return this.inventoryFactory.make(gameSystemId, actor, knownComponents); + } + + async salvageComponent({ + componentId, + sourceActorId, + targetActorId = sourceActorId, + salvageOptionId + }: ComponentSalvageOptions): Promise { + + /** + * ============================================================================================================= + * Check Source and Target Actors are valid + * ============================================================================================================= + */ + + const sourceActor = await this.gameProvider.loadActor(sourceActorId); + if (!sourceActor) { + const message = this.localizationService.format(`${DefaultCraftingAPI._LOCALIZATION_PATH}.actorNotFound`, { actorId: sourceActorId }); + throw new Error(message); + } + + if (sourceActorId !== targetActorId) { + const targetActor = await this.gameProvider.loadActor(targetActorId); + if (!targetActor) { + const message = this.localizationService.format(`${DefaultCraftingAPI._LOCALIZATION_PATH}.actorNotFound`, { actorId: targetActorId }); + throw new Error(message); + } + } + + /** + * ============================================================================================================= + * Check component exists + * ============================================================================================================= + */ + + const component = await this.componentAPI.getById(componentId); + + if (!component) { + const message = this.localizationService.format(`${DefaultCraftingAPI._LOCALIZATION_PATH}.salvage.componentNotFound`, { componentId }); + throw new Error(message); + } + + /** + * ============================================================================================================= + * Check component is owned by Source Actor + * ============================================================================================================= + */ + + const sourceInventory = await this.getInventory(targetActorId, component.craftingSystemId); + const ownedItems = sourceInventory.getContents(); + + if (!ownedItems.has(component)) { + const message = this.localizationService.format( + `${DefaultCraftingAPI._LOCALIZATION_PATH}.salvage.componentNotOwned`, + { + componentName: component.name, + actorName: sourceActor.name + } + ); + this.notificationService.error(message); + return new NoSalvageResult({ + component, + sourceActorId, + targetActorId, + description: message + }); + } + + /** + * ============================================================================================================= + * Check component has valid item data + * ============================================================================================================= + */ + + await component.load(); + + if (component.itemData.hasErrors) { + const message = this.localizationService.format( + `${DefaultCraftingAPI._LOCALIZATION_PATH}.salvage.invalidItemData`, + { componentId, cause: component.itemData.errors.join(", ") } + ); + this.notificationService.error(message); + return new NoSalvageResult({ + component, + sourceActorId, + targetActorId, + description: message + }); + } + + /** + * ============================================================================================================= + * Check component is enabled + * ============================================================================================================= + */ + + if (component.isDisabled) { + const message = this.localizationService.format( + `${DefaultCraftingAPI._LOCALIZATION_PATH}.salvage.disabledComponent`, + { componentName: component.name } + ); + this.notificationService.error(message); + return new NoSalvageResult({ + component, + sourceActorId, + targetActorId, + description: message + }); + } + + /** + * ============================================================================================================= + * Check component is salvageable + * ============================================================================================================= + */ + + if (!component.isSalvageable) { + const message = this.localizationService.format( + `${DefaultCraftingAPI._LOCALIZATION_PATH}.salvage.unsalvageableComponent`, + { componentName: component.name } + ); + this.notificationService.error(message); + return new NoSalvageResult({ + component, + sourceActorId, + targetActorId, + description: message + }); + } + + /** + * ============================================================================================================= + * Check salvage option ID ws provided if component has multiple salvage options + * ============================================================================================================= + */ + + if (!salvageOptionId && component.salvageOptionsById.size > 1) { + const message = this.localizationService.format( + `${DefaultCraftingAPI._LOCALIZATION_PATH}.salvage.salvageOptionIdRequired`, + { + componentName: component.name, + salvageOptionIds: Array.from(component.salvageOptionsById.keys()).join(", ") , + salvageOptionCount: component.salvageOptionsById.size + } + ); + this.notificationService.error(message); + return new NoSalvageResult({ + component, + sourceActorId, + targetActorId, + description: message + }); + } + + /** + * ============================================================================================================= + * Check component's crafting system is enabled + * ============================================================================================================= + */ + + const craftingSystem = await this.craftingSystemAPI.getById(component.craftingSystemId); + + if (craftingSystem.isDisabled) { + const message = this.localizationService.format( + `${DefaultCraftingAPI._LOCALIZATION_PATH}.disabledCraftingSystem`, + { + craftingSystemName: craftingSystem.details.name, + componentName: component.name + } + ); + this.notificationService.error(message); + return new NoSalvageResult({ + component, + sourceActorId, + targetActorId, + description: message + }); + } + + /** + * ============================================================================================================= + * Check all components in the salvage result are valid + * ============================================================================================================= + */ + + const selectedSalvageOption: SalvageOption = salvageOptionId ? component.salvageOptionsById.get(salvageOptionId) : component.salvageOptionsById.values().next().value; + + const salvageResultComponentReferences = selectedSalvageOption.results.combineWith(selectedSalvageOption.catalysts); + const includedComponentsById = await this.componentAPI.getAllById(salvageResultComponentReferences.members.map(component => component.id)); + + const includedComponents = Array.from(includedComponentsById.values()); + const craftingSystemIds = includedComponents + .map(component => component.craftingSystemId) + .filter((value, index, self) => self.indexOf(value) === index); + + if (craftingSystemIds.length > 1) { + const message = this.localizationService.format( + `${DefaultCraftingAPI._LOCALIZATION_PATH}.salvageResult.multipleCraftingSystems`, + { craftingSystemIds: craftingSystemIds.join(", ") } + ); + this.notificationService.error(message); + return new NoSalvageResult({ + component, + sourceActorId, + targetActorId, + description: message + }); + } + + await Promise.all(includedComponents.map(component => component.load())); + + const componentsWithErrors = includedComponents.filter(component => component.itemData.hasErrors); + if (componentsWithErrors.length > 0) { + const message = this.localizationService.format( + `${DefaultCraftingAPI._LOCALIZATION_PATH}.salvageResult.invalidItemData`, + { + componentIds: componentsWithErrors.map(component => component.id).join(", ") + } + ); + this.notificationService.error(message); + return new NoSalvageResult({ + component, + sourceActorId, + targetActorId, + description: message + }); + } + + /** + * ============================================================================================================= + * Check that the source actor has enough catalysts to perform the salvage + * ============================================================================================================= + */ + + if (selectedSalvageOption.requiresCatalysts) { + const missingCatalysts = ownedItems.without(component.id, 1) + .units + .filter(unit => selectedSalvageOption.catalysts.has(unit.element.id)) + .reduce((wantedCatalysts, ownedCatalyst) => { + return wantedCatalysts.without(ownedCatalyst.element.id, ownedCatalyst.quantity); + }, selectedSalvageOption.catalysts); + + if (!missingCatalysts.isEmpty()) { + const message = this.localizationService.format( + `${DefaultCraftingAPI._LOCALIZATION_PATH}.salvage.missingCatalysts`, + { + componentName: component.name, + missingCatalystNames: missingCatalysts.map(unit => includedComponentsById.get(unit.element.id)).join(", ") + } + ); + this.notificationService.warn(message); + return new NoSalvageResult({ + component, + sourceActorId, + targetActorId, + description: message + }); + } + } + + /** + * ============================================================================================================= + * Perform the salvage + * ============================================================================================================= + */ + + const action = new SimpleInventoryAction({ + additions: selectedSalvageOption.results.convertElements(componentReference => includedComponentsById.get(componentReference.id)), + removals: Combination.of(component), + }); + await this.applyInventoryAction(sourceActorId, targetActorId, action, craftingSystem.id); + const description = this.localizationService.localize(`${DefaultCraftingAPI._LOCALIZATION_PATH}.salvageResult.success`); + return new SuccessfulSalvageResult({ + component, + description, + sourceActorId, + targetActorId, + consumed: component, + produced: action.additions, + }); + + } + + async craftRecipe({ + recipeId, + sourceActorId, + targetActorId = sourceActorId, + requirementOptionId, + resultOptionId, + userSelectedComponents = { catalysts: {}, ingredients: {}, essenceSources: {} } + }: RecipeCraftingOptions): Promise { + + /** + * ============================================================================================================= + * Check Source and Target Actors are valid + * ============================================================================================================= + */ + + const sourceActor = await this.gameProvider.loadActor(sourceActorId); + if (!sourceActor) { + const message = this.localizationService.format(`${DefaultCraftingAPI._LOCALIZATION_PATH}.actorNotFound`, { actorId: sourceActorId }); + throw new Error(message); + } + + if (sourceActorId !== targetActorId) { + const targetActor = await this.gameProvider.loadActor(targetActorId); + if (!targetActor) { + const message = this.localizationService.format(`${DefaultCraftingAPI._LOCALIZATION_PATH}.actorNotFound`, { actorId: targetActorId }); + throw new Error(message); + } + } + + /** + * ============================================================================================================= + * Check recipe exists + * ============================================================================================================= + */ + + const recipe = await this.recipeAPI.getById(recipeId); + + if (!recipe) { + const message = this.localizationService.format(`${DefaultCraftingAPI._LOCALIZATION_PATH}.recipe.recipeNotFound`, { recipeId }); + throw new Error(message); + } + + /** + * ============================================================================================================= + * Check recipe has valid item data + * ============================================================================================================= + */ + + await recipe.load(); + + if (recipe.hasErrors) { + const message = this.localizationService.format( + `${DefaultCraftingAPI._LOCALIZATION_PATH}.recipe.invalidItemData`, + { recipeId: recipe.id, cause: recipe.errors.join(", ") } + ); + this.notificationService.error(message); + return new NoCraftingResult({ + recipe, + sourceActorId, + targetActorId, + description: message + }); + } + + /** + * ============================================================================================================= + * Check recipe is enabled + * ============================================================================================================= + */ + + if (recipe.isDisabled) { + const message = this.localizationService.format( + `${DefaultCraftingAPI._LOCALIZATION_PATH}.recipe.disabledRecipe`, + { recipeName: recipe.name } + ); + this.notificationService.error(message); + return new NoCraftingResult({ + recipe, + sourceActorId, + targetActorId, + description: message + }); + } + + /** + * ============================================================================================================= + * Check the recipe's crafting system is enabled + * ============================================================================================================= + */ + + const craftingSystem = await this.craftingSystemAPI.getById(recipe.craftingSystemId); + + if (craftingSystem.isDisabled) { + const message = this.localizationService.format( + `${DefaultCraftingAPI._LOCALIZATION_PATH}.disabledCraftingSystem`, + { craftingSystemName: craftingSystem.details.name } + ); + this.notificationService.error(message); + return new NoCraftingResult({ + recipe, + sourceActorId, + targetActorId, + description: message + }); + } + + /** + * ============================================================================================================= + * Check requirement option ID ws provided if recipe has multiple requirement options + * ============================================================================================================= + */ + + if (!requirementOptionId && recipe.requirementOptions.byId.size > 1) { + const message = this.localizationService.format( + `${DefaultCraftingAPI._LOCALIZATION_PATH}.recipe.requirementOptionIdRequired`, + { + recipeName: recipe.name, + requirementOptionIds: Array.from(recipe.requirementOptions.byId.keys()).join(", ") , + requirementOptionCount: recipe.requirementOptions.byId.size + } + ); + this.notificationService.error(message); + return new NoCraftingResult({ + recipe, + sourceActorId, + targetActorId, + description: message + }); + } + + /** + * ============================================================================================================= + * Check result option ID ws provided if recipe has multiple result options + * ============================================================================================================= + */ + + if (!resultOptionId && recipe.resultOptions.byId.size > 1) { + const message = this.localizationService.format( + `${DefaultCraftingAPI._LOCALIZATION_PATH}.recipe.resultOptionIdRequired`, + { + recipeName: recipe.name, + resultOptionIds: Array.from(recipe.resultOptions.byId.keys()).join(", ") , + resultOptionCount: recipe.resultOptions.byId.size + } + ); + this.notificationService.error(message); + return new NoCraftingResult({ + recipe, + sourceActorId, + targetActorId, + description: message + }); + } + + /** + * ============================================================================================================= + * Check all components in the recipe are valid + * ============================================================================================================= + */ + + const selectedRequirementOption: RequirementOption = requirementOptionId ? recipe.requirementOptions.byId.get(requirementOptionId) : recipe.requirementOptions.byId.values().next().value; + const selectedResultOption: ResultOption = resultOptionId ? recipe.resultOptions.byId.get(resultOptionId) : recipe.resultOptions.byId.values().next().value; + const allComponentIds = selectedRequirementOption.ingredients + .combineWith(selectedRequirementOption.catalysts) + .combineWith(selectedResultOption.results) + .map(unit => unit.element.id); + const includedComponentsById = await this.componentAPI.getAllById(allComponentIds); + + const includedComponents = Array.from(includedComponentsById.values()); + const craftingSystemIds = includedComponents + .map(component => component.craftingSystemId) + .filter((value, index, self) => self.indexOf(value) === index); + + if (craftingSystemIds.length > 1) { + const message = this.localizationService.format( + `${DefaultCraftingAPI._LOCALIZATION_PATH}.recipe.multipleCraftingSystems`, + { + craftingSystemIds: craftingSystemIds.join(", "), + recipeName: recipe.name + } + ); + this.notificationService.error(message); + return new NoCraftingResult({ + recipe, + sourceActorId, + targetActorId, + description: message + }); + } + + await Promise.all(includedComponents.map(component => component.load())); + + const componentsWithErrors = includedComponents.filter(component => component.itemData.hasErrors); + if (componentsWithErrors.length > 0) { + const message = this.localizationService.format( + `${DefaultCraftingAPI._LOCALIZATION_PATH}.recipe.invalidComponentItemData`, + { + recipeName: recipe.name, + componentIds: componentsWithErrors.map(component => component.id).join(", ") + } + ); + this.notificationService.error(message); + return new NoCraftingResult({ + recipe, + sourceActorId, + targetActorId, + description: message, + }); + } + + /** + * ============================================================================================================= + * Check all essences in the recipe are valid + * ============================================================================================================= + */ + + const allEssenceIds = Array.from(includedComponentsById.values()) + .filter(component => component.hasEssences) + .map(component => component.essences) + .reduce((essences, componentEssences) => essences.combineWith(componentEssences), Combination.EMPTY()) + .map(unit => unit.element.id); + const includedEssencesById = await this.essenceAPI.getAllById(allEssenceIds); + + const includedEssences = Array.from(includedEssencesById.values()); + const essenceCraftingSystemIds = includedEssences + .map(essence => essence.craftingSystemId) + .filter((value, index, self) => self.indexOf(value) === index); + + if (essenceCraftingSystemIds.length > 1) { + const message = this.localizationService.format( + `${DefaultCraftingAPI._LOCALIZATION_PATH}.recipe.multipleEssenceCraftingSystems`, + { craftingSystemIds: essenceCraftingSystemIds.join(", ") } + ); + this.notificationService.error(message); + return new NoCraftingResult({ + recipe, + sourceActorId, + targetActorId, + description: message, + }); + } + + await Promise.all(includedEssences.map(essence => essence.load())); + + const essencesWithErrors = includedEssences.filter(essence => essence.activeEffectSource.hasErrors); + if (essencesWithErrors.length > 0) { + const message = this.localizationService.format( + `${DefaultCraftingAPI._LOCALIZATION_PATH}.recipe.invalidEssenceItemData`, + { + essenceIds: essencesWithErrors.map(essence => essence.id).join(", ") + } + ); + this.notificationService.error(message); + return new NoCraftingResult({ + recipe, + sourceActorId, + targetActorId, + description: message, + }); + } + + /** + * ============================================================================================================= + * Check that other components in the source actor's inventory are valid + * ============================================================================================================= + */ + + const sourceInventory = await this.getInventory(sourceActorId, craftingSystem.id); + const ownedComponents = sourceInventory.getContents(); + + const otherComponentsInInventory = ownedComponents.members + .filter(component => !includedComponentsById.has(component.id)); + await Promise.all(otherComponentsInInventory.map(component => component.load())); + const otherComponentsInInventoryWithErrors = otherComponentsInInventory.filter(component => component.itemData.hasErrors); + if (otherComponentsInInventoryWithErrors.length > 0) { + const message = this.localizationService.format( + `${DefaultCraftingAPI._LOCALIZATION_PATH}.recipe.invalidComponentItemData`, + { + recipeName: recipe.name, + componentIds: otherComponentsInInventoryWithErrors.map(component => component.id).join(", ") + } + ); + this.notificationService.error(message); + return new NoCraftingResult({ + recipe, + sourceActorId, + targetActorId, + description: message, + }); + } + + /** + * ============================================================================================================= + * Select the components to use, if not specified + * ============================================================================================================= + */ + + const allComponentsById = new Map(includedComponentsById); + otherComponentsInInventory.forEach(component => allComponentsById.set(component.id, component)); + const allEssencesById = await this.essenceAPI.getAllByCraftingSystemId(craftingSystem.id); + + let selectedComponents: ComponentSelection; + if (!recipe.hasRequirements) { + selectedComponents = new EmptyComponentSelection(); + } else if (this.isEmptyUserSelection(userSelectedComponents)) { + selectedComponents = this.makeSelections( + selectedRequirementOption, + ownedComponents, + allComponentsById + ); + } else { + const userProvidedComponents = this.assignUserProvidedComponents( + selectedRequirementOption, + userSelectedComponents, + allComponentsById, + allEssencesById, + ownedComponents + ); + if (!userProvidedComponents.selected.isSufficient) { + const message = this.localizationService.format( + `${DefaultCraftingAPI._LOCALIZATION_PATH}.recipe.insufficientUserComponents`, + { + recipeName: recipe.name, + actorName: sourceActor.name, + missingComponentNames: userProvidedComponents.missing.map(unit => unit.element.name).join(", ") + } + ); + this.notificationService.warn(message); + return new NoCraftingResult({ + recipe, + sourceActorId, + targetActorId, + description: message, + }); + } + selectedComponents = userProvidedComponents.selected; + } + + /** + * ============================================================================================================= + * Check the selection is sufficient to perform the crafting + * ============================================================================================================= + */ + + if (!selectedComponents.isSufficient) { + const message = this.localizationService.format( + `${DefaultCraftingAPI._LOCALIZATION_PATH}.recipe.insufficientComponents`, + { + recipeName: recipe.name, + actorName: sourceActor.name, + } + ); + this.notificationService.warn(message); + return new NoCraftingResult({ + recipe, + sourceActorId, + targetActorId, + description: message, + }); + } + + /** + * ============================================================================================================= + * Perform the crafting + * ============================================================================================================= + */ + + const action = new SimpleInventoryAction({ + additions: selectedResultOption.results.convertElements(componentReference => includedComponentsById.get(componentReference.id)), + removals: selectedComponents.essenceSources.combineWith(selectedComponents.ingredients.target) + }); + await this.applyInventoryAction(sourceActorId, targetActorId, action, craftingSystem.id); + + return new SuccessfulCraftingResult({ + recipe, + sourceActorId, + targetActorId, + consumed: action.removals, + description: this.localizationService.format( + `${DefaultCraftingAPI._LOCALIZATION_PATH}.recipe.success`, + { + recipeName: recipe.name, + craftingSystemName: craftingSystem.details.name, + } + ), + produced: action.additions, + }); + + } + + private async applyInventoryAction(sourceActorId: string, targetActorId: string, action: SimpleInventoryAction, craftingSystemId: string): Promise { + if (sourceActorId === targetActorId) { + const inventory = await this.getInventory(targetActorId, craftingSystemId); + await inventory.perform(action); + } else { + const sourceInventory = await this.getInventory(sourceActorId, craftingSystemId); + const targetInventory = await this.getInventory(targetActorId, craftingSystemId); + await sourceInventory.perform(action.withoutAdditions()); + await targetInventory.perform(action.withoutRemovals()); + } + } + + async selectComponents({ recipeId, sourceActorId, requirementOptionId }: ComponentSelectionOptions): Promise { + + const recipe = await this.recipeAPI.getById(recipeId); + + if (!recipe) { + const message = this.localizationService.format( + `${DefaultCraftingAPI._LOCALIZATION_PATH}.recipe.recipeNotFound`, + { recipeId } + ); + this.notificationService.error(message); + return new EmptyComponentSelection(); + } + + const sourceActor = await this.gameProvider.loadActor(sourceActorId); + + if (!sourceActor) { + const message = this.localizationService.format( + `${DefaultCraftingAPI._LOCALIZATION_PATH}.actorNotFound`, + { actorId: sourceActorId } + ); + this.notificationService.error(message); + return new EmptyComponentSelection(); + } + + if (!requirementOptionId && recipe.requirementOptions.byId.size > 1) { + const message = this.localizationService.format( + `${DefaultCraftingAPI._LOCALIZATION_PATH}.recipe.requirementOptionIdRequired`, + { + recipeName: recipe.name, + requirementOptionIds: Array.from(recipe.requirementOptions.byId.keys()).join(", ") , + requirementOptionCount: recipe.requirementOptions.byId.size + } + ); + this.notificationService.error(message); + return new EmptyComponentSelection(); + } + + const requirementOption = requirementOptionId ? recipe.requirementOptions.byId.get(requirementOptionId) : recipe.requirementOptions.byId.values().next().value; + + const allCraftingSystemComponentsById = await this.componentAPI.getAllByCraftingSystemId(recipe.craftingSystemId); + const sourceInventory = await this.getInventory(sourceActorId, recipe.craftingSystemId); + const ownedComponents = sourceInventory.getContents(); + + return this.makeSelections( + requirementOption, + ownedComponents, + allCraftingSystemComponentsById + ); + } + + private makeSelections(selectedRequirementOption: RequirementOption, + ownedComponents: Combination, + allComponentsById: Map): ComponentSelection { + return this.componentSelectionStrategy.perform( + selectedRequirementOption.catalysts.convertElements(componentReference => allComponentsById.get(componentReference.id)), + selectedRequirementOption.ingredients.convertElements(componentReference => allComponentsById.get(componentReference.id)), + selectedRequirementOption.essences, + ownedComponents + ); + } + + private isEmptyUserSelection(userSelectedComponents: UserSelectedComponents) { + return Object.keys(userSelectedComponents.catalysts).length === 0 + && Object.keys(userSelectedComponents.ingredients).length === 0 + && Object.keys(userSelectedComponents.essenceSources).length === 0; + } + + private assignUserProvidedComponents(selectedRequirementOption: RequirementOption, + userSelectedComponents: UserSelectedComponents, + allComponentsById: Map, + allEssencesById: Map, + ownedComponents: Combination): { selected: ComponentSelection, missing: Combination } { + + let availableComponents = ownedComponents; + let missingComponents = Combination.EMPTY(); + + // Select Catalysts from available components up to the required amount in the user selection + + const assignComponentAmounts = (unit: Unit) => { + const component = unit.element; + const availableQuantity = Math.min(unit.quantity, availableComponents.amountFor(component)); + if (availableQuantity < unit.quantity) { + missingComponents = missingComponents.with(component, unit.quantity - availableQuantity); + } + availableComponents = availableComponents.without(component.id, availableQuantity); + return new Unit(component, availableQuantity); + }; + + const actualCatalysts = Combination.fromRecord(userSelectedComponents.catalysts, componentId => allComponentsById.get(componentId)) + .map(assignComponentAmounts) + .reduce((combination, unit) => combination.addUnit(unit), Combination.EMPTY()); + + const catalysts = new TrackedCombination({ + target: selectedRequirementOption.catalysts.convertElements(componentReference => allComponentsById.get(componentReference.id)), + actual: actualCatalysts + }); + + // Select Ingredients from available components up to the required amount in the user selection + + const actualIngredients = Combination.fromRecord(userSelectedComponents.ingredients, componentId => allComponentsById.get(componentId)) + .map(assignComponentAmounts) + .reduce((combination, unit) => combination.addUnit(unit), Combination.EMPTY()); + + const ingredients = new TrackedCombination({ + target: selectedRequirementOption.ingredients.convertElements(componentReference => allComponentsById.get(componentReference.id)), + actual: actualIngredients + }); + + // Select Essence Sources from available components up to the required amount in the user selection + + const essenceSources = Combination.fromRecord(userSelectedComponents.essenceSources, componentId => allComponentsById.get(componentId)) + .map(assignComponentAmounts) + .reduce((combination, unit) => combination.addUnit(unit), Combination.EMPTY()); + + const selectedComponents = new DefaultComponentSelection({ + catalysts, + ingredients, + essenceSources, + essences: new TrackedCombination({ + actual: essenceSources + .explode(component => component.essences) + .convertElements(essenceReference => allEssencesById.get(essenceReference.id)), + target: selectedRequirementOption.essences + .convertElements(essenceReference => allEssencesById.get(essenceReference.id)) + }) + }); + + return { selected: selectedComponents, missing: missingComponents }; + + } +} + +export { DefaultCraftingAPI }; \ No newline at end of file diff --git a/src/scripts/api/CraftingSystemAPI.ts b/src/scripts/api/CraftingSystemAPI.ts new file mode 100644 index 00000000..dfff2e5e --- /dev/null +++ b/src/scripts/api/CraftingSystemAPI.ts @@ -0,0 +1,319 @@ +import { + CraftingSystem, + CraftingSystemJson +} from "../system/CraftingSystem"; +import {IdentityFactory} from "../foundry/IdentityFactory"; +import {LocalizationService} from "../../applications/common/LocalizationService"; +import Properties from "../Properties"; +import {EntityValidationResult} from "./EntityValidator"; +import {EntityDataStore} from "../repository/EntityDataStore"; +import {CraftingSystemValidator} from "../system/CraftingSystemValidator"; +import {NotificationService} from "../foundry/NotificationService"; +import {CraftingSystemDetails} from "../system/CraftingSystemDetails"; + +/** + * CraftingSystemImportData is the data format used when importing crafting systems into Fabricate. + */ +interface CraftingSystemImportData { + id: string; + details: { + name: string; + summary: string; + description: string; + author: string; + }, + disabled: boolean; +} + +export { CraftingSystemImportData } + +/** + * An API for managing crafting systems. + * + * @interface + */ +interface CraftingSystemAPI { + + /** + * Clones a Crafting System by ID. + * + * @async + * @param {string} craftingSystemId - The ID of the Crafting System to clone. + * @returns {Promise} A Promise that resolves with the newly cloned Crafting System, or rejects with + * an Error if the Crafting System is not valid or cannot be cloned. + */ + cloneById(craftingSystemId: string): Promise; + + /** + * Creates a new crafting system with the given details. If no details are provided, a default crafting system is + * created. The crafting system id is generated automatically. + * + * @async + * @param {object} options - The crafting system details. + * @param {string} [options.name] - The name of the crafting system. + * @param {string} [options.summary] - A brief summary of the crafting system. + * @param {string} [options.description] - A more detailed description of the crafting system. + * @param {string} [options.author] - The name of the person or organization that authored the crafting system. + * @returns {Promise} A Promise that resolves with the newly created `CraftingSystem` instance, or + * rejects with an Error if the crafting system is not valid. + */ + create({ name, summary, description, author }?: { name?: string, summary?: string, description?: string, author?: string }): Promise; + + /** + * Retrieves the crafting system with the specified ID. + * + * @param {string} id - The ID of the crafting system to retrieve. + * @returns {Promise} A Promise that resolves with the crafting system, or undefined if + * it does not exist. + */ + getById(id: string): Promise; + + /** + * Saves a crafting system. + * + * @async + * @param {CraftingSystem} craftingSystem - The crafting system to save. + * @returns {Promise} A Promise that resolves with the saved crafting system, or rejects with an + * error if the crafting system is not valid, or cannot be saved. + */ + save(craftingSystem: CraftingSystem): Promise; + + /** + * Creates or overwrites a crafting system with the given details. This operation is intended to be used when + * importing a crafting system from a JSON file. Most users should use `create` or `save` crafting systems + * instead. + * + * @async + * @param craftingSystemData - The crafting system data to insert. + * @returns {Promise} A Promise that resolves with the saved crafting system, or rejects with an + * error if the crafting system is not valid, or cannot be saved. + */ + insert(craftingSystemData: CraftingSystemImportData): Promise; + + /** + * Deletes a crafting system by ID. + * + * @async + * @param {string} id - The ID of the crafting system to delete. + * @returns {Promise} - A Promise that resolves to the deleted crafting system or + * undefined if the crafting system with the given ID does not exist. + */ + deleteById(id: string): Promise; + + /** + * The Notification service used by this API. If `notifications.isSuppressed` is true, all notification messages + * will print only to the console. If false, notification messages will be displayed in both the console and the UI. + * */ + notifications: NotificationService; + + /** + * Retrieves all crafting systems. + * + * @async + * @returns {Promise} A Promise that resolves with all crafting systems. + */ + getAll(): Promise>; + +} + +export { CraftingSystemAPI }; + +class DefaultCraftingSystemAPI implements CraftingSystemAPI { + + private static readonly _LOCALIZATION_PATH: string = `${Properties.module.id}.settings` + + private readonly identityFactory: IdentityFactory; + private readonly craftingSystemStore: EntityDataStore; + private readonly localizationService: LocalizationService; + private readonly notificationService: NotificationService; + private readonly craftingSystemValidator: CraftingSystemValidator; + + private readonly user: string; + + constructor({ + identityFactory, + craftingSystemStore, + localizationService, + notificationService, + craftingSystemValidator = new CraftingSystemValidator(), + user + }: { + identityFactory: IdentityFactory; + craftingSystemStore: EntityDataStore; + localizationService: LocalizationService; + notificationService: NotificationService; + craftingSystemValidator?: CraftingSystemValidator; + user: string; + }) { + this.identityFactory = identityFactory; + this.craftingSystemStore = craftingSystemStore; + this.localizationService = localizationService; + this.notificationService = notificationService; + this.craftingSystemValidator = craftingSystemValidator; + this.user = user; + } + + get notifications(): NotificationService { + return this.notificationService; + } + + async deleteById(id: string): Promise { + const craftingSystemToDelete = await this.craftingSystemStore.getById(id); + + this.rejectDeletingEmbeddedCraftingSystem(craftingSystemToDelete); + + if (!craftingSystemToDelete) { + const message = this.localizationService.format( + `${DefaultCraftingSystemAPI._LOCALIZATION_PATH}.errors.craftingSystem.doesNotExist`, + { craftingSystemId: id } + ); + this.notificationService.error(message); + return undefined; + } + + await this.craftingSystemStore.deleteById(id); + + return craftingSystemToDelete; + } + + async getById(craftingSystemId: string): Promise { + const craftingSystem = await this.craftingSystemStore.getById(craftingSystemId); + + if (!craftingSystem) { + const message = this.localizationService.format( + `${DefaultCraftingSystemAPI._LOCALIZATION_PATH}.errors.craftingSystem.doesNotExist`, + { craftingSystemId } + ); + this.notificationService.warn(message); + return undefined; + } + + return craftingSystem; + } + + async cloneById(craftingSystemId: string): Promise { + const source = await this.getById(craftingSystemId); + if (!source) { + const message = this.localizationService.format( + `${DefaultCraftingSystemAPI._LOCALIZATION_PATH}.errors.craftingSystem.cloneTargetNotFound`, + { craftingSystemId } + ); + this.notificationService.error(message); + throw new Error(message); + } + const assignedIds = await this.craftingSystemStore.listAllEntityIds(); + const clone = source.clone({ + id: this.identityFactory.make(assignedIds), + name: `${source.details.name} (Copy)`, + embedded: false, + }); + return this.save(clone); + } + + async getAll(): Promise> { + const allCraftingSystems = await this.craftingSystemStore.getAllEntities(); + return new Map(allCraftingSystems.map(craftingSystem => [craftingSystem.id, craftingSystem])); + } + + async save(updatedCraftingSystem: CraftingSystem): Promise { + + const existingCraftingSystem = await this.craftingSystemStore.getById(updatedCraftingSystem.id); + + this.rejectModifyingEmbeddedSystem(existingCraftingSystem, updatedCraftingSystem); + await this.rejectSavingInvalidSystem(updatedCraftingSystem); + + await this.craftingSystemStore.insert(updatedCraftingSystem); + + const localizationActivity = existingCraftingSystem ? "updated" : "created"; + const message = this.localizationService.format( + `${DefaultCraftingSystemAPI._LOCALIZATION_PATH}.craftingSystem.${localizationActivity}`, + { systemName: updatedCraftingSystem.details.name } + ); + + this.notificationService.info(message); + + return updatedCraftingSystem; + } + + async insert(craftingSystemData: CraftingSystemImportData): Promise { + const craftingSystem = CraftingSystem.fromJson({ + ...craftingSystemData, + embedded: false + }); + return this.save(craftingSystem); + } + + async create({ + name = Properties.ui.defaults.craftingSystem.name, + summary = Properties.ui.defaults.craftingSystem.summary, + description = Properties.ui.defaults.craftingSystem.description, + author = Properties.ui.defaults.craftingSystem.author(this.user) + }: { + name?: string; + summary?: string; + description?: string; + author?: string; + } = {}): Promise { + const assignedIds = await this.craftingSystemStore.listAllEntityIds(); + const id = this.identityFactory.make(assignedIds); + const craftingSystemDetails = new CraftingSystemDetails({name, summary, description, author}); + const created = new CraftingSystem({ + id, + disabled: true, + craftingSystemDetails, + embedded: false + }); + return this.save(created); + } + + private async rejectSavingInvalidSystem(craftingSystem: CraftingSystem): Promise> { + const validationResult = await this.craftingSystemValidator.validate(craftingSystem); + if (!validationResult.successful) { + const message = this.localizationService.format( + `${DefaultCraftingSystemAPI._LOCALIZATION_PATH}.errors.craftingSystem.invalid`, + { + errors: validationResult.errors.join(", "), + craftingSystemId: craftingSystem.id + } + ); + this.notificationService.error(message); + throw new Error(message); + } + return validationResult; + } + + private rejectDeletingEmbeddedCraftingSystem(craftingSystem: CraftingSystem): void { + if (!craftingSystem?.isEmbedded) { + return; + } + const message = this.localizationService.format( + `${DefaultCraftingSystemAPI._LOCALIZATION_PATH}.errors.craftingSystem.cannotDeleteEmbedded`, + { craftingSystemName: craftingSystem.details.name } + ); + this.notificationService.error(message); + throw new Error(message); + + } + + private rejectModifyingEmbeddedSystem(existingCraftingSystem: CraftingSystem, updatedCraftingSystem: CraftingSystem): void { + + if (!existingCraftingSystem || !existingCraftingSystem.isEmbedded) { + return; + } + + if (existingCraftingSystem.equals(updatedCraftingSystem, true)) { + return; + } + + const message = this.localizationService.format( + `${DefaultCraftingSystemAPI._LOCALIZATION_PATH}.errors.craftingSystem.cannotModifyEmbedded`, + { craftingSystemName: existingCraftingSystem.details.name } + ); + this.notificationService.error(message); + throw new Error(message); + + } + +} + +export { DefaultCraftingSystemAPI }; diff --git a/src/scripts/api/EntityValidator.ts b/src/scripts/api/EntityValidator.ts new file mode 100644 index 00000000..79abf41d --- /dev/null +++ b/src/scripts/api/EntityValidator.ts @@ -0,0 +1,53 @@ +import {Identifiable} from "../common/Identifiable"; +import {Serializable} from "../common/Serializable"; + +interface EntityValidationResult { + + readonly entity: T; + + readonly successful: boolean; + + readonly errors: string[]; + +} + +export { EntityValidationResult }; + +class DefaultEntityValidationResult implements EntityValidationResult { + + private readonly _entity: T; + private readonly _errors: string[]; + private readonly _successful: boolean; + + constructor({entity, errors = [], isSuccessful}: { entity: T, errors?: string[], isSuccessful?: boolean }) { + this._entity = entity; + this._errors = errors; + this._successful = typeof isSuccessful !== "undefined" ? isSuccessful : errors.length === 0; + } + + get entity(): T { + return this._entity; + } + + get errors(): string[] { + return this._errors; + } + + get successful(): boolean { + return this._successful; + } + +} + +export { DefaultEntityValidationResult }; + +interface EntityValidator, A extends any[] = []> { + + validate(candidate: T, ...additionalArgs: A): Promise>; + + validateJson(candidate: J, ...additionalArgs: A): Promise>; + +} + +export { EntityValidator }; + diff --git a/src/scripts/api/EssenceAPI.ts b/src/scripts/api/EssenceAPI.ts new file mode 100644 index 00000000..ee508d04 --- /dev/null +++ b/src/scripts/api/EssenceAPI.ts @@ -0,0 +1,481 @@ +import {Essence, EssenceJson} from "../crafting/essence/Essence"; +import Properties from "../Properties"; +import {IdentityFactory} from "../foundry/IdentityFactory"; +import {EntityDataStore} from "../repository/EntityDataStore"; +import {LocalizationService} from "../../applications/common/LocalizationService"; +import {EntityValidationResult} from "./EntityValidator"; +import {CraftingSystemAPI} from "./CraftingSystemAPI"; +import {EssenceValidator} from "../crafting/essence/EssenceValidator"; +import {NotificationService} from "../foundry/NotificationService"; +import {EssenceExportModel} from "../repository/import/FabricateExportModel"; + +/** + * Represents a set of options for creating an essence. + */ +interface EssenceCreationOptions { + + /** + * The name of the essence. + */ + name?: string; + + /** + * The tooltip text to display when the essence is hovered over. + */ + tooltip?: string; + + /** + * The FontAwesome icon code for the essence icon + */ + iconCode?: string; + + /** + * A more detailed description of the essence + */ + description?: string; + + /** + * The UUID of the item that is the source of the active effect for this essence, if present + */ + activeEffectSourceItemUuid?: string; + + /** + * The ID of the crafting system to which this essence belongs. + */ + craftingSystemId: string; + + /** + * Whether the essence is disabled. Defaults to false. + */ + disabled?: boolean; + +} + +export { EssenceCreationOptions }; + +interface EssenceAPI { + + /** + * Retrieves the essence with the specified ID. + * + * @async + * @param {string} id - The ID of the essence to retrieve. + * @returns {Promise} A Promise that resolves with the essence, or undefined if it does not + * exist. + */ + getById(id: string): Promise; + + /** + * Returns all essences. + * + * @async + * @returns {Promise>} A Promise that resolves to a Map of all essences, where the keys are + * the essence IDs, or rejects with an Error if the settings cannot be read. + */ + getAll(): Promise>; + + /** + * Returns all essences with the specified IDs. + * + * @param {string[]} essenceIds - An array of essence IDs to retrieve. + * @returns {Promise>} A Promise that resolves to a Map of essences, where the keys + * are the essence IDs. Values are undefined if the essence with the corresponding ID does not exist. + */ + getAllById(essenceIds: string[]): Promise>; + + /** + * Returns all essences for a given crafting system ID. + * + * @async + * @param {string} craftingSystemId - The ID of the crafting system. + * @returns {Promise>} A Promise that resolves to a Map of essence instances for the given + * crafting system, where the keys are the essence IDs, or rejects with an Error if the settings cannot be read. + */ + getAllByCraftingSystemId(craftingSystemId: string): Promise>; + + /** + * Deletes an essence by ID. + * + * @async + * @param {string} id - The ID of the essence to delete. + * @returns {Promise} - A Promise that resolves to the deleted essence or undefined if the + * essence with the given ID does not exist. + */ + deleteById(id: string): Promise; + + /** + * Deletes all essences by item UUID. + * + * @async + * @param itemUuid + * @returns {Promise} - A Promise that resolves to the deleted essence(s) or an empty array if + * no essences were deleted. + */ + deleteByItemUuid(itemUuid: string): Promise; + + /** + * Deletes all essences by crafting system ID. + * + * @async + * @param craftingSystemId - The ID of the crafting system to which the essences belong. + * @returns {Promise} - A Promise that resolves to the deleted essence(s) or an empty array if no + * essences were deleted. + */ + deleteByCraftingSystemId(craftingSystemId: string): Promise; + + /** + * Creates a new essence with the given details. + * + * @async + * @param {object} options - The options to use when creating the essence + * @param {string} [options.name] - The name of the essence + * @param {string} [options.tooltip] - The tooltip text to display when the essence is hovered over + * @param {string} [options.iconCode] - The FontAwesome icon code for the essence icon + * @param {string} [options.description] - A more detailed description of the essence + * @param {string} [options.activeEffectSourceItemUuid] - The UUID of the item that is the source of the active + * effect for this essence, if present + * @param {string} options.craftingSystemId - The ID of the crafting system to which this essence belongs + * @returns {Promise} A Promise that resolves to the created essence. + */ + create({ name, tooltip, iconCode, description, activeEffectSourceItemUuid, craftingSystemId }: EssenceCreationOptions): Promise; + + /** + * Saves the given essence. + * + * @async + * @param {Essence} essence - The essence to save. + * @returns {Promise} A Promise that resolves to the saved essence, or rejects with an Error if the + * essence is invalid or cannot be saved. + */ + save(essence: Essence): Promise; + + /** + * Saves all the given essences. + * + * @async + * @param essences - The essences to save. + * @returns {Promise} A Promise that resolves to the saved essences, or rejects with an Error if any + * of the essences are invalid or cannot be saved. + */ + saveAll(essences: Essence[]): Promise; + + /** + * The Notification service used by this API. If `notifications.suppressed` is true, all notification messages + * will print only to the console. If false, notification messages will be displayed in both the console and the UI. + * */ + readonly notifications: NotificationService; + + /** + * Creates or overwrites an essence with the given details. This operation is intended to be used when importing a + * crafting system and its essences from a JSON file. Most users should use `create` or `save` essences instead. + * + * @async + * @param essenceData - The essence data to insert + * @returns {Promise} A Promise that resolves with the saved essence, or rejects with an error if + * the essence is not valid, or cannot be saved. + */ + insert(essenceData: EssenceExportModel): Promise; + + /** + * Creates or overwrites multiple essences with the given details. This operation is intended to be used when + * importing a crafting system and its essences from a JSON file. Most users should use `create` or `save` + * essences instead. + * + * @async + * @param essenceData - The essence data to insert + * @returns {Promise} A Promise that resolves with the saved essences, or rejects with an error if + * any of the essences are not valid, or cannot be saved. + */ + insertMany(essenceData: EssenceExportModel[]): Promise; + + /** + * Clones all provided Essences to a target Crafting System. Essences are cloned by value and the copies will be + * assigned new IDs. The cloned Essences will be assigned to the Crafting System with the given target Crafting + * System ID. This operation is not idempotent and will produce duplicate Essences with distinct IDs if called + * multiple times with the same source Essences and target Crafting System ID. + * + * @async + * @param sourceEssences - The Essences to clone. + * @param targetCraftingSystemId - The ID of the Crafting System to clone the essences to. Defaults to the source + * Essence's Crafting System ID. + * @returns {Promise} A Promise that resolves to an object containing the cloned Essences and a Map keyed + * on the source Essence IDs pointing to the newly cloned Essence IDs, or rejects with an Error if the target + * Crafting System does not exist or any of the Essences are invalid. + */ + cloneAll(sourceEssences: Essence[], targetCraftingSystemId?: string): Promise<{ essences: Essence[], idLinks: Map }>; + +} + +export { EssenceAPI }; + +class DefaultEssenceAPI implements EssenceAPI { + + private static readonly _LOCALIZATION_PATH: string = `${Properties.module.id}.settings` + + private readonly identityFactory: IdentityFactory; + private readonly essenceStore: EntityDataStore; + private readonly localizationService: LocalizationService; + private readonly notificationService: NotificationService; + private readonly essenceValidator: EssenceValidator; + + constructor({ + identityFactory, + essenceStore, + localizationService, + notificationService, + essenceValidator + }: { + identityFactory: IdentityFactory; + essenceStore: EntityDataStore; + localizationService: LocalizationService; + notificationService: NotificationService; + essenceValidator: EssenceValidator; + craftingSystemAPI: CraftingSystemAPI; + }) { + this.identityFactory = identityFactory; + this.essenceStore = essenceStore; + this.localizationService = localizationService; + this.notificationService = notificationService; + this.essenceValidator = essenceValidator; + } + + get notifications(): NotificationService { + return this.notificationService; + } + + async getById(id: string): Promise { + const essence = await this.essenceStore.getById(id); + + if (!essence) { + const message = this.localizationService.format( + `${DefaultEssenceAPI._LOCALIZATION_PATH}.errors.essence.doesNotExist`, + { essenceId: id } + ); + this.notificationService.warn(message); + return undefined; + } + + return essence; + } + + async create({ + name = Properties.ui.defaults.essence.name, + tooltip = Properties.ui.defaults.essence.tooltip, + iconCode = Properties.ui.defaults.essence.iconCode, + description = Properties.ui.defaults.essence.description, + activeEffectSourceItemUuid, + craftingSystemId + }: EssenceCreationOptions): Promise { + const assignedIds = await this.essenceStore.listAllEntityIds(); + const id = this.identityFactory.make(assignedIds); + return this.essenceStore.create({ + id, + name, + tooltip, + iconCode, + description, + disabled: false, + embedded: false, + craftingSystemId, + activeEffectSourceItemUuid, + }); + } + + async deleteById(id: string): Promise { + const essenceToDelete = await this.essenceStore.getById(id); + + this.rejectDeletingEmbeddedEssence(essenceToDelete); + + if (!essenceToDelete) { + const message = this.localizationService.format( + `${DefaultEssenceAPI._LOCALIZATION_PATH}.errors.essence.doesNotExist`, + { essenceId: id } + ); + this.notificationService.warn(message); + return undefined; + } + await this.essenceStore.deleteById(id); + return essenceToDelete; + } + + async deleteByItemUuid(itemUuid: string): Promise { + const essences = await this.essenceStore.getCollection(itemUuid, Properties.settings.collectionNames.item); + if (essences.length === 0) { + return []; + } + essences.forEach(essence => this.rejectDeletingEmbeddedEssence(essence)); + await this.essenceStore.deleteCollection(itemUuid, Properties.settings.collectionNames.item); + return essences; + } + + async deleteByCraftingSystemId(craftingSystemId: string): Promise { + const essences = await this.essenceStore.getCollection(craftingSystemId, Properties.settings.collectionNames.craftingSystem); + if (essences.length === 0) { + return []; + } + essences.forEach(essence => this.rejectDeletingEmbeddedEssence(essence)); + await this.essenceStore.deleteCollection(craftingSystemId, Properties.settings.collectionNames.craftingSystem); + return essences; + } + + async getAll(): Promise> { + const essences = await this.essenceStore.getAllEntities(); + return new Map(essences.map(essence => [essence.id, essence])); + } + + async getAllById(essenceIds: string[]): Promise> { + const essences = await this.essenceStore.getAllById(essenceIds); + const result = new Map(essences.map(essence => [essence.id, essence])); + const missingValues = essenceIds.filter(id => !result.has(id)); + if (missingValues.length > 0) { + const message = this.localizationService.format( + `${DefaultEssenceAPI._LOCALIZATION_PATH}.errors.essence.missingEssences`, + { essenceIds: missingValues.join(", ") } + ); + this.notificationService.error(message); + missingValues.forEach(id => result.set(id, undefined)); + } + return result; + } + + async getAllByCraftingSystemId(craftingSystemId: string): Promise> { + const essences = await this.essenceStore.getCollection(craftingSystemId, Properties.settings.collectionNames.craftingSystem); + return new Map(essences.map(essences => [essences.id, essences])); + } + + async save(essence: Essence): Promise { + const existing = await this.essenceStore.getById(essence.id); + + this.rejectModifyingEmbeddedEssence(existing); + await this.rejectSavingInvalidEssence(essence); + + await this.essenceStore.insert(essence); + + const localizationActivity = existing ? "updated" : "created"; + const message = this.localizationService.format( + `${DefaultEssenceAPI._LOCALIZATION_PATH}.essence.${localizationActivity}`, + { essenceName: essence.name } + ); + this.notificationService.info(message); + + return essence; + } + + async saveAll(essences: Essence[]): Promise { + + const existing = await this.essenceStore.getAllById(essences.map(essence => essence.id)); + existing.forEach(existingEssence => this.rejectModifyingEmbeddedEssence(existingEssence)); + + const validations = essences.map(essence => this.rejectSavingInvalidEssence(essence)); + await Promise.all(validations) + .catch(() => { + const message = this.localizationService.localize( + `${DefaultEssenceAPI._LOCALIZATION_PATH}.errors.essence.noneSaved` + ); + this.notificationService.error(message); + throw new Error(message); + }); + + await this.essenceStore.insertAll(essences); + + const message = this.localizationService.format( + `${DefaultEssenceAPI._LOCALIZATION_PATH}.essence.savedAll`, + { count: essences.length } + ); + this.notificationService.info(message); + + return essences; + + } + + async insert(essenceData: EssenceExportModel): Promise { + const essenceJson = { + ...essenceData, + embedded: false, + } + const essence = await this.essenceStore.buildEntity(essenceJson); + return this.save(essence); + } + + insertMany(essenceData: EssenceExportModel[]): Promise { + return Promise.all(essenceData.map(essence => this.insert(essence))); + } + + async cloneAll(sourceEssences: Essence[], targetCraftingSystemId?: string): Promise<{ essences: Essence[], idLinks: Map }> { + const existingEssences = await this.getAll(); + const excludedIdentityValues = Array.from(existingEssences.keys()); + + const cloneData = sourceEssences + .map(sourceEssence => { + const clonedEssence = sourceEssence.clone({ + craftingSystemId: targetCraftingSystemId, + id: this.identityFactory.make(excludedIdentityValues), + }); + return { + essence: clonedEssence, + sourceId: sourceEssence.id, + } + }) + .reduce( + (result, currentValue) => { + result.ids.set(currentValue.sourceId, currentValue.essence.id); + result.essences.push(currentValue.essence); + return result; + }, + { + ids: new Map, + essences: [], + } + ); + + const savedClones = await this.saveAll(cloneData.essences); + + return { + essences: savedClones, + idLinks: cloneData.ids, + }; + } + + private async rejectSavingInvalidEssence(essence: Essence): Promise> { + const validationResult = await this.essenceValidator.validate(essence); + if (validationResult.successful) { + return validationResult; + } + const message = this.localizationService.format( + `${DefaultEssenceAPI._LOCALIZATION_PATH}.errors.essence.invalid`, + { + errors: validationResult.errors.join(", "), + essenceId: essence.id + } + ); + this.notificationService.error(message); + throw new Error(message); + } + + private rejectModifyingEmbeddedEssence(essence: Essence): void { + if (!essence?.embedded) { + return; + } + const message = this.localizationService.format( + `${DefaultEssenceAPI._LOCALIZATION_PATH}.errors.essence.cannotModifyEmbedded`, + { essenceName: essence.name } + ); + this.notificationService.error(message); + throw new Error(message); + } + + private rejectDeletingEmbeddedEssence(essenceToDelete: Essence): void { + if (!essenceToDelete?.embedded) { + return; + } + const message = this.localizationService.format( + `${DefaultEssenceAPI._LOCALIZATION_PATH}.errors.essence.cannotDeleteEmbedded`, + { essenceName: essenceToDelete.name } + ); + this.notificationService.error(message); + throw new Error(message); + } + +} + +export { DefaultEssenceAPI }; diff --git a/src/scripts/api/FabricateAPI.ts b/src/scripts/api/FabricateAPI.ts new file mode 100644 index 00000000..c568c7d0 --- /dev/null +++ b/src/scripts/api/FabricateAPI.ts @@ -0,0 +1,596 @@ +import {CraftingSystemAPI} from "./CraftingSystemAPI"; +import {EssenceAPI} from "./EssenceAPI"; +import {ComponentAPI} from "./ComponentAPI"; +import {RecipeAPI} from "./RecipeAPI"; +import {SettingMigrationAPI} from "./SettingMigrationAPI"; +import {CraftingAPI} from "./CraftingAPI"; +import {Recipe} from "../crafting/recipe/Recipe"; +import {Component} from "../crafting/component/Component"; +import {Essence} from "../crafting/essence/Essence"; +import {CraftingSystem} from "../system/CraftingSystem"; +import {FabricateExportModel} from "../repository/import/FabricateExportModel"; +import Properties from "../Properties"; +import {V2Component, V2CraftingSystem, V2Essence, V2Recipe} from "../repository/migration/V2SettingsModel"; +import {NotificationService} from "../foundry/NotificationService"; +import {LocalizationService} from "../../applications/common/LocalizationService"; + +interface EntityCountStatistics { + + count: number; + + ids: string[]; + +} + +export { EntityCountStatistics } + +interface EntityCountStatisticsByCraftingSystem extends EntityCountStatistics { + + byCraftingSystem: Record; + +} + +export { EntityCountStatisticsByCraftingSystem } + +interface FabricateStatistics { + + craftingSystems: EntityCountStatistics; + + essences: EntityCountStatisticsByCraftingSystem; + + components: EntityCountStatisticsByCraftingSystem; + + recipes: EntityCountStatisticsByCraftingSystem; + +} + +export { FabricateStatistics } + +interface CraftingSystemData { + craftingSystem: CraftingSystem; + essences: Essence[]; + components: Component[]; + recipes: Recipe[]; +} + +export { CraftingSystemData } + +/** + * Represents an API for managing crafting systems, components, essences, and recipes. + * + * @interface + */ +interface FabricateAPI { + + /** + * Gets the API for managing crafting systems. + */ + readonly systems: CraftingSystemAPI; + + /** + * Gets the API for managing essences. + */ + readonly essences: EssenceAPI; + + /** + * Gets the API for managing components. + */ + readonly components: ComponentAPI; + + /** + * Gets the API for managing recipes. + */ + readonly recipes: RecipeAPI; + + /** + * Gets the API for managing Fabricate's data migrations. + */ + readonly migration: SettingMigrationAPI; + + /** + * Gets the API for performing crafting. + */ + readonly crafting: CraftingAPI; + + /** + * Suppresses notifications from Fabricate for all operations. Use {@link FabricateAPI#activateNotifications} to + * re-enable notifications. + */ + suppressNotifications(): void; + + /** + * Activates notifications from Fabricate for all operations. Use {@link FabricateAPI#suppressNotifications} to + * suppress notifications. + */ + activateNotifications(): void; + + /** + * Gets summary statistics about the Crafting Systems, Essences, Components, and Recipes in the Fabricate database. + * + * @async + * @returns {Promise} A Promise that resolves with the Fabricate statistics. + */ + getStatistics(): Promise; + + /** + * Deletes all Crafting Systems, Essences, Components, and Recipes in the Fabricate database for the given Crafting + * System id. + * + * @async + * @param id - The ID of the Crafting System to delete. + * @returns {Promise} A Promise that resolves to an object containing the deleted Crafting System, Essences, + * Components, and Recipes. + */ + deleteAllByCraftingSystemId(id: string): Promise; + + /** + * Duplicates the Crafting System with the given ID. The copy will have the same name as the original, with the + * suffix "Copy" appended to it. All Essences, Components, and Recipes in the Crafting System will also be + * duplicated. + * + * @async + * @param sourceCraftingSystemId - The ID of the Crafting System to duplicate. + * @returns {Promise} A Promise that resolves to an object containing the duplicated Crafting + * System, Essences, Components, and Recipes. + */ + duplicateCraftingSystem(sourceCraftingSystemId: string): Promise; + + /** + * Imports the given Fabricate data into the Fabricate database. + * + * @async + * @param importData - The Fabricate data to import. + * @returns {Promise} A Promise that resolves to an object containing the imported Crafting System, Essences, + * Components, and Recipes. + */ + import(importData: FabricateExportModel): Promise; + + /** + * Exports a complete Crafting System from Fabricate for the given Crafting System ID. + * + * @async + * @param craftingSystemId - The ID of the Crafting System to export. + * @returns {Promise} A Promise that resolves to the exported Fabricate Crafting System, with + * its Essences, Components, and Recipes. + */ + export(craftingSystemId: string): Promise; + +} +export { FabricateAPI } + +class DefaultFabricateAPI implements FabricateAPI { + + private readonly recipeAPI: RecipeAPI; + private readonly essenceAPI: EssenceAPI; + private readonly craftingAPI: CraftingAPI; + private readonly componentAPI: ComponentAPI; + private readonly localizationService: LocalizationService; + private readonly notificationService: NotificationService; + private readonly craftingSystemAPI: CraftingSystemAPI; + private readonly settingMigrationAPI: SettingMigrationAPI; + + constructor({ + recipeAPI, + essenceAPI, + craftingAPI, + componentAPI, + craftingSystemAPI, + localizationService, + notificationService, + settingMigrationAPI, + }: { + recipeAPI: RecipeAPI; + essenceAPI: EssenceAPI; + craftingAPI: CraftingAPI; + componentAPI: ComponentAPI; + craftingSystemAPI: CraftingSystemAPI; + localizationService: LocalizationService; + notificationService: NotificationService; + settingMigrationAPI: SettingMigrationAPI; + }) { + this.recipeAPI = recipeAPI; + this.essenceAPI = essenceAPI; + this.craftingAPI = craftingAPI; + this.componentAPI = componentAPI; + this.craftingSystemAPI = craftingSystemAPI; + this.localizationService = localizationService; + this.notificationService = notificationService; + this.settingMigrationAPI = settingMigrationAPI; + } + + activateNotifications(): void { + this.setNotificationsSuppressed(false); + } + + suppressNotifications(): void { + this.setNotificationsSuppressed(true); + } + + private setNotificationsSuppressed(value: boolean): void { + this.notificationService.suppressed = value; + this.componentAPI.notifications.suppressed = value; + this.essenceAPI.notifications.suppressed = value; + this.craftingSystemAPI.notifications.suppressed = value; + this.recipeAPI.notifications.suppressed = value; + } + + get systems(): CraftingSystemAPI { + return this.craftingSystemAPI; + } + + get essences(): EssenceAPI { + return this.essenceAPI; + } + + get components(): ComponentAPI { + return this.componentAPI; + } + + get recipes(): RecipeAPI { + return this.recipeAPI; + } + + get migration(): SettingMigrationAPI { + return this.settingMigrationAPI; + } + + get crafting(): CraftingAPI { + return this.craftingAPI; + } + + async getStatistics(): Promise { + const craftingSystems = await this.craftingSystemAPI.getAll(); + const craftingSystemIds = Array.from(craftingSystems.keys()); + + const essences = await this.essenceAPI.getAll(); + const essenceStatsByCraftingSystem = Array.from(essences.values()) + .reduce((statistics, essence) => { + const essenceStats = statistics[essence.craftingSystemId] ?? { + count: 0, + ids: [], + }; + essenceStats.count++; + essenceStats.ids.push(essence.id); + statistics[essence.craftingSystemId] = essenceStats; + return statistics; + }, >{}); + + const components = await this.componentAPI.getAll(); + const componentStatsByCraftingSystem = Array.from(components.values()) + .reduce((statistics, component) => { + const componentStats = statistics[component.craftingSystemId] ?? { + count: 0, + ids: [], + }; + componentStats.count++; + componentStats.ids.push(component.id); + statistics[component.craftingSystemId] = componentStats; + return statistics; + }, >{}); + + const recipes = await this.recipeAPI.getAll(); + const recipeStatsByCraftingSystem = Array.from(recipes.values()) + .reduce((statistics, recipe) => { + const recipeStats = statistics[recipe.craftingSystemId] ?? { + count: 0, + ids: [], + }; + recipeStats.count++; + recipeStats.ids.push(recipe.id); + statistics[recipe.craftingSystemId] = recipeStats; + return statistics; + }, >{}); + + return { + craftingSystems: { + count: craftingSystems.size, + ids: craftingSystemIds, + }, + essences: { + count: essences.size, + ids: Array.from(essences.keys()), + byCraftingSystem: essenceStatsByCraftingSystem, + }, + components: { + count: components.size, + ids: Array.from(components.keys()), + byCraftingSystem: componentStatsByCraftingSystem, + }, + recipes: { + count: recipes.size, + ids: Array.from(recipes.keys()), + byCraftingSystem: recipeStatsByCraftingSystem, + } + } + } + + async deleteAllByCraftingSystemId(id: string): Promise { + + const [ + craftingSystem, + essences, + components, + recipes + ] = await Promise.all([ + this.craftingSystemAPI.deleteById(id), + this.essenceAPI.deleteByCraftingSystemId(id), + this.componentAPI.deleteByCraftingSystemId(id), + this.recipeAPI.deleteByCraftingSystemId(id), + ]); + + const message = this.localizationService.format(`${Properties.module.id}.settings.craftingSystem.deleted`, { systemName: craftingSystem.details.name }); + this.notificationService.info(message); + + return { + craftingSystem, + essences, + components, + recipes + } + + } + + async duplicateCraftingSystem(sourceCraftingSystemId: string): Promise { + + const result: CraftingSystemData = { + craftingSystem: null, + essences: [], + components: [], + recipes: [], + }; + + try { + + const clonedCraftingSystem = await this.craftingSystemAPI.cloneById(sourceCraftingSystemId); + result.craftingSystem = clonedCraftingSystem; + + const sourceEssences = await this.essenceAPI.getAllByCraftingSystemId(sourceCraftingSystemId); + const essenceCloneResult = await this.essenceAPI.cloneAll(Array.from(sourceEssences.values()), clonedCraftingSystem.id); + result.essences = essenceCloneResult.essences; + + const sourceComponents = await this.componentAPI.getAllByCraftingSystemId(sourceCraftingSystemId); + const componentCloneResult = await this.componentAPI.cloneAll(Array.from(sourceComponents.values()), clonedCraftingSystem.id, essenceCloneResult.idLinks); + result.components = componentCloneResult.components; + + const sourceRecipes = await this.recipeAPI.getAllByCraftingSystemId(sourceCraftingSystemId); + const recipeCloneResult = await this.recipeAPI.cloneAll(Array.from(sourceRecipes.values()), clonedCraftingSystem.id, essenceCloneResult.idLinks, componentCloneResult.idLinks); + result.recipes = recipeCloneResult.recipes; + + } catch (e: any) { + const error = e instanceof Error ? e : new Error(e); + const message = this.localizationService.format( + `${Properties.module.id}.settings.craftingSystem.duplicate.failure`, + { + systemId: sourceCraftingSystemId, + cause: error.message + } + ); + this.notificationService.error(message); + return result; + } + + const message = this.localizationService.format( + `${Properties.module.id}.settings.craftingSystem.duplicate.success`, + { + cloneId: result.craftingSystem.id, + systemId: sourceCraftingSystemId, + } + ); + this.notificationService.info(message); + + return result; + } + + async import(importData: FabricateExportModel): Promise { + + + importData = this.upgradeV1ImportData(importData); + await this.validateImportData(importData); + + try { + + const importedCraftingSystem = await this.craftingSystemAPI.insert(importData.craftingSystem); + const importedEssences = await this.essenceAPI.insertMany(importData.essences); + const importedComponents = await this.componentAPI.insertMany(importData.components); + const importedRecipes = await this.recipeAPI.insertMany(importData.recipes); + const message = this.localizationService.format( + `${Properties.module.id}.settings.craftingSystem.import.success`, + { + systemName: importedCraftingSystem.details.name, + essenceCount: importedEssences.length, + componentCount: importedComponents.length, + recipeCount: importedRecipes.length, + } + ); + this.notificationService.info(message); + return { + craftingSystem: importedCraftingSystem, + essences: importedEssences, + components: importedComponents, + recipes: importedRecipes, + } + + } catch (e: any) { + + const message = this.localizationService.format( + `${Properties.module.id}.settings.craftingSystem.import.failure`, + { systemName: importData?.craftingSystem?.details?.name, cause: e.message } + ); + this.notificationService.error(message); + + } + + } + + private async validateImportData(importData: FabricateExportModel) { + + const errors: string[] = []; + + if (!importData) { + errors.push(this.localizationService.localize(`${Properties.module.id}.settings.craftingSystem.import.invalidData.noData`)); + } + + if (!importData.version || typeof importData.version !== "string") { + errors.push(this.localizationService.localize(`${Properties.module.id}.settings.craftingSystem.import.invalidData.noVersion`)); + } + + if (!importData.craftingSystem || typeof importData.craftingSystem !== "object") { + errors.push(this.localizationService.localize(`${Properties.module.id}.settings.craftingSystem.import.invalidData.noCraftingSystem`)); + } + + if (!importData.essences || !Array.isArray(importData.essences)) { + errors.push(this.localizationService.localize(`${Properties.module.id}.settings.craftingSystem.import.invalidData.noEssences`)); + } + + if (!importData.components || !Array.isArray(importData.components)) { + errors.push(this.localizationService.localize(`${Properties.module.id}.settings.craftingSystem.import.invalidData.noComponents`)); + } + + if (!importData.recipes || !Array.isArray(importData.recipes)) { + errors.push(this.localizationService.localize(`${Properties.module.id}.settings.craftingSystem.import.invalidData.noRecipes`)); + } + + if (errors.length > 0) { + const message = this.localizationService.format(`${Properties.module.id}.settings.craftingSystem.import.invalidData.summary`, { errors: errors.join(', ') }); + this.notificationService.error(message); + throw new Error(message); + } + + } + + private upgradeV1ImportData(importData: FabricateExportModel) { + + if (importData?.version === "V2") { + return importData; + } + + if (!importData || !("parts" in importData)) { + const message = this.localizationService.localize(`${Properties.module.id}.settings.craftingSystem.import.upgrade.failure`); + this.notificationService.error(message); + throw new Error(message); + } + + const legacyImportData: V2CraftingSystem = importData as unknown as V2CraftingSystem; + const upgradedImportData: FabricateExportModel = { + version: "V2", + craftingSystem: { + id: legacyImportData.id, + details: { + name: legacyImportData.details.name, + author: legacyImportData.details.author, + summary: legacyImportData.details.summary, + description: legacyImportData.details.description, + }, + disabled: legacyImportData.enabled === false, + }, + essences: Object.keys(legacyImportData.parts.essences).map(essenceId => { + const legacyEssence: V2Essence = legacyImportData.parts.essences[essenceId]; + return { + id: essenceId, + craftingSystemId: legacyImportData.id, + disabled: false, + ...legacyEssence, + } + }), + components: Object.keys(legacyImportData.parts.components).map(componentId => { + const legacyComponent: V2Component = legacyImportData.parts.components[componentId]; + return { + id: componentId, + craftingSystemId: legacyImportData.id, + disabled: legacyComponent.disabled, + essences: legacyComponent.essences, + itemUuid: legacyComponent.itemUuid, + salvageOptions: Object.keys(legacyComponent.salvageOptions).map(salvageOptionId => { + const legacySalvageOption = legacyComponent.salvageOptions[salvageOptionId]; + return { + id: salvageOptionId, + name: salvageOptionId, + catalysts: {}, + results: legacySalvageOption + } + }), + } + }), + recipes: Object.keys(legacyImportData.parts.recipes).map(recipeId => { + const legacyRecipe: V2Recipe = legacyImportData.parts.recipes[recipeId]; + return { + id: recipeId, + craftingSystemId: legacyImportData.id, + disabled: legacyRecipe.disabled, + itemUuid: legacyRecipe.itemUuid, + requirementOptions: Object.keys(legacyRecipe.ingredientOptions).map(ingredientOptionId => { + const legacyIngredientOption = legacyRecipe.ingredientOptions[ingredientOptionId]; + return { + id: ingredientOptionId, + name: ingredientOptionId, + ingredients: legacyIngredientOption.ingredients, + catalysts: legacyIngredientOption.catalysts, + essences: legacyRecipe.essences, + } + }), + resultOptions: Object.keys(legacyRecipe.resultOptions).map(resultOptionId => { + const legacyResultOption = legacyRecipe.resultOptions[resultOptionId]; + return { + id: resultOptionId, + name: resultOptionId, + results: legacyResultOption, + } + }), + } + }) + }; + return upgradedImportData; + } + + async export(craftingSystemId: string): Promise { + + const craftingSystem = await this.craftingSystemAPI.getById(craftingSystemId); + + if (!craftingSystem) { + const message = this.localizationService.format( + `${Properties.module.id}.settings.craftingSystem.export.craftingSystemNotFound`, + { craftingSystemId } + ); + this.notificationService.error(message); + throw new Error(message); + } + + const essences = await this.essenceAPI.getAllByCraftingSystemId(craftingSystemId); + const components = await this.componentAPI.getAllByCraftingSystemId(craftingSystemId); + const recipes = await this.recipeAPI.getAllByCraftingSystemId(craftingSystemId); + + return { + version: "V2", + craftingSystem: { + id: craftingSystem.id, + details: { + name: craftingSystem.details.name, + summary: craftingSystem.details.summary, + description: craftingSystem.details.description, + author: craftingSystem.details.author, + }, + disabled: craftingSystem.isDisabled, + }, + essences: Array.from(essences.values()).map(essence => essence.toJson()), + components: Array.from(components.values()).map(component => { + const componentJson = component.toJson(); + return { + ...componentJson, + salvageOptions: Object.values(componentJson.salvageOptions), + } + }), + recipes: Array.from(recipes.values()).map(recipe => { + const recipeJson = recipe.toJson(); + return { + ...recipeJson, + requirementOptions: Object.values(recipeJson.requirementOptions), + resultOptions: Object.values(recipeJson.resultOptions), + } + }) + }; + } + +} + + +export { DefaultFabricateAPI } \ No newline at end of file diff --git a/src/scripts/api/FabricateAPIFactory.ts b/src/scripts/api/FabricateAPIFactory.ts new file mode 100644 index 00000000..5e589ac2 --- /dev/null +++ b/src/scripts/api/FabricateAPIFactory.ts @@ -0,0 +1,353 @@ +import {DefaultGameProvider, GameProvider} from "../foundry/GameProvider"; +import {DefaultUIProvider, UIProvider} from "../foundry/UIProvider"; +import {DefaultDocumentManager, DocumentManager} from "../foundry/DocumentManager"; +import {DefaultIdentityFactory, IdentityFactory} from "../foundry/IdentityFactory"; +import {DefaultLocalizationService} from "../../applications/common/LocalizationService"; +import {DefaultSettingMigrationAPI, SettingMigrationAPI} from "./SettingMigrationAPI"; +import {CraftingSystemAPI, DefaultCraftingSystemAPI} from "./CraftingSystemAPI"; +import {DefaultNotificationService} from "../foundry/NotificationService"; +import {CraftingSystemValidator} from "../system/CraftingSystemValidator"; +import {EntityDataStore, SerialisedEntityData} from "../repository/EntityDataStore"; +import {CraftingSystem, CraftingSystemJson} from "../system/CraftingSystem"; +import {CraftingSystemFactory} from "../system/CraftingSystemFactory"; +import { + ComponentCollectionManager, + CraftingSystemCollectionManager, + EssenceCollectionManager, + RecipeCollectionManager +} from "../repository/CollectionManager"; +import {DefaultSettingManager, SettingManager} from "../repository/SettingManager"; +import Properties from "../Properties"; +import {DefaultEssenceAPI, EssenceAPI} from "./EssenceAPI"; +import {DefaultEssenceValidator} from "../crafting/essence/EssenceValidator"; +import {Essence, EssenceJson} from "../crafting/essence/Essence"; +import {EssenceFactory} from "../crafting/essence/EssenceFactory"; +import {ComponentAPI, DefaultComponentAPI} from "./ComponentAPI"; +import {Component, ComponentJson} from "../crafting/component/Component"; +import {ComponentFactory} from "../crafting/component/ComponentFactory"; +import {DefaultComponentValidator} from "../crafting/component/ComponentValidator"; +import {DefaultRecipeAPI, RecipeAPI} from "./RecipeAPI"; +import {Recipe, RecipeJson} from "../crafting/recipe/Recipe"; +import {RecipeFactory} from "../crafting/recipe/RecipeFactory"; +import {DefaultRecipeValidator} from "../crafting/recipe/RecipeValidator"; +import {DefaultFabricateAPI, FabricateAPI} from "./FabricateAPI"; +import {DefaultSettingsMigrator} from "../repository/migration/DefaultSettingsMigrator"; +import {SettingVersion} from "../repository/migration/SettingVersion"; +import { + DefaultEmbeddedCraftingSystemManager, + EmbeddedCraftingSystemManager +} from "../repository/embedded_systems/EmbeddedCraftingSystemManager"; +import {DefaultSettingsRegistry, SettingsRegistry} from "../repository/SettingsRegistry"; +import {SettingsMigrator} from "../repository/migration/SettingsMigrator"; +import {CraftingAPI, DefaultCraftingAPI} from "./CraftingAPI"; +import {V2ToV3SettingMigrationStep} from "../repository/migration/V2ToV3SettingMigrationStep"; +import {DefaultInventoryFactory} from "../actor/InventoryFactory"; +import {ConservativeEssenceSourcingComponentSelectionStrategy} from "../crafting/selection/ComponentSelectionStrategy"; + +interface FabricateAPIFactory { + + make(): FabricateAPI; + +} + +export { FabricateAPIFactory }; + +class DefaultFabricateAPIFactory implements FabricateAPIFactory { + + private readonly user: string; + private readonly gameSystem: string; + private readonly clientSettings: ClientSettings; + private readonly uiProvider: UIProvider; + private readonly gameProvider: GameProvider; + private readonly documentManager: DocumentManager; + private readonly identityFactory: IdentityFactory; + private readonly settingsRegistry: SettingsRegistry; + + constructor({ + user, + gameSystem, + clientSettings, + uiProvider = new DefaultUIProvider(), + gameProvider = new DefaultGameProvider(), + documentManager = new DefaultDocumentManager(), + identityFactory = new DefaultIdentityFactory(), + settingsRegistry = new DefaultSettingsRegistry({ gameProvider, clientSettings }) + }: { + user: string; + gameSystem: string; + clientSettings: ClientSettings; + uiProvider?: UIProvider; + gameProvider?: GameProvider; + documentManager?: DocumentManager; + identityFactory?: IdentityFactory; + settingsRegistry?: SettingsRegistry; + }) { + this.user = user; + this.gameSystem = gameSystem; + this.clientSettings = clientSettings; + this.uiProvider = uiProvider; + this.gameProvider = gameProvider; + this.documentManager = documentManager; + this.identityFactory = identityFactory; + this.settingsRegistry = settingsRegistry; + } + + make(): FabricateAPI { + + const craftingSystemSettingManager = this.makeSettingManger>(Properties.settings.craftingSystems.key); + const essenceSettingManager = this.makeSettingManger>(Properties.settings.essences.key); + const componentSettingManager = this.makeSettingManger>(Properties.settings.components.key); + const recipeSettingManager = this.makeSettingManger>(Properties.settings.recipes.key); + + const craftingSystemStore = this.makeCraftingSystemStore(craftingSystemSettingManager); + const essenceStore = this.makeEssenceStore(this.documentManager, essenceSettingManager); + const componentStore = this.makeComponentStore(this.documentManager, componentSettingManager); + const recipeStore = this.makeRecipeStore(this.documentManager, recipeSettingManager); + + const localizationService = new DefaultLocalizationService(this.gameProvider); + + const craftingSystemAPI = this.makeCraftingSystemAPI( + this.identityFactory, + localizationService, + craftingSystemSettingManager + ); + + const essenceAPI = this.makeEssenceAPI( + this.identityFactory, + localizationService, + craftingSystemAPI, + essenceStore + ); + + const componentAPI = this.makeComponentAPI( + this.identityFactory, + localizationService, + craftingSystemAPI, + essenceAPI, + componentStore + ); + + const recipeAPI = this.makeRecipeAPI( + this.identityFactory, + localizationService, + craftingSystemAPI, + essenceAPI, + componentAPI, + recipeStore + ); + + const settingMigrationAPI = this.makeSettingMigrationAPI( + localizationService, + this.settingsRegistry, + this.makeEmbeddedCraftingSystemManager(craftingSystemStore, essenceStore, componentStore, recipeStore), + this.makeSettingsMigrator(craftingSystemSettingManager, essenceSettingManager, componentSettingManager, recipeSettingManager) + ); + + const craftingAPI = this.makeCraftingAPI( + localizationService, + craftingSystemAPI, + essenceAPI, + componentAPI, + recipeAPI, + this.gameProvider + ); + + return new DefaultFabricateAPI({ + recipeAPI, + essenceAPI, + craftingAPI, + componentAPI, + craftingSystemAPI, + settingMigrationAPI, + localizationService, + notificationService: new DefaultNotificationService(this.uiProvider), + }); + + } + + private makeSettingManger(settingKey: string): SettingManager { + return new DefaultSettingManager({ + moduleId: Properties.module.id, + settingKey, + clientSettings: this.clientSettings + }); + } + + private makeCraftingSystemAPI(identityFactory: DefaultIdentityFactory, + localizationService: DefaultLocalizationService, + craftingSystemSettingManager: SettingManager>): CraftingSystemAPI { + return new DefaultCraftingSystemAPI({ + user: this.user, + notificationService: new DefaultNotificationService(this.uiProvider), + identityFactory, + localizationService, + craftingSystemValidator: new CraftingSystemValidator(), + craftingSystemStore: this.makeCraftingSystemStore(craftingSystemSettingManager) + }); + } + + private makeCraftingSystemStore(craftingSystemSettingManager: SettingManager>) { + return new EntityDataStore({ + entityName: "Crafting System", + entityFactory: new CraftingSystemFactory(), + collectionManager: new CraftingSystemCollectionManager(), + settingManager: craftingSystemSettingManager + }); + } + + private makeEssenceAPI(identityFactory: DefaultIdentityFactory, + localizationService: DefaultLocalizationService, + craftingSystemAPI: CraftingSystemAPI, + essenceStore: EntityDataStore): EssenceAPI { + const essenceValidator = new DefaultEssenceValidator({craftingSystemAPI}); + return new DefaultEssenceAPI({ + notificationService: new DefaultNotificationService(this.uiProvider), + localizationService, + identityFactory, + craftingSystemAPI, + essenceValidator, + essenceStore + }); + } + + private makeEssenceStore(documentManager: DocumentManager, + essenceSettingManager: SettingManager>) { + return new EntityDataStore({ + entityName: "Essence", + collectionManager: new EssenceCollectionManager(), + settingManager: essenceSettingManager, + entityFactory: new EssenceFactory(documentManager), + }); + } + + private makeComponentAPI(identityFactory: DefaultIdentityFactory, + localizationService: DefaultLocalizationService, + craftingSystemAPI: CraftingSystemAPI, + essenceAPI: EssenceAPI, + componentStore: EntityDataStore): ComponentAPI { + return new DefaultComponentAPI({ + notificationService: new DefaultNotificationService(this.uiProvider), + localizationService, + identityFactory, + componentValidator: new DefaultComponentValidator({craftingSystemAPI, essenceAPI}), + componentStore + }); + } + + private makeComponentStore(documentManager: DocumentManager, + componentSettingManager: SettingManager>) { + return new EntityDataStore({ + entityName: "Component", + entityFactory: new ComponentFactory({documentManager}), + collectionManager: new ComponentCollectionManager(), + settingManager: componentSettingManager + }); + } + + private makeRecipeAPI(identityFactory: DefaultIdentityFactory, + localizationService: DefaultLocalizationService, + craftingSystemAPI: CraftingSystemAPI, + essenceAPI: EssenceAPI, + componentAPI: ComponentAPI, + recipeStore: EntityDataStore): RecipeAPI { + return new DefaultRecipeAPI({ + notificationService: new DefaultNotificationService(this.uiProvider), + identityFactory, + localizationService, + recipeValidator: new DefaultRecipeValidator({essenceAPI, componentAPI, craftingSystemAPI}), + recipeStore + }); + } + + private makeRecipeStore(documentManager: DocumentManager, + recipeSettingManager: SettingManager>) { + return new EntityDataStore({ + entityName: "Recipe", + entityFactory: new RecipeFactory({documentManager}), + collectionManager: new RecipeCollectionManager(), + settingManager: recipeSettingManager + }); + } + + private makeSettingMigrationAPI(localizationService: DefaultLocalizationService, + settingsRegistry: SettingsRegistry, + embeddedCraftingSystemManager: EmbeddedCraftingSystemManager, + settingsMigrator: SettingsMigrator): SettingMigrationAPI { + return new DefaultSettingMigrationAPI({ + localizationService, + settingsRegistry, + gameSystemId: this.gameSystem, + notificationService: new DefaultNotificationService(this.uiProvider), + settingsMigrator, + embeddedCraftingSystemManager + }); + } + + private makeEmbeddedCraftingSystemManager(craftingSystemStore: EntityDataStore, + essenceStore: EntityDataStore, + componentStore: EntityDataStore, + recipeStore: EntityDataStore) { + return new DefaultEmbeddedCraftingSystemManager({ + craftingSystemStore, + essenceStore, + componentStore, + recipeStore, + }); + } + + private makeSettingsMigrator(craftingSystemSettingManager: SettingManager>, + essenceSettingManager: SettingManager>, + componentSettingManager: SettingManager>, + recipeSettingManager: SettingManager>) { + const versionSettingManager = this.makeVersionSettingManager(); + return new DefaultSettingsMigrator({ + versionSettingManager, + targetVersion: Properties.settings.modelVersion.targetValue, + stepsBySourceVersion: new Map([ + [SettingVersion.V2, new V2ToV3SettingMigrationStep({ + identityFactory: this.identityFactory, + embeddedCraftingSystemsIds: [], + settingManagersBySettingPath: new Map>([ + [craftingSystemSettingManager.settingKey, craftingSystemSettingManager], + [essenceSettingManager.settingKey, essenceSettingManager], + [componentSettingManager.settingKey, componentSettingManager], + [recipeSettingManager.settingKey, recipeSettingManager], + [versionSettingManager.settingKey, versionSettingManager] + ]) + })] + ]), + }); + } + + private makeVersionSettingManager(): SettingManager { + return new DefaultSettingManager({ + clientSettings: this.clientSettings, + moduleId: Properties.module.id, + settingKey: Properties.settings.modelVersion.key + }); + } + + private makeCraftingAPI(localizationService: DefaultLocalizationService, + craftingSystemAPI: CraftingSystemAPI, + essenceAPI: EssenceAPI, + componentAPI: ComponentAPI, + recipeAPI: RecipeAPI, + gameProvider: GameProvider): CraftingAPI { + const inventoryFactory = new DefaultInventoryFactory({ + localizationService, + }); + return new DefaultCraftingAPI({ + recipeAPI, + essenceAPI, + gameProvider, + componentAPI, + craftingSystemAPI, + localizationService, + notificationService: new DefaultNotificationService(this.uiProvider), + inventoryFactory, + componentSelectionStrategy: new ConservativeEssenceSourcingComponentSelectionStrategy() + }); + } +} + +export { DefaultFabricateAPIFactory}; \ No newline at end of file diff --git a/src/scripts/api/FabricateUserInterfaceAPI.ts b/src/scripts/api/FabricateUserInterfaceAPI.ts new file mode 100644 index 00000000..7ee1b05a --- /dev/null +++ b/src/scripts/api/FabricateUserInterfaceAPI.ts @@ -0,0 +1,92 @@ +import {CraftingSystemManagerAppFactory} from "../../applications/CraftingSystemManagerAppFactory"; +import {ComponentSalvageAppCatalog} from "../../applications/componentSalvageApp/ComponentSalvageAppCatalog"; +import {RecipeCraftingAppCatalog} from "../../applications/recipeCraftingApp/RecipeCraftingAppCatalog"; +import {FabricateAPI} from "./FabricateAPI"; +import {GameProvider} from "../foundry/GameProvider"; + +/** + * Represents an API for managing the Fabricate user interface. + */ +interface FabricateUserInterfaceAPI { + + /** + * Renders the crafting system manager application. + * + * @returns A Promise that resolves when the application is rendered. + */ + renderCraftingSystemManagerApp(): Promise; + + /** + * Renders the component salvage application for the specified actor and component. + * + * @param actorId - The ID of the actor that owns the component. + * @param componentId - The ID of the component to salvage. + * @returns A Promise that resolves when the application is rendered. + */ + renderComponentSalvageApp(actorId: string, componentId: string): Promise; + + /** + * Renders the recipe crafting application for the specified actor and recipe. + * + * @param actorId - The ID of the actor that owns the recipe. + * @param recipeId - The ID of the recipe to craft. + * @returns A Promise that resolves when the application is rendered. + */ + renderRecipeCraftingApp(actorId: string, recipeId: string): Promise; + +} + +export { FabricateUserInterfaceAPI }; + +class DefaultFabricateUserInterfaceAPI implements FabricateUserInterfaceAPI { + + private readonly fabricateAPI: FabricateAPI; + private readonly gameProvider: GameProvider; + private readonly recipeCraftingAppCatalog: RecipeCraftingAppCatalog; + private readonly componentSalvageAppCatalog: ComponentSalvageAppCatalog; + private readonly craftingSystemManagerAppFactory: CraftingSystemManagerAppFactory + + constructor({ + fabricateAPI, + gameProvider, + recipeCraftingAppCatalog, + componentSalvageAppCatalog, + craftingSystemManagerAppFactory, + }: { + fabricateAPI: FabricateAPI; + gameProvider: GameProvider; + recipeCraftingAppCatalog: RecipeCraftingAppCatalog; + componentSalvageAppCatalog: ComponentSalvageAppCatalog; + craftingSystemManagerAppFactory: CraftingSystemManagerAppFactory; + }) { + this.fabricateAPI = fabricateAPI; + this.gameProvider = gameProvider; + this.recipeCraftingAppCatalog = recipeCraftingAppCatalog; + this.componentSalvageAppCatalog = componentSalvageAppCatalog; + this.craftingSystemManagerAppFactory = craftingSystemManagerAppFactory; + } + + async renderComponentSalvageApp(actorId: string, componentId: string): Promise { + const actor = this.gameProvider.get().actors.get(actorId); + const component = await this.fabricateAPI.components.getById(componentId); + await component.load(); + const application = await this.componentSalvageAppCatalog.load(actor, component); + application.render(true); + } + + async renderCraftingSystemManagerApp(): Promise { + const application = this.craftingSystemManagerAppFactory.make(); + application.render(true); + } + + async renderRecipeCraftingApp(actorId: string, recipeId: string): Promise { + const actor = this.gameProvider.get().actors.get(actorId); + const recipe = await this.fabricateAPI.recipes.getById(recipeId); + await recipe.load(); + const application = await this.recipeCraftingAppCatalog.load(recipe, actor); + application.render(true); + } + +} + +export { DefaultFabricateUserInterfaceAPI }; \ No newline at end of file diff --git a/src/scripts/api/FabricateUserInterfaceAPIFactory.ts b/src/scripts/api/FabricateUserInterfaceAPIFactory.ts new file mode 100644 index 00000000..a5cd4ff5 --- /dev/null +++ b/src/scripts/api/FabricateUserInterfaceAPIFactory.ts @@ -0,0 +1,70 @@ +import {DefaultFabricateUserInterfaceAPI, FabricateUserInterfaceAPI} from "./FabricateUserInterfaceAPI"; +import {CraftingSystemManagerAppFactory} from "../../applications/CraftingSystemManagerAppFactory"; +import {DefaultComponentSalvageAppCatalog} from "../../applications/componentSalvageApp/ComponentSalvageAppCatalog"; +import {DefaultComponentSalvageAppFactory} from "../../applications/componentSalvageApp/ComponentSalvageAppFactory"; +import {DefaultRecipeCraftingAppCatalog} from "../../applications/recipeCraftingApp/RecipeCraftingAppCatalog"; +import {DefaultRecipeCraftingAppFactory} from "../../applications/recipeCraftingApp/RecipeCraftingAppFactory"; +import {FabricateAPI} from "./FabricateAPI"; +import {DefaultLocalizationService} from "../../applications/common/LocalizationService"; +import {DefaultGameProvider, GameProvider} from "../foundry/GameProvider"; + +interface FabricateUserInterfaceAPIFactory { + + make(): FabricateUserInterfaceAPI; +} + +export { FabricateUserInterfaceAPIFactory }; + +class DefaultFabricateUserInterfaceAPIFactory implements FabricateUserInterfaceAPIFactory { + + private readonly fabricateAPI: FabricateAPI; + private readonly gameProvider: GameProvider; + + constructor({ + fabricateAPI, + gameProvider = new DefaultGameProvider(), + }: { + fabricateAPI: FabricateAPI; + gameProvider?: GameProvider; + }) { + this.fabricateAPI = fabricateAPI; + this.gameProvider = gameProvider; + } + + make(): FabricateUserInterfaceAPI { + + const localizationService = new DefaultLocalizationService(this.gameProvider); + + const craftingSystemManagerAppFactory = new CraftingSystemManagerAppFactory({ + fabricateAPI: this.fabricateAPI, + localizationService + }); + + const componentSalvageAppCatalog = new DefaultComponentSalvageAppCatalog({ + componentSalvageAppFactory: new DefaultComponentSalvageAppFactory({ + localizationService, + craftingAPI: this.fabricateAPI.crafting, + componentAPI: this.fabricateAPI.components, + }) + }); + + const recipeCraftingAppCatalog = new DefaultRecipeCraftingAppCatalog({ + recipeCraftingAppFactory: new DefaultRecipeCraftingAppFactory({ + craftingAPI: this.fabricateAPI.crafting, + componentAPI: this.fabricateAPI.components, + localizationService + }) + }); + + return new DefaultFabricateUserInterfaceAPI({ + craftingSystemManagerAppFactory, + componentSalvageAppCatalog, + recipeCraftingAppCatalog, + fabricateAPI: this.fabricateAPI, + gameProvider: this.gameProvider, + }); + } + +} + +export { DefaultFabricateUserInterfaceAPIFactory }; \ No newline at end of file diff --git a/src/scripts/api/RecipeAPI.ts b/src/scripts/api/RecipeAPI.ts new file mode 100644 index 00000000..7bd3141b --- /dev/null +++ b/src/scripts/api/RecipeAPI.ts @@ -0,0 +1,677 @@ +import {Recipe, RecipeJson} from "../crafting/recipe/Recipe"; +import {LocalizationService} from "../../applications/common/LocalizationService"; +import Properties from "../Properties"; +import {EntityValidationResult} from "./EntityValidator"; +import {EntityDataStore} from "../repository/EntityDataStore"; +import {IdentityFactory} from "../foundry/IdentityFactory"; +import {RecipeValidator} from "../crafting/recipe/RecipeValidator"; +import {NotificationService} from "../foundry/NotificationService"; +import {RequirementOptionJson} from "../crafting/recipe/RequirementOption"; +import {ResultOptionJson} from "../crafting/recipe/ResultOption"; +import {RecipeExportModel} from "../repository/import/FabricateExportModel"; + +/** + * A value object representing a Requirement option + * + * @interface + */ +interface RequirementOptionValue { + + /** + * The name of the requirement option. + */ + name: string; + + /** + * The catalysts necessary for this requirement option. The object is a dictionary keyed on the component ID with + * the values representing the required quantities. + */ + catalysts: Record; + + /** + * The ingredients necessary for this requirement option. The object is a dictionary keyed on the component ID with + * the values representing the required quantities. + */ + ingredients: Record; + + /** + * The essences necessary for this requirement option. The object is a dictionary keyed on the essence ID with the + * values representing the required quantities. + */ + essences: Record; + +} + +/** + * A value object representing a Result option + */ +interface ResultOptionValue { + + /** + * The name of the result option. + */ + name: string; + + /** + * The results of this result option. The object is a dictionary keyed on the component ID with the values + * representing the created quantities. + */ + results: Record; + +} + +/** + * Options for creating a new recipe. + * + * @interface + */ +interface RecipeOptions { + + /** + * The UUID of the item associated with the recipe. + */ + itemUuid: string; + + /** + * The ID of the crafting system that the recipe belongs to. + * */ + craftingSystemId: string; + + /** + * Optional dictionary of the essences required for the recipe. The dictionary is keyed on the essence ID and with + * the values representing the required quantities. + */ + essences?: Record; + + /** + * Whether the recipe is disabled. Defaults to false. + */ + disabled?: boolean; + + /** + * Optional array of requirement options for the recipe. + */ + requirementOptions?: RequirementOptionValue[]; + + /** + * Optional array of result options for the recipe. + * */ + resultOptions?: ResultOptionValue[]; + +} + +export { RecipeOptions } + +/** + * An API for managing recipes. + * + * @interface + */ +interface RecipeAPI { + + /** + * Creates a new recipe with the given options. + * + * @async + * @param {RecipeOptions} recipeOptions - The options for the recipe. + * @returns {Promise} - A promise that resolves with the newly created recipe. As document data is loaded + * during validation, the created recipe is returned with item data loaded. + * @throws {Error} - If there is an error creating the recipe. + */ + create(recipeOptions: RecipeOptions): Promise; + + /** + * Returns all recipes. + * + * @async + * @returns {Promise>} A promise that resolves to a Map of Recipe instances, where the keys are + * the recipe IDs, or rejects with an Error if the settings cannot be read. + */ + getAll(): Promise>; + + /** + * Retrieves the recipe with the specified ID. + * + * @async + * @param {string} recipeId - The ID of the recipe to retrieve. + * @returns {Promise} A Promise that resolves with the recipe, or undefined if it does not + * exist. + */ + getById(recipeId: string): Promise; + + /** + * Retrieves all recipes with the specified IDs. + * + * @async + * @param {string[]} recipeIds - An array of recipe IDs to retrieve. + * @returns {Promise} A Promise that resolves to a Map of `Recipe` instances, where the keys are + * the recipe IDs. Values are undefined if the recipe with the corresponding ID does not exist + */ + getAllById(recipeIds: string[]): Promise>; + + /** + * Returns all recipes for a given crafting system ID. + * + * @async + * @param {string} craftingSystemId - The ID of the crafting system. + * @returns {Promise>} A Promise that resolves to a Map of Recipe instances for the given + * crafting system, where the keys are the recipe IDs, or rejects with an Error if the settings cannot be read. + */ + getAllByCraftingSystemId(craftingSystemId: string): Promise>; + + /** + * Returns all recipes in a map keyed on recipe ID, for a given item UUID. + * + * @async + * @param {string} itemUuid - The UUID of the item. + * @returns {Promise>} A Promise that resolves to a Map of recipe instances, where the keys + * are the recipe IDs, or rejects with an Error if the settings cannot be read. + */ + getAllByItemUuid(itemUuid: string): Promise>; + + /** + * Saves a recipe. + * + * @async + * @param {Recipe} recipe - The recipe to save. + * @returns {Promise} A Promise that resolves with the saved recipe, or rejects with an error if the recipe + * is not valid, or cannot be saved. As document data is loaded during validation, the created recipe is returned + * with item data loaded. + */ + save(recipe: Recipe): Promise; + + /** + * Deletes a recipe by ID. + * + * @async + * @param {string} recipeId - The ID of the recipe to delete. + * @returns {Promise} A Promise that resolves to the deleted recipe or undefined if the recipe + * with the given ID does not exist. + */ + deleteById(recipeId: string): Promise; + + /** + * Deletes all recipes associated with a given item UUID. + * + * @async + * @param {string} recipeId - The UUID of the item to delete recipes for. + * @returns {Promise} A Promise that resolves to the deleted recipe(s) or an empty array if no + * recipes were associated with the given item UUID. Rejects with an Error if the recipes could not be deleted. + */ + deleteByItemUuid(recipeId: string): Promise; + + /** + * Deletes all recipes associated with a given crafting system. + * + * @async + * @param {string} craftingSystemId - The ID of the crafting system to delete recipes for. + * @returns {Promise} A Promise that resolves to the deleted recipe(s) or an empty array if no + * recipes were associated with the given crafting system. Rejects with an Error if the recipes could not be + * deleted. + */ + deleteByCraftingSystemId(craftingSystemId: string): Promise; + + /** + * + * Removes all references to the specified crafting component from all recipes within the specified crafting system. + * @async + * @param {string} craftingComponentId - The ID of the crafting component to remove references to. + * @param {string} craftingSystemId - The ID of the crafting system containing the recipes to modify. + * @returns {Promise} A Promise that resolves with an array of all modified recipes that contain + * references to the removed crafting component, or an empty array if no modifications were made. If the specified + * crafting system has no recipes, the Promise will reject with an Error. + */ + removeComponentReferences(craftingComponentId: string, craftingSystemId: string): Promise; + + /** + * + * Removes all references to the specified essence from all recipes within the specified crafting system. + * @async + * @param {string} essenceId - The ID of the essence to remove references to. + * @param {string} craftingSystemId - The ID of the crafting system containing the recipes to modify. + * @returns {Promise} A Promise that resolves with an array of all modified recipes that contain + * references to the removed essence, or an empty array if no modifications were made. If the specified + * crafting system has no recipes, the Promise will reject with an Error. + */ + removeEssenceReferences(essenceId: string, craftingSystemId: string): Promise; + + /** + * Clones a recipe by ID. + * + * @async + * @param {string} recipeId - The ID of the recipe to clone. + * @returns {Promise} A Promise that resolves with the newly cloned recipe, or rejects with an Error if the + * recipe is not valid or cannot be cloned. + */ + cloneById(recipeId: string): Promise; + + /** + * The Notification service used by this API. If `notifications.isSuppressed` is true, all notification messages + * will print only to the console. If false, notification messages will be displayed in both the console and the UI. + * */ + notifications: NotificationService; + + /** + * Creates or overwrites a recipe with the given details. This operation is intended to be used when importing a + * crafting system and its recipes from a JSON file. Most users should use `create` or `save` recipes instead. + * + * @async + * @param recipeData - The recipe data to insert + * @returns {Promise} A Promise that resolves with the saved recipe, or rejects with an error if + * the recipe is not valid, or cannot be saved. + */ + insert(recipeData: RecipeExportModel): Promise; + + /** + * Creates or overwrites multiple recipes with the given details. This operation is intended to be used when + * importing a crafting system and its recipes from a JSON file. Most users should use `create` or `save` + * recipes instead. + * + * @async + * @param recipeData - The recipe data to insert + * @returns {Promise} A Promise that resolves with the saved recipes, or rejects with an error + * if any of the recipes are not valid, or cannot be saved. + */ + insertMany(recipeData: RecipeExportModel[]): Promise; + + /** + * Clones all recipes in the given array, optionally substituting the IDs of essences and crafting components with + * new IDs. Recipes are cloned by value and the copies will be assigned new IDs. The cloned Recipes will be + * assigned to the Crafting System with the given target Crafting System ID. This operation is not idempotent and + * will produce duplicate Recipes with distinct IDs if called multiple times with the same source Recipes and + * target Crafting System ID. As only one Recipe can be associated with a given game item within a single Crafting + * system, Recipes cloned into the same Crafting system will have their associated items removed. + * + * @param recipes - The Recipes to clone + * @param targetCraftingSystemId - The ID of the Crafting System to clone the Recipes to. Defaults to the source + * Recipe's Crafting System ID. + * @param substituteEssenceIds - An optional Map of Essence IDs to substitute with new IDs. If a Recipe references + * an Essence in this Map , the Recipe will be cloned with the new Essence ID in place of the original ID. + * @param substituteComponentIds - An optional Map of Crafting Component IDs to substitute with new IDs. If a Recipe + * references a Crafting Component in this Map , the Recipe will be cloned with the new Crafting Component ID in + * place of the original ID. + */ + cloneAll(recipes: Recipe[], targetCraftingSystemId?: string, substituteEssenceIds?: Map, substituteComponentIds?: Map): Promise<{ recipes: Recipe[], idLinks: Map }>; + +} + +export { RecipeAPI }; + +class DefaultRecipeAPI implements RecipeAPI { + + private static readonly _LOCALIZATION_PATH: string = `${Properties.module.id}.settings` + + private readonly recipeValidator: RecipeValidator; + private readonly notificationService: NotificationService; + private readonly localizationService: LocalizationService; + private readonly recipeStore: EntityDataStore; + private readonly identityFactory: IdentityFactory; + + constructor({ + notificationService, + localizationService, + recipeValidator, + recipeStore, + identityFactory + }: { + notificationService: NotificationService; + localizationService: LocalizationService; + recipeValidator: RecipeValidator; + recipeStore: EntityDataStore; + identityFactory: IdentityFactory; + }) { + this.notificationService = notificationService; + this.localizationService = localizationService; + this.recipeValidator = recipeValidator; + this.recipeStore = recipeStore; + this.identityFactory = identityFactory; + } + + get notifications() { + return this.notificationService; + } + + async deleteById(id: string): Promise { + const deletedRecipe= await this.recipeStore.getById(id); + this.rejectDeletingEmbeddedRecipe(deletedRecipe); + if (!deletedRecipe) { + const message = this.localizationService.format( + `${DefaultRecipeAPI._LOCALIZATION_PATH}.errors.recipe.doesNotExist`, + { recipeId: id } + ); + this.notificationService.error(message); + return undefined; + } + await this.recipeStore.deleteById(id); + return deletedRecipe; + } + + async save(recipe: Recipe): Promise { + const existing = await this.recipeStore.getById(recipe.id); + this.rejectModifyingEmbeddedRecipe(existing); + + await this.rejectSavingInvalidRecipe(recipe); + + await this.recipeStore.insert(recipe); + + const activityName = existing ? "updated" : "created"; + const message = this.localizationService.format( + `${DefaultRecipeAPI._LOCALIZATION_PATH}.recipe.${activityName}`, + { recipeName: recipe.name } + ); + this.notificationService.info(message); + + return recipe; + } + + private async saveAll(recipes: Recipe[]) { + + const existing = await this.recipeStore.getAllById(recipes.map(recipe => recipe.id)); + existing.forEach(existingRecipe => this.rejectModifyingEmbeddedRecipe(existingRecipe)); + + const validations = recipes.map(recipe => this.rejectSavingInvalidRecipe(recipe)); + await Promise.all(validations) + .catch(() => { + const message = this.localizationService.localize( + `${DefaultRecipeAPI._LOCALIZATION_PATH}.errors.recipe.noneSaved` + ); + this.notificationService.error(message); + throw new Error(message); + }); + + await this.recipeStore.insertAll(recipes); + + const message = this.localizationService.format( + `${DefaultRecipeAPI._LOCALIZATION_PATH}.recipe.savedAll`, + { count: recipes.length } + ); + this.notificationService.info(message); + + return recipes; + + } + + async create({ + itemUuid, + craftingSystemId, + essences = {}, + disabled = false, + requirementOptions = [], + resultOptions = [], + }: RecipeOptions): Promise { + + const assignedIds = await this.recipeStore.listAllEntityIds(); + const id = this.identityFactory.make(assignedIds); + + const mappedRequirementOptions = requirementOptions.reduce((result, requirementOption) => { + const optionId = this.identityFactory.make(); + result[optionId] = { + id: optionId, + ...requirementOption + }; + return result; + }, >{}); + + const mappedResultOptions = resultOptions.reduce((result, resultOption) => { + const optionId = this.identityFactory.make(); + result[optionId] = { + id: optionId, + ...resultOption + }; + return result; + }, >{}); + + const entityJson = { + id, + essences, + itemUuid, + disabled, + embedded: false, + craftingSystemId, + resultOptions: mappedResultOptions, + requirementOptions: mappedRequirementOptions, + }; + + const recipe = await this.recipeStore.buildEntity(entityJson); + return this.save(recipe); + } + + async getById(recipeId: string): Promise { + const recipe = await this.recipeStore.getById(recipeId); + if (!recipe) { + const message = this.localizationService.format( + `${DefaultRecipeAPI._LOCALIZATION_PATH}.errors.recipe.doesNotExist`, + { recipeId } + ); + this.notificationService.error(message); + return undefined; + } + return recipe; + } + + async getAllById(recipeIds: string[]): Promise> { + const recipes = await this.recipeStore.getAllById(recipeIds); + const result = new Map(recipes.map(recipe => [ recipe.id, recipe ])); + const missingValues = recipeIds.filter(id => !result.has(id)); + if (missingValues.length > 0) { + const message = this.localizationService.format( + `${DefaultRecipeAPI._LOCALIZATION_PATH}.errors.recipe.missingRecipes`, + { recipeIds: missingValues.join(", ") } + ); + this.notificationService.error(message); + missingValues.forEach(id => result.set(id, undefined)); + } + return result; + } + + async cloneById(recipeId: string): Promise { + const source = await this.getById(recipeId); + if (!source) { + const message = this.localizationService.format( + `${DefaultRecipeAPI._LOCALIZATION_PATH}.errors.recipe.cloneTargetNotFound`, + { recipeId } + ); + this.notificationService.error(message); + throw new Error(message); + } + const assignedIds = await this.recipeStore.listAllEntityIds(); + const clone = source.clone({ + id: this.identityFactory.make(assignedIds) + }); + return this.save(clone); + } + + async deleteByItemUuid(itemUuid: string): Promise { + const recipes = await this.recipeStore.getCollection(itemUuid, Properties.settings.collectionNames.item); + recipes.forEach(recipe => this.rejectDeletingEmbeddedRecipe(recipe)); + await this.recipeStore.deleteCollection(itemUuid, Properties.settings.collectionNames.item); + return recipes; + } + + async deleteByCraftingSystemId(craftingSystemId: string): Promise { + const recipes = await this.recipeStore.getCollection(craftingSystemId, Properties.settings.collectionNames.craftingSystem); + recipes.forEach(recipe => this.rejectDeletingEmbeddedRecipe(recipe)); + await this.recipeStore.deleteCollection(craftingSystemId, Properties.settings.collectionNames.craftingSystem); + return recipes; + } + + async getAll(): Promise> { + const recipes = await this.recipeStore.getAllEntities(); + return new Map(recipes.map(recipe => [ recipe.id, recipe ])); + } + + async getAllByCraftingSystemId(craftingSystemId: string): Promise> { + const recipes = await this.recipeStore.getCollection(craftingSystemId, Properties.settings.collectionNames.craftingSystem); + return new Map(recipes.map(recipe => [ recipe.id, recipe ])); + } + + async getAllByItemUuid(itemUuid: string): Promise> { + const recipes = await this.recipeStore.getCollection(itemUuid, Properties.settings.collectionNames.item); + return new Map(recipes.map(recipe => [ recipe.id, recipe ])); + } + + async insert({ + id, + disabled = false, + craftingSystemId, + itemUuid, + requirementOptions = [], + resultOptions = [], + }: RecipeExportModel): Promise { + const requirementOptionsRecord = requirementOptions + .reduce((result, requirementOption) => { + result[requirementOption.id] = { + ...requirementOption + }; + return result; + }, >{}); + const resultOptionsRecord = resultOptions + .reduce((result, resultOption) => { + result[resultOption.id] = { + ...resultOption + }; + return result; + }, >{}); + const componentJson = { + id, + craftingSystemId, + itemUuid, + disabled, + embedded: false, + resultOptions: resultOptionsRecord, + requirementOptions: requirementOptionsRecord, + } + const recipe = await this.recipeStore.buildEntity(componentJson); + return this.save(recipe); + } + + async insertMany(recipeImportData: RecipeExportModel[]): Promise { + return Promise.all(recipeImportData.map(recipe => this.insert(recipe))); + } + + async cloneAll(sourceRecipes: Recipe[], + targetCraftingSystemId?: string, + substituteEssenceIds?: Map, + substituteComponentIds?: Map + ): Promise<{ recipes: Recipe[]; idLinks: Map }> { + + const assignedRecipeIds = await this.recipeStore.listAllEntityIds(); + const newRecipeIdsBySourceRecipeId = sourceRecipes + .map((sourceRecipe) => { + const newId = this.identityFactory.make(assignedRecipeIds); + return [sourceRecipe.id, newId]; + }) + .reduce((result, [sourceId, newId]) => { + result.set(sourceId, newId); + return result; + }, new Map()); + + const cloneData = sourceRecipes + .map(sourceRecipe => { + const newId = newRecipeIdsBySourceRecipeId.get(sourceRecipe.id); + if (!newId) { + throw new Error(`Failed to find new id for source recipe id ${sourceRecipe.id}`); + } + const clonedRecipe = sourceRecipe.clone({ + id: newId, + craftingSystemId: targetCraftingSystemId, + substituteEssenceIds, + substituteComponentIds + }); + return { + recipe: clonedRecipe, + sourceId: sourceRecipe.id, + } + }) + .reduce( + (result, currentValue) => { + result.ids.set(currentValue.sourceId, currentValue.recipe.id); + result.recipes.push(currentValue.recipe); + return result; + }, + { + ids: new Map, + recipes: [], + } + ); + + const savedClones = await this.saveAll(cloneData.recipes); + + return { + recipes: savedClones, + idLinks: cloneData.ids, + }; + + } + + async removeComponentReferences(componentIdToDelete: string, craftingSystemId: string): Promise { + const recipesForCraftingSystem = await this.recipeStore.getCollection(craftingSystemId, Properties.settings.collectionNames.craftingSystem); + const recipesWithComponent = recipesForCraftingSystem.filter(recipe => recipe.hasComponent(componentIdToDelete)); + const modifiedRecipes = recipesWithComponent.map(recipe => { + recipe.removeComponent(componentIdToDelete); + return recipe; + }); + await this.recipeStore.updateAll(modifiedRecipes); + return modifiedRecipes; + } + + async removeEssenceReferences(essenceIdToDelete: string, craftingSystemId: string): Promise { + const recipesForCraftingSystem = await this.recipeStore.getCollection(craftingSystemId, Properties.settings.collectionNames.craftingSystem); + const recipesWithEssence = recipesForCraftingSystem.filter(recipe => recipe.hasEssence(essenceIdToDelete)); + const recipesWithEssenceRemoved = recipesWithEssence.map(recipe => { + recipe.removeEssence(essenceIdToDelete); + return recipe; + }); + await this.recipeStore.updateAll(recipesWithEssenceRemoved); + return recipesWithEssenceRemoved; + } + + private async rejectSavingInvalidRecipe(recipe: Recipe): Promise> { + const existingRecipeIds = await this.recipeStore.listAllEntityIds(); + const existingRecipesForCraftingSystem = await this.getAllByCraftingSystemId(recipe.craftingSystemId); + const existingRecipeIdsForItem = Array.from(existingRecipesForCraftingSystem.values()) + .filter(other => other.itemUuid === recipe.itemUuid) + .map(other => other.id); + const validationResult = await this.recipeValidator.validate(recipe, existingRecipeIds, existingRecipeIdsForItem); + if (validationResult.successful) { + return validationResult; + } + const message = this.localizationService.format( + `${DefaultRecipeAPI._LOCALIZATION_PATH}.errors.recipe.invalid`, + { + errors: validationResult.errors.join(", "), + recipeId: recipe.id + } + ); + this.notificationService.error(message); + throw new Error(message); + } + + private rejectModifyingEmbeddedRecipe(recipe: Recipe): void { + if (!recipe?.embedded) { + return; + } + const message = this.localizationService.format( + `${DefaultRecipeAPI._LOCALIZATION_PATH}.errors.recipe.cannotModifyEmbedded`, + { recipeName: recipe.name } + ); + this.notificationService.error(message); + throw new Error(message); + } + + private rejectDeletingEmbeddedRecipe(recipeToDelete: Recipe): void { + if (!recipeToDelete?.embedded) { + return; + } + const message = this.localizationService.format( + `${DefaultRecipeAPI._LOCALIZATION_PATH}.errors.recipe.cannotDeleteEmbedded`, + { recipeName: recipeToDelete.name } + ); + this.notificationService.error(message); + throw new Error(message); + } + +} + +export { DefaultRecipeAPI }; + diff --git a/src/scripts/api/SettingMigrationAPI.ts b/src/scripts/api/SettingMigrationAPI.ts new file mode 100644 index 00000000..fe5b3b4d --- /dev/null +++ b/src/scripts/api/SettingMigrationAPI.ts @@ -0,0 +1,142 @@ +import {SettingVersion} from "../repository/migration/SettingVersion"; +import {SettingMigrationResult} from "./SettingMigrationResult"; +import {SettingsMigrator} from "../repository/migration/SettingsMigrator"; +import {NotificationService} from "../foundry/NotificationService"; +import {SettingMigrationStatus} from "../repository/migration/SettingMigrationStatus"; +import {LocalizationService} from "../../applications/common/LocalizationService"; +import Properties from "../Properties"; +import {EmbeddedCraftingSystemManager} from "../repository/embedded_systems/EmbeddedCraftingSystemManager"; +import {SettingsRegistry} from "../repository/SettingsRegistry"; + +interface SettingMigrationAPI { + + /** + * Migrate all settings to the latest version. + * + * @async + * @returns The result of the settings migration. + */ + migrateAll(): Promise; + + /** + * Get the target version of Fabricate's settings. + * + * @returns The target version of Fabricate's settings. + */ + getTargetVersion(): SettingVersion; + + /** + * Get the current version of Fabricate's settings. + * + * @async + * @returns The current version of Fabricate's settings. + */ + getCurrentVersion(): Promise; + + /** + * Determines whether a setting migration is needed for one or more settings. + * + * @async + * @returns True if a setting migration is needed, false otherwise. + */ + isMigrationNeeded(): Promise; + + /** + * Restores the embedded crafting systems to their default values. Use this function to recover lost or corrupted + * embedded crafting systems. + * + * @async + */ + restoreEmbeddedCraftingSystems(): Promise; + + /** + * WARNING: This function will remove all user-defined crafting systems and restore the embedded crafting systems to + * their default values. Do not use this function unless you are absolutely sure you want to delete all of your + * crafting systems. + */ + clear(): Promise; + +} + +export { SettingMigrationAPI } + +class DefaultSettingMigrationAPI implements SettingMigrationAPI { + + private readonly _gameSystemId: string; + private readonly _settingsMigrator: SettingsMigrator; + private readonly _settingsRegistry: SettingsRegistry; + private readonly _localizationService: LocalizationService; + private readonly _notificationService: NotificationService; + private readonly _embeddedCraftingSystemManager: EmbeddedCraftingSystemManager; + + constructor({ + gameSystemId, + settingsMigrator, + settingsRegistry, + localizationService, + notificationService, + embeddedCraftingSystemManager + }: { + gameSystemId: string; + settingsMigrator: SettingsMigrator; + settingsRegistry: SettingsRegistry; + localizationService: LocalizationService; + notificationService: NotificationService; + embeddedCraftingSystemManager: EmbeddedCraftingSystemManager; + }) { + this._gameSystemId = gameSystemId; + this._settingsMigrator = settingsMigrator; + this._settingsRegistry = settingsRegistry; + this._localizationService = localizationService; + this._notificationService = notificationService; + this._embeddedCraftingSystemManager = embeddedCraftingSystemManager; + } + + getCurrentVersion(): Promise { + return this._settingsMigrator.getModelVersion(); + } + + + getTargetVersion(): SettingVersion { + return this._settingsMigrator.targetVersion; + } + + async isMigrationNeeded(): Promise { + return this._settingsMigrator.isMigrationNeeded(); + } + + async migrateAll(): Promise { + const startedMessage = this._localizationService.localize(`${Properties.module.id}.settings.migration.started`); + this._notificationService.info(startedMessage); + const migrationResult = await this._settingsMigrator.performMigration(); + await this.restoreEmbeddedCraftingSystems(); + const outcomeMessage = this._localizationService.localize(this._getLocalizationMessagePathBySettingMigrationStatus(migrationResult.status)); + this._notificationService.info(outcomeMessage); + return migrationResult; + } + + private _getLocalizationMessagePathBySettingMigrationStatus(status: SettingMigrationStatus): string { + switch (status) { + case SettingMigrationStatus.NOT_NEEDED: + return `${Properties.module.id}.settings.migration.notNeeded`; + case SettingMigrationStatus.SUCCESS: + return `${Properties.module.id}.settings.migration.success`; + case SettingMigrationStatus.FAILURE: + return `${Properties.module.id}.settings.migration.failed`; + default: + throw new Error(`No localization message path found for setting migration status ${status}.`); + } + } + + async restoreEmbeddedCraftingSystems(): Promise { + await this._embeddedCraftingSystemManager.restoreForGameSystem(this._gameSystemId); + } + + async clear(): Promise { + await this._settingsRegistry.clearAll(); + await this.restoreEmbeddedCraftingSystems(); + } + +} + +export { DefaultSettingMigrationAPI } \ No newline at end of file diff --git a/src/scripts/api/SettingMigrationResult.ts b/src/scripts/api/SettingMigrationResult.ts new file mode 100644 index 00000000..7f29db6b --- /dev/null +++ b/src/scripts/api/SettingMigrationResult.ts @@ -0,0 +1,26 @@ +import {SettingMigrationStatus} from "../repository/migration/SettingMigrationStatus"; +import {SettingVersion} from "../repository/migration/SettingVersion"; + +/** + * The result of a setting migration. + */ +interface SettingMigrationResult { + + /** + * The status of the migration. + */ + status: SettingMigrationStatus; + + /** + * The version the settings were migrated from. + */ + from: SettingVersion; + + /** + * The version the settings were migrated to. + */ + to: SettingVersion; + +} + +export {SettingMigrationResult}; \ No newline at end of file diff --git a/src/scripts/common/Combination.ts b/src/scripts/common/Combination.ts index 363a7421..8155a322 100644 --- a/src/scripts/common/Combination.ts +++ b/src/scripts/common/Combination.ts @@ -1,125 +1,155 @@ -import {Identifiable} from "./Identity"; - -class StringIdentity implements Identifiable { - - private static readonly _NO_VALUE: StringIdentity = new StringIdentity(""); - - private readonly _value: string; - - constructor(value: string) { - this._value = value; - } - - public static NO_VALUE(): StringIdentity { - return StringIdentity._NO_VALUE; - } - - get id(): string { - return this._value; - } - -} - -class Unit { - private readonly _part: T; - private readonly _quantity: number; - - constructor(part: T, quantity: number) { - this._part = part; - this._quantity = quantity; - } - - get part(): T { - return this._part; - } - - get quantity(): number { - return this._quantity; - } - - public add(amount: number): Unit { - return new Unit(this._part, this._quantity + amount); - } - - public minus(amount: number, floor?: number): Unit { - const targetQuantity = this._quantity - amount; - if (floor && floor > targetQuantity) { - return new Unit(this._part, floor); - } - return new Unit(this._part, targetQuantity); - } - - public invert(): Unit { - return new Unit(this._part, this._quantity * -1); - } - - public withQuantity(amount: number): Unit { - return new Unit(this._part, amount); - } - - multiply(factor: number) { - return new Unit(this._part, this._quantity * factor); - } - - combineWith(other: Unit) { - return this.add(other.quantity); - } - - flatten(): T[] { - return Array.from(Array(this._quantity).keys()).map(() => this._part); - } - -} - +import {Identifiable} from "./Identifiable"; +import {Unit} from "./Unit"; + +/** + * Represents a collection of units, each with an element and an associated quantity. + * The Combination class provides various methods to create, manipulate, and compare combinations of units. + * + * @template T - The type of the unit elements that are stored in the Combination. Must extend Identifiable. + */ class Combination { + /* + * ================================================================ + * INSTANCE MEMBERS + * ================================================================ + * */ + private readonly _amounts: Map>; - private constructor(amounts: Map>) { + /* + * ================================================================ + * CONSTRUCTOR + * ================================================================ + * */ + + private constructor(amounts: Map> = new Map()) { this._amounts = amounts; } + /* + * ================================================================ + * STATIC FACTORY METHODS + * ================================================================ + * */ + /** - * Constructs and returns an empty Combination - * */ + * Constructs and returns an empty Combination instance. + * + * @template T - A type extending Identifiable, representing the member type. + * @returns {Combination} An empty Combination instance with no Units. + */ public static EMPTY() { return new Combination(new Map()); } /** - * Create a Combination from an array of Units. Normalizes any duplicate Units into a single entry for an amount - * within the Combination. - * */ + * Create a Combination instance from an array of Units. This method consolidates any duplicate Units with the same + * identifier into a single entry, updating the amount in the resulting Combination. + * + * @template T - A type extending Identifiable, representing the member type. + * @param {Unit[]} units - An array of Units to be combined. + * @returns {Combination} A new Combination instance containing the unique Units and their consolidated amounts. + */ public static ofUnits(units: Unit[]): Combination { const amounts: Map> = new Map(); units.forEach((unit => { - if (!amounts.has(unit.part.id)) { - amounts.set(unit.part.id, unit); + if (!amounts.has(unit.element.id)) { + amounts.set(unit.element.id, unit); } else { - const current: Unit = amounts.get(unit.part.id); - amounts.set(unit.part.id, current.add(unit.quantity)); + const current: Unit = amounts.get(unit.element.id); + amounts.set(unit.element.id, current.add(unit.quantity)); } })); return new Combination(amounts); } - public static of(member: T, quantity: number): Combination { + /** + * Constructs a Combination instance from a record of identifier-amount pairs and a map of identifier-candidate + * pairs. This method enables constructing a Combination from serialized or deserialized data by mapping amounts to + * the appropriate candidate members. + * + * @template T - A type extending Identifiable, representing the member type. + * @param {Record} amounts - A record object containing identifier-amount pairs. + * @param {(key: string) => T} memberProvider - A function that takes the record key and returns the corresponding + * combination member. + * @returns {Combination} A Combination instance containing the Units mapped from the amounts and candidates. + * @throws {Error} If the memberProvider throws an error when called with a record key. + */ + public static fromRecord(amounts: Record, memberProvider: (key: string) => T): Combination { + if (!amounts) { + return Combination.EMPTY(); + } + return Object.keys(amounts) + .map(key => { + let member: T; + try { + member = memberProvider(key); + } catch (e: any) { + const cause: Error = e instanceof Error ? e : typeof e === "string" ? new Error(e) : new Error("An unknown error occurred"); + throw new Error(`Unable to construct combination member from Record key "${key}". Caused by ${cause.message}`); + } + return Combination.of(member, amounts[key]); + }) + .reduce((left, right) => left.combineWith(right), Combination.EMPTY()); + } + + /** + * Constructs a Combination instance containing a single member with the specified quantity. + * + * @template T - A type extending Identifiable, representing the member type. + * @param {T} member - The member to be included in the Combination. + * @param {number} quantity - The quantity of the member in the Combination. + * @returns {Combination} A Combination instance containing the specified member with the given quantity. + */ + public static of(member: T, quantity: number = 1): Combination { const unit: Unit = new Unit(member, quantity); return Combination.ofUnit(unit); } + /** + * Constructs a Combination instance from a single Unit. + * + * @template T - A type extending Identifiable, representing the member type. + * @param {Unit} unit - The Unit to be included in the Combination. + * @returns {Combination} A Combination instance containing the specified Unit. + */ public static ofUnit(unit: Unit): Combination { - return new Combination(new Map([[unit.part.id, unit]])); + return new Combination(new Map([[unit.element.id, unit]])); } + /* + * ================================================================ + * INSTANCE METHODS + * ================================================================ + * */ + + /** + * Constructs a new Combination instance containing only the specified member and its corresponding amount from the + * current Combination. + * + * @template T - A type extending Identifiable, representing the member type. + * @param {T} member - The member to be included in the resulting Combination. + * @returns {Combination} A new Combination instance containing only the specified member with its current amount. + */ public just(member: T): Combination { return new Combination(new Map([[member.id, new Unit(member, this.amountFor(member.id))]])); } + /** + * Retrieves a copy of the internal map containing the identifier-Unit pairs. + * + * @returns {Map>} A new Map object containing the identifier-Unit pairs of the current Combination. + */ get amounts(): Map> { return new Map(this._amounts); } + /** + * Computes and retrieves the total size of the Combination, which is the sum of the quantities of all Units. + * + * @returns {number} The total size of the Combination. + */ get size(): number { let size = 0; this.amounts.forEach((unit: Unit) => { @@ -128,37 +158,74 @@ class Combination { return size; } + /** + * Retrieves the number of distinct Units contained in the Combination. + * + * @returns {number} The count of distinct Units in the Combination. + */ public distinct(): number { return this.amounts.size; } + /** + * Creates and returns a deep copy of the current Combination instance. + * + * @returns {Combination} A new Combination instance containing the same identifier-Unit pairs as the current one. + */ public clone(): Combination { return new Combination(new Map(this._amounts)); } - public has(member: T): boolean { - return this.amounts.has(member.id); - } - - public containsAll(unit: Unit): boolean { - return this.amounts.get(unit.part.id).quantity >= unit.quantity; + /** + * Determines whether the Combination contains the specified member with an optional minimum quantity. + * + * @param {T} member - The member to check for in the Combination. + * @param quantity - The minimum quantity of the member required. The default value is 1. + * @returns {boolean} True if the Combination contains the specified member with the required quantity, otherwise + * false. + */ + public has(member: T | string, quantity = 1): boolean { + const memberId = typeof member === "string" ? member : member.id; + if (!this.amounts.has(memberId)) { + return false; + } + const unit = this._amounts.get(memberId); + return unit.quantity >= quantity; } + /** + * Retrieves the quantity of the specified member in the Combination. + * + * @param {string | T} member - The member, or its identifier, to retrieve the quantity for. + * @returns {number} The quantity of the specified member in the Combination, or 0 if the member is not present. + */ public amountFor(member: string | T): number { const memberId = typeof member === "string" ? member : member.id; return this._amounts.has(memberId) ? this._amounts.get(memberId).quantity : 0; } + /** + * Determines whether the Combination is empty, i.e., contains no Units or has a total size of zero. + * + * @returns {boolean} True if the Combination is empty, otherwise false. + */ public isEmpty(): boolean { return this.size === 0; } - public isIn(other: Combination): boolean { + /** + * Determines whether the current Combination is a subset of the specified Combination. + * A Combination is a subset of another if all its Units are present in the other Combination with at least the same quantities. + * + * @param {Combination} other - The Combination to check against. + * @returns {boolean} True if the current Combination is a subset of the specified Combination, otherwise false. + */ + public isIn(other: Combination): boolean { for (const unit of this.amounts.values()) { - if (!other.has(unit.part)) { + if (!other.has(unit.element)) { return false; } - const amount: number = other.amountFor(unit.part.id) - this.amountFor(unit.part.id); + const amount: number = other.amountFor(unit.element.id) - this.amountFor(unit.element.id); if (amount < 0) { return false; } @@ -166,14 +233,35 @@ class Combination { return true; } + /** + * Determines whether the current Combination is a superset of the specified Combination. + * A Combination is a superset of another if it contains all the Units of the other Combination with at least the same quantities. + * + * @param {Combination} other - The Combination to check against. + * @returns {boolean} True if the current Combination is a superset of the specified Combination, otherwise false. + */ + public contains(other: Combination): boolean { + return other.isIn(this); + } + + /** + * Retrieves the distinct members (members) contained in the Combination as an array. + * + * @returns {T[]} An array of distinct members (members) in the Combination. + */ public get members(): T[] { const members: T[] = []; for (const unit of this.amounts.values()) { - members.push(unit.part); + members.push(unit.element); } return members; } + /** + * Retrieves the Units contained in the Combination as an array. + * + * @returns {Unit[]} An array of Units in the Combination. + */ public get units(): Unit[] { const amounts: Unit[] = []; for (const unit of this.amounts.values()) { @@ -183,127 +271,135 @@ class Combination { } /** - * Merges two Combinations without altering them to produce a third, representing the union of both Combinations. + * Merges two Combinations without altering them to produce a new Combination, representing the union of both Combinations. + * The new Combination contains the sum of the quantities of each Unit present in both the original Combinations. * - * @param other The other combination to merge with this one - * @returns a new combination, resulting from a merge of this combination and the other provided combination - * */ + * @param {Combination} other - The other Combination to merge with this one. + * @returns {Combination} A new Combination, resulting from the merge of this Combination and the provided other Combination. + */ public combineWith(other: Combination): Combination { const combination: Map> = new Map(this.amounts); other.units.forEach((otherUnit: Unit) => { - if (!combination.has(otherUnit.part.id)) { - combination.set(otherUnit.part.id, otherUnit); + if (!combination.has(otherUnit.element.id)) { + combination.set(otherUnit.element.id, otherUnit); } else { - const current: Unit = combination.get(otherUnit.part.id); + const current: Unit = combination.get(otherUnit.element.id); const updated: Unit = current.add(otherUnit.quantity); - combination.set(otherUnit.part.id, updated); + combination.set(otherUnit.element.id, updated); } }); return new Combination(combination); } /** - * Merges the provided Combination into this one by mutating its contents to represent the union of both - * Combinations. This is a mutation operation and should be used with care. + * Merges an additional Unit into this Combination without altering it to produce a new Combination, + * representing the result of adding the new Unit. The new Combination contains the sum of the quantities of the + * Unit and the existing Unit with the same member in the original Combination. * - * @param other The other Combination to merge with this one - * @returns a self-reference (this combination) - * */ - accept(other: Combination): Combination { - other.members.forEach((otherMember: T) => { - if (this.amounts.has(otherMember.id)) { - const currentAmount: Unit = this.amounts.get(otherMember.id); - const modifiedAmount: Unit = currentAmount.add(other.amountFor(otherMember.id)); - this.amounts.set(otherMember.id, modifiedAmount); - } else { - this.amounts.set(otherMember.id, new Unit(otherMember, other.amountFor(otherMember.id))); - } - }); - return this; - } - - /** - * Merges an additional Unit into this Combination without altering it to produce a third, representing the result - * of adding the new Unit. - * - * @param additionalUnit The additional Unit to merge into this Combination - * @returns a new Combination, resulting from a merge of the provided Unit into this Combination - * */ - public add(additionalUnit: Unit): Combination { + * @param {Unit} additionalUnit - The additional Unit to merge into this Combination. + * @returns {Combination} A new Combination, resulting from the merge of the provided Unit into this Combination. + */ + public addUnit(additionalUnit: Unit): Combination { const amounts: Map> = new Map(this.amounts); - if (amounts.has(additionalUnit.part.id)) { - const currentAmount: Unit = amounts.get(additionalUnit.part.id); + if (amounts.has(additionalUnit.element.id)) { + const currentAmount: Unit = amounts.get(additionalUnit.element.id); const updatedAmount: Unit = currentAmount.add(additionalUnit.quantity); - amounts.set(additionalUnit.part.id, updatedAmount); + amounts.set(additionalUnit.element.id, updatedAmount); } else { - amounts.set(additionalUnit.part.id, additionalUnit); + amounts.set(additionalUnit.element.id, additionalUnit); } return new Combination(amounts); } - public increment(partId: string, quantity: number = 1): Combination { - const amounts: Map> = new Map(this.amounts); - if (!amounts.has(partId)) { - throw new Error(`"${partId}" is not present in this combination and cannot be incremented. Available part ids are ${Array.from(amounts.keys()).join(", ")}. `); + /** + * Increments the quantity of a member in the original Combination by the specified quantity. + * If the member id is provided, and the member is not in the original Combination, an error will be thrown. + * + * @param {string | T} memberToIncrement - The ID or instance of the member element to increment in the original Combination. + * @param {number} [quantity=1] - The quantity to increment the specified member by (default is 1). + * @returns {Combination} A new Combination with the updated quantity for the specified member. + * @throws Will throw an error if the memberToIncrement is null, or the member ID is not found in the original Combination. + */ + public increment(memberToIncrement: string | T, quantity: number = 1): Combination { + if (!memberToIncrement) { + throw new Error("Cannot increment a null combination member. "); } - const currentUnit = amounts.get(partId); - const updatedUnit = currentUnit.add(quantity); - amounts.set(partId, updatedUnit); - return new Combination(amounts); + if (( typeof memberToIncrement === "string") && !this._amounts.has(memberToIncrement)) { + throw new Error(`"${memberToIncrement}" is not present in this combination and cannot be incremented by ID. + Check that the ID is correct, or supply the new member element instance. + Available member ids are ${Array.from(this._amounts.keys()).join(", ")}. `); + } + const additionalUnit = typeof memberToIncrement === "string" ? + this._amounts.get(memberToIncrement).withQuantity(quantity) : new Unit(memberToIncrement, quantity); + return this.addUnit(additionalUnit); } - public decrement(partId: string, quantity: number = 1): Combination { - const amounts: Map> = new Map(this.amounts); - if (!amounts.has(partId)) { - throw new Error(`"${partId}" is not present in this combination and cannot be decremented. Available part ids are ${Array.from(amounts.keys()).join(", ")}. `); + /** + * Decrements the quantity of a member in the original Combination by the specified quantity. + * If the member id is provided, and the member is not in the original Combination, an error will be thrown. + * + * @param {string | T} memberToDecrement - The ID or instance of the member element to decrement in the original Combination. + * @param {number} [quantity=1] - The quantity to decrement the specified member by (default is 1). + * @returns {Combination} A new Combination with the updated quantity for the specified member. + * @throws Will throw an error if the memberToIncrement is null, or the member ID is not found in the original Combination. + */ + public decrement(memberToDecrement: string | T, quantity: number = 1): Combination { + if (!memberToDecrement) { + throw new Error("Cannot decrement a null combination member. "); } - const currentUnit = amounts.get(partId); - const updatedUnit = currentUnit.minus(quantity); - amounts.set(partId, updatedUnit); - return new Combination(amounts); + if (( typeof memberToDecrement === "string") && !this._amounts.has(memberToDecrement)) { + throw new Error(`"${memberToDecrement}" is not present in this combination and cannot be decremented by ID. + Check that the ID is correct, or supply the new member element instance. + Available member ids are ${Array.from(this._amounts.keys()).join(", ")}. `); + } + const additionalUnit = typeof memberToDecrement === "string" ? + this._amounts.get(memberToDecrement).withQuantity(quantity) : new Unit(memberToDecrement, quantity); + return this.subtractUnit(additionalUnit); } /** - * Subtracts a Unit from this Combination without altering it to produce a third, representing the result of - * subtracting the Unit. + * Subtracts the quantity of the specified Unit from the original Combination, without altering it, + * and returns a new Combination with the updated quantity. * - * @param subtractedUnit The Unit to subtract from this Combination - * @returns a new Combination, resulting from a subtraction of the provided Unit from this Combination - * */ - public minus(subtractedUnit: Unit): Combination { + * @param {Unit} subtractedUnit - The Unit to subtract its quantity from the original Combination. + * @returns {Combination} A new Combination with the updated quantity for the specified Unit's member. + * If the updated quantity is less than or equal to zero, the member is removed from the new Combination. + */ + public subtractUnit(subtractedUnit: Unit): Combination { const amounts: Map> = new Map(this.amounts); - if (!amounts.has(subtractedUnit.part.id)) { + if (!amounts.has(subtractedUnit.element.id)) { return this.clone(); } - const currentAmount: Unit = amounts.get(subtractedUnit.part.id); + const currentAmount: Unit = amounts.get(subtractedUnit.element.id); const updatedAmount: Unit = currentAmount.minus(subtractedUnit.quantity, 0); if (updatedAmount.quantity <= 0) { - amounts.delete(subtractedUnit.part.id); + amounts.delete(subtractedUnit.element.id); } else { - amounts.set(subtractedUnit.part.id, updatedAmount); + amounts.set(subtractedUnit.element.id, updatedAmount); } return new Combination(amounts); } /** - * Removes the contents of the provided Combination from this one without altering it to produce a third, - * representing the result of the subtraction operation. + * Subtracts the quantities of the members in the provided Combination from the original Combination, without altering it, + * and returns a new Combination representing the result of the subtraction operation. * - * @param other The other Combination representing the amounts to remove - * @returns a new Combination, resulting from the subtraction of the other from this one - * */ + * @param {Combination} other - The other Combination representing the amounts to remove. + * @returns {Combination} A new Combination, resulting from the subtraction of the other Combination from this one. + * If the updated quantity for a member is less than or equal to zero, the member is removed from the new Combination. + */ public subtract(other: Combination): Combination { if (other.isEmpty()) { return this.clone(); } const combination: Map> = new Map(); for (const thisElement of this._amounts.values()) { - if (!other.has(thisElement.part)) { - combination.set(thisElement.part.id, thisElement); + if (!other.has(thisElement.element)) { + combination.set(thisElement.element.id, thisElement); } else { - const resultingAmount = thisElement.quantity - other.amountFor(thisElement.part.id); + const resultingAmount = thisElement.quantity - other.amountFor(thisElement.element.id); if (resultingAmount > 0) { - combination.set(thisElement.part.id, thisElement.withQuantity(resultingAmount)); + combination.set(thisElement.element.id, thisElement.withQuantity(resultingAmount)); } } } @@ -311,38 +407,61 @@ class Combination { } /** - * Removes the contents of the provided Combination from this one. This is a mutation operation and should be used - * with care. - * - * @param other The other Combination to remove the contents of from this Combination - * @returns a self-reference (this combination) - * */ - drop(other: Combination): Combination { - other.members.forEach((otherMember: T) => { - if (this.amounts.has(otherMember.id)) { - const currentAmount: Unit = this.amounts.get(otherMember.id); - const deleteUnit: boolean = currentAmount.quantity <= other.amountFor(otherMember.id); - switch (deleteUnit) { - case true: - this.amounts.delete(otherMember.id); - break; - case false: - const modifiedAmount: Unit = currentAmount.withQuantity(currentAmount.quantity - other.amountFor(otherMember.id)); - this.amounts.set(otherMember.id, modifiedAmount); - break; - } + * Removes the specified member from the original Combination without altering it and returns a new Combination + * representing the result. + * + * @param {T} memberToRemove - The member to remove from the original Combination. + * @param {number} [amountToRemove] - The amount of the member to remove. If not specified, the member is removed + * from the Combination regardless of its current quantity. If provided, the member is only removed if its current + * quantity is equal to or less than the specified amount. Otherwise, the member's current quantity is reduced by + * the specified amount. + * @returns {Combination} A new Combination with the specified member removed. + */ + without(memberToRemove: T | string, amountToRemove?: number): Combination { + const memberId = typeof memberToRemove === "string" ? memberToRemove : memberToRemove.id; + const newAmounts: Map> = new Map(this._amounts); + if (typeof amountToRemove === "number" && newAmounts.has(memberId)) { + const currentUnit: Unit = newAmounts.get(memberId); + const updatedUnit: Unit = currentUnit.minus(amountToRemove, 0); + if (updatedUnit.quantity <= 0) { + newAmounts.delete(memberId); + } else { + newAmounts.set(memberId, updatedUnit); } - }); - return this; + } else if (newAmounts.has(memberId)) { + newAmounts.delete(memberId); + } + return new Combination(newAmounts); } - without(part: T) { - const combination: Map> = new Map(this._amounts); - combination.delete(part.id); - return new Combination(combination); + /** + * Adds the specified member in the specified quantity to the combination without altering it and returns a new + * Combination representing the result. + * + * @param memberToAdd - The member to add to the Combination. + * @param amountToAdd - The amount of the member to add. + * @returns {Combination} A new Combination with the specified member added. + */ + with(memberToAdd: T, amountToAdd: number = 1): Combination { + const newAmounts: Map> = new Map(this._amounts); + if (newAmounts.has(memberToAdd.id)) { + const currentUnit: Unit = newAmounts.get(memberToAdd.id); + const updatedUnit: Unit = currentUnit.add(amountToAdd); + newAmounts.set(memberToAdd.id, updatedUnit); + } else { + newAmounts.set(memberToAdd.id, new Unit(memberToAdd, amountToAdd)); + } + return new Combination(newAmounts); } - public multiply(factor: number) { + /** + * Multiplies the quantity of each member in the original Combination by the provided factor without altering it, + * and returns a new Combination representing the result of the multiplication operation. + * + * @param {number} factor - The factor by which to multiply the quantity of each member in the original Combination. + * @returns {Combination} A new Combination with updated quantities for each member, resulting from the multiplication. + */ + public multiply(factor: number): Combination { const modifiedAmounts: Map> = new Map(this._amounts); this.members.forEach((member: T) => { const unit: Unit = modifiedAmounts.get(member.id); @@ -351,19 +470,44 @@ class Combination { return new Combination(modifiedAmounts); } - intersects(other: Combination) { - return other.members.some((otherMember: T) => this.members.includes(otherMember)); + /** + * Checks if the original Combination intersects with the provided Combination, i.e., if they share any common members. + * + * @param {Combination} other - The other Combination to check for intersection with the original Combination. + * @returns {boolean} True if the original Combination and the provided Combination share any common members, otherwise false. + */ + intersects(other: Combination): boolean { + return other.members.some((otherMember: T) => this.members.find(value => value.id === otherMember.id) !== undefined); } - explode (transformFunction: (thisType: T) => Combination): Combination { + /** + * Transforms the original Combination into a new Combination of a different type by applying the provided transformFunction + * to each member. The transformFunction returns a new Combination for each member in the original Combination. The quantities + * of the resulting Combinations are multiplied by the original member's quantity before being merged into the final + * Combination. + * + * @template R - The type of the resulting Combination. + * @param transformFunction - The function to transform the members of the original Combination into Combinations of + * the new type. + * @returns {Combination} A new Combination of type R, resulting from the transformation and merging of the original + * Combination's members. + */ + explode(transformFunction: (thisType: T) => Combination): Combination { let exploded: Combination = Combination.EMPTY(); this.amounts.forEach((unit: Unit) => { - exploded = exploded.combineWith(transformFunction(unit.part).multiply(unit.quantity)); + exploded = exploded.combineWith(transformFunction(unit.element).multiply(unit.quantity)); }); return exploded; } - equals(other: Combination) { + /** + * Checks if the original Combination is equal to the provided Combination. Two Combinations are considered equal + * if they have the same size and contain the same members with the same quantities. + * + * @param {Combination} other - The other Combination to compare with the original Combination. + * @returns {boolean} True if the original Combination and the provided Combination are equal, otherwise false. + */ + equals(other: Combination): boolean { if (!other) { return false; } @@ -373,26 +517,84 @@ class Combination { return other.isIn(this) && this.isIn(other); } - hasPart(partId: string, quantity: number = 1) { - if (!this._amounts.has(partId)) { - return false; - } - const unit = this._amounts.get(partId); - return quantity >= unit.quantity; - } - + /** + * Converts the Combination to a JSON representation, mapping the member ID to its quantity. + * + * @returns {Record} A JSON object with member IDs as keys and their corresponding quantities as + * values. + */ public toJson(): Record { return this.units - .map(unit => {return {[unit.part.id]: unit.quantity}}) + .map(unit => {return {[unit.element.id]: unit.quantity}}) .reduce((left, right) => { return { ...left, ...right} }, {}); } - flatten(): Combination { - return Combination.ofUnits(this.units.map(unit => new Unit(new StringIdentity(unit.part.id), unit.quantity))); + /** + * Converts the combination to an Array of the specified type, by applying the provided mappingFunction to each + * unit. The mappingFunction receives a copy of each unit within the original combination, so that it can be + * safely modified without side effects. + * + * @param mappingFunction - The function to apply to each unit in the combination. + * @returns R[] An array of type R, resulting from the conversion of the original combination's units. + */ + map(mappingFunction: (unit: Unit) => R): R[] { + return this.units + .map(unit => unit.clone()) + .map(mappingFunction); + } + + /** + * Converts the Combination type to a new type by applying the provided conversionFunction to each unit. + * The conversionFunction receives a copy of each unit within the original Combination, so that it can be safely + * modified without side effects. + * + * @param conversionFunction - The function to apply to each unit in the Combination. + * @returns Combination A new Combination of type R, resulting from the conversion of the original Combination's + * units. + */ + convertUnits(conversionFunction: (unit: Unit) => Unit): Combination { + return this.units + .map(unit => unit.clone()) + .map(conversionFunction) + .reduce((left, right) => left.combineWith(Combination.ofUnit(right)), Combination.EMPTY()); + } + + /** + * Converts the Combination elements to a new type by applying the provided conversionFunction to each element + * without modifying the quantity. + * + * @param conversionFunction - The function to apply to each element in the Combination. + * @returns Combination A new Combination of type R, resulting from the conversion of the original Combination's + * elements. + */ + convertElements(conversionFunction: (element: T) => R): Combination { + return this.units + .map(unit => unit.clone()) + .map(unit => new Unit(conversionFunction(unit.element), unit.quantity)) + .reduce((left, right) => left.combineWith(Combination.ofUnit(right)), Combination.EMPTY()); + } + + /** + * Computes the intersection of the original Combination with the provided Combination, i.e., the Combination + * containing only the members that are present in both Combinations with the minimum quantity of the two. + * + * @param other - The other Combination to compute the intersection with. + * @returns {Combination} A new Combination containing only the members that are present in both Combinations + * with the minimum quantity of the two. + */ + intersectionWith(other: Combination): Combination { + const intersection: Map> = new Map(); + this.amounts.forEach((unit: Unit) => { + if (other.has(unit.element)) { + const minimumAmount = Math.min(unit.quantity, other.amountFor(unit.element.id)); + intersection.set(unit.element.id, new Unit(unit.element, minimumAmount)); + } + }); + return new Combination(intersection); } } -export { Unit, Combination, StringIdentity } \ No newline at end of file +export { Combination } \ No newline at end of file diff --git a/src/scripts/common/ComponentConsumptionCalculator.ts b/src/scripts/common/ComponentConsumptionCalculator.ts deleted file mode 100644 index 33e2dc35..00000000 --- a/src/scripts/common/ComponentConsumptionCalculator.ts +++ /dev/null @@ -1,50 +0,0 @@ -import {Combination} from "./Combination"; -import {CraftingComponent} from "./CraftingComponent"; - -enum WastageType { - PUNITIVE, - NON_PUNITIVE -} - -class ComponentConsumptionCalculatorFactory { - - make(wastageType: WastageType): ComponentConsumptionCalculator { - switch (wastageType) { - case WastageType.NON_PUNITIVE: - return new NonPunitiveComponentConsumptionCalculator(); - case WastageType.PUNITIVE: - return new PunitiveComponentConsumptionCalculator(); - } - } - -} - -interface ComponentConsumptionCalculator { - - calculate(components: Combination): Combination; - -} - -class PunitiveComponentConsumptionCalculator implements ComponentConsumptionCalculator { - - calculate(components: Combination): Combination { - return components; - } - -} - -class NonPunitiveComponentConsumptionCalculator implements ComponentConsumptionCalculator { - - calculate(_components: Combination): Combination { - return Combination.EMPTY(); - } - -} - -export { - WastageType, - ComponentConsumptionCalculator, - PunitiveComponentConsumptionCalculator, - NonPunitiveComponentConsumptionCalculator, - ComponentConsumptionCalculatorFactory -} \ No newline at end of file diff --git a/src/scripts/common/CraftingComponent.ts b/src/scripts/common/CraftingComponent.ts deleted file mode 100644 index e09ce37a..00000000 --- a/src/scripts/common/CraftingComponent.ts +++ /dev/null @@ -1,258 +0,0 @@ -import {StringIdentity, Combination, Unit} from "./Combination"; -import {Identifiable, Serializable} from "./Identity"; -import {Essence} from "./Essence"; -import {SelectableOptions} from "./SelectableOptions"; -import {FabricateItemData, ItemLoadingError, NoFabricateItemData} from "../foundry/DocumentManager"; - -interface CraftingComponentJson { - itemUuid: string; - disabled: boolean; - essences: Record; - salvageOptions: Record; -} - -type SalvageOptionJson = Record; - -class SalvageOption implements Identifiable, Serializable { - - private _salvage: Combination; - private _name: string; - - constructor({ - name, - salvage - }: { - name: string; - salvage: Combination; - }) { - this._name = name; - this._salvage = salvage; - } - - get salvage(): Combination { - return this._salvage; - } - - set salvage(value: Combination) { - this._salvage = value; - } - - set name(value: string) { - this._name = value; - } - - get isEmpty(): boolean { - return this._salvage.isEmpty(); - } - - get name(): string { - return this._name; - } - - get id(): string { - return this._name; - } - - public add(component: CraftingComponent, quantity = 1) { - this._salvage = this._salvage.add(new Unit(component, quantity)); - } - - public subtract(component: CraftingComponent, quantity = 1) { - this._salvage = this._salvage.minus(new Unit(component, quantity)); - } - - toJson(): SalvageOptionJson { - return this._salvage.toJson(); - } - -} - -class CraftingComponent implements Identifiable, Serializable { - - private static readonly _NONE: CraftingComponent = new CraftingComponent({ - id: "NO_ID", - itemData: NoFabricateItemData.INSTANCE(), - disabled: true, - essences: Combination.EMPTY(), - salvageOptions: new SelectableOptions({}) - }); - - private readonly _id: string; - private _itemData: FabricateItemData; - private _essences: Combination; - private _salvageOptions: SelectableOptions; - private _isDisabled: boolean; - - constructor({ - id, - itemData = NoFabricateItemData.INSTANCE(), - disabled = false, - essences = Combination.EMPTY(), - salvageOptions = new SelectableOptions({}) - }: { - id: string; - itemData?: FabricateItemData; - disabled?: boolean; - essences?: Combination; - salvageOptions?: SelectableOptions; - }) { - this._id = id; - this._itemData = itemData; - this._isDisabled = disabled; - this._essences = essences; - this._salvageOptions = salvageOptions; - } - - set itemData(itemData: FabricateItemData) { - this._itemData = itemData; - } - - get itemData(): FabricateItemData { - return this._itemData; - } - - get id(): string { - return this._id; - } - - get itemUuid(): string { - return this._itemData.uuid; - } - - public static NONE() { - return this._NONE; - } - - get name(): string { - return this._itemData.name; - } - - get imageUrl(): string { - return this._itemData.imageUrl; - } - - get essences(): Combination { - return this._essences; - } - - get selectedSalvage(): Combination { - return this._salvageOptions.selectedOption.salvage; - } - - get selectedSalvageOptionName(): string { - return this._salvageOptions.selectedOptionId; - } - - public selectSalvageOption(combinationId: string) { - this._salvageOptions.select(combinationId); - } - - public selectNextSalvageOption(): void { - this._salvageOptions.selectNext(); - } - - public selectPreviousSalvageOption(): void { - this._salvageOptions.selectPrevious(); - } - - get isDisabled(): boolean { - return this._isDisabled; - } - - get isSalvageable(): boolean { - if (this._salvageOptions.isEmpty) { - return false; - } - return this._salvageOptions.options - .map(option => !option.salvage.isEmpty()) - .reduce((left, right) => left || right, false); - } - - public get hasEssences(): boolean { - return !this.essences.isEmpty(); - } - - public toJson(): CraftingComponentJson { - return { - disabled: this._isDisabled, - itemUuid: this._itemData.toJson().uuid, - essences: this._essences.toJson(), - salvageOptions: this._salvageOptions.toJson() - } - } - - public clone(cloneId: string): CraftingComponent { - return new CraftingComponent({ - id: cloneId, - itemData: NoFabricateItemData.INSTANCE(), - salvageOptions: this._salvageOptions.clone(), - disabled: this._isDisabled, - essences: this._essences.clone() - }); - } - - set isDisabled(value: boolean) { - this._isDisabled = value; - } - - set essences(value: Combination) { - this._essences = value; - } - - public addSalvageOption(value: SalvageOption) { - if (this._salvageOptions.has(value.id)) { - throw new Error(`Result option ${value.id} already exists in this recipe. `); - } - this._salvageOptions.add(value); - } - - set salvageOptions(options: SalvageOption[]) { - this._salvageOptions = new SelectableOptions({ options }); - } - - get salvageOptions(): SalvageOption[] { - return this._salvageOptions.options.filter(option => !option.isEmpty); - } - - get firstOptionName(): string { - if (this._salvageOptions.isEmpty) { - return ""; - } - return this.salvageOptions[0].name; - } - - public selectFirstOption(): void { - this.selectSalvageOption(this.firstOptionName); - } - - get salvageOptionsById(): Map { - return this._salvageOptions.optionsByName; - } - - public setSalvageOption(value: SalvageOption) { - this._salvageOptions.set(value); - } - - public deleteSalvageOptionByName(id: string) { - this._salvageOptions.deleteById(id); - } - - get hasErrors(): boolean { - return this._itemData.hasErrors; - } - - get errors(): ItemLoadingError[] { - return this._itemData.errors; - } - - get errorCodes(): string[] { - return this._itemData.errors.map(error => error.code); - } - - deselectSalvage() { - this._salvageOptions.deselect(); - } - -} - -export { CraftingComponent, StringIdentity, CraftingComponentJson, SalvageOptionJson, SalvageOption } \ No newline at end of file diff --git a/src/scripts/common/Essence.ts b/src/scripts/common/Essence.ts deleted file mode 100644 index 5045ddd7..00000000 --- a/src/scripts/common/Essence.ts +++ /dev/null @@ -1,117 +0,0 @@ -import Properties from "../Properties"; -import {Identifiable, Serializable} from "./Identity"; -import {FabricateItemData, ItemLoadingError} from "../foundry/DocumentManager"; - -interface EssenceJson { - activeEffectSourceItemUuid: string; - name: string; - description: string; - tooltip: string; - iconCode: string; -} - -class Essence implements Identifiable, Serializable { - - private readonly _id: string; - private _name: string; - private _activeEffectSource: FabricateItemData; - private _description: string; - private _tooltip: string; - private _iconCode: string; - - constructor({ - id, - name, - tooltip, - description, - activeEffectSource, - iconCode = Properties.ui.defaults.essenceIconCode - }: { - id: string; - name: string; - tooltip: string; - iconCode?: string; - description: string; - activeEffectSource?: FabricateItemData; - }) { - this._id = id; - this._name = name; - this._tooltip = tooltip; - this._iconCode = iconCode; - this._description = description; - this._activeEffectSource = activeEffectSource; - } - - toJson(): EssenceJson { - return { - name: this._name, - tooltip: this._tooltip, - iconCode: this._iconCode, - description: this._description, - activeEffectSourceItemUuid: this._activeEffectSource?.uuid - } - } - - get id(): string { - return this._id; - } - - get hasActiveEffectSource(): boolean { - return !!this._activeEffectSource; - } - - get activeEffectSource(): FabricateItemData { - return this._activeEffectSource; - } - - - get name(): string { - return this._name; - } - - get description(): string { - return this._description; - } - - get tooltip(): string { - return this._tooltip; - } - - get iconCode(): string { - return this._iconCode; - } - - get hasErrors(): boolean { - if (!this._activeEffectSource) { - return false; - } - return this._activeEffectSource.hasErrors; - } - - get errors(): ItemLoadingError[] { - return this._activeEffectSource.errors; - } - - set activeEffectSource(value: FabricateItemData) { - this._activeEffectSource = value; - } - - set name(value: string) { - this._name = value; - } - - set description(value: string) { - this._description = value; - } - - set tooltip(value: string) { - this._tooltip = value; - } - - set iconCode(value: string) { - this._iconCode = value; - } - -} - -export { EssenceJson, Essence } diff --git a/src/scripts/common/Identifiable.ts b/src/scripts/common/Identifiable.ts new file mode 100644 index 00000000..c019c6d7 --- /dev/null +++ b/src/scripts/common/Identifiable.ts @@ -0,0 +1,17 @@ +interface Identifiable { + id: string; +} + +export { Identifiable }; + +class Nothing implements Identifiable { + + private static readonly _ID = "NO_ID"; + + get id(): string { + return Nothing._ID; + } + +} + +export { Nothing }; \ No newline at end of file diff --git a/src/scripts/common/Identity.ts b/src/scripts/common/IdentityProvider.ts similarity index 89% rename from src/scripts/common/Identity.ts rename to src/scripts/common/IdentityProvider.ts index 0801f3bd..f334cc53 100644 --- a/src/scripts/common/Identity.ts +++ b/src/scripts/common/IdentityProvider.ts @@ -1,14 +1,5 @@ import {Combination} from "./Combination"; - -interface Identifiable { - id: string; -} - -interface Serializable { - - toJson(): T; - -} +import {Identifiable} from "./Identifiable"; interface IdentityProvider { @@ -22,7 +13,7 @@ class HashcodeIdentityProvider implements IdentityProvid public getForCombination(combination: Combination): number { return combination.units - .map(unit => this.hashcodeForString(unit.part.id) * unit.quantity) + .map(unit => this.hashcodeForString(unit.element.id) * unit.quantity) .reduce((left, right) => left + right, 0); } @@ -46,9 +37,9 @@ class PrimeNumberIdentityProvider implements IdentityPro private readonly _valuesById: Map; private constructor({ - numericalIdentitiesById = new Map(), - valuesById = new Map() - }: { + numericalIdentitiesById = new Map(), + valuesById = new Map() + }: { numericalIdentitiesById?: Map, valuesById?: Map }) { @@ -101,4 +92,6 @@ class PrimeNumberIdentityProvider implements IdentityPro } -export { Identifiable, Serializable, IdentityProvider, PrimeNumberIdentityProvider, HashcodeIdentityProvider }; \ No newline at end of file +export {PrimeNumberIdentityProvider}; +export {HashcodeIdentityProvider}; +export {IdentityProvider}; \ No newline at end of file diff --git a/src/scripts/common/Recipe.ts b/src/scripts/common/Recipe.ts deleted file mode 100644 index 2de32c7f..00000000 --- a/src/scripts/common/Recipe.ts +++ /dev/null @@ -1,461 +0,0 @@ -import {Combination, Unit} from "./Combination"; -import {Identifiable, Serializable} from "./Identity"; -import {CraftingComponent} from "./CraftingComponent"; -import {Essence} from "./Essence"; -import {SelectableOptions} from "./SelectableOptions"; -import {FabricateItemData, ItemLoadingError, NoFabricateItemData} from "../foundry/DocumentManager"; - -interface RecipeJson { - itemUuid: string; - disabled: boolean; - essences: Record, - resultOptions: Record; - ingredientOptions: Record; -} - -type ResultOptionJson = Record; - -interface RequirementOptionJson { - catalysts: Record; - ingredients: Record; -} - -class RequirementOption implements Identifiable, Serializable { - - private _catalysts: Combination; - private _ingredients: Combination; - private _name: string; - - constructor({ - name, - catalysts = Combination.EMPTY(), - ingredients = Combination.EMPTY() - }: { - name: string; - catalysts?: Combination; - ingredients?: Combination; - }) { - this._name = name; - this._catalysts = catalysts; - this._ingredients = ingredients; - } - - get requiresCatalysts(): boolean { - return !this._catalysts.isEmpty(); - } - - get requiresIngredients(): boolean { - return !this._ingredients.isEmpty(); - } - - set catalysts(value: Combination) { - this._catalysts = value; - } - - set ingredients(value: Combination) { - this._ingredients = value; - } - - get catalysts(): Combination { - return this._catalysts; - } - - get ingredients(): Combination { - return this._ingredients; - } - - get name(): string { - return this._name; - } - - set name(value: string) { - this._name = value; - } - - get id(): string { - return this._name; - } - - public addCatalyst(component: CraftingComponent, amount = 1) { - this._catalysts = this._catalysts.add(new Unit(component, amount)); - } - - public subtractCatalyst(component: CraftingComponent, amount = 1) { - this._catalysts = this._catalysts.minus(new Unit(component, amount)); - } - - public addIngredient(component: CraftingComponent, amount = 1) { - this._ingredients = this._ingredients.add(new Unit(component, amount)); - } - - public subtractIngredient(component: CraftingComponent, amount = 1) { - this._ingredients = this._ingredients.minus(new Unit(component, amount)); - } - - public get isEmpty(): boolean { - return this._ingredients.isEmpty() && this._catalysts.isEmpty(); - } - - toJson(): RequirementOptionJson { - return { - catalysts: this._catalysts.toJson(), - ingredients: this._ingredients.toJson() - }; - } - -} - -class ResultOption implements Identifiable, Serializable { - - private _results: Combination; - private _name: string; - - constructor({ - name, - results - }: { - name: string; - results: Combination; - }) { - this._name = name; - this._results = results; - } - - get isEmpty(): boolean { - return this._results.isEmpty(); - } - - get results(): Combination { - return this._results; - } - - set results(value: Combination) { - this._results = value; - } - - set name(value: string) { - this._name = value; - } - - get name(): string { - return this._name; - } - - get id(): string { - return this._name; - } - - public add(component: CraftingComponent, amount = 1) { - this._results = this._results.add(new Unit(component, amount)); - } - - public subtract(component: CraftingComponent, amount = 1) { - this._results = this._results.minus(new Unit(component, amount)); - } - - toJson(): ResultOptionJson { - return this._results.toJson() - } - -} - -class Recipe implements Identifiable, Serializable { - - /* =========================== - * Instance members - * =========================== */ - - private readonly _id: string; - private _itemData: FabricateItemData; - private _essences: Combination; - private _ingredientOptions: SelectableOptions; - private _resultOptions: SelectableOptions; - private _disabled: boolean; - - /* =========================== - * Constructor - * =========================== */ - - constructor({ - id, - disabled, - essences = Combination.EMPTY(), - itemData = NoFabricateItemData.INSTANCE(), - resultOptions = new SelectableOptions({}), - ingredientOptions = new SelectableOptions({}) - }: { - id: string; - itemData?: FabricateItemData; - disabled?: boolean; - essences?: Combination; - resultOptions?: SelectableOptions; - ingredientOptions?: SelectableOptions; - }) { - this._id = id; - this._itemData = itemData; - this._disabled = disabled; - this._ingredientOptions = ingredientOptions; - this._essences = essences; - this._resultOptions = resultOptions; - } - - /* =========================== - * Getters - * =========================== */ - - get id(): string { - return this._id; - } - - get itemUuid(): string { - return this._itemData.uuid; - } - - get name(): string { - return this._itemData.name; - } - - get imageUrl(): string { - return this._itemData.imageUrl; - } - - get itemData(): FabricateItemData { - return this._itemData; - } - - set itemData(value: FabricateItemData) { - this._itemData = value; - } - - get ingredientOptionsById(): Map { - return this._ingredientOptions.optionsByName; - } - - get ingredientOptions(): RequirementOption[] { - return this._ingredientOptions.options; - } - - get essences(): Combination { - return this._essences; - } - - set essences(value: Combination) { - this._essences = value; - } - - set isDisabled(value: boolean) { - this._disabled = value; - } - - get isDisabled(): boolean { - return this._disabled; - } - - get resultOptionsById(): Map { - return this._resultOptions.optionsByName; - } - - get resultOptions(): ResultOption[] { - return this._resultOptions.options; - } - - public get hasOptions(): boolean { - return this.hasIngredientOptions || this.hasResultOptions; - } - - public get hasIngredientOptions(): boolean { - return this._ingredientOptions.requiresUserChoice; - } - - public get hasResultOptions(): boolean { - return this._resultOptions.requiresUserChoice - } - - public ready(): boolean { - if (!this.hasOptions) { - return true; - } - return this._ingredientOptions.isReady && this._resultOptions.isReady; - } - - public getSelectedIngredients(): RequirementOption { - if (this._ingredientOptions.isReady) { - return this._ingredientOptions.selectedOption - } - throw new Error("You must select an ingredient group. "); - } - - public getSelectedResults(): ResultOption { - if (this._resultOptions.isReady) { - return this._resultOptions.selectedOption; - } - throw new Error("You must select a result group. "); - } - - public get hasIngredients() { - return !this._ingredientOptions.isEmpty; - } - - public get hasResults(): boolean { - return !this._resultOptions.isEmpty; - } - - public get requiresEssences(): boolean { - return !this._essences || !this._essences.isEmpty(); - } - - public selectIngredientOption(optionName: string) { - return this._ingredientOptions.select(optionName); - } - - public selectResultOption(optionName: string) { - return this._resultOptions.select(optionName); - } - - get selectedIngredientOptionName(): string { - return this._ingredientOptions.selectedOptionId; - } - - public selectNextIngredientOption(): string { - this._ingredientOptions.selectNext(); - return this.selectedIngredientOptionName; - } - - public selectPreviousIngredientOption(): string { - this._ingredientOptions.selectPrevious(); - return this.selectedIngredientOptionName; - } - - get selectedResultOptionName(): string { - return this._resultOptions.selectedOptionId; - } - - public selectNextResultOption(): string { - this._resultOptions.selectNext(); - return this.selectedResultOptionName; - } - - public selectPreviousResultOption(): string { - this._resultOptions.selectPrevious(); - return this.selectedResultOptionName; - } - - get firstIngredientOptionName(): string { - if (this._ingredientOptions.isEmpty) { - return ""; - } - return this.ingredientOptions[0].name; - } - - get firstResultOptionName(): string { - if (this._resultOptions.isEmpty) { - return ""; - } - return this.resultOptions[0].name; - } - - public makeDefaultSelections() { - if (!this._ingredientOptions.isEmpty) { - this.selectIngredientOption(this.ingredientOptions[0].name); - } - if (!this._resultOptions.isEmpty) { - this.selectResultOption(this.resultOptions[0].name); - } - } - - public editIngredientOption(ingredientOption: RequirementOption) { - if (!this._ingredientOptions.has(ingredientOption.id)) { - throw new Error(`Cannot edit Ingredient Option "${ingredientOption.id}". It does not exist in this Recipe.`); - } - this._ingredientOptions.set(ingredientOption); - } - - set ingredientOptions(options: RequirementOption[]) { - this._ingredientOptions = new SelectableOptions({ - options - }); - } - - set resultOptions(options: ResultOption[]) { - this._resultOptions = new SelectableOptions({ - options - }); - } - - public toJson(): RecipeJson { - return { - itemUuid: this._itemData.uuid, - disabled: this._disabled, - essences: this._essences.toJson(), - resultOptions: this._resultOptions.toJson(), - ingredientOptions: this._ingredientOptions.toJson() - }; - } - - addIngredientOption(value: RequirementOption) { - if (this._ingredientOptions.has(value.id)) { - throw new Error(`Ingredient option ${value.id} already exists in this recipe. `); - } - this._ingredientOptions.add(value); - } - - addResultOption(value: ResultOption) { - if (this._resultOptions.has(value.id)) { - throw new Error(`Result option ${value.id} already exists in this recipe. `); - } - this._resultOptions.add(value); - } - - setIngredientOption(value: RequirementOption) { - this._ingredientOptions.set(value); - } - - deleteIngredientOptionByName(id: string) { - this._ingredientOptions.deleteById(id); - } - - deleteResultOptionByName(id: string) { - this._resultOptions.deleteById(id); - } - - setResultOption(value: ResultOption) { - this._resultOptions.set(value); - } - - deleteResultOptionById(id: string) { - this._resultOptions.deleteById(id); - } - - get hasErrors(): boolean { - return this._itemData.hasErrors; - } - - get errorCodes(): string[] { - return this._itemData.errors.map(error => error.code); - } - - get errors(): ItemLoadingError[] { - return this._itemData.errors; - } - - deselectIngredients() { - this._ingredientOptions.deselect(); - } - - deselectResults() { - this._resultOptions.deselect(); - } - - clone(cloneId: string) { - return new Recipe({ - id: cloneId, - itemData: NoFabricateItemData.INSTANCE(), - disabled: this._disabled, - essences: this._essences.clone(), - resultOptions: this._resultOptions.clone(), - ingredientOptions: this._ingredientOptions.clone() - }); - } -} - -export { Recipe, RecipeJson, ResultOptionJson, ResultOption, RequirementOptionJson, RequirementOption } \ No newline at end of file diff --git a/src/scripts/common/Serializable.ts b/src/scripts/common/Serializable.ts new file mode 100644 index 00000000..85af81bb --- /dev/null +++ b/src/scripts/common/Serializable.ts @@ -0,0 +1,7 @@ +interface Serializable { + + toJson(): T; + +} + +export {Serializable}; \ No newline at end of file diff --git a/src/scripts/common/TrackedCombination.ts b/src/scripts/common/TrackedCombination.ts index 301ca736..d7e398b1 100644 --- a/src/scripts/common/TrackedCombination.ts +++ b/src/scripts/common/TrackedCombination.ts @@ -1,5 +1,6 @@ -import {Combination, Unit} from "./Combination"; -import {Identifiable} from "./Identity"; +import {Combination} from "./Combination"; +import {Identifiable} from "./Identifiable"; +import {Unit} from "./Unit"; class TrackedUnit { @@ -78,7 +79,7 @@ class TrackedCombination { return this._target.units .map(target => new TrackedUnit({ target, - actual: new Unit(target.part, this._actual.amountFor(target.part.id)) + actual: new Unit(target.element, this._actual.amountFor(target.element.id)) })); } diff --git a/src/scripts/common/Unit.ts b/src/scripts/common/Unit.ts new file mode 100644 index 00000000..1806e43f --- /dev/null +++ b/src/scripts/common/Unit.ts @@ -0,0 +1,115 @@ +import {Identifiable, Nothing} from "./Identifiable"; + +/** + * Represents a single unit with an associated quantity of an Identifiable object. + * The unit quantity can be manipulated without altering the original object. Units are immutable. + * + * @template T - A type that extends the Identifiable interface. + */ +class Unit { + + private readonly _element: T; + private readonly _quantity: number; + + /** + * Creates a new Unit instance with the provided member and quantity. + * + * @param {T} member - The Identifiable object of the unit. + * @param {number} quantity - The quantity of the unit. + */ + constructor(member: T, quantity: number) { + this._element = member; + this._quantity = quantity; + } + + /** + * @returns {T} The Identifiable object of the unit. + */ + get element(): T { + return this._element; + } + + /** + * @returns {number} The quantity of the unit. + */ + get quantity(): number { + return this._quantity; + } + + /** + * Adds a specified amount to the current quantity of the unit. + * + * @param {number} amount - The amount to add to the current quantity. + * @returns {Unit} A new Unit instance with the updated quantity. + */ + public add(amount: number): Unit { + return new Unit(this._element, this._quantity + amount); + } + + /** + * Subtracts a specified amount from the current quantity of the unit. + * Optionally applies a floor to the resulting quantity. + * + * @param {number} amount - The amount to subtract from the current quantity. + * @param {number} [floor] - Optional floor to apply to the resulting quantity. + * @returns {Unit} A new Unit instance with the updated quantity. + */ + public minus(amount: number, floor?: number): Unit { + const targetQuantity = this._quantity - amount; + if ((typeof floor !== "undefined") && floor > targetQuantity) { + return new Unit(this._element, floor); + } + return new Unit(this._element, targetQuantity); + } + + /** + * Inverts the quantity of the unit (multiplies the quantity by -1). + * + * @returns {Unit} A new Unit instance with the inverted quantity. + */ + public invert(): Unit { + return new Unit(this._element, this._quantity * -1); + } + + /** + * Creates a new Unit instance with the same identifiable object and the specified quantity. + * + * @param {number} amount - The quantity to set for the new Unit instance. + * @returns {Unit} A new Unit instance with the specified quantity. + */ + public withQuantity(amount: number): Unit { + return new Unit(this._element, amount); + } + + /** + * Multiplies the unit quantity by a specified factor. + * + * @param {number} factor - The multiplication factor. + * @returns {Unit} A new Unit instance with the updated quantity. + */ + multiply(factor: number): Unit { + return new Unit(this._element, this._quantity * factor); + } + + /** + * Combines the current Unit with another Unit of the same type. + * The resulting Unit will have the sum of both quantities. + * + * @param {Unit} other - The other Unit to combine with the current Unit. + * @returns {Unit} A new Unit instance with the combined quantities. + */ + combineWith(other: Unit): Unit { + return this.add(other.quantity); + } + + clone() { + return new Unit(this._element, this._quantity); + } + + static NONE() { + return new Unit(new Nothing() as T, 0); + } + +} + +export { Unit }; \ No newline at end of file diff --git a/src/scripts/component/ComponentSelection.ts b/src/scripts/component/ComponentSelection.ts index 3fbd5894..8f0eb77f 100644 --- a/src/scripts/component/ComponentSelection.ts +++ b/src/scripts/component/ComponentSelection.ts @@ -1,24 +1,24 @@ -import {CraftingComponent} from "../common/CraftingComponent"; +import {Component} from "../crafting/component/Component"; import {TrackedCombination} from "../common/TrackedCombination"; -import {Essence} from "../common/Essence"; import {Combination} from "../common/Combination"; +import {Essence} from "../crafting/essence/Essence"; interface ComponentSelection { isSufficient: boolean; - catalysts: TrackedCombination; - ingredients: TrackedCombination; + catalysts: TrackedCombination; + ingredients: TrackedCombination; essences: TrackedCombination; - essenceSources: Combination; + essenceSources: Combination; } class DefaultComponentSelection implements ComponentSelection { - private readonly _catalysts: TrackedCombination; + private readonly _catalysts: TrackedCombination; private readonly _essences: TrackedCombination; - private readonly _ingredients: TrackedCombination; - private readonly _essenceSources: Combination; + private readonly _ingredients: TrackedCombination; + private readonly _essenceSources: Combination; constructor({ catalysts, @@ -26,10 +26,10 @@ class DefaultComponentSelection implements ComponentSelection { ingredients, essenceSources }: { - catalysts: TrackedCombination; + catalysts: TrackedCombination; essences: TrackedCombination; - ingredients: TrackedCombination; - essenceSources: Combination; + ingredients: TrackedCombination; + essenceSources: Combination; }) { this._catalysts = catalysts; this._essences = essences; @@ -41,7 +41,7 @@ class DefaultComponentSelection implements ComponentSelection { return this._catalysts.isSufficient && this._essences.isSufficient && this._ingredients.isSufficient; } - get catalysts(): TrackedCombination { + get catalysts(): TrackedCombination { return this._catalysts; } @@ -49,19 +49,47 @@ class DefaultComponentSelection implements ComponentSelection { return this._essences; } - get ingredients(): TrackedCombination { + get ingredients(): TrackedCombination { return this._ingredients; } - get essenceSources(): Combination { + get essenceSources(): Combination { return this._essenceSources; } - get selectedComponents(): Combination { + get selectedComponents(): Combination { const namedIngredients = this._catalysts.target.combineWith(this._ingredients.target); return namedIngredients.combineWith(this._essenceSources); } } -export { ComponentSelection, DefaultComponentSelection } \ No newline at end of file +class EmptyComponentSelection implements ComponentSelection { + + get catalysts(): TrackedCombination { + return TrackedCombination.EMPTY(); + } + + get essences(): TrackedCombination { + return TrackedCombination.EMPTY(); + } + + get ingredients(): TrackedCombination { + return TrackedCombination.EMPTY(); + } + + get essenceSources(): Combination { + return Combination.EMPTY(); + } + + get isSufficient(): boolean { + return false; + } + + get selectedComponents(): Combination { + return Combination.EMPTY(); + } + +} + +export { ComponentSelection, DefaultComponentSelection, EmptyComponentSelection } \ No newline at end of file diff --git a/src/scripts/crafting/alchemy/AlchemicalEffect.ts b/src/scripts/crafting/alchemy/AlchemicalEffect.ts deleted file mode 100644 index b838c836..00000000 --- a/src/scripts/crafting/alchemy/AlchemicalEffect.ts +++ /dev/null @@ -1,43 +0,0 @@ -import {ItemData} from "@league-of-foundry-developers/foundry-vtt-types/src/foundry/common/data/data.mjs"; - -interface AlchemicalCombination { - - applyToItemData(itemData: ItemData): ItemData; - -} - -interface AlchemicalCombiner { - - mix(effects: AlchemicalEffect[]): T; - -} - -interface AlchemicalEffect { - - mixInto(combination: C): C; - - describe(): string; - -} - -class NoAlchemicalCombination implements AlchemicalCombination { - - applyToItemData(itemData: ItemData): ItemData { - return itemData; - } - -} - -class NoAlchemicalEffect implements AlchemicalEffect { - - mixInto(combination: NoAlchemicalCombination): NoAlchemicalCombination { - return combination; - } - - describe(): string { - return "No effect. "; - } - -} - -export { AlchemicalEffect, NoAlchemicalEffect, NoAlchemicalCombination, AlchemicalCombination, AlchemicalCombiner } \ No newline at end of file diff --git a/src/scripts/crafting/alchemy/AlchemyAttempt.ts b/src/scripts/crafting/alchemy/AlchemyAttempt.ts deleted file mode 100644 index 4fd9fedc..00000000 --- a/src/scripts/crafting/alchemy/AlchemyAttempt.ts +++ /dev/null @@ -1,160 +0,0 @@ -import {CraftingCheck} from "../check/CraftingCheck"; -import {AlchemyResult, NoAlchemyResult, SuccessfulAlchemyResult, UnsuccessfulAlchemyResult} from "./AlchemyResult"; -import {AlchemicalCombination, AlchemicalCombiner, AlchemicalEffect} from "./AlchemicalEffect"; -import {CraftingCheckResult} from "../check/CraftingCheckResult"; -import {Combination} from "../../common/Combination"; -import {CraftingComponent} from "../../common/CraftingComponent"; -import {ComponentConsumptionCalculator} from "../../common/ComponentConsumptionCalculator"; -import {AlchemyFormula} from "./AlchemyFormula"; - -interface BoundedNumber { - min: number; - max: number; -} - -interface AlchemyConstraints { - components: BoundedNumber; - effects: BoundedNumber; -} - -interface AlchemyAttemptConfig { - baseComponent: CraftingComponent; - components: Combination; - alchemyFormula: AlchemyFormula; - alchemicalCombiner: AlchemicalCombiner; - componentConsumptionCalculator: ComponentConsumptionCalculator; - alchemyConstraintEnforcer: AlchemyConstraintEnforcer; -} - -interface AlchemyAttempt { - - perform(actor: Actor, craftingCheck: CraftingCheck): AlchemyResult; - -} - -interface ConstraintCheck { - isOk: boolean; - detail: string; -} - -interface AlchemyConstraintEnforcer { - - checkComponents(componentCount: number): ConstraintCheck; - - checkEffectMatches(effectMatchCount: number): ConstraintCheck; - -} - -class DefaultAlchemyConstraintEnforcer implements AlchemyConstraintEnforcer { - - private readonly _constraints: AlchemyConstraints; - - constructor(constraints: AlchemyConstraints) { - this._constraints = constraints; - } - - checkComponents(componentCount: number): ConstraintCheck { - - if (componentCount < this._constraints.components.min) { - return { isOk: false, detail: "Too few components provided. " }; - } else if (componentCount > this._constraints.components.max) { - return { isOk: false, detail: "Too many components provided. " }; - } - - return { isOk: true, detail: "The number of components looks ok! " }; - - } - - checkEffectMatches(effectMatchCount: number): ConstraintCheck { - - if (effectMatchCount < this._constraints.effects.min) { - return { isOk: false, detail: "Too few effects matched. " }; - } else if (effectMatchCount > this._constraints.effects.max) { - return { isOk: false, detail: "Too many effects matched. " }; - } - - return { isOk: true, detail: "The number of effects looks ok! " }; - - } - - -} - -class AbandonedAlchemyAttempt implements AlchemyAttempt { - - private readonly _reason: string; - - constructor(reason: string) { - this._reason = reason; - } - - perform(_actor: Actor, _craftingCheck: CraftingCheck): NoAlchemyResult { - return new NoAlchemyResult({ - detail: this._reason, - consumed: Combination.EMPTY() - }); - } - -} - -class DefaultAlchemyAttempt implements AlchemyAttempt { - - private readonly _baseComponent: CraftingComponent; - private readonly _alchemicalCombiner: AlchemicalCombiner; - private readonly _alchemyFormula: AlchemyFormula; - private readonly _components: Combination; - private readonly _alchemyConstraintEnforcer: AlchemyConstraintEnforcer; - private readonly _componentConsumptionCalculator: ComponentConsumptionCalculator; - - constructor(config: AlchemyAttemptConfig) { - this._components = config.components; - this._baseComponent = config.baseComponent; - this._alchemyFormula = config.alchemyFormula; - this._alchemicalCombiner = config.alchemicalCombiner; - this._alchemyConstraintEnforcer = config.alchemyConstraintEnforcer; - this._componentConsumptionCalculator = config.componentConsumptionCalculator; - } - - perform(actor: Actor, craftingCheck: CraftingCheck): AlchemyResult { - const consumed = this._componentConsumptionCalculator.calculate(this._components); - - const componentConstraintCheck: ConstraintCheck = this._alchemyConstraintEnforcer.checkComponents(this._components.size); - if (!componentConstraintCheck.isOk) { - return new UnsuccessfulAlchemyResult({ - detail: componentConstraintCheck.detail, - consumed: consumed - }); - } - - const craftingCheckResult: CraftingCheckResult = craftingCheck.perform(actor, this._components); - if (!craftingCheckResult.isSuccessful) { - return new UnsuccessfulAlchemyResult({ - detail: craftingCheckResult.describe(), - consumed: consumed - }); - } - - const matchingEffects: AlchemicalEffect[] = this._alchemyFormula.getEffectsForComponents(this._components); - - const effectConstraintCheck: ConstraintCheck = this._alchemyConstraintEnforcer.checkEffectMatches(matchingEffects.length); - if (!effectConstraintCheck.isOk) { - return new UnsuccessfulAlchemyResult({ - detail: effectConstraintCheck.detail, - consumed: consumed - }); - } - - const alchemicalEffect: AlchemicalCombination = this._alchemicalCombiner.mix(matchingEffects); - - return new SuccessfulAlchemyResult({ - consumed: consumed, - baseComponent: this._baseComponent, - detail: `Alchemy success! ${matchingEffects.length} effects were applied to the ${this._baseComponent.name}. `, - alchemicalEffect: alchemicalEffect - }); - - } - -} - -export { AlchemyAttempt, DefaultAlchemyAttempt, AbandonedAlchemyAttempt, AlchemyConstraints, BoundedNumber, DefaultAlchemyConstraintEnforcer } \ No newline at end of file diff --git a/src/scripts/crafting/alchemy/AlchemyAttemptFactory.ts b/src/scripts/crafting/alchemy/AlchemyAttemptFactory.ts deleted file mode 100644 index f8bf2198..00000000 --- a/src/scripts/crafting/alchemy/AlchemyAttemptFactory.ts +++ /dev/null @@ -1,94 +0,0 @@ -import {CraftingComponent} from "../../common/CraftingComponent"; -import {Combination} from "../../common/Combination"; -import {AlchemyFormula} from "./AlchemyFormula"; -import { - AbandonedAlchemyAttempt, - AlchemyAttempt, - AlchemyConstraints, - DefaultAlchemyAttempt, - DefaultAlchemyConstraintEnforcer -} from "./AlchemyAttempt"; -import {ComponentConsumptionCalculator} from "../../common/ComponentConsumptionCalculator"; -import {AlchemicalCombination, AlchemicalCombiner} from "./AlchemicalEffect"; - -interface AlchemyAttemptFactory { - - formulaeByBasePartId: Map; - - make(baseComponent: CraftingComponent, componentSelection: Combination): AlchemyAttempt; - - isEnabled(): boolean; -} - -class DefaultAlchemyAttemptFactory implements AlchemyAttemptFactory { - - private readonly _constraints: AlchemyConstraints; - private readonly _alchemicalCombiner: AlchemicalCombiner; - private readonly _componentConsumptionCalculator: ComponentConsumptionCalculator; - private readonly _alchemyFormulaeByBasePartId: Map; - - constructor({ - componentConsumptionCalculator, - constraints, - alchemyFormulae, - alchemicalCombiner - }: { - componentConsumptionCalculator: ComponentConsumptionCalculator; - constraints: AlchemyConstraints, - alchemyFormulae: AlchemyFormula[], - alchemicalCombiner: AlchemicalCombiner - }) { - this._componentConsumptionCalculator = componentConsumptionCalculator; - this._constraints = constraints; - this._alchemicalCombiner = alchemicalCombiner; - this._alchemyFormulaeByBasePartId = new Map(alchemyFormulae.map(formula => [formula.basePartId, formula])); - } - - get formulaeByBasePartId(): Map { - return this._alchemyFormulaeByBasePartId; - } - - isEnabled(): boolean { - return true; - } - - make(baseComponent: CraftingComponent, components: Combination): AlchemyAttempt { - - if (!this._alchemyFormulaeByBasePartId.has(baseComponent.id)) { - throw new Error(`There is no Alchemy Formula specified for the base component with ID: ${baseComponent.id}. `); - } - - if (components.isEmpty()) { - return new AbandonedAlchemyAttempt("You cannot perform Alchemy without any ingredients. "); - } - - return new DefaultAlchemyAttempt({ - components: components, - baseComponent: baseComponent, - alchemicalCombiner: this._alchemicalCombiner, - componentConsumptionCalculator: this._componentConsumptionCalculator, - alchemyFormula: this._alchemyFormulaeByBasePartId.get(baseComponent.id), - alchemyConstraintEnforcer: new DefaultAlchemyConstraintEnforcer(this._constraints) - }); - - } - -} - -class DisabledAlchemyAttemptFactory implements AlchemyAttemptFactory { - - isEnabled(): boolean { - return false; - } - - make(_baseComponent: CraftingComponent, _componentSelection: Combination): AlchemyAttempt { - return new AbandonedAlchemyAttempt("This crafting system does not support Alchemy. "); - } - - get formulaeByBasePartId(): Map { - return new Map(); - } - -} - -export { AlchemyAttemptFactory, DefaultAlchemyAttemptFactory, DisabledAlchemyAttemptFactory } \ No newline at end of file diff --git a/src/scripts/crafting/alchemy/AlchemyFormula.ts b/src/scripts/crafting/alchemy/AlchemyFormula.ts deleted file mode 100644 index 50bbd9a4..00000000 --- a/src/scripts/crafting/alchemy/AlchemyFormula.ts +++ /dev/null @@ -1,94 +0,0 @@ -import {AlchemicalCombination, AlchemicalEffect, NoAlchemicalEffect} from "./AlchemicalEffect"; -import {Essence} from "../../common/Essence"; -import {Combination} from "../../common/Combination"; -import {CraftingComponent} from "../../common/CraftingComponent"; -import {PrimeNumberIdentityProvider} from "../../common/Identity"; - -interface AlchemyFormula { - - basePartId: string; - - registerEffect(essences: Combination, effect: AlchemicalEffect): AlchemicalEffect; - - getEffect(essences: Combination): AlchemicalEffect; - - getAllEffects(): AlchemicalEffect[]; - - getEffectsForComponents(components: Combination): AlchemicalEffect[]; - - hasEffectFor(essences: Combination): boolean; - -} - -class DefaultAlchemyFormula implements AlchemyFormula { - - private readonly _basePartId: string; - private readonly _effectsByEssenceIdentity: Map>; - private readonly _essenceIdentityProvider: PrimeNumberIdentityProvider; - - constructor({ - basePartId, - essenceIdentityProvider, - effectsByEssenceIdentity = new Map() - }: { - basePartId: string; - essenceIdentityProvider: PrimeNumberIdentityProvider; - effectsByEssenceIdentity?: Map>; - }) { - this._basePartId = basePartId; - this._essenceIdentityProvider = essenceIdentityProvider; - this._effectsByEssenceIdentity = effectsByEssenceIdentity ? effectsByEssenceIdentity : new Map(); - } - - get basePartId() { - return this._basePartId; - } - - getAllEffects(): AlchemicalEffect[] { - return Array.from(this._effectsByEssenceIdentity.values()) - } - - getEffect(essences: Combination): AlchemicalEffect { - if (essences.isEmpty()) { - return new NoAlchemicalEffect(); - } - const combinationIdentity: number = this._essenceIdentityProvider.getForCombination(essences); - if (!this._effectsByEssenceIdentity.has(combinationIdentity)) { - return new NoAlchemicalEffect(); - } - return this._effectsByEssenceIdentity.get(combinationIdentity); - } - - hasEffectFor(essences: Combination): boolean { - if (essences.isEmpty()) { - return false; - } - const combinationIdentity: number = this._essenceIdentityProvider.getForCombination(essences); - return this._effectsByEssenceIdentity.has(combinationIdentity); - } - - registerEffect(essences: Combination, effect: AlchemicalEffect): AlchemicalEffect { - if (essences.isEmpty()) { - return new NoAlchemicalEffect(); - } - const combinationIdentity: number = this._essenceIdentityProvider.getForCombination(essences); - if (!this._effectsByEssenceIdentity.has(combinationIdentity)) { - this._effectsByEssenceIdentity.set(combinationIdentity, effect); - return new NoAlchemicalEffect(); - } - const previousEffect = this._effectsByEssenceIdentity.get(combinationIdentity); - this._effectsByEssenceIdentity.set(combinationIdentity, effect); - return previousEffect; - } - - getEffectsForComponents(components: Combination): AlchemicalEffect[] { - if (components.isEmpty()) { - return [] - } - return components.units.flatMap(unit => unit.flatten()) - .map(part => this.getEffect(part.essences)); - } - -} - -export { AlchemyFormula, DefaultAlchemyFormula } \ No newline at end of file diff --git a/src/scripts/crafting/alchemy/AlchemyResult.ts b/src/scripts/crafting/alchemy/AlchemyResult.ts deleted file mode 100644 index 5b1be042..00000000 --- a/src/scripts/crafting/alchemy/AlchemyResult.ts +++ /dev/null @@ -1,126 +0,0 @@ -import {Combination} from "../../common/Combination"; -import {CraftingComponent} from "../../common/CraftingComponent"; -import {ItemData} from "@league-of-foundry-developers/foundry-vtt-types/src/foundry/common/data/data.mjs"; -import {AlchemicalCombination, NoAlchemicalCombination} from "./AlchemicalEffect"; - -interface AlchemyResult { - - describe(): string; - - consumed: Combination; - - effects: AlchemicalCombination; - - baseComponent: CraftingComponent; - -} - -interface AlchemyResultConfig { - detail: string; - consumed: Combination; -} - -interface NoAlchemyResultConfig extends AlchemyResultConfig { - -} - -class NoAlchemyResult implements AlchemyResult { - - private readonly _description: string; - private readonly _consumed: Combination; - - constructor(config: NoAlchemyResultConfig) { - this._consumed = config.consumed; - this._description = config.detail; - } - - get consumed(): Combination { - return this._consumed; - } - - describe() { - return this._description; - } - - get effects(): AlchemicalCombination { - return new NoAlchemicalCombination(); - } - - get baseComponent(): CraftingComponent { - return null; - } - -} - -interface SuccessfulAlchemyResultConfig extends AlchemyResultConfig { - alchemicalEffect: AlchemicalCombination; - baseComponent: CraftingComponent; -} - -class SuccessfulAlchemyResult implements AlchemyResult { - - private readonly _detail: string; - private readonly _baseComponent: CraftingComponent; - private readonly _alchemicalEffect: AlchemicalCombination; - private readonly _consumed: Combination; - - constructor(config: SuccessfulAlchemyResultConfig) { - this._consumed = config.consumed; - this._detail = config.detail; - this._baseComponent = config.baseComponent; - this._alchemicalEffect = config.alchemicalEffect; - } - - get consumed(): Combination { - return this._consumed; - } - - describe() { - return this._detail; - } - - get effects(): AlchemicalCombination { - return this._alchemicalEffect; - } - - get baseComponent(): CraftingComponent { - return this._baseComponent; - } -} - -interface UnsuccessfulAlchemyResultConfig extends AlchemyResultConfig { - -} - -class UnsuccessfulAlchemyResult implements AlchemyResult { - - private readonly _description: string; - private readonly _consumed: Combination; - - constructor(config: UnsuccessfulAlchemyResultConfig) { - this._description = config.detail; - } - - get consumed(): Combination { - return this._consumed; - } - - describe() { - return `Alchemy attempt failed. ${this._description}. `; - } - - applyToBaseItemData(baseItemData: ItemData): ItemData { - return baseItemData; - } - - get baseComponent(): CraftingComponent { - return null; - } - - get effects(): AlchemicalCombination { - return new NoAlchemicalCombination(); - } - -} - -export {AlchemyResult, SuccessfulAlchemyResult, UnsuccessfulAlchemyResult, NoAlchemyResult} \ No newline at end of file diff --git a/src/scripts/crafting/attempt/CraftingAttempt.ts b/src/scripts/crafting/attempt/CraftingAttempt.ts deleted file mode 100644 index c642e110..00000000 --- a/src/scripts/crafting/attempt/CraftingAttempt.ts +++ /dev/null @@ -1,123 +0,0 @@ -import {ComponentSelection} from "../../component/ComponentSelection"; -import {TrackedCombination} from "../../common/TrackedCombination"; -import {Essence} from "../../common/Essence"; -import {CraftingComponent} from "../../common/CraftingComponent"; -import {Combination} from "../../common/Combination"; - -interface CraftingAttempt { - - isPossible: boolean; - essenceAmounts: TrackedCombination; - ingredientAmounts: TrackedCombination; - catalystAmounts: TrackedCombination; - requiresEssences: boolean; - requiresIngredients: boolean; - requiresCatalysts: boolean; - essenceSources: Combination; - -} - -export {CraftingAttempt} - -class DefaultCraftingAttempt implements CraftingAttempt { - - private readonly _componentSelection: ComponentSelection; - private readonly _possible: boolean; - - constructor({ - componentSelection, - possible - }: { - componentSelection: ComponentSelection; - possible: boolean; - }) { - this._componentSelection = componentSelection; - this._possible = possible; - } - - get isPossible(): boolean { - return this._possible; - } - - get essenceAmounts(): TrackedCombination { - return this._componentSelection.essences; - } - - get ingredientAmounts(): TrackedCombination { - return this._componentSelection.ingredients; - } - - get catalystAmounts(): TrackedCombination { - return this._componentSelection.catalysts; - } - - get essenceSources(): Combination { - return this._componentSelection.essenceSources; - } - - get requiresEssences(): boolean { - return !this._componentSelection.essences.isEmpty; - } - - get requiresIngredients(): boolean { - return !this._componentSelection.ingredients.isEmpty; - } - - get requiresCatalysts(): boolean { - return !this._componentSelection.catalysts.isEmpty; - } - - get consumedComponents(): Combination { - return this._componentSelection.essenceSources - .combineWith(this._componentSelection.ingredients.target); - } - -} - -export {DefaultCraftingAttempt}; - -class NoCraftingAttempt implements CraftingAttempt { - - private static readonly _INSTANCE: NoCraftingAttempt = new NoCraftingAttempt(); - - private constructor() {} - - static get INSTANCE(): NoCraftingAttempt { - return NoCraftingAttempt._INSTANCE; - } - - get isPossible(): boolean { - return false; - } - - get catalystAmounts(): TrackedCombination { - return TrackedCombination.EMPTY(); - } - - get essenceAmounts(): TrackedCombination { - return TrackedCombination.EMPTY(); - } - - get ingredientAmounts(): TrackedCombination { - return TrackedCombination.EMPTY(); - } - - get requiresCatalysts(): boolean { - return false; - } - - get requiresEssences(): boolean { - return false; - } - - get requiresIngredients(): boolean { - return false; - } - - get essenceSources(): Combination { - return Combination.EMPTY(); - } - -} - -export {NoCraftingAttempt}; \ No newline at end of file diff --git a/src/scripts/crafting/attempt/RecipeCraftingPrep.ts b/src/scripts/crafting/attempt/RecipeCraftingPrep.ts deleted file mode 100644 index d3e93315..00000000 --- a/src/scripts/crafting/attempt/RecipeCraftingPrep.ts +++ /dev/null @@ -1,97 +0,0 @@ -import {Recipe} from "../../common/Recipe"; -import {CraftingAttempt} from "./CraftingAttempt"; - -interface RecipeCraftingPrep { - - recipe: Recipe; - isSingleton: boolean; - getCraftingAttemptByIngredientOptionName(optionName: string): CraftingAttempt; - getAllCraftingAttempts(): CraftingAttempt[]; - getSingletonCraftingAttempt(): CraftingAttempt; - -} - -export { RecipeCraftingPrep } - -class MultipleRecipeCraftingPrep implements RecipeCraftingPrep { - - private readonly _recipe: Recipe; - private readonly _craftingAttemptsByIngredientOptionName: Map; - - constructor({ - recipe, - craftingAttemptsByIngredientOptionName = new Map() - }: { - recipe: Recipe; - craftingAttemptsByIngredientOptionName?: Map; - }) { - this._recipe = recipe; - this._craftingAttemptsByIngredientOptionName = craftingAttemptsByIngredientOptionName; - } - - get isSingleton(): boolean { - return false; - } - - get recipe(): Recipe { - return this._recipe; - } - - getAllCraftingAttempts(): CraftingAttempt[] { - return Array.from(this._craftingAttemptsByIngredientOptionName.values()); - } - - getCraftingAttemptByIngredientOptionName(optionName: string): CraftingAttempt { - if (!this._craftingAttemptsByIngredientOptionName.has(optionName)) { - throw new Error(`The ingredient option with the name ${optionName} has no prepared crafting attempt. `); - } - return this._craftingAttemptsByIngredientOptionName.get(optionName); - } - - getSingletonCraftingAttempt(): CraftingAttempt { - throw new Error(`The recipe "${this._recipe.name}" has no singleton crafting attempt. It has ${this._craftingAttemptsByIngredientOptionName.size} ingredient options. `); - } - -} - -export { MultipleRecipeCraftingPrep } - -class SingletonRecipeCraftingPrep implements RecipeCraftingPrep { - - private readonly _recipe: Recipe; - private readonly _singletonCraftingAttempt: CraftingAttempt; - - constructor({ - recipe, - singletonCraftingAttempt - }: { - recipe: Recipe; - singletonCraftingAttempt: CraftingAttempt; - }) { - this._recipe = recipe; - this._singletonCraftingAttempt = singletonCraftingAttempt; - } - - get isSingleton(): boolean { - return true; - } - - get recipe(): Recipe { - return this._recipe; - } - - getAllCraftingAttempts(): CraftingAttempt[] { - throw new Error(`Cannot get multiple crafting attempts. The Recipe "${this._recipe.name}" has no ingredient options.`); - } - - getCraftingAttemptByIngredientOptionName(optionName: string): CraftingAttempt { - throw new Error(`Cannot get the crafting attempts for the ingredient option ${optionName}. The Recipe "${this._recipe.name}" has no ingredient options.`); - } - - getSingletonCraftingAttempt(): CraftingAttempt { - return this._singletonCraftingAttempt; - } - -} - -export { SingletonRecipeCraftingPrep } \ No newline at end of file diff --git a/src/scripts/crafting/attempt/RecipeCraftingPrepFactory.ts b/src/scripts/crafting/attempt/RecipeCraftingPrepFactory.ts deleted file mode 100644 index 17f31a2f..00000000 --- a/src/scripts/crafting/attempt/RecipeCraftingPrepFactory.ts +++ /dev/null @@ -1,73 +0,0 @@ -import {Combination} from "../../common/Combination"; -import {CraftingComponent} from "../../common/CraftingComponent"; -import {ComponentSelectionStrategy} from "../selection/ComponentSelectionStrategy"; -import {RequirementOption, Recipe} from "../../common/Recipe"; -import {CraftingAttempt, DefaultCraftingAttempt} from "./CraftingAttempt"; -import {MultipleRecipeCraftingPrep, RecipeCraftingPrep, SingletonRecipeCraftingPrep} from "./RecipeCraftingPrep"; -import {Essence} from "../../common/Essence"; - -class RecipeCraftingPrepFactory { - - private readonly _selectionStrategy: ComponentSelectionStrategy; - - constructor({ - selectionStrategy - }: { - selectionStrategy: ComponentSelectionStrategy; - }) { - this._selectionStrategy = selectionStrategy; - } - - make(recipe: Recipe, availableComponents: Combination): RecipeCraftingPrep { - - if (!recipe.hasIngredients) { - const componentSelection = this._selectionStrategy.perform(Combination.EMPTY(), Combination.EMPTY(), recipe.essences, availableComponents); - const singletonCraftingAttempt = new DefaultCraftingAttempt({ - componentSelection, - possible: componentSelection.isSufficient - }); - return new SingletonRecipeCraftingPrep({ recipe, singletonCraftingAttempt }); - } - - if (recipe.ingredientOptions.length === 1) { - const ingredientSelection = recipe.getSelectedIngredients(); - const componentSelection = this._selectionStrategy.perform(ingredientSelection.catalysts, ingredientSelection.ingredients, recipe.essences, availableComponents); - const singletonCraftingAttempt = new DefaultCraftingAttempt({ - componentSelection, - possible: componentSelection.isSufficient - }); - return new SingletonRecipeCraftingPrep({ recipe, singletonCraftingAttempt }); - } - - const craftingAttemptsByIngredientOptionName = new Map(recipe.ingredientOptions - .map(ingredientOption => [ingredientOption.id, this.makeCraftingAttempt( - ingredientOption, - recipe.essences, - availableComponents) - ]) - ); - - return new MultipleRecipeCraftingPrep({ - recipe, - craftingAttemptsByIngredientOptionName - }); - - } - - private makeCraftingAttempt(ingredientOption: RequirementOption, - essences: Combination, - availableComponents: Combination): CraftingAttempt { - - const componentSelection = this._selectionStrategy.perform( - ingredientOption.catalysts, - ingredientOption.ingredients, - essences, - availableComponents); - - return new DefaultCraftingAttempt({ componentSelection, possible: componentSelection.isSufficient }); - - } - -} - -export { RecipeCraftingPrepFactory } \ No newline at end of file diff --git a/src/scripts/crafting/check/ContributionCounter.ts b/src/scripts/crafting/check/ContributionCounter.ts deleted file mode 100644 index ce986fc9..00000000 --- a/src/scripts/crafting/check/ContributionCounter.ts +++ /dev/null @@ -1,185 +0,0 @@ -import {Combination, Unit} from "../../common/Combination"; -import {CraftingComponent} from "../../common/CraftingComponent"; - -/** - * Determines how a Combination of Crafting Components impacts the difficulty of a Crafting check (if at all) - * */ -interface ContributionCounter { - - /** - * Determine the modifier value for the given Combination of Crafting Components - * - * @param components The components to be considered for the Crafting Check - * @returns number The numeric modifier for the providedComponents to apply to the Check - * */ - determineDCModifier(components: Combination): number; - -} - -/** - * Determines how the number of different types of Essences contribute to the difficulty of the check - * */ -class EssenceContributionCounter implements ContributionCounter { - - /** - * The numeric value to apply for each unique Essence type. Can be positive or negative - * */ - private readonly _essenceModifier: number; - - constructor(essenceModifier: number) { - this._essenceModifier = essenceModifier; - } - - /** - * Determine the modifier value for the given Combination of Crafting Components by only considering the number - * of unique essences in that Combination of Components - * - * @param components The components to be considered for the Crafting Check - * @returns number The numeric modifier for the providedComponents to apply to the Check - * */ - determineDCModifier(components: Combination): number { - return components.explode((component: CraftingComponent) => component.essences) - .units - .map(((unit) => this._essenceModifier * unit.quantity)) - .reduce((left: number, right: number) => left + right, 0); - } - -} - -/** - * Determines how the number of Ingredients contribute to the difficulty of the check - * */ -class IngredientContributionCounter implements ContributionCounter { - - /** - * The numeric value to apply for each Ingredient (also considering quantity). Can be positive or negative - * */ - private readonly _ingredientModifier: number; - - constructor(ingredientModifier: number) { - this._ingredientModifier = ingredientModifier; - } - - /** - * Determine the modifier value for the given Combination of Crafting Components by only considering the quantity of - * Ingredients in that Combination of Components - * - * @param components The components to be considered for the Crafting Check - * @returns number The numeric modifier for the providedComponents to apply to the Check - * */ - determineDCModifier(components: Combination): number { - return components.units.map((unit: Unit) => this._ingredientModifier * unit.quantity) - .reduce((left: number, right: number) => left + right, 0); - } - -} - -/** - * Determines how the number of Ingredients and the number of Essences contribute to the difficulty of the check - * */ -class CombinedContributionCounter implements ContributionCounter { - - /** - * The numeric value to apply for each unique Essence type. Can be positive or negative - * */ - private readonly _essenceContributionCounter: EssenceContributionCounter; - /** - * The numeric value to apply for each Ingredient (also considering quantity). Can be positive or negative - * */ - private readonly _ingredientContributionCounter: IngredientContributionCounter; - - constructor(ingredientModifier: number, essenceModifier: number) { - this._essenceContributionCounter = new EssenceContributionCounter(essenceModifier); - this._ingredientContributionCounter = new IngredientContributionCounter(ingredientModifier); - } - - /** - * Determine the modifier value for the given Combination of Crafting Components by considering both the quantity of - * Ingredients in and the number of unique essences in that Combination of Components - * - * @param components The components to be considered for the Crafting Check - * @returns number The numeric modifier for the providedComponents to apply to the Check - * */ - determineDCModifier(components: Combination): number { - const ingredientContribution: number = this._ingredientContributionCounter.determineDCModifier(components); - const essenceContribution: number = this._essenceContributionCounter.determineDCModifier(components); - return ingredientContribution + essenceContribution; - } - -} - -/** - * Determines that no amount or arrangement of Essences or Ingredients can contribute to the difficulty of the Check - * */ -class NoContributionCounter implements ContributionCounter { - /** - * Returns zero, as no amount or arrangement of Essences or Ingredients can contribute to the difficulty of the - * Check - * - * @returns number zero, for no contribution - * */ - determineDCModifier(): number { - return 0; - } -} - -export {ContributionCounter, CombinedContributionCounter, EssenceContributionCounter, IngredientContributionCounter, NoContributionCounter} - -interface ContributionCounterConfig { - ingredientContribution: number | 0; - essenceContribution: number | 0; -} - -/** - * Provides the appropriate ContributionCounter based on the values selected for Essence type and Ingredient quantity - * contributions to the difficulty of the Check - * */ -class ContributionCounterFactory { - - /** - * The numeric value to apply for each unique Essence type. Can be positive or negative - * */ - private readonly _essenceContribution: number; - /** - * The numeric value to apply for each Ingredient (also considering quantity). Can be positive or negative - * */ - private readonly _ingredientContribution: number; - - constructor(config: ContributionCounterConfig) { - this._ingredientContribution = config.ingredientContribution; - this._essenceContribution = config.essenceContribution; - } - - /** - * Creates and returns a concrete ContributionCounter based on the values provided for Ingredient and Essence - * contribution. - * - * | Ingredient Contribution | Essence Contribution | ContributionCounter | - * | :---------------------- | :---------------------- | :------------------------------ | - * | 0 | 0 | NoContributionCounter | - * | 0 | 1 | EssenceContributionCounter | - * | 1 | 0 | IngredientContributionCounter | - * | 1 | 1 | CombinedContributionCounter | - * - * @returns ContributionCounter the appropriate ContributionCounter - * */ - public make(): ContributionCounter { - const essenceCountContributes = this._essenceContribution && this._essenceContribution > 0; - const ingredientCountContributes = this._ingredientContribution && this._ingredientContribution > 0; - if (!essenceCountContributes && !ingredientCountContributes) { - return new NoContributionCounter(); - } - if (essenceCountContributes && ingredientCountContributes) { - return new CombinedContributionCounter(this._ingredientContribution, this._essenceContribution); - } - if (ingredientCountContributes) { - return new IngredientContributionCounter(this._ingredientContribution); - } - if (essenceCountContributes) { - return new EssenceContributionCounter(this._essenceContribution); - } - } -} - -export {ContributionCounterFactory}; -export {ContributionCounterConfig}; \ No newline at end of file diff --git a/src/scripts/crafting/check/CraftingCheck.ts b/src/scripts/crafting/check/CraftingCheck.ts deleted file mode 100644 index 6bece5ad..00000000 --- a/src/scripts/crafting/check/CraftingCheck.ts +++ /dev/null @@ -1,89 +0,0 @@ -import {Combination} from "../../common/Combination"; -import {CraftingComponent} from "../../common/CraftingComponent"; -import { - CraftingCheckResult, - FailedCraftingCheckResult, - NoCraftingCheckResult, - SuccessfulCraftingCheckResult -} from "./CraftingCheckResult"; -import {DiceRoller} from "../../foundry/DiceRoller"; -import {OutcomeType} from "../result/OutcomeType"; -import {ThresholdCalculator} from "./Threshold"; -import {GameSystemRollModifierProvider} from "./GameSystemRollModifierProvider"; -import {DnD5ECraftingCheckSpec} from "../../system/bundled/DnD5e"; - -interface CraftingCheck { - - perform(actor: A, components: Combination): CraftingCheckResult; - - toCheckDefinition(): DnD5ECraftingCheckSpec; -} - -interface CraftingCheckConfig { - diceRoller: DiceRoller; - gameSystemRollModifierProvider: GameSystemRollModifierProvider; - thresholdCalculator: ThresholdCalculator; -} - -class DefaultCraftingCheck implements CraftingCheck { - - private readonly _diceRoller: DiceRoller; - private readonly _gameSystemRollModifierProvider: GameSystemRollModifierProvider; - private readonly _thresholdCalculator: ThresholdCalculator; - - constructor(craftingCheckConfig: CraftingCheckConfig) { - this._diceRoller = craftingCheckConfig.diceRoller; - this._gameSystemRollModifierProvider = craftingCheckConfig.gameSystemRollModifierProvider; - this._thresholdCalculator = craftingCheckConfig.thresholdCalculator; - } - - perform(actor: A, components: Combination): CraftingCheckResult { - - const roll = this._diceRoller.createUnmodifiedRoll(); - const modifiers = this._gameSystemRollModifierProvider.getForActor(actor); - const rollResult = this._diceRoller.evaluate(roll, modifiers); - const threshold = this._thresholdCalculator.calculateFor(components); - const outcome = threshold.test(rollResult); - - switch (outcome) { - case OutcomeType.FAILURE: - return new FailedCraftingCheckResult({ - expression: rollResult.expression, - result: rollResult.value, - successThreshold: threshold.target - }); - case OutcomeType.SUCCESS: - return new SuccessfulCraftingCheckResult({ - expression: rollResult.expression, - result: rollResult.value, - successThreshold: threshold.target - }); - default: - return new NoCraftingCheckResult(); - } - - } - - toCheckDefinition(): DnD5ECraftingCheckSpec { - return undefined; - } - -} - -export {CraftingCheck, CraftingCheckConfig, DefaultCraftingCheck} - -class NoCraftingCheck implements CraftingCheck { - - constructor() {} - - perform(_actor: Actor, _components: Combination): CraftingCheckResult { - return new NoCraftingCheckResult(); - } - - toCheckDefinition(): DnD5ECraftingCheckSpec { - return undefined; - } - -} - -export {NoCraftingCheck}; \ No newline at end of file diff --git a/src/scripts/crafting/check/CraftingCheckResult.ts b/src/scripts/crafting/check/CraftingCheckResult.ts deleted file mode 100644 index 3fbaed0a..00000000 --- a/src/scripts/crafting/check/CraftingCheckResult.ts +++ /dev/null @@ -1,124 +0,0 @@ -interface CraftingCheckResult { - - isSuccessful: boolean; - expression: string; - result: number; - successThreshold: number; - - describe(): string; - -} - -interface FailedCraftingCheckResultConfig { - expression: string; - result: number; - successThreshold: number; -} - -class FailedCraftingCheckResult implements CraftingCheckResult { - private readonly _expression: string; - private readonly _result: number; - private readonly _successThreshold: number; - - constructor(config: FailedCraftingCheckResultConfig) { - this._expression = config.expression; - this._result = config.result; - this._successThreshold = config.successThreshold; - } - - get isSuccessful(): boolean { - return false; - } - - get expression(): string { - return this._expression; - } - - get result(): number { - return this._result; - } - - get successThreshold(): number { - return this._successThreshold; - } - - describe(): string { - return `The crafting check was a failure. You needed ${this._successThreshold} or higher, but rolled ${this.result}. `; - } - -} - -class NoCraftingCheckResult implements CraftingCheckResult { - - constructor() { - } - - get isSuccessful(): boolean { - return true; - } - - get expression(): string { - return ''; - } - - get result(): number { - return 0; - } - - get successThreshold(): number { - return 0; - } - - describe(): string { - return `The crafting check was not attempted. `; - } - -} - -interface SuccessfulCraftingCheckResultConfig { - expression: string; - result: number; - successThreshold: number; -} - -class SuccessfulCraftingCheckResult implements CraftingCheckResult { - private readonly _expression: string; - private readonly _result: number; - private readonly _successThreshold: number; - - constructor(config: SuccessfulCraftingCheckResultConfig) { - this._expression = config.expression; - this._result = config.result; - this._successThreshold = config.successThreshold; - } - - get isSuccessful(): boolean { - return true; - } - - get expression(): string { - return this._expression; - } - - get result(): number { - return this._result; - } - - get successThreshold(): number { - return this._successThreshold; - } - - describe(): string { - return `The crafting check was a success! You needed ${this._successThreshold} or higher, but rolled ${this.result}. `; - } - -} - -export { - CraftingCheckResult, - FailedCraftingCheckResult, - FailedCraftingCheckResultConfig, - NoCraftingCheckResult, - SuccessfulCraftingCheckResultConfig, - SuccessfulCraftingCheckResult -}; \ No newline at end of file diff --git a/src/scripts/crafting/check/GameSystemRollModifierProvider.ts b/src/scripts/crafting/check/GameSystemRollModifierProvider.ts deleted file mode 100644 index cf821261..00000000 --- a/src/scripts/crafting/check/GameSystemRollModifierProvider.ts +++ /dev/null @@ -1,19 +0,0 @@ -interface ModifierCalculator { - - calculate(actor: A): RollTerm; - -} - -interface RollModifierProviderFactory { - - make(craftingCheckSpecification?: any): GameSystemRollModifierProvider; // todo: fix this any typing - -} - -interface GameSystemRollModifierProvider { - - getForActor(actor: A): RollTerm[]; - -} - -export { RollModifierProviderFactory, GameSystemRollModifierProvider, ModifierCalculator } \ No newline at end of file diff --git a/src/scripts/crafting/check/Threshold.ts b/src/scripts/crafting/check/Threshold.ts deleted file mode 100644 index 577ebd70..00000000 --- a/src/scripts/crafting/check/Threshold.ts +++ /dev/null @@ -1,103 +0,0 @@ -import {RollResult} from "../../foundry/DiceRoller"; -import {OutcomeType} from "../result/OutcomeType"; -import {Combination} from "../../common/Combination"; -import {CraftingComponent} from "../../common/CraftingComponent"; -import {ContributionCounter} from "./ContributionCounter"; - -enum ThresholdType { - MEET, - EXCEED -} - -interface Threshold { - - target: number; - - test(rollResult: RollResult): OutcomeType; - -} - -class ExclusiveThreshold implements Threshold { - - private readonly _floor: number; - - constructor(floor: number) { - this._floor = floor; - } - - test(rollResult: RollResult): OutcomeType { - if (rollResult.value > this._floor) { - return OutcomeType.SUCCESS; - } - return OutcomeType.FAILURE; - } - - get target(): number { - return this._floor + 1; - } - -} - -class InclusiveThreshold implements Threshold { - - private readonly _target: number; - - constructor(target: number) { - this._target = target; - } - - test(rollResult: RollResult): OutcomeType { - if (rollResult.value >= this._target) { - return OutcomeType.SUCCESS; - } - return OutcomeType.FAILURE; - } - - get target(): number { - return this._target; - } - -} - -export {ThresholdType, InclusiveThreshold, ExclusiveThreshold, Threshold}; - -interface ThresholdCalculator { - - calculateFor(components: Combination): Threshold; - -} - -interface ThresholdCalculatorConfig { - baseValue: number; - thresholdType: ThresholdType; - contributionCounter: ContributionCounter; -} - -class DefaultThresholdCalculator implements ThresholdCalculator { - - private readonly _baseValue: number; - private readonly _thresholdType: ThresholdType; - private readonly _contributionCounter: ContributionCounter; - - constructor(config: ThresholdCalculatorConfig) { - this._baseValue = config.baseValue; - this._thresholdType = config.thresholdType; - this._contributionCounter = config.contributionCounter; - } - - calculateFor(components: Combination): Threshold { - const dcModifier: number = this._contributionCounter.determineDCModifier(components); - const thresholdValue: number = dcModifier + this._baseValue; - switch (this._thresholdType) { - case ThresholdType.EXCEED: - return new ExclusiveThreshold(thresholdValue); - case ThresholdType.MEET: - return new InclusiveThreshold(thresholdValue); - } - } - -} - -export {DefaultThresholdCalculator}; -export {ThresholdCalculatorConfig}; -export {ThresholdCalculator}; \ No newline at end of file diff --git a/src/scripts/crafting/component/Component.ts b/src/scripts/crafting/component/Component.ts new file mode 100644 index 00000000..d432609d --- /dev/null +++ b/src/scripts/crafting/component/Component.ts @@ -0,0 +1,298 @@ +import {Combination} from "../../common/Combination"; +import {Identifiable} from "../../common/Identifiable"; +import {SelectableOptions} from "../selection/SelectableOptions"; +import {FabricateItemData, ItemLoadingError, NoFabricateItemData} from "../../foundry/DocumentManager"; +import {Serializable} from "../../common/Serializable"; +import {ComponentReference} from "./ComponentReference"; +import {SalvageOption, SalvageOptionConfig, SalvageOptionJson} from "./SalvageOption"; +import {EssenceReference} from "../essence/EssenceReference"; +import {Unit} from "../../common/Unit"; + +interface ComponentJson { + id: string; + embedded: boolean; + itemUuid: string; + disabled: boolean; + essences: Record; + salvageOptions: Record; + craftingSystemId: string; +} + +class Component implements Identifiable, Serializable { + + private static readonly _NONE: Component = new Component({ + id: "NO_ID", + craftingSystemId: "NO_CRAFTING_SYSTEM_ID", + itemData: NoFabricateItemData.INSTANCE(), + disabled: true, + essences: Combination.EMPTY(), + salvageOptions: new SelectableOptions({}) + }); + + private readonly _id: string; + private readonly _embedded: boolean; + private readonly _craftingSystemId: string; + + private _itemData: FabricateItemData; + private _essences: Combination; + private _salvageOptions: SelectableOptions; + private _disabled: boolean; + + constructor({ + id, + craftingSystemId, + disabled = false, + embedded = false, + itemData = NoFabricateItemData.INSTANCE(), + essences = Combination.EMPTY(), + salvageOptions = new SelectableOptions({}) + }: { + id: string; + disabled?: boolean; + embedded?: boolean; + craftingSystemId: string; + itemData?: FabricateItemData; + essences?: Combination; + salvageOptions?: SelectableOptions; + }) { + this._id = id; + this._embedded = embedded; + this._itemData = itemData; + this._disabled = disabled; + this._essences = essences; + this._salvageOptions = salvageOptions; + this._craftingSystemId = craftingSystemId; + } + + set itemData(itemData: FabricateItemData) { + this._itemData = itemData; + } + + get itemData(): FabricateItemData { + return this._itemData; + } + + get id(): string { + return this._id; + } + + get embedded(): boolean { + return this._embedded; + } + + get craftingSystemId(): string { + return this._craftingSystemId; + } + + get itemUuid(): string { + return this._itemData.uuid; + } + + public static NONE() { + return this._NONE; + } + + get name(): string { + return this._itemData.name; + } + + get imageUrl(): string { + return this._itemData.imageUrl; + } + + get essences(): Combination { + return this._essences; + } + + get selectedSalvage(): Combination { + return this._salvageOptions.selectedOption.results; + } + + get selectedSalvageOptionName(): string { + return this._salvageOptions.selectedOptionId; + } + + public selectSalvageOption(combinationId: string) { + this._salvageOptions.select(combinationId); + } + + public selectNextSalvageOption(): void { + this._salvageOptions.selectNext(); + } + + public selectPreviousSalvageOption(): void { + this._salvageOptions.selectPrevious(); + } + + get isDisabled(): boolean { + return this._disabled; + } + + get isSalvageable(): boolean { + if (this._salvageOptions.isEmpty) { + return false; + } + return this._salvageOptions.all + .map(option => !option.results.isEmpty()) + .reduce((left, right) => left || right, false); + } + + public get hasEssences(): boolean { + return !this.essences.isEmpty(); + } + + public toJson(): ComponentJson { + return { + id: this._id, + embedded: false, + disabled: this._disabled, + essences: this._essences.toJson(), + itemUuid: this._itemData.toJson().uuid, + craftingSystemId: this._craftingSystemId, + salvageOptions: this._salvageOptions.toJson() + } + } + + public toReference(): ComponentReference { + return new ComponentReference(this._id); + } + + public clone({ + id, + embedded = false, + craftingSystemId = this._craftingSystemId, + substituteEssenceIds = new Map(), + }: { + id: string; + embedded?: boolean; + craftingSystemId?: string; + substituteEssenceIds?: Map; + }): Component { + const itemData = craftingSystemId === this._craftingSystemId ? NoFabricateItemData.INSTANCE() : this._itemData; + const essences = this._essences + .map(essenceUnit => { + if (!substituteEssenceIds.has(essenceUnit.element.id)) { + return essenceUnit; + } + const substituteId = substituteEssenceIds.get(essenceUnit.element.id); + return new Unit(new EssenceReference(substituteId), essenceUnit.quantity); + }) + .reduce((left, right) => left.addUnit(right), Combination.EMPTY()); + return new Component({ + id, + embedded, + itemData, + essences, + craftingSystemId, + salvageOptions: this._salvageOptions.clone(), + disabled: this._disabled, + }); + } + + set isDisabled(value: boolean) { + this._disabled = value; + } + + set essences(value: Combination) { + this._essences = value; + } + + public addSalvageOption(value: SalvageOption) { + if (this._salvageOptions.has(value.id)) { + throw new Error(`Result option ${value.id} already exists in this recipe. `); + } + this._salvageOptions.set(value); + } + + set salvageOptions(options: SelectableOptions) { + this._salvageOptions = options + } + + get salvageOptions(): SelectableOptions { + return this._salvageOptions; + } + + public selectFirstOption(): void { + this._salvageOptions.selectFirst() + } + + get salvageOptionsById(): Map { + return this._salvageOptions.byId; + } + + public deleteSalvageOptionById(id: string) { + this._salvageOptions.deleteById(id); + } + + get hasErrors(): boolean { + return this._itemData.hasErrors; + } + + get errors(): ItemLoadingError[] { + return this._itemData.errors; + } + + get errorCodes(): string[] { + return this._itemData.errors.map(error => error.code); + } + + deselectSalvage() { + this._salvageOptions.deselect(); + } + + removeEssence(essenceIdToDelete: string) { + this._essences = this._essences.without(essenceIdToDelete); + } + + removeComponentFromSalvageOptions(componentId: string) { + const options = this._salvageOptions.all + .map(option => option.without(componentId)); + this._salvageOptions = new SelectableOptions({ options }); + } + + async load(forceCacheRefresh: boolean = false) { + if (this._itemData.loaded && !forceCacheRefresh) { + return; + } + this.itemData = await this.itemData.load(); + } + + get loaded(): boolean { + return this.itemData.loaded; + } + + public setSalvageOption(value: SalvageOptionConfig) { + const optionId = this._salvageOptions.nextId(); + const salvageOption = SalvageOption.fromJson({ + id: optionId, + ...value + }); + this._salvageOptions.set(salvageOption); + } + + public saveSalvageOption(value: SalvageOption) { + this._salvageOptions.set(value); + } + + addEssence(essenceId: string, quantity: number = 1) { + this._essences = this._essences.addUnit(new Unit(new EssenceReference(essenceId), quantity)); + } + + subtractEssence(essenceId: string, quantity: number = 1) { + this._essences = this._essences.subtractUnit(new Unit(new EssenceReference(essenceId), quantity)); + } + + getUniqueReferencedComponents(): ComponentReference[] { + return this._salvageOptions.all + .map(salvageOption => salvageOption.results.combineWith(salvageOption.catalysts)) + .reduce((left, right) => left.combineWith(right), Combination.EMPTY()) + .members; + } + + getUniqueReferencedEssences(): EssenceReference[] { + return this._essences.members; + } + +} + +export { Component, ComponentJson } \ No newline at end of file diff --git a/src/scripts/crafting/component/ComponentFactory.ts b/src/scripts/crafting/component/ComponentFactory.ts new file mode 100644 index 00000000..94f67d38 --- /dev/null +++ b/src/scripts/crafting/component/ComponentFactory.ts @@ -0,0 +1,45 @@ +import {Component, ComponentJson} from "./Component"; +import {DocumentManager} from "../../foundry/DocumentManager"; +import {Combination} from "../../common/Combination"; +import {SelectableOptions} from "../selection/SelectableOptions"; +import {EntityFactory} from "../../repository/EntityFactory"; +import {SalvageOption, SalvageOptionJson} from "./SalvageOption"; +import {EssenceReference} from "../essence/EssenceReference"; + +class ComponentFactory implements EntityFactory { + + private readonly documentManager: DocumentManager; + + constructor({ + documentManager + }: { + documentManager: DocumentManager; + }) { + this.documentManager = documentManager; + } + + public async make(componentJson: ComponentJson): Promise { + + const itemData = this.documentManager.prepareItemDataByDocumentUuid(componentJson.itemUuid); + + return new Component({ + itemData, + id: componentJson.id, + embedded: componentJson.embedded, + disabled: componentJson.disabled, + craftingSystemId: componentJson.craftingSystemId, + essences: Combination.fromRecord(componentJson.essences, EssenceReference.fromEssenceId), + salvageOptions: this.buildSalvageOptions(componentJson.salvageOptions) + }); + + } + + private buildSalvageOptions(salvageOptionsJson: Record): SelectableOptions { + const options = Object.values(salvageOptionsJson) + .map(json => SalvageOption.fromJson(json)); + return new SelectableOptions({ options }); + } + +} + +export { ComponentFactory }; \ No newline at end of file diff --git a/src/scripts/crafting/component/ComponentReference.ts b/src/scripts/crafting/component/ComponentReference.ts new file mode 100644 index 00000000..60301cc2 --- /dev/null +++ b/src/scripts/crafting/component/ComponentReference.ts @@ -0,0 +1,38 @@ +import {Identifiable} from "../../common/Identifiable"; +import {Serializable} from "../../common/Serializable"; + +interface ComponentReferenceJson { + id: string; +} + +export { ComponentReferenceJson } + +class ComponentReference implements Identifiable, Serializable { + + private readonly _id: string; + + constructor(id: string) { + this._id = id + } + + get id(): string { + return this._id; + } + + toJson(): ComponentReferenceJson { + return { + id: this._id + }; + } + + static fromComponentId(componentId: string): ComponentReference { + return new ComponentReference(componentId); + } + + static NONE() { + return new ComponentReference("NO ID"); + } + +} + +export { ComponentReference } \ No newline at end of file diff --git a/src/scripts/crafting/component/ComponentValidator.ts b/src/scripts/crafting/component/ComponentValidator.ts new file mode 100644 index 00000000..06999299 --- /dev/null +++ b/src/scripts/crafting/component/ComponentValidator.ts @@ -0,0 +1,85 @@ +import {DefaultEntityValidationResult, EntityValidationResult} from "../../api/EntityValidator"; +import {Component} from "./Component"; +import {CraftingSystemAPI} from "../../api/CraftingSystemAPI"; +import {EssenceAPI} from "../../api/EssenceAPI"; +import {NoFabricateItemData} from "../../foundry/DocumentManager"; + +interface ComponentValidator { + + validate(candidate: Component, existingComponentIdsForSystem: string[], existingComponentsIdsForItem: string[]): Promise>; + +} + +export { ComponentValidator } + +class DefaultComponentValidator implements ComponentValidator { + + private readonly craftingSystemAPI: CraftingSystemAPI; + private readonly essenceAPI: EssenceAPI; + + constructor({ + craftingSystemAPI, + essenceAPI + }: { + craftingSystemAPI: CraftingSystemAPI; + essenceAPI: EssenceAPI; + }) { + this.craftingSystemAPI = craftingSystemAPI; + this.essenceAPI = essenceAPI; + } + + async validate(candidate: Component, existingComponentIdsForSystem: string[], existingComponentsIdsForItem: string[]): Promise> { + + // Prepare an array to capture any errors that are found + const errors: string[] = []; + + // Check that the crafting system exists + const craftingSystem = await this.craftingSystemAPI.getById(candidate.craftingSystemId); + if (!craftingSystem) { + errors.push(`The crafting system with ID ${candidate.craftingSystemId} does not exist. `); + } + + // Check that this item is not already a component for this crafting system + for (const existingComponentId of existingComponentsIdsForItem) { + if (existingComponentId !== candidate.id && existingComponentIdsForSystem.includes(existingComponentId)) { + errors.push(`The item with UUID ${candidate.itemUuid} is already a component in the system "${candidate.craftingSystemId}" with the ID "${existingComponentId}". `); + } + } + + // If the component has an item specified, check it is valid + if (!(candidate.itemData instanceof NoFabricateItemData)){ + // Check that the item exists and can be loaded + if (!candidate.itemData.loaded) { + await candidate.load(); + } + if (candidate.itemData.hasErrors) { + const itemDataErrorMessages = candidate.itemData.errors.map(error => error.message); + const cause = itemDataErrorMessages.length > 0 ? `Caused by: ${itemDataErrorMessages.join(", ")}. ` : ""; + errors.push(`The item with UUID ${candidate.itemUuid} could not be loaded. ${cause} `); + } + } + + // Check that the salvage and catalysts referenced by this component all exist + const undefinedSalvageComponents = candidate.getUniqueReferencedComponents() + .filter(componentReference => !existingComponentIdsForSystem.includes(componentReference.id)) + .map(componentReference => componentReference.id); + if (undefinedSalvageComponents.length > 0) { + errors.push(`The components with the following IDs do not exist: ${undefinedSalvageComponents.join(", ")}`); + } + + // Check that the essences referenced by this component all exist + const existingEssences = await this.essenceAPI.getAll(); + const undefinedEssences = candidate.getUniqueReferencedEssences() + .filter(essenceReference => !existingEssences.has(essenceReference.id)) + .map(essenceReference => essenceReference.id); + if (undefinedEssences.length > 0) { + errors.push(`The essences with the following IDs do not exist: ${undefinedEssences.join(", ")}`); + } + + return new DefaultEntityValidationResult({ entity: candidate, errors: errors }); + + } + +} + +export { DefaultComponentValidator }; \ No newline at end of file diff --git a/src/scripts/crafting/component/SalvageOption.ts b/src/scripts/crafting/component/SalvageOption.ts new file mode 100644 index 00000000..9484fad0 --- /dev/null +++ b/src/scripts/crafting/component/SalvageOption.ts @@ -0,0 +1,138 @@ +import {Identifiable} from "../../common/Identifiable"; +import {Serializable} from "../../common/Serializable"; +import {Combination} from "../../common/Combination"; +import {ComponentReference} from "./ComponentReference"; +import {Unit} from "../../common/Unit"; + +interface SalvageOptionConfig { + name: string; + results: Record; + catalysts: Record; +} + +export { SalvageOptionConfig }; + +interface SalvageOptionJson { + id: string; + name: string; + results: Record; + catalysts: Record; +} + +export { SalvageOptionJson }; + +class SalvageOption implements Identifiable, Serializable { + + private readonly _id: string; + private _name: string; + private _results: Combination; + private _catalysts: Combination; + + constructor({ + id, + name, + results, + catalysts = Combination.EMPTY() + }: { + id: string; + name: string; + results: Combination; + catalysts?: Combination; + }) { + this._id = id; + this._name = name; + this._results = results; + this._catalysts = catalysts; + } + + get results(): Combination { + return this._results; + } + + get hasResults(): boolean { + return !this._results.isEmpty(); + } + + set results(value: Combination) { + this._results = value; + } + + get catalysts(): Combination { + return this._catalysts; + } + + get requiresCatalysts(): boolean { + return !this._catalysts.isEmpty(); + } + + set catalysts(value: Combination) { + this._catalysts = value; + } + + set name(value: string) { + this._name = value; + } + + get isEmpty(): boolean { + return this._results.isEmpty() && this._catalysts.isEmpty(); + } + + get name(): string { + return this._name; + } + + get id(): string { + return this._id; + } + + toJson(): SalvageOptionJson { + return { + id: this._id, + name: this._name, + results: this._results.toJson(), + catalysts: this._catalysts.toJson() + }; + } + + static fromJson(salvageOptionJson: SalvageOptionJson): SalvageOption { + try { + return new SalvageOption({ + id: salvageOptionJson.id, + name: salvageOptionJson.name, + results: Combination.fromRecord(salvageOptionJson.results, ComponentReference.fromComponentId), + catalysts: Combination.fromRecord(salvageOptionJson.catalysts, ComponentReference.fromComponentId) + }); + } catch (e: any) { + const cause: Error = e instanceof Error ? e : typeof e === "string" ? new Error(e) : new Error("An unknown error occurred"); + throw new Error(`Unable to build result option ${salvageOptionJson.name}`, {cause}); + } + } + + without(componentId: string): SalvageOption { + return new SalvageOption({ + id: this._id, + name: this._name, + results: this._results.without(componentId), + catalysts: this._catalysts.without(componentId) + }); + } + + addResult(componentId: string, quantity: number = 1) { + this._results = this._results.addUnit(new Unit(new ComponentReference(componentId), quantity)); + } + + subtractResult(componentId: string, quantity: number = 1) { + this._results = this._results.subtractUnit(new Unit(new ComponentReference(componentId), quantity)); + } + + addCatalyst(componentId: string, number: number = 1) { + this._catalysts = this._catalysts.addUnit(new Unit(new ComponentReference(componentId), number)); + } + + subtractCatalyst(componentId: string, number: number = 1) { + this._catalysts = this._catalysts.subtractUnit(new Unit(new ComponentReference(componentId), number)); + } + +} + +export { SalvageOption }; \ No newline at end of file diff --git a/src/scripts/crafting/essence/Essence.ts b/src/scripts/crafting/essence/Essence.ts new file mode 100644 index 00000000..a477af86 --- /dev/null +++ b/src/scripts/crafting/essence/Essence.ts @@ -0,0 +1,194 @@ +import {Identifiable} from "../../common/Identifiable"; +import {FabricateItemData, ItemLoadingError, NoFabricateItemData} from "../../foundry/DocumentManager"; +import {Serializable} from "../../common/Serializable"; +import {EssenceReference} from "./EssenceReference"; + +interface EssenceJson { + id: string; + name: string; + tooltip: string; + iconCode: string; + embedded: boolean; + disabled: boolean; + description: string; + craftingSystemId: string; + activeEffectSourceItemUuid: string; +} + +class Essence implements Identifiable, Serializable { + + private readonly _id: string; + private readonly _craftingSystemId: string; + private readonly _embedded: boolean; + + private _name: string; + private _tooltip: string; + private _iconCode: string; + private _disabled: boolean; + private _description: string; + private _activeEffectSource: FabricateItemData; + + constructor({ + id, + name, + tooltip, + iconCode, + embedded = false, + disabled = false, + description, + craftingSystemId, + activeEffectSource = NoFabricateItemData.INSTANCE(), + }: { + id: string; + name: string; + tooltip: string; + iconCode: string; + embedded?: boolean; + disabled?: boolean; + description: string; + craftingSystemId: string; + activeEffectSource?: FabricateItemData; + }) { + this._id = id; + this._name = name; + this._tooltip = tooltip; + this._iconCode = iconCode; + this._embedded = embedded; + this._disabled = disabled; + this._description = description; + this._craftingSystemId = craftingSystemId; + this._activeEffectSource = activeEffectSource; + } + + toJson(): EssenceJson { + return { + id: this._id, + name: this._name, + tooltip: this._tooltip, + embedded: this._embedded, + disabled: this._disabled, + iconCode: this._iconCode, + description: this._description, + craftingSystemId: this._craftingSystemId, + activeEffectSourceItemUuid: this._activeEffectSource?.uuid + } + } + + get loaded(): boolean { + if (!this.hasActiveEffectSource) { + return true; + } + return this._activeEffectSource.loaded; + } + + public async load(): Promise { + if (!this.hasActiveEffectSource) { + return; + } + this.activeEffectSource = await this._activeEffectSource.load(); + return this; + } + + get id(): string { + return this._id; + } + + get embedded(): boolean { + return this._embedded; + } + + get craftingSystemId(): string { + return this._craftingSystemId; + } + + get hasActiveEffectSource(): boolean { + if (!this._activeEffectSource) { + return false; + } + return this._activeEffectSource.uuid !== NoFabricateItemData.INSTANCE().uuid; + } + + get activeEffectSource(): FabricateItemData { + return this._activeEffectSource; + } + + + get name(): string { + return this._name; + } + + get description(): string { + return this._description; + } + + get tooltip(): string { + return this._tooltip; + } + + get iconCode(): string { + return this._iconCode; + } + + get hasErrors(): boolean { + if (!this._activeEffectSource) { + return false; + } + return this._activeEffectSource.hasErrors; + } + + get errors(): ItemLoadingError[] { + return this._activeEffectSource.errors; + } + + get disabled(): boolean { + return this._disabled; + } + + set disabled(value: boolean) { + this._disabled = value; + } + + set activeEffectSource(value: FabricateItemData) { + this._activeEffectSource = value; + } + + set name(value: string) { + this._name = value; + } + + set description(value: string) { + this._description = value; + } + + set tooltip(value: string) { + this._tooltip = value; + } + + set iconCode(value: string) { + this._iconCode = value; + } + + toReference(): EssenceReference { + return new EssenceReference(this._id); + } + + clone({ + id, + embedded = false, + craftingSystemId = this._craftingSystemId, + }: { id: string, craftingSystemId?: string, embedded?: boolean }): Essence { + return new Essence({ + id, + embedded, + craftingSystemId, + name: this._name, + tooltip: this._tooltip, + iconCode: this._iconCode, + disabled: this._disabled, + description: this._description, + }); + } + +} + +export { EssenceJson, Essence } diff --git a/src/scripts/crafting/essence/EssenceFactory.ts b/src/scripts/crafting/essence/EssenceFactory.ts new file mode 100644 index 00000000..56143930 --- /dev/null +++ b/src/scripts/crafting/essence/EssenceFactory.ts @@ -0,0 +1,29 @@ +import {Essence, EssenceJson} from "./Essence"; +import {DocumentManager} from "../../foundry/DocumentManager"; +import {EntityFactory} from "../../repository/EntityFactory"; + +class EssenceFactory implements EntityFactory { + + private readonly documentManager: DocumentManager; + + constructor(documentManager: DocumentManager) { this.documentManager = documentManager; } + + async make(entityJson: EssenceJson): Promise { + const itemData = this.documentManager.prepareItemDataByDocumentUuid(entityJson.activeEffectSourceItemUuid); + + return new Essence({ + id: entityJson.id, + craftingSystemId: entityJson.craftingSystemId, + name: entityJson.name, + tooltip: entityJson.tooltip, + description: entityJson.description, + embedded: entityJson.embedded, + activeEffectSource: itemData, + iconCode: entityJson.iconCode, + disabled: entityJson.disabled + }) + } + +} + +export { EssenceFactory } \ No newline at end of file diff --git a/src/scripts/crafting/essence/EssenceReference.ts b/src/scripts/crafting/essence/EssenceReference.ts new file mode 100644 index 00000000..9701405c --- /dev/null +++ b/src/scripts/crafting/essence/EssenceReference.ts @@ -0,0 +1,34 @@ +import {Identifiable} from "../../common/Identifiable"; +import {Serializable} from "../../common/Serializable"; + +interface EssenceReferenceJson { + id: string; +} + +export { EssenceReferenceJson } + +class EssenceReference implements Identifiable, Serializable { + + private readonly _id: string; + + constructor(id: string) { + this._id = id + } + + get id(): string { + return this._id; + } + + toJson(): EssenceReferenceJson { + return { + id: this._id + }; + } + + static fromEssenceId(essenceId: string): EssenceReference { + return new EssenceReference(essenceId); + } + +} + +export { EssenceReference } \ No newline at end of file diff --git a/src/scripts/crafting/essence/EssenceValidator.ts b/src/scripts/crafting/essence/EssenceValidator.ts new file mode 100644 index 00000000..f4fcf4e5 --- /dev/null +++ b/src/scripts/crafting/essence/EssenceValidator.ts @@ -0,0 +1,60 @@ +import {DefaultEntityValidationResult, EntityValidationResult} from "../../api/EntityValidator"; +import {Essence} from "./Essence"; +import {CraftingSystemAPI} from "../../api/CraftingSystemAPI"; +import {NoFabricateItemData} from "../../foundry/DocumentManager"; + +interface EssenceValidator { + + validate(candidate: Essence): Promise>; + +} + +export { EssenceValidator } + +class DefaultEssenceValidator implements EssenceValidator { + + private readonly craftingSystemAPI: CraftingSystemAPI; + + constructor({ + craftingSystemAPI + }: { + craftingSystemAPI: CraftingSystemAPI; + }) { + this.craftingSystemAPI = craftingSystemAPI; + } + + async validate(candidate: Essence): Promise> { + + // Prepare an array to capture any errors that are found + const errors: string[] = []; + + // Check that the crafting system exists + const craftingSystem = await this.craftingSystemAPI.getById(candidate.craftingSystemId); + if (!craftingSystem) { + errors.push(`The crafting system with ID ${candidate.craftingSystemId} does not exist. `); + } + + if (!candidate.name) { + errors.push("The essence name is required. "); + } + + // if the essence has an active effect source, check it is valid + if (candidate.hasActiveEffectSource && !(candidate.activeEffectSource instanceof NoFabricateItemData)) { + // Check that the item exists and can be loaded + if (!candidate.loaded) { + await candidate.load(); + } + if (candidate.activeEffectSource.hasErrors) { + const itemDataErrorMessages = candidate.activeEffectSource.errors.map(error => error.message); + const cause = itemDataErrorMessages.length > 0 ? `Caused by: ${itemDataErrorMessages.join(", ")}. ` : ""; + errors.push(`The item with UUID ${candidate.activeEffectSource?.uuid} could not be loaded. ${cause} `); + } + } + + return new DefaultEntityValidationResult({ entity: candidate, errors: errors }); + + } + +} + +export { DefaultEssenceValidator }; \ No newline at end of file diff --git a/src/scripts/crafting/recipe/Recipe.ts b/src/scripts/crafting/recipe/Recipe.ts new file mode 100644 index 00000000..dec04c32 --- /dev/null +++ b/src/scripts/crafting/recipe/Recipe.ts @@ -0,0 +1,451 @@ +import {Combination} from "../../common/Combination"; +import {Identifiable} from "../../common/Identifiable"; +import {SelectableOptions} from "../selection/SelectableOptions"; +import {FabricateItemData, ItemLoadingError, NoFabricateItemData} from "../../foundry/DocumentManager"; +import {Serializable} from "../../common/Serializable"; +import {ComponentReference} from "../component/ComponentReference"; +import {RequirementOption} from "./RequirementOption"; +import {RequirementOptionJson} from "./RequirementOption"; +import {RequirementOptionConfig} from "./RequirementOption"; +import {ResultOption, ResultOptionJson} from "./ResultOption"; +import {ResultOptionConfig} from "./ResultOption"; +import {EssenceReference} from "../essence/EssenceReference"; + +interface RecipeJson { + id: string; + embedded: boolean; + itemUuid: string; + disabled: boolean; + craftingSystemId: string; + resultOptions: Record; + requirementOptions: Record; +} + +class Recipe implements Identifiable, Serializable { + + /* =========================== + * Instance members + * =========================== */ + + private readonly _id: string; + private readonly _craftingSystemId: string; + private readonly _embedded: boolean; + + private _itemData: FabricateItemData; + private _requirementOptions: SelectableOptions; + private _resultOptions: SelectableOptions; + private _disabled: boolean; + + /* =========================== + * Constructor + * =========================== */ + + constructor({ + id, + embedded = false, + craftingSystemId, + disabled = false, + itemData = NoFabricateItemData.INSTANCE(), + resultOptions = new SelectableOptions({}), + requirementOptions = new SelectableOptions({}) + }: { + id: string; + embedded?: boolean; + craftingSystemId: string; + itemData?: FabricateItemData; + disabled?: boolean; + resultOptions?: SelectableOptions; + requirementOptions?: SelectableOptions; + }) { + this._id = id; + this._embedded = embedded; + this._craftingSystemId = craftingSystemId; + this._itemData = itemData; + this._disabled = disabled; + this._requirementOptions = requirementOptions; + this._resultOptions = resultOptions; + } + + /* =========================== + * Getters + * =========================== */ + + get id(): string { + return this._id; + } + + get embedded(): boolean { + return this._embedded; + } + + get craftingSystemId(): string { + return this._craftingSystemId; + } + + get itemUuid(): string { + return this._itemData.uuid; + } + + get name(): string { + return this._itemData.name; + } + + get imageUrl(): string { + return this._itemData.imageUrl; + } + + get itemData(): FabricateItemData { + return this._itemData; + } + + set itemData(value: FabricateItemData) { + this._itemData = value; + } + + get requirementOptions(): SelectableOptions { + return this._requirementOptions; + } + + set isDisabled(value: boolean) { + this._disabled = value; + } + + get isDisabled(): boolean { + return this._disabled; + } + + get resultOptions(): SelectableOptions { + return this._resultOptions; + } + + public get hasOptions(): boolean { + return this.hasRequirementChoices || this.hasResultChoices; + } + + public get hasRequirementChoices(): boolean { + return this._requirementOptions.requiresUserChoice; + } + + public get hasResultChoices(): boolean { + return this._resultOptions.requiresUserChoice + } + + public ready(): boolean { + if (!this.hasOptions) { + return true; + } + return this._requirementOptions.isReady && this._resultOptions.isReady; + } + + public getSelectedIngredients(): RequirementOption { + if (this._requirementOptions.isReady) { + return this._requirementOptions.selectedOption; + } + throw new Error("You must select an ingredient group. "); + } + + public getSelectedResults(): ResultOption { + if (this._resultOptions.isReady) { + return this._resultOptions.selectedOption; + } + throw new Error("You must select a result group. "); + } + + public get hasRequirements() { + return !this._requirementOptions.isEmpty; + } + + public get hasResults(): boolean { + return !this._resultOptions.isEmpty; + } + + public selectRequirementOption(optionName: string) { + return this._requirementOptions.select(optionName); + } + + public selectResultOption(optionName: string) { + return this._resultOptions.select(optionName); + } + + get selectedRequirementOption(): RequirementOption { + return this._requirementOptions.selectedOption; + } + + public selectNextRequirementOption(): RequirementOption { + this._requirementOptions.selectNext(); + return this.selectedRequirementOption; + } + + public selectPreviousRequirementOption(): RequirementOption { + this._requirementOptions.selectPrevious(); + return this.selectedRequirementOption; + } + + get selectedResultOption(): ResultOption { + return this._resultOptions.selectedOption; + } + + public selectNextResultOption(): ResultOption { + this._resultOptions.selectNext(); + return this.selectedResultOption; + } + + public selectPreviousResultOption(): ResultOption { + this._resultOptions.selectPrevious(); + return this.selectedResultOption; + } + + public makeDefaultSelections() { + this._requirementOptions.selectFirst(); + this._resultOptions.selectFirst(); + } + + public editRequirementOption(requirementOption: RequirementOption) { + if (!this._requirementOptions.has(requirementOption.id)) { + throw new Error(`Cannot edit Ingredient Option "${requirementOption.id}". It does not exist in this Recipe.`); + } + this._requirementOptions.set(requirementOption); + } + + set resultOptions(value: SelectableOptions) { + this._resultOptions = value; + } + + saveRequirementOption(value: RequirementOption) { + this._requirementOptions.set(value); + } + + saveResultOption(value: ResultOption) { + this._resultOptions.set(value); + } + + setResultOption(value: ResultOptionConfig) { + const optionId = this._resultOptions.nextId(); + const resultOption = ResultOption.fromJson({ + id: optionId, + ...value + }); + this._resultOptions.set(resultOption); + } + + setRequirementOption({ + name, + catalysts = {}, + ingredients = {}, + essences = {} + }: RequirementOptionConfig) { + const optionId = this._requirementOptions.nextId(); + const salvageOption = RequirementOption.fromJson({ + id: optionId, + name, + catalysts, + ingredients, + essences + }); + this._requirementOptions.set(salvageOption); + } + + deleteResultOptionById(id: string) { + this._resultOptions.deleteById(id); + } + + deleteRequirementOptionById(id: string) { + this._requirementOptions.deleteById(id); + } + + get hasErrors(): boolean { + return this._itemData.hasErrors; + } + + get errorCodes(): string[] { + return this._itemData.errors.map(error => error.code); + } + + get errors(): ItemLoadingError[] { + return this._itemData.errors; + } + + deselectRequirements() { + this._requirementOptions.deselect(); + } + + deselectResults() { + this._resultOptions.deselect(); + } + + public clone({ + id, + embedded = false, + craftingSystemId = this._craftingSystemId, + substituteEssenceIds = new Map(), + substituteComponentIds = new Map(), + }: { + id: string; + embedded?: boolean; + craftingSystemId?: string; + substituteEssenceIds?: Map; + substituteComponentIds?: Map; + }): Recipe { + const itemData = craftingSystemId === this._craftingSystemId ? NoFabricateItemData.INSTANCE() : this._itemData; + return new Recipe({ + id, + itemData, + embedded, + craftingSystemId, + disabled: this._disabled, + resultOptions: this._resultOptions.clone((resultOption) => { + return resultOption.clone({ + substituteComponentIds, + }); + }), + requirementOptions: this._requirementOptions.clone((requirementOption) => { + return requirementOption.clone({ + substituteEssenceIds, + substituteComponentIds, + }); + }) + }); + } + + getUniqueReferencedComponents(): ComponentReference[] { + const componentsFromResults = this.resultOptions.all + .map(resultOption => resultOption.results) + .reduce((previousValue, currentValue) => { + return previousValue.combineWith(currentValue); + }, Combination.EMPTY()); + const componentFromRequirements = this.requirementOptions.all + .map(requirementOption => requirementOption.ingredients.combineWith(requirementOption.catalysts)) + .reduce((previousValue, currentValue) => { + return previousValue.combineWith(currentValue); + }, Combination.EMPTY()); + return componentFromRequirements.combineWith(componentsFromResults).members; + } + + async load() { + this.itemData = await this.itemData.load(); + } + + get loaded(): boolean { + return this.itemData.loaded; + } + + public toJson(): RecipeJson { + return { + id: this._id, + itemUuid: this._itemData.uuid, + craftingSystemId: this._craftingSystemId, + disabled: this._disabled, + embedded: this._embedded, + resultOptions: this._resultOptions.toJson(), + requirementOptions: this._requirementOptions.toJson() + }; + } + + equals(other: Recipe) { + if (!this.equalsNotLoaded(other)) { + return false; + } + return this._craftingSystemId === other.craftingSystemId + && this.isDisabled === other.isDisabled + && this._itemData.equals(other.itemData) + && this._requirementOptions.equals(other.requirementOptions) + && this._resultOptions.equals(other.resultOptions); + } + + public equalsNotLoaded(other: Recipe): boolean { + if (!other) { + return false; + } + if (this === other ) { + return true; + } + return this._id === other.id; + } + + hasEssenceRequirementOption() { + return this._requirementOptions.all.some(option => !option.essences.isEmpty()); + } + + hasComponent(componentId: string): boolean { + const inRequirements = this.requirementOptions.all + .map(option => option.ingredients.has(componentId) || option.catalysts.has(componentId)) + .reduce((previousValue, currentValue) => { + return previousValue || currentValue; + }, false); + + if (inRequirements) { + return true; + } + + return this.resultOptions.all + .map(option => option.results.has(componentId)) + .reduce((previousValue, currentValue) => { + return previousValue || currentValue; + }, false); + } + + removeComponent(componentId: string) { + const modifiedRequirementOptions = this.requirementOptions.all.map(option => { + const ingredients = option.ingredients.without(componentId); + const catalysts = option.catalysts.without(componentId); + return new RequirementOption({ + id: option.id, + name: option.name, + essences: option.essences, + catalysts, + ingredients + }); + }); + this._requirementOptions = new SelectableOptions({ options: modifiedRequirementOptions }); + const modifiedResultOptions = this.resultOptions.all.map(option => { + const results = option.results.without(componentId); + return new ResultOption({ + id: option.id, + name: option.name, + results + }); + }); + this._resultOptions = new SelectableOptions({ options: modifiedResultOptions }); + } + + removeEssence(essenceIdToRemove: string) { + const modifiedRequirementOptions = this.requirementOptions.all.map(option => { + const essences = option.essences.without(essenceIdToRemove); + return new RequirementOption({ + id: option.id, + name: option.name, + essences, + catalysts: option.catalysts, + ingredients: option.ingredients + }); + }); + this._requirementOptions = new SelectableOptions({ options: modifiedRequirementOptions }); + } + + hasEssence(essenceId: string) { + return this.getUniqueReferencedEssences().some(essence => essence.id === essenceId); + } + + getUniqueReferencedEssences(): EssenceReference[] { + return this.requirementOptions.all + .flatMap(option => option.essences.members) + .filter((essence, index, array) => array.findIndex(other => other.id === essence.id) === index); + } + + static fromJson(recipeJson: RecipeJson) { + const resultOptions = SelectableOptions.fromJson(recipeJson.resultOptions, ResultOption.fromJson); + const requirementOptions = SelectableOptions.fromJson(recipeJson.requirementOptions, RequirementOption.fromJson); + return new Recipe({ + id: recipeJson.id, + embedded: recipeJson.embedded, + craftingSystemId: recipeJson.craftingSystemId, + disabled: recipeJson.disabled, + itemData: NoFabricateItemData.INSTANCE(), + resultOptions, + requirementOptions + }); + } + +} + +export { Recipe, RecipeJson } \ No newline at end of file diff --git a/src/scripts/crafting/recipe/RecipeFactory.ts b/src/scripts/crafting/recipe/RecipeFactory.ts new file mode 100644 index 00000000..d87def2e --- /dev/null +++ b/src/scripts/crafting/recipe/RecipeFactory.ts @@ -0,0 +1,52 @@ +import {Recipe, RecipeJson} from "./Recipe"; +import {DefaultDocumentManager, DocumentManager} from "../../foundry/DocumentManager"; +import {SelectableOptions} from "../selection/SelectableOptions"; +import {EntityFactory} from "../../repository/EntityFactory"; +import {RequirementOption, RequirementOptionJson} from "./RequirementOption"; +import {ResultOption, ResultOptionJson} from "./ResultOption"; + +class RecipeFactory implements EntityFactory { + + private readonly documentManager: DocumentManager; + + constructor({ + documentManager = new DefaultDocumentManager() + }: { + documentManager?: DocumentManager; + }) { + this.documentManager = documentManager; + } + + async make(recipeJson: RecipeJson): Promise { + const { id, craftingSystemId, disabled, itemUuid } = recipeJson; + const itemData = this.documentManager.prepareItemDataByDocumentUuid(itemUuid); + try { + return new Recipe({ + id, + craftingSystemId, + itemData, + disabled, + requirementOptions: this.buildRequirementOptions(recipeJson.requirementOptions), + resultOptions: this.buildResultOptions(recipeJson.resultOptions) + }); + } catch (e: any) { + const cause: Error = e instanceof Error ? e : typeof e === "string" ? new Error(e) :new Error("An unknown error occurred"); + throw new Error(`Unable to build recipe ${id}`, { cause }); + } + } + + private buildRequirementOptions(requirementOptionsJson: Record): SelectableOptions { + const options = Object.values(requirementOptionsJson) + .map(json => RequirementOption.fromJson(json)); + return new SelectableOptions({ options }); + } + + private buildResultOptions(resultOptionsJson: Record): SelectableOptions { + const options = Object.values(resultOptionsJson) + .map(json => ResultOption.fromJson(json)); + return new SelectableOptions({ options }); + } + +} + +export { RecipeFactory } \ No newline at end of file diff --git a/src/scripts/crafting/recipe/RecipeValidator.ts b/src/scripts/crafting/recipe/RecipeValidator.ts new file mode 100644 index 00000000..1e8ea30c --- /dev/null +++ b/src/scripts/crafting/recipe/RecipeValidator.ts @@ -0,0 +1,91 @@ +import {DefaultEntityValidationResult, EntityValidationResult} from "../../api/EntityValidator"; +import {Recipe} from "./Recipe"; +import {CraftingSystemAPI} from "../../api/CraftingSystemAPI"; +import {EssenceAPI} from "../../api/EssenceAPI"; +import {ComponentAPI} from "../../api/ComponentAPI"; +import {NoFabricateItemData} from "../../foundry/DocumentManager"; + +interface RecipeValidator { + + validate(candidate: Recipe, existingRecipeIdsForSystem: string[], existingRecipeIdsForItem: string[]): Promise>; + +} + +export { RecipeValidator } + +class DefaultRecipeValidator implements RecipeValidator { + + private readonly craftingSystemAPI: CraftingSystemAPI; + private readonly componentAPI: ComponentAPI; + private readonly essenceAPI: EssenceAPI; + + constructor({ + craftingSystemAPI, + componentAPI, + essenceAPI + }: { + craftingSystemAPI: CraftingSystemAPI; + componentAPI: ComponentAPI; + essenceAPI: EssenceAPI; + }) { + this.craftingSystemAPI = craftingSystemAPI; + this.componentAPI = componentAPI; + this.essenceAPI = essenceAPI; + } + + async validate(candidate: Recipe, existingRecipeIdsForSystem: string[], existingRecipeIdsForItem: string[]): Promise> { + + // Prepare an array to capture any errors that are found + const errors: string[] = []; + + // Check that the crafting system exists + const craftingSystem = await this.craftingSystemAPI.getById(candidate.craftingSystemId); + if (!craftingSystem) { + errors.push(`The crafting system with ID ${candidate.craftingSystemId} does not exist. `); + } + + // Check that this item is not already a recipe for this crafting system + for (const existingRecipeId of existingRecipeIdsForSystem) { + if (existingRecipeId !== candidate.id && existingRecipeIdsForItem.includes(existingRecipeId)) { + errors.push(`The item with UUID ${candidate.itemUuid} is already a recipe in the system "${candidate.craftingSystemId}" with the ID "${existingRecipeId}". `); + } + } + + // If the recipe has an item specified, check it is valid + if (!(candidate.itemData instanceof NoFabricateItemData)){ + // Check that the item exists and can be loaded + if (!candidate.itemData.loaded) { + await candidate.load(); + } + if (candidate.itemData.hasErrors) { + const itemDataErrorMessages = candidate.itemData.errors.map(error => error.message); + const cause = itemDataErrorMessages.length > 0 ? `Caused by: ${itemDataErrorMessages.join(", ")}. ` : ""; + errors.push(`The item with UUID ${candidate.itemUuid} could not be loaded. ${cause} `); + } + } + + // Check that the essences referenced by this recipe all exist + const existingEssences = await this.essenceAPI.getAll(); + const undefinedEssences = candidate.getUniqueReferencedEssences() + .filter(essenceReference => !existingEssences.has(essenceReference.id)) + .map(essenceReference => essenceReference.id); + if (undefinedEssences.length > 0) { + errors.push(`The essences with the following IDs do not exist: ${undefinedEssences.join(", ")}`); + } + + // Check that the required and resultant components in all options referenced by this recipe exist + const componentsForSystem = await this.componentAPI.getAllByCraftingSystemId(candidate.craftingSystemId); + const undefinedComponents = candidate.getUniqueReferencedComponents() + .filter(componentReference => !componentsForSystem.has(componentReference.id)) + .map(componentReference => componentReference.id); + if (undefinedComponents.length > 0) { + errors.push(`The components with the following IDs do not exist: ${undefinedComponents.join(", ")}`); + } + + return new DefaultEntityValidationResult({ entity: candidate, errors: errors }); + + } + +} + +export { DefaultRecipeValidator }; \ No newline at end of file diff --git a/src/scripts/crafting/recipe/RequirementOption.ts b/src/scripts/crafting/recipe/RequirementOption.ts new file mode 100644 index 00000000..4b3fbc0a --- /dev/null +++ b/src/scripts/crafting/recipe/RequirementOption.ts @@ -0,0 +1,204 @@ +import {Identifiable} from "../../common/Identifiable"; +import {Serializable} from "../../common/Serializable"; +import {Combination} from "../../common/Combination"; +import {ComponentReference} from "../component/ComponentReference"; +import {EssenceReference} from "../essence/EssenceReference"; +import {Unit} from "../../common/Unit"; + +interface RequirementOptionConfig { + name: string, + catalysts?: Record; + ingredients?: Record; + essences?: Record; +} + +interface RequirementOptionJson { + id: string, + name: string, + catalysts: Record; + ingredients: Record; + essences: Record; +} + +class RequirementOption implements Identifiable, Serializable { + + private readonly _id: string; + private _name: string; + private _catalysts: Combination; + private _ingredients: Combination; + private _essences: Combination; + + constructor({ + id, + name, + essences = Combination.EMPTY(), + catalysts = Combination.EMPTY(), + ingredients = Combination.EMPTY(), + }: { + id: string; + name: string; + essences?: Combination; + catalysts?: Combination; + ingredients?: Combination; + }) { + this._id = id; + this._name = name; + this._essences = essences; + this._catalysts = catalysts; + this._ingredients = ingredients; + } + + get essences(): Combination { + return this._essences; + } + + set essences(value: Combination) { + this._essences = value; + } + + get requiresCatalysts(): boolean { + return !this._catalysts.isEmpty(); + } + + get requiresIngredients(): boolean { + return !this._ingredients.isEmpty(); + } + + get requiresEssences(): boolean { + return !this._essences.isEmpty(); + } + + get isEmpty(): boolean { + return this._catalysts.isEmpty() && this._ingredients.isEmpty() && this._essences.isEmpty(); + } + + set catalysts(value: Combination) { + this._catalysts = value; + } + + set ingredients(value: Combination) { + this._ingredients = value; + } + + get catalysts(): Combination { + return this._catalysts; + } + + get ingredients(): Combination { + return this._ingredients; + } + + get name(): string { + return this._name; + } + + set name(value: string) { + this._name = value; + } + + get id(): string { + return this._id; + } + + public addCatalyst(componentId: string, amount = 1) { + this._catalysts = this._catalysts.addUnit(new Unit(new ComponentReference(componentId), amount)); + } + + public subtractCatalyst(componentId: string, amount = 1) { + this._catalysts = this._catalysts.subtractUnit(new Unit(new ComponentReference(componentId), amount)); + } + + public addIngredient(componentId: string, amount = 1) { + this._ingredients = this._ingredients.addUnit(new Unit(new ComponentReference(componentId), amount)); + } + + public subtractIngredient(componentId: string, amount = 1) { + this._ingredients = this._ingredients.subtractUnit(new Unit(new ComponentReference(componentId), amount)); + } + + public addEssence(essenceId: string, amount = 1) { + this._essences = this._essences.addUnit(new Unit(new EssenceReference(essenceId), amount)); + } + + public subtractEssence(essenceId: string, amount = 1) { + this._essences = this._essences.subtractUnit(new Unit(new EssenceReference(essenceId), amount)); + } + + toJson(): RequirementOptionJson { + return { + id: this._id, + name: this._name, + catalysts: this._catalysts.toJson(), + ingredients: this._ingredients.toJson(), + essences: this._essences.toJson() + }; + } + + static fromJson(requirementOptionJson: RequirementOptionJson): RequirementOption { + try { + return new RequirementOption({ + id: requirementOptionJson.id, + name: requirementOptionJson.name, + catalysts: Combination.fromRecord(requirementOptionJson.catalysts, ComponentReference.fromComponentId), + ingredients: Combination.fromRecord(requirementOptionJson.ingredients, ComponentReference.fromComponentId), + essences: Combination.fromRecord(requirementOptionJson.essences, EssenceReference.fromEssenceId) + }); + } catch (e: any) { + const cause: Error = e instanceof Error ? e : typeof e === "string" ? new Error(e) : new Error("An unknown error occurred"); + throw new Error(`Unable to build requirement option ${requirementOptionJson.name}`, {cause}); + } + } + + clone({ + substituteEssenceIds = new Map(), + substituteComponentIds = new Map(), + }: { + substituteEssenceIds: Map; + substituteComponentIds: Map; + }) { + + const catalysts = this._catalysts + .map((catalystUnit) => { + if (!substituteComponentIds.has(catalystUnit.element.id)) { + return catalystUnit; + } + const substituteId = substituteComponentIds.get(catalystUnit.element.id); + return new Unit(new ComponentReference(substituteId), catalystUnit.quantity); + }) + .reduce((left, right) => left.addUnit(right), Combination.EMPTY()); + + const ingredients = this._ingredients + .map((ingredientUnit) => { + if (!substituteComponentIds.has(ingredientUnit.element.id)) { + return ingredientUnit; + } + const substituteId = substituteComponentIds.get(ingredientUnit.element.id); + return new Unit(new ComponentReference(substituteId), ingredientUnit.quantity); + }) + .reduce((left, right) => left.addUnit(right), Combination.EMPTY()); + + const essences = this._essences + .map((essenceUnit) => { + if (!substituteEssenceIds.has(essenceUnit.element.id)) { + return essenceUnit; + } + const substituteId = substituteEssenceIds.get(essenceUnit.element.id); + return new Unit(new EssenceReference(substituteId), essenceUnit.quantity); + }) + .reduce((left, right) => left.addUnit(right), Combination.EMPTY()); + + return new RequirementOption({ + essences, + catalysts, + ingredients, + id: this._id, + name: this._name, + }); + + } + +} + +export {RequirementOption}; +export {RequirementOptionJson}; +export {RequirementOptionConfig}; \ No newline at end of file diff --git a/src/scripts/crafting/recipe/ResultOption.ts b/src/scripts/crafting/recipe/ResultOption.ts new file mode 100644 index 00000000..c5d3998e --- /dev/null +++ b/src/scripts/crafting/recipe/ResultOption.ts @@ -0,0 +1,111 @@ +import {Identifiable} from "../../common/Identifiable"; +import {Serializable} from "../../common/Serializable"; +import {Combination} from "../../common/Combination"; +import {ComponentReference} from "../component/ComponentReference"; +import {Unit} from "../../common/Unit"; + +export interface ResultOptionConfig { + name: string; + results: Record; +} + +interface ResultOptionJson { + id: string; + name: string; + results: Record; +} + +class ResultOption implements Identifiable, Serializable { + + private _results: Combination; + private readonly _id: string; + private _name: string; + + constructor({ + id, + name, + results + }: { + id: string; + name: string; + results: Combination; + }) { + this._id = id; + this._name = name; + this._results = results; + } + + get isEmpty(): boolean { + return this._results.isEmpty(); + } + + get results(): Combination { + return this._results; + } + + set results(value: Combination) { + this._results = value; + } + + set name(value: string) { + this._name = value; + } + + get name(): string { + return this._name; + } + + get id(): string { + return this._id; + } + + public add(componentId: string, amount = 1) { + this._results = this._results.addUnit(new Unit(new ComponentReference(componentId), amount)); + } + + public subtract(componentId: string, amount = 1) { + this._results = this._results.subtractUnit(new Unit(new ComponentReference(componentId), amount)); + } + + toJson(): ResultOptionJson { + return { + id: this._id, + name: this._name, + results: this._results.toJson() + } + } + + static fromJson(resultOptionJson: ResultOptionJson) { + try { + return new ResultOption({ + id: resultOptionJson.id, + name: resultOptionJson.name, + results: Combination.fromRecord(resultOptionJson.results, ComponentReference.fromComponentId) + }); + } catch (e: any) { + const cause: Error = e instanceof Error ? e : typeof e === "string" ? new Error(e) : new Error("An unknown error occurred"); + throw new Error(`Unable to build result option ${resultOptionJson.name}`, {cause}); + } + } + + clone({ substituteComponentIds = new Map() }: { substituteComponentIds?: Map }) { + const results= this._results + .map((resultUnit) => { + if (!substituteComponentIds.has(resultUnit.element.id)) { + return resultUnit; + } + const substituteId = substituteComponentIds.get(resultUnit.element.id); + return new Unit(new ComponentReference(substituteId), resultUnit.quantity); + }) + .reduce((left, right) => left.addUnit(right), Combination.EMPTY()); + return new ResultOption({ + results, + id: this._id, + name: this._name, + }); + } + +} + +export {ResultOption}; +export {ResultOptionJson}; \ No newline at end of file diff --git a/src/scripts/crafting/result/CraftingResult.ts b/src/scripts/crafting/result/CraftingResult.ts index a17ca448..988ff90e 100644 --- a/src/scripts/crafting/result/CraftingResult.ts +++ b/src/scripts/crafting/result/CraftingResult.ts @@ -1,13 +1,15 @@ import {Combination} from "../../common/Combination"; -import {Recipe} from "../../common/Recipe"; -import {CraftingCheckResult, NoCraftingCheckResult} from "../check/CraftingCheckResult"; -import {CraftingComponent} from "../../common/CraftingComponent"; +import {Recipe} from "../recipe/Recipe"; +import {Component} from "../component/Component"; interface CraftingResult { - consumed: Combination; - created: Combination; - isSuccessful: boolean; + recipe: Recipe; + sourceActorId: string; + targetActorId: string; + description: string; + consumed: Combination; + produced: Combination; } @@ -15,68 +17,112 @@ export { CraftingResult } class NoCraftingResult implements CraftingResult { - constructor() {} + private readonly _recipe: Recipe; + private readonly _description: string; + private readonly _sourceActorId: string; + private readonly _targetActorId: string; + + constructor({ + recipe, + description, + sourceActorId, + targetActorId, + }: { + recipe: Recipe; + description: string; + sourceActorId: string; + targetActorId: string; + }) { + this._recipe = recipe; + this._description = description; + this._sourceActorId = sourceActorId; + this._targetActorId = targetActorId; + } - get created(): Combination { + get description(): string { + return this._description; + } + + get produced(): Combination { return Combination.EMPTY(); } - get consumed(): Combination { + get consumed(): Combination { return Combination.EMPTY(); } - get isSuccessful(): boolean { - return false; + get recipe(): Recipe { + return this._recipe; + } + + get sourceActorId(): string { + return this._sourceActorId; + } + + get targetActorId(): string { + return this._targetActorId; } } -export {NoCraftingResult}; +export { NoCraftingResult }; -class DefaultCraftingResult implements CraftingResult { +class SuccessfulCraftingResult implements CraftingResult { private readonly _recipe: Recipe; - private readonly _consumed: Combination; - private readonly _created: Combination; - private readonly _checkResult?: CraftingCheckResult; + private readonly _consumed: Combination; + private readonly _produced: Combination; + private readonly _description: string; + private readonly _sourceActorId: string; + private readonly _targetActorId: string; constructor({ recipe, consumed = Combination.EMPTY(), - created = Combination.EMPTY(), - checkResult = new NoCraftingCheckResult() + produced = Combination.EMPTY(), + description, + sourceActorId, + targetActorId, }: { recipe: Recipe; - consumed?: Combination; - created?: Combination; - checkResult?: CraftingCheckResult; + consumed?: Combination; + produced?: Combination; + description: string; + sourceActorId: string; + targetActorId: string; }) { this._recipe = recipe; this._consumed = consumed; - this._created = created; - this._checkResult = checkResult; + this._produced = produced; + this._description = description; + this._sourceActorId = sourceActorId; + this._targetActorId = targetActorId; + } + + get description(): string { + return this._description; } - get consumed(): Combination { + get consumed(): Combination { return this._consumed; } - get created(): Combination { - return this._created; + get produced(): Combination { + return this._produced; } get recipe(): Recipe { return this._recipe; } - get checkResult(): CraftingCheckResult { - return this._checkResult; + get sourceActorId(): string { + return this._sourceActorId; } - get isSuccessful(): boolean { - return this._checkResult.isSuccessful; + get targetActorId(): string { + return this._targetActorId; } } -export {DefaultCraftingResult}; \ No newline at end of file +export { SuccessfulCraftingResult }; \ No newline at end of file diff --git a/src/scripts/crafting/result/OutcomeType.ts b/src/scripts/crafting/result/OutcomeType.ts deleted file mode 100644 index 278f4402..00000000 --- a/src/scripts/crafting/result/OutcomeType.ts +++ /dev/null @@ -1,7 +0,0 @@ -enum OutcomeType { - SUCCESS = 'SUCCESS', - NOT_ATTEMPTED = 'NOT_ATTEMPTED', - FAILURE = 'FAILURE' -} - -export {OutcomeType} \ No newline at end of file diff --git a/src/scripts/crafting/result/SalvageResult.ts b/src/scripts/crafting/result/SalvageResult.ts index 429ccbff..89e00ba6 100644 --- a/src/scripts/crafting/result/SalvageResult.ts +++ b/src/scripts/crafting/result/SalvageResult.ts @@ -1,11 +1,15 @@ import {Combination} from "../../common/Combination"; -import {CraftingComponent} from "../../common/CraftingComponent"; +import {Component} from "../component/Component"; interface SalvageResult { - consumed: Combination; - created: Combination; - isSuccessful: boolean; + readonly component: Component; + readonly targetActorId: string; + readonly sourceActorId: string; + readonly description: string; + readonly consumed: Component | undefined; + readonly produced: Combination; + readonly isSuccessful: boolean; } @@ -13,73 +17,158 @@ export { SalvageResult } class NoSalvageResult implements SalvageResult { - constructor() {} + private readonly _component: Component; + private readonly _description: string; + private readonly _sourceActorId: string; + private readonly _targetActorId: string; - get created(): Combination { - return Combination.EMPTY(); + constructor({ + component, + description, + sourceActorId, + targetActorId, + }: { + component: Component; + description: string; + sourceActorId: string; + targetActorId: string; + }) { + this._component = component; + this._description = description; + this._sourceActorId = sourceActorId; + this._targetActorId = targetActorId; } - get consumed(): Combination { + get component(): Component { + return this._component; + } + + get produced(): Combination { return Combination.EMPTY(); } + get consumed(): Component { + return undefined + } + get isSuccessful(): boolean { return false; } + get targetActorId(): string { + return this._targetActorId; + } + + get sourceActorId(): string { + return this._sourceActorId; + } + + get description(): string { + return this._description; + } + } -export {NoSalvageResult}; +export { NoSalvageResult }; class SuccessfulSalvageResult implements SalvageResult { - private readonly _consumed: Combination; - private readonly _created: Combination; + private readonly _component: Component; + private readonly _description: string; + private readonly _sourceActorId: string; + private readonly _targetActorId: string; + private readonly _consumed: Component; + private readonly _produced: Combination; constructor({ + component, + description, + sourceActorId, + targetActorId, consumed, - created + produced, }: { - consumed: Combination; - created: Combination; + component: Component; + description: string; + sourceActorId: string; + targetActorId: string; + consumed: Component; + produced: Combination; }) { + this._component = component; + this._description = description; + this._sourceActorId = sourceActorId; + this._targetActorId = targetActorId; this._consumed = consumed; - this._created = created; + this._produced = produced; + } + + get component(): Component { + return this._component; } - get consumed(): Combination { + get consumed(): Component { return this._consumed; } - get created(): Combination { - return this._created; + get produced(): Combination { + return this._produced; } get isSuccessful(): boolean { return true; } + get targetActorId(): string { + return this._targetActorId; + } + + get sourceActorId(): string { + return this._sourceActorId; + } + + get description(): string { + return this._description; + } + } export { SuccessfulSalvageResult }; class UnsuccessfulSalvageResult implements SalvageResult { - private readonly _consumed: Combination; + private readonly _component: Component; + private readonly _description: string; + private readonly _sourceActorId: string; + private readonly _consumed: Component; constructor({ + component, + description, + sourceActorId, consumed }: { - consumed: Combination; + component: Component; + description: string; + sourceActorId: string; + consumed: Component; }) { + this._component = component; + this._description = description; + this._sourceActorId = sourceActorId; this._consumed = consumed; } - get created(): Combination { + + get component(): Component { + return this._component; + } + + get produced(): Combination { return Combination.EMPTY(); } - get consumed(): Combination { + get consumed(): Component { return this._consumed; } @@ -87,6 +176,18 @@ class UnsuccessfulSalvageResult implements SalvageResult { return false; } + get sourceActorId(): string { + return this._sourceActorId; + } + + get targetActorId(): string { + return ""; + } + + get description(): string { + return this._description; + } + } export { UnsuccessfulSalvageResult }; \ No newline at end of file diff --git a/src/scripts/crafting/selection/ComponentSelectionStrategy.ts b/src/scripts/crafting/selection/ComponentSelectionStrategy.ts index 91a2b417..8b089c7b 100644 --- a/src/scripts/crafting/selection/ComponentSelectionStrategy.ts +++ b/src/scripts/crafting/selection/ComponentSelectionStrategy.ts @@ -1,30 +1,31 @@ -import {Combination, Unit} from "../../common/Combination"; +import {Combination} from "../../common/Combination"; import {ComponentSelection, DefaultComponentSelection} from "../../component/ComponentSelection"; -import {CraftingComponent} from "../../common/CraftingComponent"; -import {Essence} from "../../common/Essence"; +import {Component} from "../component/Component"; import {TrackedCombination} from "../../common/TrackedCombination"; import {EssenceSelection} from "../../actor/EssenceSelection"; -import {Identifiable} from "../../common/Identity"; +import {Identifiable} from "../../common/Identifiable"; +import {Unit} from "../../common/Unit"; +import {EssenceReference} from "../essence/EssenceReference"; interface ComponentSelectionStrategy { perform( - requiredCatalysts: Combination, - requiredIngredients: Combination, - requiredEssences: Combination, - availableComponents: Combination + requiredCatalysts: Combination, + requiredIngredients: Combination, + requiredEssences: Combination, + availableComponents: Combination ): ComponentSelection; } export {ComponentSelectionStrategy} -class DefaultComponentSelectionStrategy implements ComponentSelectionStrategy { +class ConservativeEssenceSourcingComponentSelectionStrategy implements ComponentSelectionStrategy { - perform(requiredCatalysts: Combination, - requiredIngredients: Combination, - requiredEssences: Combination, - availableComponents: Combination): ComponentSelection { + perform(requiredCatalysts: Combination, + requiredIngredients: Combination, + requiredEssences: Combination, + availableComponents: Combination): ComponentSelection { const catalysts = this.selectCombination(requiredCatalysts, availableComponents) @@ -37,7 +38,7 @@ class DefaultComponentSelectionStrategy implements ComponentSelectionStrategy { const componentsForEssences = componentsForIngredients.subtract(ingredientAmountToSubtract); const essenceSelectionAlgorithm = new EssenceSelection(requiredEssences); - const essenceSources: Combination = essenceSelectionAlgorithm.perform(componentsForEssences); + const essenceSources: Combination = essenceSelectionAlgorithm.perform(componentsForEssences); const actualEssences = essenceSources.explode(component => component.essences); const essences = new TrackedCombination({ target: requiredEssences, actual: actualEssences }) @@ -52,7 +53,7 @@ class DefaultComponentSelectionStrategy implements ComponentSelectionStrategy { private selectCombination(target: Combination, pool: Combination): TrackedCombination { const actualUnits = target.units - .map(unit => new Unit(unit.part, pool.amountFor(unit.part))); + .map(unit => new Unit(unit.element, pool.amountFor(unit.element))); const actual = Combination.ofUnits(actualUnits); return new TrackedCombination({ target: target, @@ -62,4 +63,4 @@ class DefaultComponentSelectionStrategy implements ComponentSelectionStrategy { } -export {DefaultComponentSelectionStrategy}; \ No newline at end of file +export {ConservativeEssenceSourcingComponentSelectionStrategy}; \ No newline at end of file diff --git a/src/scripts/common/SelectableOptions.ts b/src/scripts/crafting/selection/SelectableOptions.ts similarity index 68% rename from src/scripts/common/SelectableOptions.ts rename to src/scripts/crafting/selection/SelectableOptions.ts index e10cd64a..d5d95f0d 100644 --- a/src/scripts/common/SelectableOptions.ts +++ b/src/scripts/crafting/selection/SelectableOptions.ts @@ -1,4 +1,5 @@ -import {Identifiable, Serializable} from "./Identity"; +import {Identifiable} from "../../common/Identifiable"; +import {Serializable} from "../../common/Serializable"; class SelectableOptions> implements Serializable> { @@ -7,18 +8,24 @@ class SelectableOptions> implements constructor({ options = [], - selectedOptionId + selectedOptionId = "" }: { options?: T[], selectedOptionId?: string - }) { + } = {}) { this._options = this.prepareOptions(options); this._selectedOptionId = selectedOptionId; if (this._selectedOptionId) { this.validateSelection(this._selectedOptionId, this._options); + } else { + this.selectFirst(); } } + public static EMPTY>(): SelectableOptions { + return new SelectableOptions(); + } + private prepareOptions(options: T[]): Map { if (!options || options.length === 0) { return new Map(); @@ -48,11 +55,11 @@ class SelectableOptions> implements } } - get optionsByName(): Map { + get byId(): Map { return new Map(this._options); } - get options(): T[] { + get all(): T[] { return Array.from(this._options.values()); } @@ -65,6 +72,9 @@ class SelectableOptions> implements } get selectedOption(): T { + if (this.isEmpty) { + throw new Error("Cannot get selected option from an empty set of options. "); + } if (this._options.size === 1) { return Array.from(this._options.values())[0]; } @@ -87,21 +97,10 @@ class SelectableOptions> implements } toJson(): Record { - return Array.from(this._options.values()) - .reduce((previousValue, currentValue) => { - if (previousValue[currentValue.id]) { - throw new Error("Two options cannot have the same identity. "); - } - previousValue[currentValue.id] = currentValue.toJson(); - return previousValue; - }, >{}); - } - - add(value: T) { - if (!value.id) { - throw new Error("ID must not be null. "); - } - this._options.set(value.id, value); + return Array.from(this._options.values()).reduce((result, option) => { + result[option.id] = option.toJson(); + return result; + }, >{}); } has(id: string) { @@ -109,6 +108,9 @@ class SelectableOptions> implements } set(value: T) { + if (!value.id) { + throw new Error("Option ID must be anon-empty string. "); + } this._options.set(value.id, value); } @@ -120,14 +122,19 @@ class SelectableOptions> implements } getById(id: string) { - this._options.get(id); + return this._options.get(id); } private clearSelection() { this._selectedOptionId = null; } - clone(): SelectableOptions { + clone(mappingFunction?: (option: T) => T): SelectableOptions { + if (mappingFunction) { + return new SelectableOptions({ + options: Array.from(this._options.values()).map(mappingFunction) + }); + } return new SelectableOptions({ options: Array.from(this._options.values()) }) @@ -194,6 +201,41 @@ class SelectableOptions> implements const next = this.findNext(); this._selectedOptionId = next.id; } + + equals(other: SelectableOptions) { + if (!other) { + return false; + } + if (this.size !== other.size) { + return false; + } + return Array.from(this._options.values()) + .map(thisOption => other.has(thisOption.id)) + .reduce((left, right) => left && right, true); + } + + selectFirst() { + if (this._options.size === 0) { + this._selectedOptionId = ""; + return; + } + this._selectedOptionId = Array.from(this._options.values())[0].id; + } + + nextId(): string { + let generationAttempts = 1; + let nextId; + do { + nextId = `option-${this._options.size + generationAttempts}`; + generationAttempts++; + } while (this._options.has(nextId)); + return nextId; + } + + static fromJson>(optionsJson: Record, optionFactoryFunction: (json: J) => T) { + const options = Object.values(optionsJson).map(optionJson => optionFactoryFunction(optionJson)); + return new SelectableOptions({options}); + } } export { SelectableOptions } \ No newline at end of file diff --git a/src/scripts/error/InventoryContentsNotFoundError.ts b/src/scripts/error/InventoryContentsNotFoundError.ts index ca180cc2..d73f0638 100644 --- a/src/scripts/error/InventoryContentsNotFoundError.ts +++ b/src/scripts/error/InventoryContentsNotFoundError.ts @@ -1,11 +1,11 @@ -import {CraftingComponent} from "../common/CraftingComponent"; -import {Unit} from "../common/Combination"; +import {Component} from "../crafting/component/Component"; + +import {Unit} from "../common/Unit"; class InventoryContentsNotFoundError extends Error { - constructor(wanted: Unit, foundQuantity: number, actorId: string) { - const message: string = `Needed to remove ${wanted.quantity} ${wanted.part.name} from the inventory of Actor - ${actorId} to complete crafting, but found only ${foundQuantity}. `; + constructor(wanted: Unit, foundQuantity: number) { + const message: string = `Needed to remove ${wanted.quantity} ${wanted.element.name} from an inventory to complete crafting, but found only ${foundQuantity}. `; super(message); } diff --git a/src/scripts/foundry/DocumentManager.ts b/src/scripts/foundry/DocumentManager.ts index 30cc9d13..eddf4f65 100644 --- a/src/scripts/foundry/DocumentManager.ts +++ b/src/scripts/foundry/DocumentManager.ts @@ -59,15 +59,88 @@ interface FabricateItemDataJson { uuid?: string; } +/** + * Fabricate Item Data abstracts away the document loading process, detects and describes loading errors, as well as + * providing a reduced document data model that Fabricate uses to render parts in the Foundry UI. + * + * @interface + */ interface FabricateItemData { + + /** + * The UUID of the item. + * + * @type {string} + */ uuid: string; + + /** + * The name of the item. Accessing this property before the item has been loaded will produce an error. + * + * @type {string} + */ name: string; + + /** + * The item's image URL. Accessing this property before the item has been loaded will produce an error. + * + * @type {string} + */ imageUrl: string; + + /** + * The source document for the item. Accessing this property before the item has been loaded will produce an error. + * + * @type {any} + */ sourceDocument: any; + + /** + * An array of item loading errors. Will be empty if no errors occurred when loading the item, or if it has not been + * loaded yet. + * + * @type {ItemLoadingError[]} + */ errors: ItemLoadingError[]; + + /** + * A convenience function that indicates whether the item has loading errors or not. Checks the length of `errors` + * is zero. + * + * @type {boolean} + */ hasErrors: boolean; + + /** + * Indicates whether the item is loaded or not. + * + * @type {boolean} + */ loaded: boolean; + + /** + * Converts the item data to its JSON representation. + * + * @returns {FabricateItemDataJson} - The JSON representation of the item data. + */ toJson(): FabricateItemDataJson; + + /** + * Loads the item data asynchronously. If no Item UUID has been set this method will produce an error. + * + * @async + * @returns {Promise} - A promise that resolves when the item data has been loaded. + */ + load(): Promise; + + /** + * Determines whether the provided item data is equivalent to the current item data instance. + * + * @param {FabricateItemData} other - The other item data to compare with the current instance + * @returns {boolean} - Returns `true` if the provided item data is equal to the current instance, `false` otherwise. + */ + equals(other: FabricateItemData): boolean; + } class NoFabricateItemData implements FabricateItemData { @@ -75,7 +148,7 @@ class NoFabricateItemData implements FabricateItemData { private static readonly _INSTANCE = new NoFabricateItemData(); private static readonly _UUID = "NO_ITEM_UUID"; private static readonly _NAME = "No Item Configured"; - private static readonly _ERRORS = [new ItemNotConfiguredError()]; + private static readonly _ERRORS = [ new ItemNotConfiguredError() ]; private static readonly _IMAGE_URL = Properties.ui.defaults.noItemImageUrl; public static INSTANCE(): NoFabricateItemData { @@ -118,14 +191,24 @@ class NoFabricateItemData implements FabricateItemData { return { uuid: null }; } + load(): Promise { + throw new Error("An attempt was made to load item data for a part with no item UUID set. "); + } + + equals(other: FabricateItemData): boolean { + return this === other; + } + } class PendingFabricateItemData implements FabricateItemData { private readonly _itemUuid: string; + private readonly _cachedLoadingFunction: CachedLoadingFunction; - constructor(itemUuid?: string) { + constructor(itemUuid: string, cachedLoadingFunction: CachedLoadingFunction) { this._itemUuid = itemUuid; + this._cachedLoadingFunction = cachedLoadingFunction; } get errors(): ItemLoadingError[] { @@ -137,7 +220,8 @@ class PendingFabricateItemData implements FabricateItemData { } get imageUrl(): string { - return null; + throw new Error("Cannot get the item image URL before the item has been loaded. " + + "Call the part's `load()` method before accessing this field. "); } get loaded(): boolean { @@ -145,11 +229,13 @@ class PendingFabricateItemData implements FabricateItemData { } get name(): string { - return null; + throw new Error("Cannot get the item name before the item has been loaded. " + + "Call the part's `load()` method before accessing this field. "); } get sourceDocument(): any { - return null; + throw new Error("Cannot get the item source document before the item has been loaded. " + + "Call the part's `load()` method before accessing this field. "); } get uuid(): string { @@ -157,13 +243,24 @@ class PendingFabricateItemData implements FabricateItemData { } toJson(): FabricateItemDataJson { - console.warn("Serialising pending item data. The item UUID has not been checked and may not be valid. "); return { uuid: this._itemUuid }; } + load(): Promise { + return this._cachedLoadingFunction(this._itemUuid); + } + + equals(other: FabricateItemData): boolean { + if (!other || !(other instanceof PendingFabricateItemData)) { + return false; + } + return this.uuid === other.uuid; + } + } class LoadedFabricateItemData implements FabricateItemData { + private readonly _errors: ItemLoadingError[]; private readonly _itemUuid: string; private readonly _name: string; @@ -222,6 +319,27 @@ class LoadedFabricateItemData implements FabricateItemData { return { uuid: this._itemUuid }; } + async load(): Promise { + return this; + } + + equals(other: FabricateItemData): boolean { + if (!other || !(other instanceof LoadedFabricateItemData)) { + return false; + } + if (this === other) { + return true; + } + return this.uuid === other.uuid + && this.loaded === other.loaded + && this.hasErrors === other.hasErrors + && this.sourceDocument?.id === other.sourceDocument?.id + && this.errors.length === other.errors.length + && this.errors + .map(thisError => other.errors.includes(thisError)) + .reduce((left, right) => left && right, true); + } + } class BrokenFabricateItemData implements FabricateItemData { @@ -272,35 +390,64 @@ class BrokenFabricateItemData implements FabricateItemData { return { uuid: this._itemUuid }; } + load(): Promise { + throw new Error("Cannot load item data for this part. There are problems with the item document."); + } + + equals(other: FabricateItemData): boolean { + if (!other || !(other instanceof BrokenFabricateItemData)) { + return false; + } + if (this === other) { + return true; + } + return this.uuid === other.uuid + && this.errors.length === other.errors.length + && this.errors + .map(thisError => other.errors.includes(thisError)) + .reduce((left, right) => left && right, true); + } + } +type CachedLoadingFunction = (uuid: string) => Promise; + interface DocumentManager { - getDocumentByUuid(id: string): Promise; + loadItemDataByDocumentUuid(id: string): Promise; - getDocumentsByUuid(ids: string[]): Promise>; + loadItemDataForDocumentsByUuid(ids: string[]): Promise>; + + prepareItemDataByDocumentUuid(uuid: string): FabricateItemData; } class DefaultDocumentManager implements DocumentManager { - public async getDocumentByUuid(id: string): Promise { - if (!id || id === NoFabricateItemData.UUID()) { + public async loadItemDataByDocumentUuid(uuid: string): Promise { + if (!uuid || uuid === NoFabricateItemData.UUID()) { return new NoFabricateItemData(); } - const document = await fromUuid(id); + const document = await fromUuid(uuid); if (document) { return this.formatItemData(document); } else { return new BrokenFabricateItemData({ - itemUuid: id, - errors: [new ItemNotFoundError(id)] + itemUuid: uuid, + errors: [new ItemNotFoundError(uuid)] }); } } - public async getDocumentsByUuid(ids: string[]): Promise> { - const itemData = await Promise.all(ids.map(id => this.getDocumentByUuid(id).catch(e => e))); + prepareItemDataByDocumentUuid(uuid: string): FabricateItemData { + if (!uuid || uuid === NoFabricateItemData.UUID()) { + return new NoFabricateItemData(); + } + return new PendingFabricateItemData(uuid, (id: string) => this.loadItemDataByDocumentUuid(id)); + } + + public async loadItemDataForDocumentsByUuid(uuids: string[]): Promise> { + const itemData = await Promise.all(uuids.map(id => this.loadItemDataByDocumentUuid(id).catch(e => e))); return new Map(itemData.map(item => [item.uuid, item])); } diff --git a/src/scripts/foundry/GameProvider.ts b/src/scripts/foundry/GameProvider.ts index 3cb0b4eb..aa1bcc63 100644 --- a/src/scripts/foundry/GameProvider.ts +++ b/src/scripts/foundry/GameProvider.ts @@ -1,19 +1,38 @@ +import {BaseActor} from "@league-of-foundry-developers/foundry-vtt-types/src/foundry/common/documents.mjs"; + interface GameProvider { get(): Game; + getGameSystemId(): string; + + loadActor(actorId: string): Promise; + } +export { GameProvider } + class DefaultGameProvider implements GameProvider { get(): Game { if (!game) { - throw new Error(`Game object not yet initialised. - Wait for the Foundry 'init' hook before calling this method`); + throw new Error(`Game object not yet initialised. Wait for the Foundry 'init' hook before calling this method`); } return game; } + getGameSystemId(): string { + return this.get().system.id; + } + + async loadActor(actorId: string): Promise { + const actor = await this.get().actors.get(actorId); + if (!actor) { + throw new Error(`Actor with id ${actorId} not found`); + } + return actor; + } + } -export { GameProvider, DefaultGameProvider } \ No newline at end of file +export { DefaultGameProvider } \ No newline at end of file diff --git a/src/scripts/foundry/IdentityFactory.ts b/src/scripts/foundry/IdentityFactory.ts new file mode 100644 index 00000000..fd5b4ad3 --- /dev/null +++ b/src/scripts/foundry/IdentityFactory.ts @@ -0,0 +1,21 @@ +interface IdentityFactory { + + make(excludedValues?: string[]): string; + +} + +export { IdentityFactory } + +class DefaultIdentityFactory implements IdentityFactory { + + make(excludedValues: string[] = []): string { + const generated = randomID(); + if (!excludedValues.includes(generated)) { + return generated; + } + return this.make(excludedValues); + } + +} + +export { DefaultIdentityFactory } \ No newline at end of file diff --git a/src/scripts/foundry/NotificationService.ts b/src/scripts/foundry/NotificationService.ts new file mode 100644 index 00000000..b264a1d8 --- /dev/null +++ b/src/scripts/foundry/NotificationService.ts @@ -0,0 +1,85 @@ +import {UI, UIProvider} from "./UIProvider"; + +/** + * A notification service that can be used to display messages to the user. + */ +interface NotificationService { + + /** + * Displays an informational message to the user. + * @param message + */ + info(message: string): void; + + /** + * Displays a warning message to the user. + * @param message + */ + warn(message: string): void; + + /** + * Displays an error message to the user. + * @param message + */ + error(message: string): void; + + /** + * If true, all notification messages will print only to the console. If false, notification messages will be + * displayed in both the console and the UI. + */ + suppressed: boolean; + +} + +export { NotificationService }; + +class DefaultNotificationService implements NotificationService { + + private static readonly _CONSOLE_MESSAGE_FORMATTER = (consoleMessage: string) => `Fabricate | ${consoleMessage}`; + + private _suppressed: boolean; + private readonly ui: UI; + + constructor(uiProvider: UIProvider, suppressed: boolean = false) { + this.ui = uiProvider.get(); + this._suppressed = suppressed; + } + + private format(message: string): string { + return DefaultNotificationService._CONSOLE_MESSAGE_FORMATTER(message); + } + + get suppressed(): boolean { + return this._suppressed; + } + set suppressed(value: boolean) { + this._suppressed = value; + } + + error(message: string): void { + if (this._suppressed) { + console.error(this.format(message)); + return; + } + this.ui.notifications.error(message); + } + + info(message: string): void { + if (this._suppressed) { + console.info(this.format(message)); + return; + } + this.ui.notifications.info(message); + } + + warn(message: string): void { + if (this._suppressed) { + console.warn(this.format(message)); + return; + } + this.ui.notifications.warn(message); + } + +} + +export { DefaultNotificationService }; \ No newline at end of file diff --git a/src/scripts/foundry/ObjectUtility.ts b/src/scripts/foundry/ObjectUtility.ts index bba10989..a3accc5a 100644 --- a/src/scripts/foundry/ObjectUtility.ts +++ b/src/scripts/foundry/ObjectUtility.ts @@ -4,6 +4,10 @@ interface ObjectUtility { merge(target: T, source: T): T; + getPropertyValue(propertyPath: string, object: object): T; + + setPropertyValue(propertyPath: string, object: object, value: T): void; + } class DefaultObjectUtility implements ObjectUtility { @@ -16,6 +20,14 @@ class DefaultObjectUtility implements ObjectUtility { return mergeObject(target, source) as T; } + getPropertyValue(propertyPath: string, object: object): T { + return getProperty(object, propertyPath) as T; + } + + setPropertyValue(propertyPath: string, object: object, value: T): void { + setProperty(object, propertyPath, value); + } + } export { ObjectUtility, DefaultObjectUtility } \ No newline at end of file diff --git a/src/scripts/foundry/UIProvider.ts b/src/scripts/foundry/UIProvider.ts new file mode 100644 index 00000000..0f60b72f --- /dev/null +++ b/src/scripts/foundry/UIProvider.ts @@ -0,0 +1,31 @@ +interface UI { + notifications: { + warn: (message: string) => void; + error: (message: string) => void; + info: (message: string) => void; + } +} + +export { UI } + +interface UIProvider { + + get(): UI; + +} + +export { UIProvider } + +class DefaultUIProvider implements UIProvider { + + get(): UI { + if (!ui) { + throw new Error(`UI object not yet initialised. + Wait for the Foundry 'init' hook before calling this method`); + } + return ui; + } + +} + +export { DefaultUIProvider } \ No newline at end of file diff --git a/src/scripts/interface/FabricateApplication.ts b/src/scripts/interface/FabricateApplication.ts index 72535a4c..1e6b3717 100644 --- a/src/scripts/interface/FabricateApplication.ts +++ b/src/scripts/interface/FabricateApplication.ts @@ -1,51 +1,34 @@ -import {SystemRegistry} from "../registries/SystemRegistry"; import {SvelteApplication} from "../../applications/SvelteApplication"; import {ComponentSalvageAppCatalog} from "../../applications/componentSalvageApp/ComponentSalvageAppCatalog"; import {RecipeCraftingAppCatalog} from "../../applications/recipeCraftingApp/RecipeCraftingAppCatalog"; +import {FabricateAPI} from "../api/FabricateAPI"; class FabricateApplication { - private _systemRegistry: SystemRegistry; + private _fabricateAPI: FabricateAPI; + private _craftingSystemManagerApp: SvelteApplication; private _componentSalvageAppCatalog: ComponentSalvageAppCatalog; private _recipeCraftingAppCatalog: RecipeCraftingAppCatalog; constructor() {} - get systemRegistry(): SystemRegistry { - return this._systemRegistry; - } - - set systemRegistry(value: SystemRegistry) { - this._systemRegistry = value; + get fabricateAPI(): FabricateAPI { + return this._fabricateAPI; } get craftingSystemManagerApp(): SvelteApplication { return this._craftingSystemManagerApp; } - set craftingSystemManagerApp(value: SvelteApplication) { - this._craftingSystemManagerApp = value; - } - get componentSalvageAppCatalog(): ComponentSalvageAppCatalog { return this._componentSalvageAppCatalog; } - set componentSalvageAppCatalog(value: ComponentSalvageAppCatalog) { - this._componentSalvageAppCatalog = value; - } - get recipeCraftingAppCatalog(): RecipeCraftingAppCatalog { return this._recipeCraftingAppCatalog; } - set recipeCraftingAppCatalog(value: RecipeCraftingAppCatalog) { - this._recipeCraftingAppCatalog = value; - } - } -const fabricateApplicationInstance = new FabricateApplication(); - -export default fabricateApplicationInstance; \ No newline at end of file +export { FabricateApplication } \ No newline at end of file diff --git a/src/scripts/module.ts b/src/scripts/module.ts index d46bda46..58e48b2d 100644 --- a/src/scripts/module.ts +++ b/src/scripts/module.ts @@ -1,26 +1,90 @@ import Properties from "./Properties"; -import {DefaultGameProvider, GameProvider} from "./foundry/GameProvider"; -import FabricateApplication from "./interface/FabricateApplication"; -import { - DefaultSettingManager, - FabricateSetting, - SettingManager, - SettingState -} from "./settings/FabricateSetting"; -import {DefaultSystemRegistry} from "./registries/SystemRegistry"; -import {CraftingSystemFactory} from "./system/CraftingSystemFactory"; -import {CraftingSystemJson} from "./system/CraftingSystem"; -import {CraftingSystemManagerAppFactory} from "../applications/CraftingSystemManagerAppFactory"; -import {V2CraftingSystemSettingMigrator} from "./settings/migrators/V2CraftingSystemSettingMigrator"; -import {DefaultComponentSalvageAppCatalog} from "../applications/componentSalvageApp/ComponentSalvageAppCatalog"; -import {DefaultComponentSalvageAppFactory} from "../applications/componentSalvageApp/ComponentSalvageAppFactory"; +import {DefaultGameProvider} from "./foundry/GameProvider"; import {itemUpdated, itemDeleted, itemCreated} from "../applications/common/EventBus"; import {BaseItem} from "@league-of-foundry-developers/foundry-vtt-types/src/foundry/common/documents.mjs"; -import {DefaultRecipeCraftingAppCatalog} from "../applications/recipeCraftingApp/RecipeCraftingAppCatalog"; -import {DefaultRecipeCraftingAppFactory} from "../applications/recipeCraftingApp/RecipeCraftingAppFactory"; +import {DefaultFabricateAPIFactory, FabricateAPIFactory} from "./api/FabricateAPIFactory"; +import {FabricateAPI} from "./api/FabricateAPI"; +import {DefaultIdentityFactory} from "./foundry/IdentityFactory"; +import {DefaultDocumentManager} from "./foundry/DocumentManager"; +import {DefaultSettingsRegistry, SettingsRegistry} from "./repository/SettingsRegistry"; +import {DefaultFabricateUserInterfaceAPIFactory} from "./api/FabricateUserInterfaceAPIFactory"; +import {DefaultUIProvider} from "./foundry/UIProvider"; +import {FabricateUserInterfaceAPI} from "./api/FabricateUserInterfaceAPI"; + +let fabricateAPI: FabricateAPI; +let fabricateUserInterfaceAPI: FabricateUserInterfaceAPI; -Hooks.on("deleteItem", async (item: any) => { +Hooks.once('ready', async () => { + + const gameProvider = new DefaultGameProvider(); + const uiProvider = new DefaultUIProvider(); + const gameObject = gameProvider.get(); + + const settingsRegistry: SettingsRegistry = new DefaultSettingsRegistry({ + gameProvider, + clientSettings: gameObject.settings, + settingKeys: [ + Properties.settings.recipes.key, + Properties.settings.essences.key, + Properties.settings.components.key, + Properties.settings.craftingSystems.key, + Properties.settings.modelVersion.key + ], + defaultValuesBySettingKey: new Map([ + [Properties.settings.recipes.key, { entities: {}, collections: {} }], + [Properties.settings.essences.key, { entities: {}, collections: {} }], + [Properties.settings.components.key, { entities: {}, collections: {} }], + [Properties.settings.craftingSystems.key, { entities: {}, collections: {} }], + [Properties.settings.modelVersion.key, ""], + ]) + }); + + settingsRegistry.registerAll(); + + const fabricateAPIFactory: FabricateAPIFactory = new DefaultFabricateAPIFactory({ + gameProvider, + uiProvider, + settingsRegistry, + user: gameObject.user.name, + gameSystem: gameObject.system.id, + clientSettings: gameObject.settings, + identityFactory: new DefaultIdentityFactory(), + documentManager: new DefaultDocumentManager(), + }); + + fabricateAPI = fabricateAPIFactory.make(); + + const fabricateUserInterfaceAPIFactory = new DefaultFabricateUserInterfaceAPIFactory({ + fabricateAPI, + gameProvider, + }); + + fabricateUserInterfaceAPI = fabricateUserInterfaceAPIFactory.make(); + + // Sets the default value of game.fabricate to an empty object + // @ts-ignore + gameObject[Properties.module.id] = {}; + // Makes the Fabricate API available globally as game.fabricate.api + // @ts-ignore + gameObject[Properties.module.id].api = fabricateAPI; + // Makes the Fabricate User Interface API available globally as game.fabricate.ui + // @ts-ignore + gameObject[Properties.module.id].ui = fabricateUserInterfaceAPI; + + Hooks.callAll(`${Properties.module.id}.ready`, fabricateAPI, fabricateUserInterfaceAPI); + +}); + +Hooks.once(`${Properties.module.id}.ready`, async (fabricateAPI: FabricateAPI) => { + + const migrationNeeded = await fabricateAPI.migration.isMigrationNeeded(); + if (migrationNeeded) { + await fabricateAPI.migration.migrateAll(); + } +}); + +Hooks.on("deleteItem", async (item: any) => { itemDeleted(item); }); @@ -44,58 +108,54 @@ Hooks.on("renderItemSheet", async (itemSheet: ItemSheet, html: any) => { if (!sourceItemUuid) { return; } - // todo: optimise by partially loading recipes/components/essences without fetching related item data or populating references - // this would allow for me to use the getItemSheetHeaderButtons hook, though I'd lose tooltips - // more importantly though I could load individual items only as needed - const allCraftingSystems = await FabricateApplication.systemRegistry.getAllCraftingSystems(); - const loadedSystems = await Promise.all(Array.from(allCraftingSystems.values()) - .filter(craftingSystem => craftingSystem.enabled) - .map(async craftingSystem => { - await craftingSystem.loadPartDictionary(); - return craftingSystem; - })); - const headerButtonsToAdd = loadedSystems.filter(craftingSystem => craftingSystem.includesItemUuid(sourceItemUuid)) - .flatMap(craftingSystem => { - const additionalHeaderButtons = []; - - if (craftingSystem.includesRecipeByItemUuid(sourceItemUuid)) { - const recipe = craftingSystem.getRecipeByItemUuid(sourceItemUuid); - if (!recipe.isDisabled && !recipe.hasErrors && recipe.hasResults) { - additionalHeaderButtons.push({ - label: "Craft", - tooltip: craftingSystem.name, - icon: "fa-solid fa-screwdriver-wrench", - onclick: async () => { - const app = await FabricateApplication.recipeCraftingAppCatalog.load(recipe, craftingSystem, document.actor); - app.render(true); - } - }); + + const craftingSystemsById = await fabricateAPI.systems.getAll(); + const componentsForSourceItem = await fabricateAPI.components.getAllByItemUuid(sourceItemUuid); + await Promise.all(Array.from(componentsForSourceItem.values()).map(component => component.load())); + const recipesForSourceItem = await fabricateAPI.recipes.getAllByItemUuid(sourceItemUuid); + await Promise.all(Array.from(recipesForSourceItem.values()).map(recipe => recipe.load())); + if (componentsForSourceItem.size === 0 && recipesForSourceItem.size === 0) { + return; + } + + const additionalHeaderButtons = []; + + const salvageButtons = Array.from(componentsForSourceItem.values()) + .filter(component => !component.isDisabled && component.isSalvageable && !component.hasErrors) + .map(component => { + return { + label: "Salvage", + tooltip: craftingSystemsById.get(component.craftingSystemId).details.name, + class: "fab-item-sheet-header-button", + icon: "fa-solid fa-recycle", + onclick: async () => { + await fabricateUserInterfaceAPI.renderComponentSalvageApp(document.actor.id, component.id); } - } - - if (craftingSystem.includesComponentByItemUuid(sourceItemUuid)) { - const craftingComponent = craftingSystem.getComponentByItemUuid(sourceItemUuid); - if (!craftingComponent.isDisabled && !craftingComponent.hasErrors && craftingComponent.isSalvageable) { - additionalHeaderButtons.push({ - label: "Salvage", - tooltip: craftingSystem.name, - class: "fab-item-sheet-header-button", - icon: "fa-solid fa-recycle", - onclick: async () => { - const app = await FabricateApplication.componentSalvageAppCatalog.load(craftingComponent, craftingSystem, document.actor); - app.render(true); - } - }); + }; + }); + additionalHeaderButtons.push(...salvageButtons); + + const craftingButtons = Array.from(recipesForSourceItem.values()) + .filter(recipe => !recipe.isDisabled && recipe.hasResults && !recipe.hasErrors) + .map(recipe => { + return { + label: "Craft", + tooltip: craftingSystemsById.get(recipe.craftingSystemId).details.name, + class: "fab-item-sheet-header-button", + icon: "fa-solid fa-screwdriver-wrench", + onclick: async () => { + await fabricateUserInterfaceAPI.renderRecipeCraftingApp(document.actor.id, recipe.id); } - } - return additionalHeaderButtons; + }; }); + additionalHeaderButtons.push(...craftingButtons); + const header = html.find(".window-header"); if (!header) { throw new Error("Fabricate was unable to render header buttons for the Item Sheet application"); } let title = header.children(".window-title"); - headerButtonsToAdd.forEach(headerButton => { + additionalHeaderButtons.forEach(headerButton => { const button = $(`${headerButton.label}`); button.click(() => headerButton.onclick()); button.insertAfter(title); @@ -113,100 +173,9 @@ Hooks.on("renderSidebarTab", async (app: any, html: any) => { const button = $(``); button.on('click', async (_event) => { - FabricateApplication.craftingSystemManagerApp.render(true); + await fabricateUserInterfaceAPI.renderCraftingSystemManagerApp(); + fabricateAPI.suppressNotifications(); }); buttons.append(button); -}); - -function registerSettings(gameObject: Game, defaultSettingValue: FabricateSetting>) { - /* - * Register game settings for Fabricate - */ - gameObject.settings.register(Properties.module.id, Properties.settings.craftingSystems.key, { - name: "", - hint: "", - scope: "world", - config: false, - type: Object, - default: defaultSettingValue - }); -} - -async function validateAndMigrateSettings(gameProvider: GameProvider, craftingSystemSettingManager: DefaultSettingManager>): Promise>> { - - const checkResult = craftingSystemSettingManager.check() - const gameObject = gameProvider.get(); - - if (checkResult.state === SettingState.INVALID) { - const errorDetails = checkResult.validationCheck.errors - .map(errorKey => gameObject.i18n.localize(`fabricate.ui.notifications.settings.errors.${errorKey}`)); - const errorSummary = gameObject.i18n.format( - "fabricate.ui.notifications.settings.errors.summary", - { settingKey: craftingSystemSettingManager.settingKey } - ); - ui.notifications.error(`${errorSummary} ${errorDetails.join(", ")}`); - } - - if (checkResult.state === SettingState.OUTDATED) { - ui.notifications.info(gameObject.i18n.localize("fabricate.ui.notifications.settings.migration.started")); - const migrationResult = await craftingSystemSettingManager.migrate(); - if (migrationResult.isSuccessful) { - ui.notifications.info(gameObject.i18n.localize("fabricate.ui.notifications.settings.migration.finished")); - } else { - ui.notifications.error(gameObject.i18n.format( - "fabricate.ui.notifications.settings.errors.migration", - { settingKey: craftingSystemSettingManager.settingKey } - )); - } - } - - return craftingSystemSettingManager; -} - -Hooks.once('ready', async () => { - - const gameProvider = new DefaultGameProvider(); - const gameObject = gameProvider.get(); - - const v2settingMigrator = new V2CraftingSystemSettingMigrator(); - const craftingSystemSettingManager = new DefaultSettingManager>({ - gameProvider: gameProvider, - moduleId: Properties.module.id, - settingKey: Properties.settings.craftingSystems.key, - targetVersion: Properties.settings.craftingSystems.targetVersion, - settingsMigrators: [v2settingMigrator] - }); - - /* - * Create the Crafting System registry - */ - const systemRegistry = new DefaultSystemRegistry({ - settingManager: craftingSystemSettingManager, - gameSystem: gameObject.system.id, - craftingSystemFactory: new CraftingSystemFactory({}) - }); - FabricateApplication.systemRegistry = systemRegistry; - - registerSettings(gameObject, systemRegistry.getDefaultSettingValue()); - await validateAndMigrateSettings(gameProvider, craftingSystemSettingManager); - - FabricateApplication.craftingSystemManagerApp = await CraftingSystemManagerAppFactory.make(systemRegistry); - - FabricateApplication.componentSalvageAppCatalog = new DefaultComponentSalvageAppCatalog({ - componentSalvageAppFactory: new DefaultComponentSalvageAppFactory(), - systemRegistry - }); - - FabricateApplication.recipeCraftingAppCatalog = new DefaultRecipeCraftingAppCatalog({ - recipeCraftingAppFactory: new DefaultRecipeCraftingAppFactory(), - systemRegistry - }); - - // Makes the system registry externally available - // @ts-ignore - gameObject[Properties.module.id] = {}; - // @ts-ignore - gameObject[Properties.module.id].SystemRegistry = systemRegistry; - }); \ No newline at end of file diff --git a/src/scripts/registries/SystemRegistry.ts b/src/scripts/registries/SystemRegistry.ts deleted file mode 100644 index a5c6fe3b..00000000 --- a/src/scripts/registries/SystemRegistry.ts +++ /dev/null @@ -1,260 +0,0 @@ -import {CraftingSystem, CraftingSystemJson} from "../system/CraftingSystem"; -import {CraftingSystemFactory} from "../system/CraftingSystemFactory"; -import {FabricateSetting, SettingManager} from "../settings/FabricateSetting"; -import {ALCHEMISTS_SUPPLIES_SYSTEM_DATA as ALCHEMISTS_SUPPLIES} from "../system/bundled/AlchemistsSuppliesV16"; - -interface SystemRegistry { - - /** - * Gets a crafting system by ID. - * - * @param id The ID of the Crafting system to get - * @return A Promise that resolves to the crafting system with the provided ID, or undefined - * */ - getCraftingSystemById(id: string): Promise; - - /** - * Gets all crafting systems, including both embedded and user-defined systems. - * - * @return A Promise that resolves to a Map of crafting systems, keyed on the crafting system ID - * */ - getAllCraftingSystems(): Promise>; - - /** - * Gets all crafting systems that were defined by users. Does not include embedded systems. - * - * @return A Promise that resolves to a Map of crafting systems, keyed on the crafting system ID - * */ - getUserDefinedSystems(): Promise>; - - /** - * Gets all crafting systems that were embedded with Fabricate for the current game system only. Does not include - * user-defined systems. - * - * @return A Promise that resolves to a Map of crafting systems, keyed on the crafting system ID - * */ - getEmbeddedSystems(): Promise>; - - /** - * Delete a crafting system by ID. This will remove the crafting system, its recipes, components and essences. This - * operation is permanent and cannot be undone - * - * @param id The ID of the Crafting system to delete - * @return A Promise that resolves when the crafting system has been deleted, or if the crafting system ID is not - * found - * */ - deleteCraftingSystemById(id: string): Promise; - - /** - * Saves a crafting system, updating the existing data for that system and modifying all essences, recipes and - * components within the crafting system. Any deleted essences, components and recipes are removed from the saved - * data. - * - * @param craftingSystem The crafting system to save - * @return A Promise that resolves when the crafting system has been saved - * */ - saveCraftingSystem(craftingSystem: CraftingSystem): Promise; - - /** - * Saves multiple crafting systems, updating the existing data for each system and modifying all essences, recipes - * and components within each crafting system. Any deleted essences, components and recipes are removed from the - * saved crafting system data. - * - * @param craftingSystems A map of crafting systems to save, keyed on the crafting system ID - * @return A Promise that resolves when the crafting systems have been saved - * */ - saveCraftingSystems(craftingSystems: Map): Promise>; - - /** - * Creates a copy of a crafting system, including all essences, recipes and components within the source crafting - * system. The cloned system is created with a modified name and a new, unique ID. - * - * @param id The ID of the crafting system to duplicate - * @return A Promise that resolves when the crafting system has been duplicated and saved - * */ - cloneCraftingSystemById(id: string): Promise; - - /** - * Gets the serialised representation of all crafting systems embedded with Fabricate for the current game system - * only. - * - * @return an array of serialised crafting system data for the embedded crafting systems that support the current - * game system - * */ - getEmbeddedCraftingSystemsJson(): CraftingSystemJson[]; - - /** - * Performs a complete reset of all Fabricate data, restoring the state of the module data at the time of - * installation. All user-defined crafting systems will be deleted during this reset. - * - * @return A Promise that resolves when all Fabricate data has been reset - * */ - reset(): Promise; - - /** - * Gets the default value for Fabricate's crafting system settings. This will include embedded crafting systems for - * the current game system, but no user-defined crafting systems present. - * - * @return the default setting value for Fabricate - * */ - getDefaultSettingValue(): FabricateSetting>; - - /** - * Creates a crafting system from a serialised `CraftingSystemJson` definition. - * - * @param systemDefinition the serialised crafting system definition - * @return a promise that resolves to the newly created crafting system - * */ - createCraftingSystem(systemDefinition: CraftingSystemJson): Promise; - - /** - * Checks if a crafting system with a given ID exists. - * - * @param id The ID of the Crafting system to check - * @return A Promise that resolves to a boolean value that is true if a crafting system with the given ID exists, - * false if not - * */ - hasCraftingSystem(id: string): Promise; - -} - -enum ErrorDecisionType { - DELETE, - RETAIN, - RESET -} - -class DefaultSystemRegistry implements SystemRegistry { - - private readonly _gameSystem: string; - private readonly _settingsManager: SettingManager>; - private readonly _craftingSystemFactory: CraftingSystemFactory; - - constructor({ - settingManager, - craftingSystemFactory, - gameSystem - }: { - settingManager: SettingManager>; - craftingSystemFactory: CraftingSystemFactory; - gameSystem: string; - }) { - this._settingsManager = settingManager; - this._craftingSystemFactory = craftingSystemFactory; - this._gameSystem = gameSystem; - } - - async deleteCraftingSystemById(id: string): Promise { - const allCraftingSystemSettings = await this._settingsManager.read(); - delete allCraftingSystemSettings[id]; - await this._settingsManager.write(allCraftingSystemSettings); - } - - public async getAllCraftingSystems(): Promise> { - const allCraftingSystems = await Promise.all([ - this.getUserDefinedSystems(), - this.getEmbeddedSystems() - ]); - return new Map(allCraftingSystems - .flatMap(systemsById => Array.from(systemsById.values())) - .map(value => <[string, CraftingSystem][]>[[value.id, value]]) - .reduce((left, right) => left.concat(right), [])); - } - - async getEmbeddedSystems(): Promise> { - const embeddedSystems = await Promise.all(this.getEmbeddedCraftingSystemsJson() - .map(craftingSystemJson => this._craftingSystemFactory.make(craftingSystemJson))); - return new Map(embeddedSystems.map(embeddedSystem => [embeddedSystem.id, embeddedSystem])); - } - - async getUserDefinedSystems(): Promise> { - let userDefinedCraftingSystemsJson: Record = {}; - userDefinedCraftingSystemsJson = await this._settingsManager.read(); - return this.prepareCraftingSystemsData(userDefinedCraftingSystemsJson); - } - - private async prepareCraftingSystemsData(userDefinedCraftingSystemsJson: Record): Promise> { - const userDefinedSystems = await Promise.all(Object.values(userDefinedCraftingSystemsJson) - .map(craftingSystemJson => this._craftingSystemFactory.make(craftingSystemJson))); - return new Map(userDefinedSystems.map(userDefinedSystem => [userDefinedSystem.id, userDefinedSystem])); - } - - async getCraftingSystemById(id: string): Promise { - const allCraftingSystemSettings: Record = await this._settingsManager.read(); - if (id in allCraftingSystemSettings) { - const craftingSystemJson = allCraftingSystemSettings[id]; - return this._craftingSystemFactory.make(craftingSystemJson); - } - return undefined; - } - - async hasCraftingSystem(id: string): Promise { - const allCraftingSystemSettings: Record = await this._settingsManager.read(); - return allCraftingSystemSettings[id] !== undefined; - } - - async cloneCraftingSystemById(id: string): Promise { - const allCraftingSystemSettings: Record = await this._settingsManager.read(); - const sourceCraftingSystem = allCraftingSystemSettings[id]; - const clonedSystemJson = deepClone(sourceCraftingSystem); - clonedSystemJson.id = randomID(); - clonedSystemJson.details.name = `${sourceCraftingSystem.details.name} (copy)` - clonedSystemJson.locked = false; - allCraftingSystemSettings[clonedSystemJson.id] = clonedSystemJson; - await this._settingsManager.write(allCraftingSystemSettings); - return this._craftingSystemFactory.make(clonedSystemJson); - } - - public async saveCraftingSystem(craftingSystem: CraftingSystem): Promise { - const craftingSystemJson = craftingSystem.toJson(); - const allCraftingSystemSettings: Record = await this._settingsManager.read(); - allCraftingSystemSettings[craftingSystem.id] = craftingSystemJson; - await this._settingsManager.write(allCraftingSystemSettings); - return craftingSystem; - } - - async saveCraftingSystems(craftingSystemsToSave: Map): Promise> { - const allCraftingSystemSettings: Record = {}; - craftingSystemsToSave - .forEach((value, key) => allCraftingSystemSettings[key] = value.toJson()); - await this._settingsManager.write(allCraftingSystemSettings); - return this.prepareCraftingSystemsData(allCraftingSystemSettings); - } - - public getEmbeddedCraftingSystemsJson(): CraftingSystemJson[] { - const bundledSystems = [ALCHEMISTS_SUPPLIES]; - return bundledSystems.filter(bundledSystem => !bundledSystem.gameSystem || bundledSystem.gameSystem === this._gameSystem) - .map(bundledSystem => bundledSystem.definition); - } - - public async reset(): Promise { - await this._settingsManager.delete(); - } - - public getDefaultSettingValue(): FabricateSetting> { - return this._settingsManager.asVersionedSetting({}); - } - - public async createCraftingSystem(systemDefinition: CraftingSystemJson): Promise { - if (!systemDefinition.id) { - systemDefinition.id = randomID(); - } - if (!systemDefinition.parts) { - systemDefinition.parts = { - components: {}, - recipes: {}, - essences: {} - } - } - if (!systemDefinition.parts.components) { systemDefinition.parts.components = {}; } - if (!systemDefinition.parts.recipes) { systemDefinition.parts.recipes = {}; } - if (!systemDefinition.parts.essences) { systemDefinition.parts.essences = {}; } - - const craftingSystem = await this._craftingSystemFactory.make(systemDefinition); - await craftingSystem.loadPartDictionary(); - return this.saveCraftingSystem(craftingSystem); - } - -} - -export { SystemRegistry, DefaultSystemRegistry, ErrorDecisionType } \ No newline at end of file diff --git a/src/scripts/repository/CollectionManager.ts b/src/scripts/repository/CollectionManager.ts new file mode 100644 index 00000000..591e7a1d --- /dev/null +++ b/src/scripts/repository/CollectionManager.ts @@ -0,0 +1,88 @@ +import {Identifiable} from "../common/Identifiable"; +import {RecipeJson} from "../crafting/recipe/Recipe"; +import Properties from "../Properties"; +import {CraftingSystemJson} from "../system/CraftingSystem"; +import {EssenceJson} from "../crafting/essence/Essence"; +import {ComponentJson} from "../crafting/component/Component"; + +/** + * The CollectionManager is responsible for determining which collections an entity belongs to. + * */ +interface CollectionManager { + + /** + * Returns a list of collections that the entity belongs to. + * */ + listCollectionMemberships(entityJson: T): { prefix: string, name: string }[]; + +} + +export { CollectionManager }; + +class NoCollectionManager implements CollectionManager { + + private static readonly _INSTANCE: NoCollectionManager = new NoCollectionManager(); + + private constructor() { + } + + public static get INSTANCE(): NoCollectionManager { + return NoCollectionManager._INSTANCE; + } + + listCollectionMemberships(_entityJson: T): { prefix: string, name: string }[] { + return []; + } + +} + +export { NoCollectionManager }; + +class RecipeCollectionManager implements CollectionManager { + + listCollectionMemberships(recipeJson: RecipeJson): { prefix: string; name: string }[] { + return [ + { prefix: Properties.settings.collectionNames.craftingSystem, name: recipeJson.craftingSystemId }, + { prefix: Properties.settings.collectionNames.item, name: recipeJson.itemUuid } + ]; + } + +} + +export { RecipeCollectionManager }; + +class CraftingSystemCollectionManager implements CollectionManager { + + listCollectionMemberships(_craftingSystemJson: CraftingSystemJson): { prefix: string; name: string }[] { + return []; + } + +} + +export { CraftingSystemCollectionManager }; + +class EssenceCollectionManager implements CollectionManager { + + listCollectionMemberships(essenceJson: EssenceJson): { prefix: string; name: string }[] { + return [ + { prefix: Properties.settings.collectionNames.craftingSystem, name: essenceJson.craftingSystemId }, + { prefix: Properties.settings.collectionNames.item, name: essenceJson.activeEffectSourceItemUuid } + ]; + } + +} + +export { EssenceCollectionManager }; + +class ComponentCollectionManager implements CollectionManager { + + listCollectionMemberships(entityJson: ComponentJson): { prefix: string; name: string }[] { + return [ + { prefix: Properties.settings.collectionNames.craftingSystem, name: entityJson.craftingSystemId }, + { prefix: Properties.settings.collectionNames.item, name: entityJson.itemUuid } + ]; + } + +} + +export { ComponentCollectionManager }; \ No newline at end of file diff --git a/src/scripts/repository/EntityDataStore.ts b/src/scripts/repository/EntityDataStore.ts new file mode 100644 index 00000000..132147a3 --- /dev/null +++ b/src/scripts/repository/EntityDataStore.ts @@ -0,0 +1,261 @@ +import {SettingManager} from "./SettingManager"; +import {CollectionManager, NoCollectionManager} from "./CollectionManager"; +import {Identifiable} from "../common/Identifiable"; +import {Serializable} from "../common/Serializable"; +import {EntityFactory} from "./EntityFactory"; + +interface SerialisedEntityData { + + entities: Record; + + collections: Record; + +} + +export { SerialisedEntityData } + +class EntityDataStore> { + + private readonly collectionManager: CollectionManager; + private readonly entityFactory: EntityFactory; + private readonly _entityName: string; + private readonly settingManager: SettingManager>; + + constructor({ + entityName = "Unnamed entity", + entityFactory, + collectionManager = NoCollectionManager.INSTANCE, + settingManager + }: { + entityName?: string; + entityFactory: EntityFactory; + collectionManager?: CollectionManager; + settingManager: SettingManager>; + }) { + this._entityName = entityName; + this.entityFactory = entityFactory; + this.collectionManager = collectionManager; + this.settingManager = settingManager; + } + + get entityName(): string { + return this._entityName; + } + + async size(): Promise { + const storedData = await this.getStoredData(); + return Object.keys(storedData.entities).length; + } + + async collectionCount(): Promise { + const storedData = await this.getStoredData(); + return Object.keys(storedData.collections).length; + } + + async create(entityJson: J): Promise { + const entity = await this.buildEntity(entityJson); + await this.insert(entity); + return entity; + } + + async insert(entity: T): Promise { + const storedData = await this.getStoredData(); + storedData.entities[entity.id] = entity.toJson(); + const collectionMemberships = this.collectionManager.listCollectionMemberships(entity.toJson()); + collectionMemberships.forEach(({ name, prefix }) => { + this.addToCollection(entity.id, name, prefix, storedData); + }); + await this.settingManager.write(storedData); + return entity; + } + + async insertAll(entities: T[]): Promise { + const storedData = await this.getStoredData(); + entities.forEach(entity => { + storedData.entities[entity.id] = entity.toJson(); + const collectionMemberships = this.collectionManager.listCollectionMemberships(entity.toJson()); + collectionMemberships.forEach(({ name, prefix }) => { + this.addToCollection(entity.id, name, prefix, storedData); + }); + }); + await this.settingManager.write(storedData); + return entities; + } + + async getById(id: string): Promise { + const storedData = await this.getStoredData(); + const entityJson = storedData.entities[id]; + if (!entityJson) { + return undefined; + } + return await this.buildEntity(entityJson); + } + + public async buildEntity(entityJson: Record[string]): Promise { + const entity = await this.entityFactory.make(entityJson); + if (!entity) { + throw new Error(`Failed to create ${this.entityName} from JSON: ${JSON.stringify(entityJson)}. `); + } + return entity; + } + + async has(id: string): Promise { + const storedData = await this.getStoredData(); + return !!storedData.entities[id]; + } + + async deleteById(id: string): Promise { + const storedData = await this.getStoredData(); + const entityJson = storedData.entities[id]; + if (!entityJson) { + return false; + } + + const collectionMemberships = this.collectionManager.listCollectionMemberships(entityJson); + + collectionMemberships.forEach(({ name, prefix }) => { + this.removeFromCollection(entityJson.id, name, prefix, storedData); + }); + + delete storedData.entities[id]; + await this.settingManager.write(storedData); + return true; + } + + async getAllEntities(): Promise { + const storedData = await this.getStoredData(); + return Promise.all( + Object.values(storedData.entities) + .map(entityJson => this.buildEntity(entityJson)) + ); + } + + private getCollectionName(name: string, prefix: string = "") { + if (!prefix || prefix === "") { + return name; + } + return `${prefix}.${name}`; + } + + private addToCollection(entityId: string, collectionName: string, collectionNamePrefix: string = "", storedData: SerialisedEntityData): void { + if (!storedData.entities[entityId]) { + throw new Error(`Entity with ID ${entityId} does not exist. You must save an entity before adding it to a collection.`); + } + const fullCollectionName = this.getCollectionName(collectionName, collectionNamePrefix); + if (!storedData.collections[fullCollectionName]) { + storedData.collections[fullCollectionName] = []; + } + if (storedData.collections[fullCollectionName].findIndex(id => id === entityId) === -1) { + storedData.collections[fullCollectionName].push(entityId); + } + } + + private removeFromCollection(entityId: string, collectionName: string, collectionNamePrefix: string = "", storedData: SerialisedEntityData): boolean { + const fullCollectionName = this.getCollectionName(collectionName, collectionNamePrefix); + const collection = storedData.collections[fullCollectionName]; + if (!collection) { + return false; + } + if (collection.indexOf(entityId) === -1) { + return false; + } + collection.splice(collection.indexOf(entityId), 1); + return true; + } + + async getCollection(collectionName: string, collectionNamePrefix: string = ""): Promise { + const storedData = await this.getStoredData(); + const fullCollectionName = this.getCollectionName(collectionName, collectionNamePrefix); + const collection = storedData.collections[fullCollectionName]; + if (!collection) { + return []; + } + return Promise.all( + collection.map(entityId => this.buildEntity(storedData.entities[entityId])) + ); + } + + async listAllEntityIds(): Promise { + const storedData = await this.getStoredData(); + return Object.keys(storedData.entities); + } + + async listCollectionEntityIds(collectionName: string, collectionNamePrefix: string = ""): Promise { + const storedData = await this.getStoredData(); + const fullCollectionName = this.getCollectionName(collectionName, collectionNamePrefix); + if (!storedData.collections[fullCollectionName]) { + return []; + } + return storedData.collections[fullCollectionName]; + } + + /** + * Removes a collection, its members, and all references to its members from other collections + * + * @async + * @param collectionName The name of the collection to delete + * @param collectionNamePrefix The prefix of the collection to delete + * @returns A promise that resolves to true if the collection and its members were deleted, or false if it did not + * exist + * */ + async deleteCollection(collectionName: string, collectionNamePrefix: string = ""): Promise { + const storedData = await this.getStoredData(); + const fullCollectionName = this.getCollectionName(collectionName, collectionNamePrefix); + const idsToDelete = storedData.collections[fullCollectionName]; + if (!idsToDelete || idsToDelete.length === 0) { + return false; + } + + // remove the collection + delete storedData.collections[fullCollectionName]; + + // Remove the ids from other collections + Object.keys(storedData.collections) + .forEach(collectionName => { + storedData.collections[collectionName] = storedData.collections[collectionName] + .filter(id => !idsToDelete.includes(id)); + }); + + // remove the entities + idsToDelete.forEach(id => { + delete storedData.entities[id]; + }); + + await this.settingManager.write(storedData); + return true; + } + + async getAllById(ids: string[]): Promise { + const storedData = await this.getStoredData(); + return Promise.all( + ids.filter(id => !!storedData.entities[id]) + .map(id => this.buildEntity(storedData.entities[id])) + ); + } + + async updateAll(entities: T[]): Promise { + const storedData = await this.getStoredData(); + entities.forEach(entity => { + storedData.entities[entity.id] = entity.toJson(); + }); + await this.settingManager.write(storedData); + } + + private async getStoredData(): Promise> { + // call settingManager.read() and assert it has the properties `entities` and `collections` before returning + const storedData = await this.settingManager.read(); + if (!storedData.entities && !storedData.collections) { + throw new Error(`The settings value at "${this.settingManager.settingPath}" for the ${this.entityName} data store is missing both of the required "entities" and "collections" properties`); + } + if (!storedData.entities) { + throw new Error(`The settings value at "${this.settingManager.settingPath}" for the ${this.entityName} data store is missing the required "entities" property`); + } + if (!storedData.collections) { + throw new Error(`The settings value at "${this.settingManager.settingPath}" for the ${this.entityName} data store is missing the required "collections" property`); + } + return storedData; + } + +} + +export { EntityDataStore } \ No newline at end of file diff --git a/src/scripts/repository/EntityFactory.ts b/src/scripts/repository/EntityFactory.ts new file mode 100644 index 00000000..8f73bbfe --- /dev/null +++ b/src/scripts/repository/EntityFactory.ts @@ -0,0 +1,10 @@ +import {Identifiable} from "../common/Identifiable"; +import {Serializable} from "../common/Serializable"; + +interface EntityFactory> { + + make(entityJson: J): Promise; + +} + +export { EntityFactory } \ No newline at end of file diff --git a/src/scripts/repository/SettingManager.ts b/src/scripts/repository/SettingManager.ts new file mode 100644 index 00000000..08ab41dd --- /dev/null +++ b/src/scripts/repository/SettingManager.ts @@ -0,0 +1,70 @@ +import Properties from "../Properties"; + +interface SettingManager { + + /** + * The full path to the setting, including the module ID. + */ + readonly settingPath: string; + + /** + * The key of the setting, without the module ID. + */ + readonly settingKey: string; + + read(): Promise; + + write(value: T): Promise; + + delete(): Promise; + +} + +export { SettingManager } + +class DefaultSettingManager implements SettingManager { + + private readonly moduleId: string; + private readonly _settingKey: string; + private readonly clientSettings: ClientSettings; + + constructor({ + moduleId = Properties.module.id, + settingKey, + clientSettings + }: { + moduleId?: string; + settingKey: string; + clientSettings: ClientSettings; + }) { + this.moduleId = moduleId; + this._settingKey = settingKey; + this.clientSettings = clientSettings; + } + + get settingPath(): string { + return `${this.moduleId}.${this._settingKey}`; + } + + get settingKey(): string { + return this._settingKey; + } + + async delete(): Promise { + const setting = this.clientSettings.storage.get("world").getSetting(this.settingPath); + await setting.delete(); + return setting; + } + + async read(): Promise { + return this.clientSettings.get(this.moduleId, this._settingKey) as T; + } + + async write(writeValue: T): Promise { + await this.clientSettings.set(this.moduleId, this._settingKey, writeValue); + return; + } + +} + +export { DefaultSettingManager } \ No newline at end of file diff --git a/src/scripts/repository/SettingsRegistry.ts b/src/scripts/repository/SettingsRegistry.ts new file mode 100644 index 00000000..038a31b0 --- /dev/null +++ b/src/scripts/repository/SettingsRegistry.ts @@ -0,0 +1,73 @@ +import Properties from "../Properties"; +import {GameProvider} from "../foundry/GameProvider"; + +interface SettingsRegistry { + + clearAll(): Promise; + + registerAll(): void; + +} + +export { SettingsRegistry }; + +class DefaultSettingsRegistry implements SettingsRegistry { + + private static readonly DEFAULT_SETTING_KEYS = [ + Properties.settings.craftingSystems.key, + Properties.settings.essences.key, + Properties.settings.components.key, + Properties.settings.recipes.key, + Properties.settings.modelVersion.key, + ]; + + private readonly _clientSettings: ClientSettings; + private readonly _gameProvider: GameProvider; + private readonly _settingKeys: string[]; + private readonly _defaultValuesBySettingKey: Map; + + constructor({ + settingKeys = DefaultSettingsRegistry.DEFAULT_SETTING_KEYS, + gameProvider, + clientSettings, + defaultValuesBySettingKey = new Map(), + }: { + settingKeys?: string[]; + gameProvider: GameProvider; + clientSettings: ClientSettings; + defaultValuesBySettingKey?: Map; + }) { + this._settingKeys = settingKeys; + this._gameProvider = gameProvider; + this._clientSettings = clientSettings; + this._defaultValuesBySettingKey = defaultValuesBySettingKey; + } + + async clearAll(): Promise { + await Promise.all(this._settingKeys.map(async settingKey => await this._clearSetting(settingKey))); + } + + registerAll(): void { + this._settingKeys.map(async settingKey => this._registerSetting(settingKey)); + } + + private _registerSetting(settingKey: string) { + const gameObject = this._gameProvider.get(); + gameObject.settings.register(Properties.module.id, settingKey, { + name: "", + hint: "", + scope: "world", + config: false, + type: Object, + default: this._defaultValuesBySettingKey.has(settingKey) ? this._defaultValuesBySettingKey.get(settingKey) : {} + }); + } + + private async _clearSetting(settingKey: string) { + const setting = this._clientSettings.storage.get("world").getSetting(settingKey); + await setting.delete(); + } + +} + +export { DefaultSettingsRegistry }; \ No newline at end of file diff --git a/src/scripts/repository/embedded_systems/AlchemistsSuppliesV16SystemDefinition.ts b/src/scripts/repository/embedded_systems/AlchemistsSuppliesV16SystemDefinition.ts new file mode 100644 index 00000000..43da9a35 --- /dev/null +++ b/src/scripts/repository/embedded_systems/AlchemistsSuppliesV16SystemDefinition.ts @@ -0,0 +1,931 @@ +import {EmbeddedCraftingSystemDefinition} from "./EmbeddedCraftingSystemDefinition"; +import {CraftingSystem} from "../../system/CraftingSystem"; +import {CraftingSystemDetails} from "../../system/CraftingSystemDetails"; +import {Essence} from "../../crafting/essence/Essence"; +import {Component} from "../../crafting/component/Component"; +import {FabricateItemData, LoadedFabricateItemData, PendingFabricateItemData} from "../../foundry/DocumentManager"; +import {Combination} from "../../common/Combination"; +import {Unit} from "../../common/Unit"; +import {EssenceReference} from "../../crafting/essence/EssenceReference"; +import { + Recipe +} from "../../crafting/recipe/Recipe"; +import {SelectableOptions} from "../../crafting/selection/SelectableOptions"; +import {ComponentReference} from "../../crafting/component/ComponentReference"; +import {RequirementOption, RequirementOptionJson} from "../../crafting/recipe/RequirementOption"; +import {ResultOption, ResultOptionJson} from "../../crafting/recipe/ResultOption"; + +class AlchemistsSuppliesV16SystemDefinition implements EmbeddedCraftingSystemDefinition { + + private static readonly CRAFTING_SYSTEM_ID = "alchemists-supplies-v1.6"; + private static readonly DEFAULT_ITEM_DATA_LOADING_FUNCTION = () => { + throw new Error("No item data loading function provided. "); + }; + + private readonly _itemDataLoadingFunction: (uuid: string) => Promise; + + constructor(itemDataLoadingFunction: (uuid: string) => Promise = AlchemistsSuppliesV16SystemDefinition.DEFAULT_ITEM_DATA_LOADING_FUNCTION) { + this._itemDataLoadingFunction = itemDataLoadingFunction; + } + + get supportedGameSystem(): string { + return "dnd5e"; + } + + get craftingSystem(): CraftingSystem { + return new CraftingSystem({ + id: AlchemistsSuppliesV16SystemDefinition.CRAFTING_SYSTEM_ID, + embedded: true, + craftingSystemDetails: new CraftingSystemDetails({ + name: "Alchemist's Supplies v1.6", + description: "Alchemy is the skill of exploiting unique properties of certain plants, minerals, and creature parts, combining them to produce fantastic substances. This allows even non-spellcasters to mimic minor magical effects, although the creations themselves are non-magical.", + summary: "A crafting system for 5th Edition by u/calculusChild", + author: "u/calculusChild", + }), + disabled: true, + }); + } + + get essences(): Essence[] { + return [ + new Essence({ + id: "water", + craftingSystemId: AlchemistsSuppliesV16SystemDefinition.CRAFTING_SYSTEM_ID, + name: "Water", + description: "Elemental water, one of the fundamental forces of nature", + iconCode: "fa-solid fa-droplet", + tooltip: "Elemental water", + embedded: true, + disabled: false, + }), + new Essence({ + id: "fire", + craftingSystemId: AlchemistsSuppliesV16SystemDefinition.CRAFTING_SYSTEM_ID, + name: "Fire", + description: "Elemental fire, one of the fundamental forces of nature", + iconCode: "fa-solid fa-fire", + tooltip: "Elemental fire", + embedded: true, + disabled: false, + }), + new Essence({ + id: "earth", + craftingSystemId: AlchemistsSuppliesV16SystemDefinition.CRAFTING_SYSTEM_ID, + name: "Earth", + description: "Elemental earth, one of the fundamental forces of nature", + iconCode: "fa-solid fa-mountain", + tooltip: "Elemental earth", + embedded: true, + disabled: false, + }), + new Essence({ + id: "air", + craftingSystemId: AlchemistsSuppliesV16SystemDefinition.CRAFTING_SYSTEM_ID, + name: "Air", + description: "Elemental air, one of the fundamental forces of nature", + iconCode: "fa-solid fa-wind", + tooltip: "Elemental air", + embedded: true, + disabled: false, + }), + new Essence({ + id: "positive-energy", + craftingSystemId: AlchemistsSuppliesV16SystemDefinition.CRAFTING_SYSTEM_ID, + name: "Positive Energy", + description: "Positive Energy - The essence of life and creation", + iconCode: "fa-solid fa-sun", + tooltip: "Positive Energy", + embedded: true, + disabled: false, + }), + new Essence({ + id: "negative-energy", + craftingSystemId: AlchemistsSuppliesV16SystemDefinition.CRAFTING_SYSTEM_ID, + name: "Negative Energy", + description: "Negative Energy - The essence of death and destruction", + iconCode: "fa-solid fa-moon", + tooltip: "Negative Energy", + embedded: true, + disabled: false, + }) + ]; + } + + get components(): Component[] { + return [ + new Component({ + id: "night-eyes", + craftingSystemId: AlchemistsSuppliesV16SystemDefinition.CRAFTING_SYSTEM_ID, + itemData: new PendingFabricateItemData( + "Compendium.fabricate.alchemists-supplies-v16.1lx7oTedScyEgTOm", + this._itemDataLoadingFunction + ), + embedded: true, + disabled: false, + }), + new Component({ + id: "hydrathistle", + craftingSystemId: AlchemistsSuppliesV16SystemDefinition.CRAFTING_SYSTEM_ID, + itemData: new PendingFabricateItemData( + "Compendium.fabricate.alchemists-supplies-v16.0FiywpRZf3cOYs4U", + this._itemDataLoadingFunction + ), + embedded: true, + disabled: false, + essences: Combination.ofUnits([ + new Unit(new EssenceReference("water"), 2), + ]), + }), + new Component({ + id: "instant-rope", + craftingSystemId: AlchemistsSuppliesV16SystemDefinition.CRAFTING_SYSTEM_ID, + itemData: new PendingFabricateItemData( + "Compendium.fabricate.alchemists-supplies-v16.zogQbVwUggRUWK2l", + this._itemDataLoadingFunction + ), + embedded: true, + disabled: false, + }), + new Component({ + id: "breath-bottle", + craftingSystemId: AlchemistsSuppliesV16SystemDefinition.CRAFTING_SYSTEM_ID, + itemData: new PendingFabricateItemData( + "Compendium.fabricate.alchemists-supplies-v16.4U429BrJKdMKDZaR", + this._itemDataLoadingFunction + ), + embedded: true, + disabled: false, + }), + new Component({ + id: "dust-of-dryness", + craftingSystemId: AlchemistsSuppliesV16SystemDefinition.CRAFTING_SYSTEM_ID, + itemData: new PendingFabricateItemData( + "Compendium.fabricate.alchemists-supplies-v16.8AdG3048UmUOo5Qx", + this._itemDataLoadingFunction + ), + embedded: true, + disabled: false, + }), + new Component({ + id: "alchemical-bomb", + craftingSystemId: AlchemistsSuppliesV16SystemDefinition.CRAFTING_SYSTEM_ID, + itemData: new PendingFabricateItemData( + "Compendium.fabricate.alchemists-supplies-v16.90z9nOwmGnP4aUUk", + this._itemDataLoadingFunction + ), + embedded: true, + disabled: false, + }), + new Component({ + id: "wrackwort-bulbs", + craftingSystemId: AlchemistsSuppliesV16SystemDefinition.CRAFTING_SYSTEM_ID, + itemData: new PendingFabricateItemData( + "Compendium.fabricate.alchemists-supplies-v16.Act4cJsuz2HhID55", + this._itemDataLoadingFunction + ), + embedded: true, + disabled: false, + essences: Combination.ofUnits([ + new Unit(new EssenceReference("earth"), 1), + new Unit(new EssenceReference("fire"), 1), + ]), + }), + new Component({ + id: "lightningbug-thorax", + craftingSystemId: AlchemistsSuppliesV16SystemDefinition.CRAFTING_SYSTEM_ID, + itemData: new PendingFabricateItemData( + "Compendium.fabricate.alchemists-supplies-v16.BPiO2qqMvJbdlxJf", + this._itemDataLoadingFunction + ), + embedded: true, + disabled: false, + }), + new Component({ + id: "luminous-cap", + craftingSystemId: AlchemistsSuppliesV16SystemDefinition.CRAFTING_SYSTEM_ID, + itemData: new PendingFabricateItemData( + "Compendium.fabricate.alchemists-supplies-v16.BjAyhAmkbFJdVVQW", + this._itemDataLoadingFunction + ), + embedded: true, + disabled: false, + }), + new Component({ + id: "radiant-synthseed", + craftingSystemId: AlchemistsSuppliesV16SystemDefinition.CRAFTING_SYSTEM_ID, + itemData: new PendingFabricateItemData( + "Compendium.fabricate.alchemists-supplies-v16.BzC8HmlN3bNY4D8Z", + this._itemDataLoadingFunction + ), + embedded: true, + disabled: false, + essences: Combination.ofUnits([ + new Unit(new EssenceReference("air"), 1), + new Unit(new EssenceReference("fire"), 1), + ]), + }), + new Component({ + id: "melt-powder", + craftingSystemId: AlchemistsSuppliesV16SystemDefinition.CRAFTING_SYSTEM_ID, + itemData: new PendingFabricateItemData( + "Compendium.fabricate.alchemists-supplies-v16.EDGSJpxtSuvq7XWW", + this._itemDataLoadingFunction + ), + embedded: true, + disabled: false, + }), + new Component({ + id: "gashglue", + craftingSystemId: AlchemistsSuppliesV16SystemDefinition.CRAFTING_SYSTEM_ID, + itemData: new PendingFabricateItemData( + "Compendium.fabricate.alchemists-supplies-v16.Ffv7X37ZT7LRzDvP", + this._itemDataLoadingFunction + ), + embedded: true, + disabled: false, + }), + new Component({ + id: "flash-pellet", + craftingSystemId: AlchemistsSuppliesV16SystemDefinition.CRAFTING_SYSTEM_ID, + itemData: new PendingFabricateItemData( + "Compendium.fabricate.alchemists-supplies-v16.HyicGjYWESWIdEhc", + this._itemDataLoadingFunction + ), + embedded: true, + disabled: false, + }), + new Component({ + id: "voidroot", + craftingSystemId: AlchemistsSuppliesV16SystemDefinition.CRAFTING_SYSTEM_ID, + itemData: new PendingFabricateItemData( + "Compendium.fabricate.alchemists-supplies-v16.JuAZVO3QioTf7ACf", + this._itemDataLoadingFunction + ), + embedded: true, + disabled: false, + essences: Combination.ofUnits([ + new Unit(new EssenceReference("negative-energy"), 1), + ]), + }), + new Component({ + id: "alchemists-fire", + craftingSystemId: AlchemistsSuppliesV16SystemDefinition.CRAFTING_SYSTEM_ID, + itemData: new PendingFabricateItemData( + "Compendium.fabricate.alchemists-supplies-v16.Kv4752l95eP4IeBM", + this._itemDataLoadingFunction + ), + embedded: true, + disabled: false, + }), + new Component({ + id: "blue-toadshade", + craftingSystemId: AlchemistsSuppliesV16SystemDefinition.CRAFTING_SYSTEM_ID, + itemData: new PendingFabricateItemData( + "Compendium.fabricate.alchemists-supplies-v16.LnFU0qkUkKz3iV1Q", + this._itemDataLoadingFunction + ), + embedded: true, + disabled: false, + essences: Combination.ofUnits([ + new Unit(new EssenceReference("water"), 1), + new Unit(new EssenceReference("air"), 1), + ]), + }), + new Component({ + id: "drakus-flower", + craftingSystemId: AlchemistsSuppliesV16SystemDefinition.CRAFTING_SYSTEM_ID, + itemData: new PendingFabricateItemData( + "Compendium.fabricate.alchemists-supplies-v16.PwEY1nRmDzoFN5TW", + this._itemDataLoadingFunction + ), + embedded: true, + disabled: false, + essences: Combination.ofUnits([ + new Unit(new EssenceReference("fire"), 2) + ]), + }), + new Component({ + id: "firesnuff", + craftingSystemId: AlchemistsSuppliesV16SystemDefinition.CRAFTING_SYSTEM_ID, + itemData: new PendingFabricateItemData( + "Compendium.fabricate.alchemists-supplies-v16.QXN2n98HnjFzSsNJ", + this._itemDataLoadingFunction + ), + embedded: true, + disabled: false, + }), + new Component({ + id: "ironwood-heart", + craftingSystemId: AlchemistsSuppliesV16SystemDefinition.CRAFTING_SYSTEM_ID, + itemData: new PendingFabricateItemData( + "Compendium.fabricate.alchemists-supplies-v16.R50XdiWxV5awA6HL", + this._itemDataLoadingFunction + ), + embedded: true, + disabled: false, + essences: Combination.ofUnits([ + new Unit(new EssenceReference("earth"), 2) + ]), + }), + new Component({ + id: "amanita-cap", + craftingSystemId: AlchemistsSuppliesV16SystemDefinition.CRAFTING_SYSTEM_ID, + itemData: new PendingFabricateItemData( + "Compendium.fabricate.alchemists-supplies-v16.SmE4zjl9xLBOSo4A", + this._itemDataLoadingFunction + ), + embedded: true, + disabled: false, + essences: Combination.ofUnits([ + new Unit(new EssenceReference("water"), 1) + ]), + }), + new Component({ + id: "rockvine", + craftingSystemId: AlchemistsSuppliesV16SystemDefinition.CRAFTING_SYSTEM_ID, + itemData: new PendingFabricateItemData( + "Compendium.fabricate.alchemists-supplies-v16.TAdDF3reNmXc00ST", + this._itemDataLoadingFunction + ), + embedded: true, + disabled: false, + essences: Combination.ofUnits([ + new Unit(new EssenceReference("earth"), 1) + ]), + }), + new Component({ + id: "noxious-smokestick", + craftingSystemId: AlchemistsSuppliesV16SystemDefinition.CRAFTING_SYSTEM_ID, + itemData: new PendingFabricateItemData( + "Compendium.fabricate.alchemists-supplies-v16.VHUXcExXZLCDfqmM", + this._itemDataLoadingFunction + ), + embedded: true, + disabled: false, + }), + new Component({ + id: "smokestick", + craftingSystemId: AlchemistsSuppliesV16SystemDefinition.CRAFTING_SYSTEM_ID, + itemData: new PendingFabricateItemData( + "Compendium.fabricate.alchemists-supplies-v16.bNOIJaY21OSsdybw", + this._itemDataLoadingFunction + ), + embedded: true, + disabled: false, + }), + new Component({ + id: "fennel-silk", + craftingSystemId: AlchemistsSuppliesV16SystemDefinition.CRAFTING_SYSTEM_ID, + itemData: new PendingFabricateItemData( + "Compendium.fabricate.alchemists-supplies-v16.dVgM7j75KoBDKRep", + this._itemDataLoadingFunction + ), + embedded: true, + disabled: false, + }), + new Component({ + id: "wisp-stalks", + craftingSystemId: AlchemistsSuppliesV16SystemDefinition.CRAFTING_SYSTEM_ID, + itemData: new PendingFabricateItemData( + "Compendium.fabricate.alchemists-supplies-v16.dvrdsBAt0B2ag8Q8", + this._itemDataLoadingFunction + ), + embedded: true, + disabled: false, + essences: Combination.ofUnits([ + new Unit(new EssenceReference("air"), 2) + ]), + }), + new Component({ + id: "titan-gum", + craftingSystemId: AlchemistsSuppliesV16SystemDefinition.CRAFTING_SYSTEM_ID, + itemData: new PendingFabricateItemData( + "Compendium.fabricate.alchemists-supplies-v16.fZsZ5EYMs9ioZ6Kk", + this._itemDataLoadingFunction + ), + embedded: true, + disabled: false, + }), + new Component({ + id: "zaebelles-torpor", + craftingSystemId: AlchemistsSuppliesV16SystemDefinition.CRAFTING_SYSTEM_ID, + itemData: new PendingFabricateItemData( + "Compendium.fabricate.alchemists-supplies-v16.m51mvhPcubqfPbWB", + this._itemDataLoadingFunction + ), + embedded: true, + disabled: false, + }), + new Component({ + id: "tanglefoot-bag", + craftingSystemId: AlchemistsSuppliesV16SystemDefinition.CRAFTING_SYSTEM_ID, + itemData: new PendingFabricateItemData( + "Compendium.fabricate.alchemists-supplies-v16.sB64MUbYpMqiWCkS", + this._itemDataLoadingFunction + ), + embedded: true, + disabled: false, + }), + new Component({ + id: "frozen-seedlings", + craftingSystemId: AlchemistsSuppliesV16SystemDefinition.CRAFTING_SYSTEM_ID, + itemData: new PendingFabricateItemData( + "Compendium.fabricate.alchemists-supplies-v16.xtrSxjCohTVr1bZy", + this._itemDataLoadingFunction + ), + embedded: true, + disabled: false, + essences: Combination.ofUnits([ + new Unit(new EssenceReference("earth"), 1), + new Unit(new EssenceReference("water"), 1), + ]), + }), + new Component({ + id: "snappowder", + craftingSystemId: AlchemistsSuppliesV16SystemDefinition.CRAFTING_SYSTEM_ID, + itemData: new PendingFabricateItemData( + "Compendium.fabricate.alchemists-supplies-v16.zgDyoqjRxwhD9cNR", + this._itemDataLoadingFunction + ), + embedded: true, + disabled: false, + }), + ]; + } + + get recipes(): Recipe[] { + return [ + new Recipe({ + id: "instant-rope", + craftingSystemId: AlchemistsSuppliesV16SystemDefinition.CRAFTING_SYSTEM_ID, + itemData: new PendingFabricateItemData( + "Compendium.fabricate.alchemists-supplies-v16.3N7TGYWJRrUJ9thy", + this._itemDataLoadingFunction + ), + requirementOptions: new SelectableOptions({ + options: [ + new RequirementOption({ + id: "option-1", + name: "Option 1", + essences: Combination.ofUnits([ + new Unit(new EssenceReference("water"), 2), + new Unit(new EssenceReference("earth"), 2), + ]) + }), + ], + }), + resultOptions: new SelectableOptions({ + options: [ + new ResultOption({ + id: "option-1", + name: "Option 1", + results: Combination.ofUnits([ + new Unit(new ComponentReference("instant-rope"), 1), + ]) + }), + ] + }) + }), + new Recipe({ + id: "zaebelles-torpor", + craftingSystemId: AlchemistsSuppliesV16SystemDefinition.CRAFTING_SYSTEM_ID, + itemData: new PendingFabricateItemData( + "Compendium.fabricate.alchemists-supplies-v16.6mKiAWQIzDZRPNOP", + this._itemDataLoadingFunction + ), + requirementOptions: new SelectableOptions({ + options: [ + new RequirementOption({ + id: "option-1", + name: "Option 1", + essences: Combination.ofUnits([ + new Unit(new EssenceReference("air"), 2), + new Unit(new EssenceReference("negative-energy"), 1), + ]) + }), + ], + }), + resultOptions: new SelectableOptions({ + options: [ + new ResultOption({ + id: "option-1", + name: "Option 1", + results: Combination.ofUnits([ + new Unit(new ComponentReference("zaebelles-torpor"), 1), + ]) + }), + ] + }) + }), + new Recipe({ + id: "flash-pellet", + craftingSystemId: AlchemistsSuppliesV16SystemDefinition.CRAFTING_SYSTEM_ID, + itemData: new PendingFabricateItemData( + "Compendium.fabricate.alchemists-supplies-v16.8M0vwnOuCEIEP9ww", + this._itemDataLoadingFunction + ), + requirementOptions: new SelectableOptions({ + options: [ + new RequirementOption({ + id: "option-1", + name: "Option 1", + essences: Combination.ofUnits([ + new Unit(new EssenceReference("fire"), 2), + new Unit(new EssenceReference("positive-energy"), 1), + ]) + }), + ], + }), + resultOptions: new SelectableOptions({ + options: [ + new ResultOption({ + id: "option-1", + name: "Option 1", + results: Combination.ofUnits([ + new Unit(new ComponentReference("flash-pellet"), 1), + ]) + }), + ] + }) + }), + new Recipe({ + id: "titan-gum", + craftingSystemId: AlchemistsSuppliesV16SystemDefinition.CRAFTING_SYSTEM_ID, + itemData: new PendingFabricateItemData( + "Compendium.fabricate.alchemists-supplies-v16.EQfnAlIaDLh57ETN", + this._itemDataLoadingFunction + ), + requirementOptions: new SelectableOptions({ + options: [ + new RequirementOption({ + id: "option-1", + name: "Option 1", + essences: Combination.ofUnits([ + new Unit(new EssenceReference("earth"), 2), + new Unit(new EssenceReference("positive-energy"), 1), + ]) + }), + ], + }), + resultOptions: new SelectableOptions({ + options: [ + new ResultOption({ + id: "option-1", + name: "Option 1", + results: Combination.ofUnits([ + new Unit(new ComponentReference("titan-gum"), 1), + ]) + }), + ] + }) + }), + new Recipe({ + id: "gashglue", + craftingSystemId: AlchemistsSuppliesV16SystemDefinition.CRAFTING_SYSTEM_ID, + itemData: new PendingFabricateItemData( + "Compendium.fabricate.alchemists-supplies-v16.F2dhRws4mZppZVIa", + this._itemDataLoadingFunction + ), + requirementOptions: new SelectableOptions({ + options: [ + new RequirementOption({ + id: "option-1", + name: "Option 1", + essences: Combination.ofUnits([ + new Unit(new EssenceReference("water"), 2), + new Unit(new EssenceReference("positive-energy"), 1), + ]) + }), + ], + }), + resultOptions: new SelectableOptions({ + options: [ + new ResultOption({ + id: "option-1", + name: "Option 1", + results: Combination.ofUnits([ + new Unit(new ComponentReference("gashglue"), 1), + ]) + }), + ] + }) + }), + new Recipe({ + id: "dust-of-dryness", + craftingSystemId: AlchemistsSuppliesV16SystemDefinition.CRAFTING_SYSTEM_ID, + itemData: new PendingFabricateItemData( + "Compendium.fabricate.alchemists-supplies-v16.IVqzRv8v1MrfpKSa", + this._itemDataLoadingFunction + ), + requirementOptions: new SelectableOptions({ + options: [ + new RequirementOption({ + id: "option-1", + name: "Option 1", + essences: Combination.ofUnits([ + new Unit(new EssenceReference("earth"), 3), + new Unit(new EssenceReference("negative-energy"), 2 ), + ]) + }), + ], + }), + resultOptions: new SelectableOptions({ + options: [ + new ResultOption({ + id: "option-1", + name: "Option 1", + results: Combination.ofUnits([ + new Unit(new ComponentReference("dust-of-dryness"), 1), + ]) + }), + ] + }) + }), + new Recipe({ + id: "firesnuff", + craftingSystemId: AlchemistsSuppliesV16SystemDefinition.CRAFTING_SYSTEM_ID, + itemData: new PendingFabricateItemData( + "Compendium.fabricate.alchemists-supplies-v16.JEewjPpD6JA2ifE3", + this._itemDataLoadingFunction + ), + requirementOptions: new SelectableOptions({ + options: [ + new RequirementOption({ + id: "option-1", + name: "Option 1", + essences: Combination.ofUnits([ + new Unit(new EssenceReference("fire"), 1), + new Unit(new EssenceReference("negative-energy"), 1), + ]) + }), + ], + }), + resultOptions: new SelectableOptions({ + options: [ + new ResultOption({ + id: "option-1", + name: "Option 1", + results: Combination.ofUnits([ + new Unit(new ComponentReference("firesnuff"), 1), + ]) + }), + ] + }) + }), + new Recipe({ + id: "snappowder", + craftingSystemId: AlchemistsSuppliesV16SystemDefinition.CRAFTING_SYSTEM_ID, + itemData: new PendingFabricateItemData( + "Compendium.fabricate.alchemists-supplies-v16.O0FPlz7ARBLuLY1E", + this._itemDataLoadingFunction + ), + requirementOptions: new SelectableOptions({ + options: [ + new RequirementOption({ + id: "option-1", + name: "Option 1", + essences: Combination.ofUnits([ + new Unit(new EssenceReference("earth"), 2), + new Unit(new EssenceReference("positive-energy"), 1), + ]) + }), + ], + }), + resultOptions: new SelectableOptions({ + options: [ + new ResultOption({ + id: "option-1", + name: "Option 1", + results: Combination.ofUnits([ + new Unit(new ComponentReference("snappowder"), 1), + ]) + }), + ] + }) + }), + new Recipe({ + id: "alchemists-fire", + craftingSystemId: AlchemistsSuppliesV16SystemDefinition.CRAFTING_SYSTEM_ID, + itemData: new PendingFabricateItemData( + "Compendium.fabricate.alchemists-supplies-v16.Pl65b1VSVhUJ2li9", + this._itemDataLoadingFunction + ), + requirementOptions: new SelectableOptions({ + options: [ + new RequirementOption({ + id: "option-1", + name: "Option 1", + essences: Combination.ofUnits([ + new Unit(new EssenceReference("fire"), 2), + new Unit(new EssenceReference("earth"), 1), + new Unit(new EssenceReference("water"), 1), + new Unit(new EssenceReference("positive-energy"), 1), + ]) + }), + ], + }), + resultOptions: new SelectableOptions({ + options: [ + new ResultOption({ + id: "option-1", + name: "Option 1", + results: Combination.ofUnits([ + new Unit(new ComponentReference("alchemists-fire"), 1), + ]) + }), + ] + }) + }), + new Recipe({ + id: "melt-powder", + craftingSystemId: AlchemistsSuppliesV16SystemDefinition.CRAFTING_SYSTEM_ID, + itemData: new PendingFabricateItemData( + "Compendium.fabricate.alchemists-supplies-v16.WseDmjhOWaT1XeJf", + this._itemDataLoadingFunction + ), + requirementOptions: new SelectableOptions({ + options: [ + new RequirementOption({ + id: "option-1", + name: "Option 1", + essences: Combination.ofUnits([ + new Unit(new EssenceReference("earth"), 1), + new Unit(new EssenceReference("fire"), 2), + ]) + }), + ], + }), + resultOptions: new SelectableOptions({ + options: [ + new ResultOption({ + id: "option-1", + name: "Option 1", + results: Combination.ofUnits([ + new Unit(new ComponentReference("melt-powder"), 1), + ]) + }), + ] + }) + }), + new Recipe({ + id: "tanglefoot-bag", + craftingSystemId: AlchemistsSuppliesV16SystemDefinition.CRAFTING_SYSTEM_ID, + itemData: new PendingFabricateItemData( + "Compendium.fabricate.alchemists-supplies-v16.hJFEOjoiLdTOOoBn", + this._itemDataLoadingFunction + ), + requirementOptions: new SelectableOptions({ + options: [ + new RequirementOption({ + id: "option-1", + name: "Option 1", + essences: Combination.ofUnits([ + new Unit(new EssenceReference("water"), 2), + new Unit(new EssenceReference("earth"), 2), + ]) + }), + ], + }), + resultOptions: new SelectableOptions({ + options: [ + new ResultOption({ + id: "option-1", + name: "Option 1", + results: Combination.ofUnits([ + new Unit(new ComponentReference("tanglefoot-bag"), 1), + ]) + }), + ] + }) + }), + new Recipe({ + id: "breath-bottle", + craftingSystemId: AlchemistsSuppliesV16SystemDefinition.CRAFTING_SYSTEM_ID, + itemData: new PendingFabricateItemData( + "Compendium.fabricate.alchemists-supplies-v16.kI3lqJmjWai0aX5L", + this._itemDataLoadingFunction + ), + requirementOptions: new SelectableOptions({ + options: [ + new RequirementOption({ + id: "option-1", + name: "Option 1", + essences: Combination.ofUnits([ + new Unit(new EssenceReference("air"), 3), + ]) + }), + ], + }), + resultOptions: new SelectableOptions({ + options: [ + new ResultOption({ + id: "option-1", + name: "Option 1", + results: Combination.ofUnits([ + new Unit(new ComponentReference("breath-bottle"), 1), + ]) + }), + ] + }) + }), + new Recipe({ + id: "smokestick", + craftingSystemId: AlchemistsSuppliesV16SystemDefinition.CRAFTING_SYSTEM_ID, + itemData: new PendingFabricateItemData( + "Compendium.fabricate.alchemists-supplies-v16.x4MkisNL6yBoTZ94", + this._itemDataLoadingFunction + ), + requirementOptions: new SelectableOptions({ + options: [ + new RequirementOption({ + id: "option-1", + name: "Option 1", + essences: Combination.ofUnits([ + new Unit(new EssenceReference("air"), 2), + new Unit(new EssenceReference("fire"), 1), + ]) + }), + ], + }), + resultOptions: new SelectableOptions({ + options: [ + new ResultOption({ + id: "option-1", + name: "Option 1", + results: Combination.ofUnits([ + new Unit(new ComponentReference("smokestick"), 1), + ]) + }), + ] + }) + }), + new Recipe({ + id: "night-eyes", + craftingSystemId: AlchemistsSuppliesV16SystemDefinition.CRAFTING_SYSTEM_ID, + itemData: new PendingFabricateItemData( + "Compendium.fabricate.alchemists-supplies-v16.ylQuglLVjQFuH9w1", + this._itemDataLoadingFunction + ), + requirementOptions: new SelectableOptions({ + options: [ + new RequirementOption({ + id: "option-1", + name: "Option 1", + essences: Combination.ofUnits([ + new Unit(new EssenceReference("water"), 1), + new Unit(new EssenceReference("fire"), 1), + new Unit(new EssenceReference("negative-energy"), 1), + ]) + }), + ], + }), + resultOptions: new SelectableOptions({ + options: [ + new ResultOption({ + id: "option-1", + name: "Option 1", + results: Combination.ofUnits([ + new Unit(new ComponentReference("night-eyes"), 1), + ]) + }), + ] + }) + }), + new Recipe({ + id: "noxious-smokestick", + craftingSystemId: AlchemistsSuppliesV16SystemDefinition.CRAFTING_SYSTEM_ID, + itemData: new PendingFabricateItemData( + "Compendium.fabricate.alchemists-supplies-v16.yqKIdplKhpa5Ir7A", + this._itemDataLoadingFunction + ), + requirementOptions: new SelectableOptions({ + options: [ + new RequirementOption({ + id: "option-1", + name: "Option 1", + essences: Combination.ofUnits([ + new Unit(new EssenceReference("air"), 2), + new Unit(new EssenceReference("fire"), 2), + new Unit(new EssenceReference("negative-energy"), 1), + ]) + }), + ], + }), + resultOptions: new SelectableOptions({ + options: [ + new ResultOption({ + id: "option-1", + name: "Option 1", + results: Combination.ofUnits([ + new Unit(new ComponentReference("noxious-smokestick"), 1), + ]) + }), + ] + }) + }), + ]; + } + +} + +export { AlchemistsSuppliesV16SystemDefinition } \ No newline at end of file diff --git a/src/scripts/repository/embedded_systems/EmbeddedCraftingSystemDefinition.ts b/src/scripts/repository/embedded_systems/EmbeddedCraftingSystemDefinition.ts new file mode 100644 index 00000000..ccc1d4a6 --- /dev/null +++ b/src/scripts/repository/embedded_systems/EmbeddedCraftingSystemDefinition.ts @@ -0,0 +1,16 @@ +import {CraftingSystem} from "../../system/CraftingSystem"; +import {Recipe} from "../../crafting/recipe/Recipe"; +import {Component} from "../../crafting/component/Component"; +import {Essence} from "../../crafting/essence/Essence"; + +interface EmbeddedCraftingSystemDefinition { + + readonly supportedGameSystem: string; + readonly craftingSystem: CraftingSystem; + readonly essences: Essence[]; + readonly components: Component[]; + readonly recipes: Recipe[]; + +} + +export { EmbeddedCraftingSystemDefinition } \ No newline at end of file diff --git a/src/scripts/repository/embedded_systems/EmbeddedCraftingSystemManager.ts b/src/scripts/repository/embedded_systems/EmbeddedCraftingSystemManager.ts new file mode 100644 index 00000000..7956b9a2 --- /dev/null +++ b/src/scripts/repository/embedded_systems/EmbeddedCraftingSystemManager.ts @@ -0,0 +1,99 @@ +import {EmbeddedCraftingSystemDefinition} from "./EmbeddedCraftingSystemDefinition"; +import {AlchemistsSuppliesV16SystemDefinition} from "./AlchemistsSuppliesV16SystemDefinition"; +import {EntityDataStore} from "../EntityDataStore"; +import {Recipe, RecipeJson} from "../../crafting/recipe/Recipe"; +import {Component, ComponentJson} from "../../crafting/component/Component"; +import {Essence, EssenceJson} from "../../crafting/essence/Essence"; +import {CraftingSystem, CraftingSystemJson} from "../../system/CraftingSystem"; +import Properties from "../../Properties"; + +interface EmbeddedCraftingSystemManager { + + restoreForGameSystem(gameSystemId: string): Promise; + +} + +export { EmbeddedCraftingSystemManager }; + +class DefaultEmbeddedCraftingSystemManager implements EmbeddedCraftingSystemManager { + + private static readonly DEFAULT_EMBEDDED_CRAFTING_SYSTEMS: EmbeddedCraftingSystemDefinition[] = [ + new AlchemistsSuppliesV16SystemDefinition() + ] + + private readonly _recipeStore: EntityDataStore; + private readonly _essenceStore: EntityDataStore; + private readonly _componentStore: EntityDataStore; + private readonly _craftingSystemStore: EntityDataStore; + private readonly _embeddedCraftingSystems: EmbeddedCraftingSystemDefinition[]; + + constructor({ + recipeStore, + essenceStore, + componentStore, + craftingSystemStore, + embeddedCraftingSystems = DefaultEmbeddedCraftingSystemManager.DEFAULT_EMBEDDED_CRAFTING_SYSTEMS, + }: { + recipeStore: EntityDataStore; + essenceStore: EntityDataStore; + componentStore: EntityDataStore; + craftingSystemStore: EntityDataStore; + embeddedCraftingSystems?: EmbeddedCraftingSystemDefinition[]; + }) { + this._recipeStore = recipeStore; + this._essenceStore = essenceStore; + this._componentStore = componentStore; + this._craftingSystemStore = craftingSystemStore; + this._embeddedCraftingSystems = embeddedCraftingSystems; + } + + async restoreForGameSystem(gameSystemId: string): Promise { + const embeddedCraftingSystemsForGameSystem = this._embeddedCraftingSystems + .filter(embeddedSystemDefinition => embeddedSystemDefinition.supportedGameSystem === gameSystemId); + await Promise.all(embeddedCraftingSystemsForGameSystem.map(embeddedSystemDefinition => this._restoreEmbeddedCraftingSystem(embeddedSystemDefinition))); + } + + private async _restoreEmbeddedCraftingSystem(embeddedSystemDefinition: EmbeddedCraftingSystemDefinition): Promise { + await this._restoreCraftingSystem(embeddedSystemDefinition.craftingSystem); + + await this.deleteEssencesForCraftingSystem(embeddedSystemDefinition.craftingSystem.id); + await this._restoreEssences(embeddedSystemDefinition.essences); + + await this.deleteComponentsForCraftingSystem(embeddedSystemDefinition.craftingSystem.id); + await this._restoreComponents(embeddedSystemDefinition.components); + + await this.deleteRecipesForCraftingSystem(embeddedSystemDefinition.craftingSystem.id); + await this._restoreRecipes(embeddedSystemDefinition.recipes); + } + + private async _restoreCraftingSystem(craftingSystem: CraftingSystem): Promise { + await this._craftingSystemStore.insert(craftingSystem); + } + + private async _restoreEssences(essences: Essence[]): Promise { + await this._essenceStore.insertAll(essences); + } + + private async _restoreComponents(components: Component[]): Promise { + await this._componentStore.insertAll(components); + } + + private async _restoreRecipes(recipes: Recipe[]): Promise { + await this._recipeStore.insertAll(recipes); + } + + private async deleteEssencesForCraftingSystem(craftingSystemId: string) { + await this._essenceStore.deleteCollection(craftingSystemId, Properties.settings.collectionNames.craftingSystem); + } + + private async deleteComponentsForCraftingSystem(craftingSystemId: string) { + await this._componentStore.deleteCollection(craftingSystemId, Properties.settings.collectionNames.craftingSystem); + } + + private async deleteRecipesForCraftingSystem(craftingSystemId: string) { + await this._recipeStore.deleteCollection(craftingSystemId, Properties.settings.collectionNames.craftingSystem); + } + +} + +export { DefaultEmbeddedCraftingSystemManager }; \ No newline at end of file diff --git a/src/scripts/repository/import/FabricateExportModel.ts b/src/scripts/repository/import/FabricateExportModel.ts new file mode 100644 index 00000000..28d100e7 --- /dev/null +++ b/src/scripts/repository/import/FabricateExportModel.ts @@ -0,0 +1,82 @@ +interface CraftingSystemExportModel { + id: string; + details: { + name: string; + summary: string; + description: string; + author: string; + }; + disabled: boolean; +} + +export { CraftingSystemExportModel } + +interface EssenceExportModel { + id: string; + name: string; + tooltip: string; + iconCode: string; + disabled: boolean; + description: string; + craftingSystemId: string; + activeEffectSourceItemUuid: string; +} + +export { EssenceExportModel } + +interface ComponentExportModel { + id: string; + itemUuid: string; + disabled: boolean; + essences: Record; + salvageOptions: { + id: string; + name: string; + results: Record; + catalysts: Record; + }[]; + craftingSystemId: string; +} + +export { ComponentExportModel } + +interface RecipeExportModel { + id: string; + itemUuid: string; + disabled: boolean; + craftingSystemId: string; + resultOptions: { + id: string; + name: string; + results: Record; + }[]; + requirementOptions: { + id: string, + name: string, + catalysts: Record; + ingredients: Record; + essences: Record; + }[]; +} + +export { RecipeExportModel } + +type ExportModelVersion = "V2"; + +export { ExportModelVersion } + +interface FabricateExportModel { + + version: ExportModelVersion; + + craftingSystem: CraftingSystemExportModel; + + essences: EssenceExportModel[]; + + components: ComponentExportModel[]; + + recipes: RecipeExportModel[]; + +} + +export { FabricateExportModel } \ No newline at end of file diff --git a/src/scripts/repository/migration/DefaultSettingsMigrator.ts b/src/scripts/repository/migration/DefaultSettingsMigrator.ts new file mode 100644 index 00000000..2d7d651a --- /dev/null +++ b/src/scripts/repository/migration/DefaultSettingsMigrator.ts @@ -0,0 +1,88 @@ +import {SettingsMigrator} from "./SettingsMigrator"; +import {SettingMigrationStep} from "./SettingMigrationStep"; +import {SettingVersion} from "./SettingVersion"; +import {SettingMigrationResult} from "../../api/SettingMigrationResult"; +import {SettingManager} from "../SettingManager"; +import Properties from "../../Properties"; +import {SettingMigrationStatus} from "./SettingMigrationStatus"; + +class DefaultSettingsMigrator implements SettingsMigrator { + + /** + * The default model version for Fabricate settings is V2. This is the version that was used prior to the introduction + * of the global settings property used by the SettingsMigrator. + * + * @private + */ + private static readonly DEFAULT_MODEL_VERSION = SettingVersion.V2; + + private readonly _targetVersion: SettingVersion; + private readonly _stepsBySourceVersion: Map; + private readonly _versionSettingManager: SettingManager; + + constructor({ + targetVersion = Properties.settings.modelVersion.targetValue, + stepsBySourceVersion, + versionSettingManager + }: { + targetVersion: SettingVersion; + stepsBySourceVersion: Map; + versionSettingManager: SettingManager; + }) { + this._targetVersion = targetVersion; + this._stepsBySourceVersion = stepsBySourceVersion; + this._versionSettingManager = versionSettingManager; + } + + async getModelVersion(): Promise { + try { + const versionSetting = await this._versionSettingManager.read(); + return SettingVersion.fromString(versionSetting); + } catch (e: any) { + const error = e instanceof Error ? e : new Error(e); + console.warn(`Unable to read model version from settings, caused by "${error.message}". Using default version.`); + return DefaultSettingsMigrator.DEFAULT_MODEL_VERSION; + } + } + + async isMigrationNeeded(): Promise { + const currentVersion = await this.getModelVersion(); + return this._targetVersion !== currentVersion; + } + + get targetVersion(): SettingVersion { + return this._targetVersion; + } + + async performMigration(): Promise { + const doMigration = await this.isMigrationNeeded(); + if (!doMigration) { + return { + to: this._targetVersion, + from: this._targetVersion, + status: SettingMigrationStatus.NOT_NEEDED, + } + } + + let currentVersion = await this.getModelVersion(); + const initialVersion = currentVersion; + + while (await this.isMigrationNeeded()) { + const migrationStep = this._stepsBySourceVersion.get(currentVersion); + if (!migrationStep) { + throw new Error(`No migration step found for version ${currentVersion}.`); + } + await migrationStep.perform(); + currentVersion = await this.getModelVersion(); + } + + return { + to: this._targetVersion, + from: initialVersion, + status: SettingMigrationStatus.SUCCESS, + } + } + +} + +export { DefaultSettingsMigrator } \ No newline at end of file diff --git a/src/scripts/repository/migration/SettingMigrationStatus.ts b/src/scripts/repository/migration/SettingMigrationStatus.ts new file mode 100644 index 00000000..dc28a43a --- /dev/null +++ b/src/scripts/repository/migration/SettingMigrationStatus.ts @@ -0,0 +1,23 @@ +/** + * The status of a setting migration. + */ +enum SettingMigrationStatus { + + /** + * The migration was successful. Your settings are now up-to-date. + */ + SUCCESS, + + /** + * The migration failed. Your settings may be in an invalid state. + */ + FAILURE, + + /** + * The migration was not necessary. The current version is the same as the target version. + */ + NOT_NEEDED, + +} + +export { SettingMigrationStatus }; \ No newline at end of file diff --git a/src/scripts/repository/migration/SettingMigrationStep.ts b/src/scripts/repository/migration/SettingMigrationStep.ts new file mode 100644 index 00000000..433b1fdf --- /dev/null +++ b/src/scripts/repository/migration/SettingMigrationStep.ts @@ -0,0 +1,14 @@ +import {SettingVersion} from "./SettingVersion"; + +interface SettingMigrationStep { + + readonly from: SettingVersion; + + readonly to: SettingVersion; + + perform(): Promise; + +} + +export { SettingMigrationStep } + diff --git a/src/scripts/repository/migration/SettingVersion.ts b/src/scripts/repository/migration/SettingVersion.ts new file mode 100644 index 00000000..ad426ca5 --- /dev/null +++ b/src/scripts/repository/migration/SettingVersion.ts @@ -0,0 +1,37 @@ +/** + * The possible versions of Fabricate Settings + */ +enum SettingVersion { + V2 = "V2", + V3 = "V3", +} + +namespace SettingVersion { + + export function fromString(value: string): SettingVersion { + if (!value) { + throw new Error("Cannot convert null or undefined to SettingVersion"); + } + if (value == "V2") { + return SettingVersion.V2; + } + if (value == "V3") { + return SettingVersion.V3; + } + throw new Error(`Unknown SettingVersion: ${value}`); + } + + export function toString(value: SettingVersion): string { + switch (value) { + case SettingVersion.V2: + return "V2"; + case SettingVersion.V3: + return "V3"; + default: + throw new Error(`Unknown SettingVersion: ${value}`); + } + } + +} + +export { SettingVersion }; \ No newline at end of file diff --git a/src/scripts/repository/migration/SettingsMigrator.ts b/src/scripts/repository/migration/SettingsMigrator.ts new file mode 100644 index 00000000..771ecd4c --- /dev/null +++ b/src/scripts/repository/migration/SettingsMigrator.ts @@ -0,0 +1,16 @@ +import {SettingVersion} from "./SettingVersion"; +import {SettingMigrationResult} from "../../api/SettingMigrationResult"; + +interface SettingsMigrator { + + readonly targetVersion: SettingVersion; + + isMigrationNeeded(): Promise; + + performMigration(): Promise; + + getModelVersion(): Promise; + +} + +export { SettingsMigrator } \ No newline at end of file diff --git a/src/scripts/repository/migration/V2SettingsModel.ts b/src/scripts/repository/migration/V2SettingsModel.ts new file mode 100644 index 00000000..e72b3ba7 --- /dev/null +++ b/src/scripts/repository/migration/V2SettingsModel.ts @@ -0,0 +1,68 @@ +interface VersionedSettings> { + + readonly version: string; + + readonly value: T; + +} + +export { VersionedSettings } + +interface V2Component { + itemUuid: string; + disabled: boolean; + essences: Record; + salvageOptions: Record>; +} + +export { V2Component } + +interface V2Recipe { + itemUuid: string; + disabled: boolean; + essences: Record; + resultOptions: Record>; + ingredientOptions: Record; + ingredients: Record; + }>; +} + +export { V2Recipe } + +interface V2Essence { + name: string; + tooltip: string; + iconCode: string; + description: string; + activeEffectSourceItemUuid: string; +} + +export { V2Essence } + +interface V2CraftingSystem { + id: string; + details: { + name: string; + summary: string; + description: string; + author: string; + }; + enabled: boolean; + locked: boolean; + parts: { + components: Record; + recipes: Record; + essences: Record; + }; +} + +export { V2CraftingSystem } + +interface V2SettingsModel { + + craftingSystems: VersionedSettings>; + +} + +export { V2SettingsModel } \ No newline at end of file diff --git a/src/scripts/repository/migration/V2ToV3SettingMigrationStep.ts b/src/scripts/repository/migration/V2ToV3SettingMigrationStep.ts new file mode 100644 index 00000000..8c324a76 --- /dev/null +++ b/src/scripts/repository/migration/V2ToV3SettingMigrationStep.ts @@ -0,0 +1,266 @@ +import {SettingVersion} from "./SettingVersion"; +import {IdentityFactory} from "../../foundry/IdentityFactory"; +import {SettingManager} from "../SettingManager"; +import Properties from "../../Properties"; +import {V3SettingsModel} from "./V3SettingsModel"; +import {V2Component, V2CraftingSystem, V2Essence, V2Recipe, V2SettingsModel} from "./V2SettingsModel"; +import {EssenceJson} from "../../crafting/essence/Essence"; +import {CraftingSystemJson} from "../../system/CraftingSystem"; +import {ComponentJson} from "../../crafting/component/Component"; +import {SalvageOptionJson} from "../../crafting/component/SalvageOption"; +import {RecipeJson} from "../../crafting/recipe/Recipe"; +import {ResultOptionJson} from "../../crafting/recipe/ResultOption"; +import {RequirementOptionJson} from "../../crafting/recipe/RequirementOption"; +import {SettingMigrationStep} from "./SettingMigrationStep"; + +/** + * This migration step is responsible for migrating the V2 settings model to the V3 settings model. It filters out + * embedded crafting systems, as these should be overwritten separately during the migration process. + */ +class V2ToV3SettingMigrationStep implements SettingMigrationStep { + + private static readonly FROM_VERSION = SettingVersion.V2; + private static readonly TO_VERSION = SettingVersion.V3; + + private readonly _identityFactory: IdentityFactory; + private readonly _embeddedCraftingSystemIds: string[]; + private readonly _settingManagersBySettingPath: Map>; + + constructor({ + identityFactory, + embeddedCraftingSystemsIds, + settingManagersBySettingPath, + }: { + identityFactory: IdentityFactory; + embeddedCraftingSystemsIds: string[]; + settingManagersBySettingPath: Map>; + }) { + this._identityFactory = identityFactory; + this._embeddedCraftingSystemIds = embeddedCraftingSystemsIds; + this._settingManagersBySettingPath = settingManagersBySettingPath; + } + + get from(): SettingVersion { + return V2ToV3SettingMigrationStep.FROM_VERSION; + } + + get to(): SettingVersion { + return V2ToV3SettingMigrationStep.TO_VERSION; + } + + private _getSettingManagerByKey(key: string): SettingManager { + if (!this._settingManagersBySettingPath.has(key)) { + throw new Error(`No setting manager found for setting path ${key}`); + } + return this._settingManagersBySettingPath.get(key); + } + + private _getVersionSettingManager(): SettingManager { + return this._getSettingManagerByKey(Properties.settings.modelVersion.key); + } + + async perform(): Promise { + + const v3CraftingSystemSetting: V3SettingsModel["craftingSystems"] = { + entities: {}, + collections: {} + }; + + const v3EssenceSetting: V3SettingsModel["essences"] = { + entities: {}, + collections: {} + }; + + const v3ComponentSetting: V3SettingsModel["components"] = { + entities: {}, + collections: {} + }; + + const v3RecipeSetting: V3SettingsModel["recipes"] = { + entities: {}, + collections: {} + }; + + const craftingSystemsSettingManager = this._getSettingManagerByKey(Properties.settings.craftingSystems.key); + const sourceData = await craftingSystemsSettingManager.read() as V2SettingsModel["craftingSystems"]; + + const canMigrate = sourceData !== null && sourceData.hasOwnProperty("value"); + if (!canMigrate) { + throw new Error("Cannot migrate V2 settings model to V3 settings model. V2 source data must be an object with a 'value' property."); + } + + Object.keys(sourceData.value) + .filter(craftingSystemId => !this._embeddedCraftingSystemIds.includes(craftingSystemId)) + .forEach(craftingSystemId => { + const craftingSystem = sourceData.value[craftingSystemId]; + const migratedCraftingSystem = this.migrateCraftingSystem(craftingSystem, craftingSystemId); + v3CraftingSystemSetting.entities[migratedCraftingSystem.id] = migratedCraftingSystem; + + const craftingSystemCollectionName = `${Properties.settings.collectionNames.craftingSystem}.${craftingSystemId}`; + + Object.keys(craftingSystem.parts.essences) + .map(essenceId => { + const essence = craftingSystem.parts.essences[essenceId]; + return this.migrateEssence(essence, essenceId, craftingSystemId); + }) + .forEach(migratedEssence => { + v3EssenceSetting.entities[migratedEssence.id] = migratedEssence; + this.pushSafelyToCollection(v3EssenceSetting.collections, craftingSystemCollectionName, migratedEssence.id); + if (migratedEssence.activeEffectSourceItemUuid) { + const itemCollectionName = `${Properties.settings.collectionNames.item}.${migratedEssence.activeEffectSourceItemUuid}`; + this.pushSafelyToCollection(v3EssenceSetting.collections, itemCollectionName, migratedEssence.id); + } + }); + + Object.keys(craftingSystem.parts.components) + .map(componentId => { + const component = craftingSystem.parts.components[componentId]; + return this.migrateComponent(component, componentId, craftingSystemId); + }) + .forEach(migratedComponent => { + v3ComponentSetting.entities[migratedComponent.id] = migratedComponent; + this.pushSafelyToCollection(v3ComponentSetting.collections, craftingSystemCollectionName, migratedComponent.id); + const itemCollectionName = `${Properties.settings.collectionNames.item}.${migratedComponent.itemUuid}`; + this.pushSafelyToCollection(v3ComponentSetting.collections, itemCollectionName, migratedComponent.id); + }); + + Object.keys(craftingSystem.parts.recipes) + .map(recipeId => { + const recipe = craftingSystem.parts.recipes[recipeId]; + return this.migrateRecipe(recipe, recipeId, craftingSystemId); + }) + .forEach(migratedRecipe => { + v3RecipeSetting.entities[migratedRecipe.id] = migratedRecipe; + this.pushSafelyToCollection(v3RecipeSetting.collections, craftingSystemCollectionName, migratedRecipe.id); + const itemCollectionName = `${Properties.settings.collectionNames.item}.${migratedRecipe.itemUuid}`; + this.pushSafelyToCollection(v3RecipeSetting.collections, itemCollectionName, migratedRecipe.id); + }); + }); + + await craftingSystemsSettingManager.write(v3CraftingSystemSetting); + + const essenceSettingManager = this._getSettingManagerByKey(Properties.settings.essences.key); + await essenceSettingManager.write(v3EssenceSetting); + + const componentSettingManager = this._getSettingManagerByKey(Properties.settings.components.key); + await componentSettingManager.write(v3ComponentSetting); + + const recipeSettingManager = this._getSettingManagerByKey(Properties.settings.recipes.key); + await recipeSettingManager.write(v3RecipeSetting); + + await this._getVersionSettingManager().write(V2ToV3SettingMigrationStep.TO_VERSION.toString()); + } + + private pushSafelyToCollection(collectionsObject: Record, collectionName: string, value: any) { + if (!collectionsObject[collectionName]) { + collectionsObject[collectionName] = []; + } + collectionsObject[collectionName].push(value); + } + + private migrateEssence(source: V2Essence, essenceId: string, craftingSystemId: string): EssenceJson { + return { + id: essenceId, + disabled: false, + name: source.name, + tooltip: source.tooltip, + iconCode: source.iconCode, + description: source.description, + craftingSystemId: craftingSystemId, + embedded: false, + activeEffectSourceItemUuid: source.activeEffectSourceItemUuid, + }; + } + + private migrateCraftingSystem(source: V2CraftingSystem, craftingSystemId: string): CraftingSystemJson { + return { + id: craftingSystemId, + details: { + name: source.details.name, + summary: source.details.summary, + description: source.details.description, + author: source.details.author + }, + embedded: false, + disabled: !!source.enabled + }; + } + + private migrateComponent(source: V2Component, componentId: string, craftingSystemId: string): ComponentJson { + const salvageOptions = Object.keys(source.salvageOptions) + .map(salvageOptionId => { + return { + id: this._identityFactory.make(), + name: salvageOptionId, + results: source.salvageOptions[salvageOptionId], + catalysts: {}, + } + }) + .reduce((record, value) => { + record[value.id] = value; + return record; + }, >{}); + return { + id: componentId, + salvageOptions, + embedded: false, + craftingSystemId, + disabled: source.disabled, + itemUuid: source.itemUuid, + essences: source.essences, + }; + } + + private migrateRecipe(source: V2Recipe, recipeId: string, craftingSystemId: string): RecipeJson { + const resultOptions = Object.keys(source.resultOptions) + .map(resultOptionId => { + return { + id: this._identityFactory.make(), + name: resultOptionId, + results: source.resultOptions[resultOptionId], + catalysts: {}, + } + }) + .reduce((record, value) => { + record[value.id] = value; + return record; + }, >{}); + const requirementOptions = Object.keys(source.ingredientOptions) + .map(ingredientOptionId => { + const ingredientOption = source.ingredientOptions[ingredientOptionId]; + return { + id: this._identityFactory.make(), + name: ingredientOptionId, + essences: source.essences, + ingredients: ingredientOption.ingredients, + catalysts: ingredientOption.catalysts, + } + }) + .reduce((record, value) => { + record[value.id] = value; + return record; + }, >{}); + if (Object.keys(source.essences).length > 0 && Object.keys(requirementOptions).length === 0) { + const optionId = this._identityFactory.make(); + requirementOptions[optionId] = { + id: optionId, + name: "Essences only", + essences: source.essences, + ingredients: {}, + catalysts: {}, + } + } + return { + id: recipeId, + resultOptions, + embedded: false, + craftingSystemId, + requirementOptions, + disabled: false, + itemUuid: source.itemUuid, + }; + } + +} + +export {V2ToV3SettingMigrationStep}; \ No newline at end of file diff --git a/src/scripts/repository/migration/V3SettingsModel.ts b/src/scripts/repository/migration/V3SettingsModel.ts new file mode 100644 index 00000000..ce049fd6 --- /dev/null +++ b/src/scripts/repository/migration/V3SettingsModel.ts @@ -0,0 +1,47 @@ +import {CraftingSystemJson} from "../../system/CraftingSystem"; +import {ComponentJson} from "../../crafting/component/Component"; +import {RecipeJson} from "../../crafting/recipe/Recipe"; +import {EssenceJson} from "../../crafting/essence/Essence"; + +type V3EntityDataStoreModel = { + + entities: Record; + collections: Record; + +}; + +export { V3EntityDataStoreModel } + +interface V3SettingsModel { + + modelVersion: "V3"; + + /** + * Accepts no collection keys + */ + craftingSystems: V3EntityDataStoreModel; + + /** + * Accepts keys: + * - ${Properties.settings.collectionNames.craftingSystem}.craftingSystemId + * - ${Properties.settings.collectionNames.item}.activeEffectSourceItemUuid + */ + essences: V3EntityDataStoreModel; + + /** + * Accepts keys: + * - ${Properties.settings.collectionNames.craftingSystem}.craftingSystemId + * - ${Properties.settings.collectionNames.item}.itemUuid + */ + components: V3EntityDataStoreModel; + + /** + * Accepts keys: + * - ${Properties.settings.collectionNames.craftingSystem}.craftingSystemId + * - ${Properties.settings.collectionNames.item}.itemUuid + */ + recipes: V3EntityDataStoreModel; + +} + +export { V3SettingsModel } \ No newline at end of file diff --git a/src/scripts/settings/FabricateSetting.ts b/src/scripts/settings/FabricateSetting.ts deleted file mode 100644 index e26ae689..00000000 --- a/src/scripts/settings/FabricateSetting.ts +++ /dev/null @@ -1,253 +0,0 @@ -import Properties from "../Properties"; -import {DefaultGameProvider, GameProvider} from "../foundry/GameProvider"; - -interface FabricateSetting { - version: string; - value: T; -} - -interface FabricateSettingMigrator { - fromVersion: string; - toVersion: string; - perform: (from: F) => T; -} - -enum SettingState { - OUTDATED= "OUTDATED", - INVALID = "INVALID", - VALID = "VALID" -} - -interface SettingManager { - - read(): T; - - write(value: T): Promise; - - delete(): Promise; - - asVersionedSetting(value: T): FabricateSetting; - - migrate(): Promise; - - check(): CheckResult; - -} -interface MigrationCheck { - requiresMigration: boolean; - currentVersion: string; - targetVersion: string; -} - -interface CheckResult { - state: SettingState; - validationCheck: ValidationCheck; - migrationCheck?: MigrationCheck; -} - -interface MigrationResult { - finalVersion: string; - initialVersion: string; - steps: number; - isSuccessful: boolean; -} - -interface ValidationCheck { - isValid: boolean; - errors: string[]; -} - -class DefaultSettingManager implements SettingManager { - - private readonly _moduleId: string; - private readonly _settingKey: string; - private readonly _targetVersion: string; - private readonly _gameProvider: GameProvider; - private readonly _settingsMigratorsByInputVersion: Map>; - - constructor({ - moduleId = Properties.module.id, - settingKey, - targetVersion, - gameProvider = new DefaultGameProvider(), - settingsMigrators = [], - }: { - moduleId?: string; - settingKey?: string; - targetVersion: string - gameProvider?: GameProvider; - settingsMigrators?: FabricateSettingMigrator[]; - }) { - this._moduleId = moduleId; - this._settingKey = settingKey; - this._gameProvider = gameProvider; - this._targetVersion = targetVersion; - this._settingsMigratorsByInputVersion = this.mapSettingsMigrators(settingsMigrators, targetVersion); - } - - public asVersionedSetting(value: T): FabricateSetting { - return { - value, - version: this._targetVersion - }; - } - - public read(): T { - const storedSetting: FabricateSetting = this.load(); - const validationCheck = this.validate(storedSetting); - if (validationCheck.isValid) { - return storedSetting.value; - } - throw new Error(`Unable to read setting value for key ${this._settingKey}. `); - } - - async migrate(): Promise { - const initialSetting = this.load(); - console.log(initialSetting); // todo: delete me - const result: MigrationResult = { - initialVersion: initialSetting.version, - finalVersion: null, - isSuccessful: false, - steps: 0 - }; - try { - const migration = this.migrateSettingValue( - initialSetting, - this._targetVersion, - this._settingsMigratorsByInputVersion - ); - const settingToStore = this.asVersionedSetting(migration.value); - await this.save(settingToStore); - result.isSuccessful = true; - result.steps = migration.steps; - result.finalVersion = this._targetVersion - return result; - } catch (e: any) { - if (e instanceof Error) { - console.error(e.stack); - } - return result; - } - } - - check(): CheckResult { - const storedSetting: FabricateSetting = this.load(); - const validationCheck = this.validate(storedSetting); - const result: CheckResult = { - state: SettingState.VALID, - migrationCheck: null, - validationCheck: validationCheck - }; - if (!validationCheck.isValid) { - result.state = SettingState.INVALID; - return result; - } - const migrationCheck: MigrationCheck = { - requiresMigration: false, - currentVersion: storedSetting.version, - targetVersion: this._targetVersion - } - result.migrationCheck = migrationCheck; - if (storedSetting.version !== this._targetVersion) { - migrationCheck.requiresMigration = true; - result.state = SettingState.OUTDATED; - } - return result; - } - - private validate(setting: FabricateSetting): ValidationCheck { - const result: ValidationCheck = { - isValid: false, - errors: [] - } - if (!setting) { - result.errors.push("notFound"); - return result; - } - if (!setting.version) { - result.errors.push("noVersion"); - } - if (!setting.value) { - result.errors.push("noValue"); - } - if (result.errors.length === 0) { - result.isValid = true; - } - return result; - } - - async write(value: T): Promise { - await this.save(this.asVersionedSetting(value)); - return; - } - - private migrateSettingValue(storedSetting: FabricateSetting, - targetVersion: string, - settingsMigratorsByInputVersion: Map>): { steps: number, value: T } { - let setting: FabricateSetting = storedSetting; - let steps = 0; - while (settingsMigratorsByInputVersion.has(setting.version)) { - const settingMigrator = settingsMigratorsByInputVersion.get(setting.version); - const value = settingMigrator.perform(setting.value); - setting = { - version: settingMigrator.toVersion, - value - } - steps++; - } - if (setting.version === targetVersion) { - return { value: setting.value as T, steps }; - } - throw new Error(`Could not migrate stored setting value: \n ${storedSetting}. `); - } - - save(value: FabricateSetting): Promise> { - return this._gameProvider.get().settings.set(this._moduleId, this._settingKey, value); - } - - load(): FabricateSetting { - return this._gameProvider.get().settings.get(this._moduleId, this._settingKey) as FabricateSetting; - } - - async delete(): Promise { - const setting = this._gameProvider.get().settings.storage.get("world").getSetting(`${this._moduleId}.${this._settingKey}`); - await setting.delete(); - return setting; - } - - get settingKey(): string { - return this._settingKey; - } - - private mapSettingsMigrators(settingsMigrators: FabricateSettingMigrator[], targetVersion: string): Map> { - const result = new Map>(); - if (settingsMigrators.length === 0 ) { - return result; - } - let targetVersionOutputFound = false; - settingsMigrators.forEach(settingMigrator => { - if (result.has(settingMigrator.fromVersion)) { - throw new Error(`Duplicate settings migrators were found for the input version ${settingMigrator.fromVersion}. `); - } - result.set(settingMigrator.fromVersion, settingMigrator); - if (settingMigrator.toVersion === targetVersion) { - targetVersionOutputFound = true; - } - }); - if (!targetVersionOutputFound) { - throw new Error(`Target version ${targetVersion} is not reachable through the configured settings migrators.`); - } - return result; - } -} - -export { - SettingManager, - DefaultSettingManager, - FabricateSetting, - FabricateSettingMigrator, - CheckResult, - SettingState, - MigrationCheck, - ValidationCheck -} \ No newline at end of file diff --git a/src/scripts/settings/migrators/V2CraftingSystemSettingMigrator.ts b/src/scripts/settings/migrators/V2CraftingSystemSettingMigrator.ts deleted file mode 100644 index 4baed7c6..00000000 --- a/src/scripts/settings/migrators/V2CraftingSystemSettingMigrator.ts +++ /dev/null @@ -1,119 +0,0 @@ -import {FabricateSettingMigrator} from "../FabricateSetting"; -import {CraftingSystemJson} from "../../system/CraftingSystem"; -import {V1ComponentJson, V1EssenceJson, V1RecipeJson, V1SystemJson} from "../../system/setting_versions/V1Json"; -import {EssenceJson} from "../../common/Essence"; -import {CraftingComponentJson, SalvageOptionJson} from "../../common/CraftingComponent"; -import {RequirementOptionJson, RecipeJson, ResultOptionJson} from "../../common/Recipe"; - -class V2CraftingSystemSettingMigrator implements FabricateSettingMigrator, Record> { - - private static readonly _FROM_VERSION: string = "1"; - private static readonly _TO_VERSION: string = "2"; - - get fromVersion(): string { - return V2CraftingSystemSettingMigrator._FROM_VERSION; - } - - get toVersion(): string { - return V2CraftingSystemSettingMigrator._TO_VERSION; - } - - perform(from: Record): Record { - const result: Record = {}; - Object.keys(from).forEach(systemId => { - result[systemId] = this.migrateCraftingSystem(from[systemId]); - }) - return result; - } - - private migrateCraftingSystem(inputSystem: V1SystemJson): CraftingSystemJson { - return { - id: inputSystem.id, - details: inputSystem.details, - locked: inputSystem.locked, - enabled: inputSystem.enabled, - parts: { - essences: this.migrateEssences(inputSystem.parts.essences), - components: this.migrateComponents(inputSystem.parts.components), - recipes: this.migrateRecipes(inputSystem.parts.recipes) - } - }; - } - - private migrateEssences(inputEssences: Record): Record { - const result: Record = {}; - Object.keys(inputEssences).forEach(essenceId => { - const inputEssence: V1EssenceJson = inputEssences[essenceId]; - result[essenceId] = { - name: inputEssence.name, - description: inputEssence.description, - iconCode: inputEssence.iconCode, - tooltip: inputEssence.tooltip, - activeEffectSourceItemUuid: null - }; - }); - return result; - } - - private migrateComponents(inputComponents: Record): Record { - const result: Record = {}; - Object.keys(inputComponents).forEach(componentId => { - const inputComponent: V1ComponentJson = inputComponents[componentId]; - result[componentId] = { - essences: inputComponent.essences, - disabled: false, - itemUuid: inputComponent.itemUuid, - salvageOptions: this.migrateSalvage(inputComponent.salvage) - } - }); - return result; - - } - - private migrateSalvage(salvage: Record): Record { - if (!salvage || Object.keys(salvage).length === 0) { - return {}; - } - const optionName = "Option 1"; - return {[optionName]: salvage}; - } - - private migrateRecipes(inputRecipes: Record): Record { - const result: Record = {}; - Object.keys(inputRecipes).forEach(recipeId => { - const inputRecipe: V1RecipeJson = inputRecipes[recipeId]; - result[recipeId] = { - essences: inputRecipe.essences, - disabled: false, - itemUuid: inputRecipe.itemUuid, - ingredientOptions: this.migrateIngredients(inputRecipe.ingredientGroups, inputRecipe.catalysts), - resultOptions: this.migrateResults(inputRecipe.resultGroups) - } - }); - return result; - } - - private migrateIngredients(ingredientGroups: Record[], catalysts: Record): Record { - const result: Record = {}; - ingredientGroups.forEach((value, index) => { - const optionName = `Option ${index + 1}`; - const option: RequirementOptionJson = { - ingredients: value, - catalysts: catalysts - }; - result[optionName] = option; - }); - return result; - } - - private migrateResults(resultGroups: Record[]): Record { - const result: Record = {}; - resultGroups.forEach((value, index) => { - const optionName = `Option ${index + 1}`; - result[optionName] = value; - }); - return result; - } -} - -export { V2CraftingSystemSettingMigrator } \ No newline at end of file diff --git a/src/scripts/system/ComponentDictionary.ts b/src/scripts/system/ComponentDictionary.ts index 93ef4627..cb3863d4 100644 --- a/src/scripts/system/ComponentDictionary.ts +++ b/src/scripts/system/ComponentDictionary.ts @@ -1,23 +1,24 @@ import {Dictionary} from "./Dictionary"; -import {CraftingComponent, CraftingComponentJson, SalvageOption, SalvageOptionJson} from "../common/CraftingComponent"; +import {Component, ComponentJson} from "../crafting/component/Component"; import { DocumentManager, FabricateItemData, NoFabricateItemData } from "../foundry/DocumentManager"; import {EssenceDictionary} from "./EssenceDictionary"; -import {combinationFromRecord} from "./DictionaryUtils"; -import {SelectableOptions} from "../common/SelectableOptions"; -import {Essence} from "../common/Essence"; +import {SelectableOptions} from "../crafting/selection/SelectableOptions"; +import {Essence} from "../crafting/essence/Essence"; import Properties from "../Properties"; +import {Combination} from "../common/Combination"; +import {SalvageOption, SalvageOptionJson} from "../crafting/component/SalvageOption"; -export class ComponentDictionary implements Dictionary { +export class ComponentDictionary implements Dictionary { - private _sourceData: Record; + private _sourceData: Record; private readonly _documentManager: DocumentManager; private readonly _essenceDictionary: EssenceDictionary; - private readonly _entriesById: Map; - private readonly _entriesByItemUuid: Map; + private readonly _entriesById: Map; + private readonly _entriesByItemUuid: Map; private _loaded: boolean; constructor({ @@ -28,11 +29,11 @@ export class ComponentDictionary implements Dictionary; + sourceData: Record; documentManager: DocumentManager; essenceDictionary: EssenceDictionary; - entriesById?: Map; - entriesByItemUuid?: Map; + entriesById?: Map; + entriesByItemUuid?: Map; loaded?: boolean; }) { this._sourceData = sourceData; @@ -51,7 +52,7 @@ export class ComponentDictionary implements Dictionary entry.hasErrors); } @@ -71,30 +72,30 @@ export class ComponentDictionary implements Dictionary { + getAll(): Map { return new Map(this._entriesById); } - getById(id: string): CraftingComponent { + getById(id: string): Component { if (!this._entriesById.has(id)) { throw new Error(`No Component data was found for the id "${id}". Known Component IDs for this system are: ${Array.from(this._entriesById.keys()).join(", ")}`); } return this._entriesById.get(id); } - get allByItemUuid(): Map { + get allByItemUuid(): Map { return new Map(this._entriesByItemUuid) } - get sourceData(): Record { + get sourceData(): Record { return this._sourceData; } - set sourceData(value: Record) { + set sourceData(value: Record) { this._sourceData = value; } - insert(craftingComponent: CraftingComponent): void { + insert(craftingComponent: Component): void { if (craftingComponent.itemUuid !== NoFabricateItemData.UUID()) { this._entriesByItemUuid.set(craftingComponent.itemUuid, craftingComponent); } @@ -124,14 +125,14 @@ export class ComponentDictionary implements Dictionary { + async loadAll(): Promise { if (!this._sourceData) { throw new Error("Unable to load Crafting components. No source data was provided. "); } await this.loadDependencies(); const itemUuids = Object.values(this._sourceData) .map(data => data.itemUuid); - const cachedItemDataByUUid = await this._documentManager.getDocumentsByUuid(itemUuids); + const cachedItemDataByUUid = await this._documentManager.loadItemDataForDocumentsByUuid(itemUuids); this._entriesById.clear(); this._entriesByItemUuid.clear(); // Loads all components _without_ loading salvage data @@ -139,10 +140,10 @@ export class ComponentDictionary implements Dictionary { return {id, json: this._sourceData[id]} }) - .map(data => new CraftingComponent({ + .map(data => new Component({ id: data.id, itemData: cachedItemDataByUUid.get(data.json.itemUuid), - essences: combinationFromRecord(data.json.essences, this._essenceDictionary.getAll()), + essences: Combination.fromRecord(data.json.essences, this._essenceDictionary.getAll()), disabled: data.json.disabled, salvageOptions: new SelectableOptions({}) })) @@ -160,7 +161,7 @@ export class ComponentDictionary implements Dictionary { + async loadById(id: string): Promise { const sourceRecord = this._sourceData[id]; if (!sourceRecord) { throw new Error(`Unable to load Crafting Component with ID ${id}. No definition for the component was found in source data. @@ -168,21 +169,21 @@ export class ComponentDictionary implements Dictionary, allComponents: Map): SelectableOptions { + private buildSalvageOptions(salvageOptionsJson: Record, allComponents: Map): SelectableOptions { const options = Object.keys(salvageOptionsJson) .map(name => this.buildSalvageOption(name, salvageOptionsJson[name], allComponents)); return new SelectableOptions({ @@ -190,10 +191,10 @@ export class ComponentDictionary implements Dictionary): SalvageOption { + private buildSalvageOption(name: string, salvageOptionJson: SalvageOptionJson, allComponents: Map): SalvageOption { return new SalvageOption({ name, - salvage: combinationFromRecord(salvageOptionJson, allComponents) + results: Combination.fromRecord(salvageOptionJson, allComponents) }); } @@ -203,7 +204,7 @@ export class ComponentDictionary implements Dictionary { + toJson(): Record { return Array.from(this._entriesById.entries()) .map(entry => { return {key: entry[0], value: entry[1].toJson()} @@ -211,7 +212,7 @@ export class ComponentDictionary implements Dictionary { left[right.key] = right.value; return left; - }, >{}); + }, >{}); } get isEmpty(): boolean { @@ -233,8 +234,8 @@ export class ComponentDictionary implements Dictionary { - const itemData = await this._documentManager.getDocumentByUuid(craftingComponentJson.itemUuid); + async create(craftingComponentJson: ComponentJson): Promise { + const itemData = await this._documentManager.loadItemDataByDocumentUuid(craftingComponentJson.itemUuid); if (itemData.hasErrors) { throw new Error(`Could not load document with UUID "${craftingComponentJson.itemUuid}". Errors ${itemData.errors.join(", ")} `); } @@ -247,7 +248,7 @@ export class ComponentDictionary implements Dictionary { + async mutate(id: string, mutation: ComponentJson): Promise { if (!this._loaded) { throw new Error("Fabricate doesn't currently support modifying components before the component dictionary has been loaded. "); } @@ -255,7 +256,7 @@ export class ComponentDictionary implements Dictionary { - return await this._partDictionary.mutateComponent(id, mutation); - } - - public getRecipes(): Recipe[] { - return this._partDictionary.getRecipes(); - } - - get recipes(): Recipe[] { - return this._partDictionary.getRecipes(); - } - - get craftingComponents(): CraftingComponent[] { - return this._partDictionary.getComponents(); - } - - get craftingComponentsByItemUuid(): Map { - return this._partDictionary.componentsByUuid; - } - - get essences(): Essence[] { - return this._partDictionary.getEssences(); - } - - public hasRecipe(id: string) { - return this._partDictionary.hasRecipe(id); - } - - public getRecipeById(id: string): Recipe { - return this._partDictionary.getRecipe(id); - } - - public deleteRecipeById(id: string): void { - return this._partDictionary.deleteRecipeById(id); - } - - public editRecipe(recipe: Recipe): void { - return this._partDictionary.insertRecipe(recipe); - } - - public async mutateRecipe(id: string, mutation: RecipeJson): Promise { - return await this._partDictionary.mutateRecipe(id, mutation); - } - - public async createRecipe(recipeJson: RecipeJson): Promise { - return this._partDictionary.createRecipe(recipeJson); - } - - public async createComponent(craftingComponentJson: CraftingComponentJson): Promise { - return this._partDictionary.createComponent(craftingComponentJson); - } - - public async createEssence(essenceJson: EssenceJson): Promise { - return this._partDictionary.createEssence(essenceJson); - } - - get summary(): string { - return this._details.summary; - } - - set summary(value: string) { - this.details.summary = value; - } - - get description(): string { - return this._details.description; - } - - set description(value: string) { - this.details.description = value; - } - - get author(): string { - return this._details.author; - } - - set author(value: string) { - this.details.author = value; + set isDisabled(value: boolean) { + this._disabled = value; } get id(): string { return this._id; } - get name(): string { - return this._details.name; - } - - set name(value: string) { - this.details.name = value; + get details(): CraftingSystemDetails { + return this._details; } set details(value: CraftingSystemDetails) { this._details = value; } - set enabled(value: boolean) { - this._enabled = value; - } - - public async reload(): Promise { - return this.loadPartDictionary(); - } - - public async loadPartDictionary(): Promise { - if (!this.isLoaded) { - await this._partDictionary.loadAll(); - } - await this._partDictionary.loadAll(this._partDictionary.toJson()); - } - - public async loadEssences(updatedSource?: Record): Promise { - await this._partDictionary.loadEssences(updatedSource); - } - - public async loadComponents(updatedSource?: Record): Promise { - await this._partDictionary.loadComponents(updatedSource); - } - - public async loadRecipes(updatedSource?: Record): Promise { - await this._partDictionary.loadRecipes(updatedSource); - } - - get isLoaded(): boolean { - return this._partDictionary.isLoaded; - } - - get hasErrors(): boolean { - return this._partDictionary.hasErrors; - } - toJson(): CraftingSystemJson { return { id: this._id, details: this._details.toJson(), - enabled: this._enabled, - locked: this._isLocked, - parts: this._partDictionary.toJson() + disabled: this._disabled, + embedded: this._embedded }; } - clone({id, name, locked}: { name: string; id: string; locked: boolean }) { + clone({id, name, embedded = false}: { name?: string; id: string; embedded?: boolean }): CraftingSystem { return new CraftingSystem({ id, - details: new CraftingSystemDetails({ - name, - summary: this._details.summary, - description: this._details.description, - author: this._details.author, - }), - locked, - enabled: this._enabled, - partDictionary: this._partDictionary.clone() + embedded, + craftingSystemDetails: this._details.clone(name), + disabled: this._disabled, }); } + + static fromJson(craftingSystemJson: CraftingSystemJson) { + return new CraftingSystem({ + id: craftingSystemJson.id, + embedded: craftingSystemJson.embedded, + craftingSystemDetails: CraftingSystemDetails.fromJson(craftingSystemJson.details), + disabled: craftingSystemJson.disabled, + }); + } + + equals(other: CraftingSystem, excludeDisabled: boolean = false): boolean { + return this._id === other._id + && this._embedded === other._embedded + && this._details.equals(other._details) + && (excludeDisabled || this._disabled === other._disabled); + } + } -export { CraftingSystem, CraftingSystemJson }; \ No newline at end of file +export { CraftingSystem } \ No newline at end of file diff --git a/src/scripts/system/CraftingSystemDetails.ts b/src/scripts/system/CraftingSystemDetails.ts index 5c96e72d..fb203b96 100644 --- a/src/scripts/system/CraftingSystemDetails.ts +++ b/src/scripts/system/CraftingSystemDetails.ts @@ -70,6 +70,30 @@ class CraftingSystemDetails { } } + clone(name?: string): CraftingSystemDetails { + return new CraftingSystemDetails({ + name: name ?? this._name, + summary: this._summary, + description: this._description, + author: this._author + }); + } + + static fromJson(craftingSystemDetailsJson: CraftingSystemDetailsJson) { + return new CraftingSystemDetails({ + name: craftingSystemDetailsJson.name, + summary: craftingSystemDetailsJson.summary, + description: craftingSystemDetailsJson.description, + author: craftingSystemDetailsJson.author + }); + } + + equals(other: CraftingSystemDetails) { + return this._name === other._name && + this._summary === other._summary && + this._description === other._description && + this._author === other._author; + } } export { CraftingSystemDetails, CraftingSystemDetailsJson } \ No newline at end of file diff --git a/src/scripts/system/CraftingSystemFactory.ts b/src/scripts/system/CraftingSystemFactory.ts index 6ef286a6..cb2e448b 100644 --- a/src/scripts/system/CraftingSystemFactory.ts +++ b/src/scripts/system/CraftingSystemFactory.ts @@ -1,44 +1,24 @@ import {CraftingSystem, CraftingSystemJson} from "./CraftingSystem"; -import {RecipeCraftingPrepFactory} from "../crafting/attempt/RecipeCraftingPrepFactory"; import {CraftingSystemDetails} from "./CraftingSystemDetails"; -import {PartDictionaryFactory} from "./PartDictionary"; -import {DefaultDocumentManager, DocumentManager} from "../foundry/DocumentManager"; -import {NoCraftingCheck} from "../crafting/check/CraftingCheck"; -import {DisabledAlchemyAttemptFactory} from "../crafting/alchemy/AlchemyAttemptFactory"; -import {DefaultComponentSelectionStrategy} from "../crafting/selection/ComponentSelectionStrategy"; +import {EntityFactory} from "../repository/EntityFactory"; -class CraftingSystemFactory { - - private readonly _documentManager: DocumentManager; - - constructor({ - documentManager = new DefaultDocumentManager() - }: { - documentManager?: DocumentManager - }) { - this._documentManager = documentManager - } +class CraftingSystemFactory implements EntityFactory { public async make(craftingSystemJson: CraftingSystemJson): Promise { - const partDictionaryFactory = new PartDictionaryFactory({documentManager: this._documentManager}); - const partDictionary = partDictionaryFactory.make(craftingSystemJson.parts); - return new CraftingSystem({ id: craftingSystemJson.id, - details: new CraftingSystemDetails(craftingSystemJson.details), - locked: craftingSystemJson.locked, - enabled: craftingSystemJson.enabled, - partDictionary: partDictionary, - craftingChecks: { - alchemy: new NoCraftingCheck(), // todo: implement user-defined, system-agnostic, flexible macro-based checks in the UI - recipe: new NoCraftingCheck() // todo: implement user-defined, system-agnostic, flexible macro-based checks in the UI - }, - craftingAttemptFactory: new RecipeCraftingPrepFactory({ - selectionStrategy: new DefaultComponentSelectionStrategy() + embedded: craftingSystemJson.embedded, + gameSystem: craftingSystemJson.gameSystem, + craftingSystemDetails: new CraftingSystemDetails({ + name: craftingSystemJson.details.name, + summary: craftingSystemJson.details.summary, + description: craftingSystemJson.details.description, + author: craftingSystemJson.details.author, }), - alchemyAttemptFactory: new DisabledAlchemyAttemptFactory() // todo: implement user-defined, system-agnostic, flexible macro-based alchemy in the UI + disabled: craftingSystemJson.disabled, }); + } } diff --git a/src/scripts/system/CraftingSystemValidator.ts b/src/scripts/system/CraftingSystemValidator.ts new file mode 100644 index 00000000..c5f65bec --- /dev/null +++ b/src/scripts/system/CraftingSystemValidator.ts @@ -0,0 +1,39 @@ +import {DefaultEntityValidationResult, EntityValidationResult, EntityValidator} from "../api/EntityValidator"; +import {CraftingSystem, CraftingSystemJson} from "./CraftingSystem"; + +class CraftingSystemValidator implements EntityValidator { + + async validate(entity: CraftingSystem): Promise> { + + const validationResult = await this.validateJson(entity.toJson()); + return new DefaultEntityValidationResult({entity, errors: validationResult.errors}); + + } + + async validateJson(entity: CraftingSystemJson): Promise> { + + const errors: string[] = []; + + if (!entity) { + throw new Error(`Cannot validate crafting system. Candidate is ${typeof entity} `); + } + + if (!entity.details?.name) { + errors.push("Crafting system name is required"); + } + + if (!entity.details?.summary) { + errors.push("Crafting system summary is required"); + } + + if (!entity.details?.author) { + errors.push("Crafting system author is required"); + } + + return new DefaultEntityValidationResult({entity, errors}); + + } + +} + +export {CraftingSystemValidator}; \ No newline at end of file diff --git a/src/scripts/system/Dictionary.ts b/src/scripts/system/Dictionary.ts index 28597cd2..6325d097 100644 --- a/src/scripts/system/Dictionary.ts +++ b/src/scripts/system/Dictionary.ts @@ -1,4 +1,5 @@ -import {Identifiable, Serializable} from "../common/Identity"; +import {Identifiable} from "../common/Identifiable"; +import {Serializable} from "../common/Serializable"; export interface Dictionary> { diff --git a/src/scripts/system/DictionaryUtils.ts b/src/scripts/system/DictionaryUtils.ts deleted file mode 100644 index 1bc55183..00000000 --- a/src/scripts/system/DictionaryUtils.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {Identifiable} from "../common/Identity"; -import {Combination} from "../common/Combination"; - -export const combinationFromRecord: (amounts: Record, candidatesById: Map) => Combination = (amounts, candidatesById) => { - if (!amounts) { - return Combination.EMPTY(); - } - return Object.keys(amounts) - .map(id => { - if (!candidatesById.has(id)) { - throw new Error(`Unable to resolve ID ${id}. `); - } - return Combination.of(candidatesById.get(id), amounts[id]); - }) - .reduce((left, right) => left.combineWith(right), Combination.EMPTY()); -} \ No newline at end of file diff --git a/src/scripts/system/EssenceDictionary.ts b/src/scripts/system/EssenceDictionary.ts index f486bbbf..998fa1d3 100644 --- a/src/scripts/system/EssenceDictionary.ts +++ b/src/scripts/system/EssenceDictionary.ts @@ -1,5 +1,5 @@ import {Dictionary} from "./Dictionary"; -import {Essence, EssenceJson} from "../common/Essence"; +import {Essence, EssenceJson} from "../crafting/essence/Essence"; import {DocumentManager, FabricateItemData, PendingFabricateItemData} from "../foundry/DocumentManager"; import Properties from "../Properties"; @@ -88,7 +88,7 @@ export class EssenceDictionary implements Dictionary { const itemUuids = Object.values(this._sourceData) .filter(data => !!data.activeEffectSourceItemUuid) .map(data => data.activeEffectSourceItemUuid); - const cachedItemDataByUUid = await this._documentManager.getDocumentsByUuid(itemUuids); + const cachedItemDataByUUid = await this._documentManager.loadItemDataForDocumentsByUuid(itemUuids); this._entries.clear(); const essences = await Promise.all(Object.keys(this._sourceData).map(id => this.loadById(id, cachedItemDataByUUid))); essences.forEach(essence => this._entries.set(essence.id, essence)); @@ -109,7 +109,7 @@ export class EssenceDictionary implements Dictionary { if (itemDataCache.has(essence.activeEffectSource.uuid)) { itemData = itemDataCache.get(essence.activeEffectSource.uuid); } else { - itemData = await this._documentManager.getDocumentByUuid(essence.activeEffectSource.uuid); + itemData = await this._documentManager.loadItemDataByDocumentUuid(essence.activeEffectSource.uuid); } essence.activeEffectSource = itemData; } @@ -146,7 +146,7 @@ export class EssenceDictionary implements Dictionary { let essence: Essence; const essenceId = randomID(); if (essenceJson.activeEffectSourceItemUuid) { - const itemData = await this._documentManager.getDocumentByUuid(essenceJson.activeEffectSourceItemUuid); + const itemData = await this._documentManager.loadItemDataByDocumentUuid(essenceJson.activeEffectSourceItemUuid); if (itemData.hasErrors) { throw new Error(`Could not load document with UUID "${essenceJson.activeEffectSourceItemUuid}". Errors ${itemData.errors.join(", ")} `); } diff --git a/src/scripts/system/PartDictionary.ts b/src/scripts/system/PartDictionary.ts deleted file mode 100644 index ef5459f8..00000000 --- a/src/scripts/system/PartDictionary.ts +++ /dev/null @@ -1,254 +0,0 @@ -import {CraftingComponent, CraftingComponentJson} from "../common/CraftingComponent"; -import {Recipe, RecipeJson} from "../common/Recipe"; -import {Essence, EssenceJson} from "../common/Essence"; -import {DefaultDocumentManager, DocumentManager} from "../foundry/DocumentManager"; -import {EssenceDictionary} from "./EssenceDictionary"; -import {ComponentDictionary} from "./ComponentDictionary"; -import {RecipeDictionary} from "./RecipeDictionary"; - - -class PartDictionary { - - private readonly _essenceDictionary: EssenceDictionary; - private readonly _componentDictionary: ComponentDictionary; - private readonly _recipeDictionary: RecipeDictionary; - - constructor({ - essenceDictionary, - componentDictionary, - recipeDictionary - }: { - essenceDictionary: EssenceDictionary; - componentDictionary: ComponentDictionary; - recipeDictionary: RecipeDictionary; - }) { - this._essenceDictionary = essenceDictionary; - this._componentDictionary = componentDictionary; - this._recipeDictionary = recipeDictionary; - } - - get isLoaded(): boolean { - return this._essenceDictionary.isLoaded && this._componentDictionary.isLoaded && this._recipeDictionary.isLoaded; - } - - get hasErrors(): boolean { - return this._essenceDictionary.hasErrors || this._componentDictionary.hasErrors || this._recipeDictionary.hasErrors; - } - - public hasEssence(id: string): boolean { - return this._essenceDictionary.contains(id); - } - - public hasEssences(): boolean { - return !this._essenceDictionary.isEmpty; - } - - public hasComponent(id: string): boolean { - return this._componentDictionary.contains(id); - } - - public hasRecipe(id: string): boolean { - return this._recipeDictionary.contains(id); - } - - public getRecipe(id: string): Recipe { - return this._recipeDictionary.getById(id); - } - - public getComponent(id: string): CraftingComponent { - return this._componentDictionary.getById(id); - } - - public getEssence(id: string): Essence { - return this._essenceDictionary.getById(id); - } - - get size(): number { - return this._recipeDictionary.size + this._componentDictionary.size + this._essenceDictionary.size; - } - - public getComponents(): CraftingComponent[] { - const componentsById = this._componentDictionary.getAll(); - return Array.from(componentsById.values()); - } - - get componentsByUuid(): Map { - return this._componentDictionary.allByItemUuid; - } - - public getRecipes(): Recipe[] { - const recipesById = this._recipeDictionary.getAll(); - return Array.from(recipesById.values()); - } - - public getEssences(): Essence[] { - const essencesById = this._essenceDictionary.getAll(); - return Array.from(essencesById.values()); - } - - public insertComponent(craftingComponent: CraftingComponent): void { - this._componentDictionary.insert(craftingComponent); - } - - async mutateComponent(id: string, mutation: CraftingComponentJson): Promise { - return this._componentDictionary.mutate(id, mutation); - } - - async mutateRecipe(id: string, mutation: RecipeJson): Promise { - return this._recipeDictionary.mutate(id, mutation); - } - - public insertRecipe(recipe: Recipe): void { - this._recipeDictionary.insert(recipe); - } - - public insertEssence(essence: Essence): void { - this._essenceDictionary.insert(essence); - } - - public deleteComponentById(id: string): void { - const componentToDelete = this._componentDictionary.getById(id); - this._componentDictionary.deleteById(id); - this._recipeDictionary.dropComponentReferences(componentToDelete); - } - - public deleteRecipeById(id: string): void { - return this._recipeDictionary.deleteById(id); - } - - public deleteEssenceById(id: string): void { - const essenceToDelete = this._essenceDictionary.getById(id); - this._essenceDictionary.deleteById(id); - this._componentDictionary.dropEssenceReferences(essenceToDelete); - this._recipeDictionary.dropEssenceReferences(essenceToDelete); - } - - async loadAll(updatedSource?: PartDictionaryJson): Promise { - if (updatedSource) { - this._essenceDictionary.sourceData = updatedSource.essences; - this._componentDictionary.sourceData = updatedSource.components; - this._recipeDictionary.sourceData = updatedSource.recipes; - } - await this._essenceDictionary.loadAll(); - await this._componentDictionary.loadAll(); - await this._recipeDictionary.loadAll(); - } - - async loadEssences(updatedSource?: Record): Promise { - if (updatedSource) { - this._essenceDictionary.sourceData = updatedSource; - } - await this._essenceDictionary.loadAll(); - } - - async loadComponents(updatedSource?: Record): Promise { - if (updatedSource) { - this._componentDictionary.sourceData = updatedSource; - } - await this._componentDictionary.loadAll(); - } - - async loadRecipes(updatedSource?: Record): Promise { - if (updatedSource) { - this._recipeDictionary.sourceData = updatedSource; - } - await this._recipeDictionary.loadAll(); - } - - public toJson(): PartDictionaryJson { - if (!this.isLoaded) { - throw new Error("Fabricate currently requires that a part dictionary is loaded before it is serialized and saved. "); - } - const essences = this._essenceDictionary.toJson(); - const components = this._componentDictionary.toJson(); - const recipes = this._recipeDictionary.toJson(); - return { - components, - recipes, - essences - } - } - - clone() { - return new PartDictionary({ - essenceDictionary: this._essenceDictionary.clone(), - recipeDictionary: this._recipeDictionary.clone(), - componentDictionary: this._componentDictionary.clone() - }); - } - - hasComponentUuid(itemUuid: string) { - return this._componentDictionary.containsItemByUuid(itemUuid); - } - - hasRecipeUuid(itemUuid: string) { - return this._recipeDictionary.containsItemByUuid(itemUuid); - } - - getComponentByItemUuid(uuid: string) { - return this._componentDictionary.getByItemUuid(uuid); - } - - getRecipeByItemUuid(uuid: string) { - return this._recipeDictionary.getByItemUuid(uuid); - } - - async createRecipe(recipeJson: RecipeJson): Promise { - return this._recipeDictionary.create(recipeJson); - } - - async createComponent(craftingComponentJson: CraftingComponentJson): Promise { - return this._componentDictionary.create(craftingComponentJson); - } - - async createEssence(essenceJson: EssenceJson): Promise { - return this._essenceDictionary.create(essenceJson); - } - - -} - -class PartDictionaryFactory { - private readonly _documentManager: DocumentManager; - - constructor({ - documentManager = new DefaultDocumentManager() - }: { - documentManager?: DocumentManager; - }) { - this._documentManager = documentManager; - } - - make(sourceData: PartDictionaryJson): PartDictionary { - const documentManager = this._documentManager; - const essenceDictionary = new EssenceDictionary({ - sourceData: sourceData.essences, - documentManager - }); - const componentDictionary = new ComponentDictionary({ - sourceData: sourceData.components, - documentManager, - essenceDictionary - }); - const recipeDictionary = new RecipeDictionary({ - sourceData: sourceData.recipes, - documentManager, - essenceDictionary, - componentDictionary - }); - return new PartDictionary({ - essenceDictionary, - componentDictionary, - recipeDictionary - }); - } - -} - -interface PartDictionaryJson { - components: Record; - recipes: Record; - essences: Record; -} - -export { PartDictionary, PartDictionaryJson, PartDictionaryFactory } \ No newline at end of file diff --git a/src/scripts/system/RecipeDictionary.ts b/src/scripts/system/RecipeDictionary.ts index d096cf7a..0c3f6abf 100644 --- a/src/scripts/system/RecipeDictionary.ts +++ b/src/scripts/system/RecipeDictionary.ts @@ -1,20 +1,18 @@ import {Dictionary} from "./Dictionary"; import { - RequirementOption, - RequirementOptionJson, Recipe, - RecipeJson, - ResultOption, - ResultOptionJson -} from "../common/Recipe"; + RecipeJson +} from "../crafting/recipe/Recipe"; import {DocumentManager, FabricateItemData, NoFabricateItemData} from "../foundry/DocumentManager"; import {EssenceDictionary} from "./EssenceDictionary"; import {ComponentDictionary} from "./ComponentDictionary"; -import {combinationFromRecord} from "./DictionaryUtils"; -import {CraftingComponent} from "../common/CraftingComponent"; -import {SelectableOptions} from "../common/SelectableOptions"; -import {Essence} from "../common/Essence"; +import {Component} from "../crafting/component/Component"; +import {SelectableOptions} from "../crafting/selection/SelectableOptions"; +import {Essence} from "../crafting/essence/Essence"; import Properties from "../Properties"; +import {Combination} from "../common/Combination"; +import {RequirementOption, RequirementOptionJson} from "../crafting/recipe/RequirementOption"; +import {ResultOption, ResultOptionJson} from "../crafting/recipe/ResultOption"; export class RecipeDictionary implements Dictionary { private _sourceData: Record; @@ -121,7 +119,7 @@ export class RecipeDictionary implements Dictionary { await this.loadDependencies(); const itemUuids = Object.values(this._sourceData) .map(data => data.itemUuid); - const cachedItemDataByUUid = await this._documentManager.getDocumentsByUuid(itemUuids); + const cachedItemDataByUUid = await this._documentManager.loadItemDataForDocumentsByUuid(itemUuids); this._entriesById.clear(); this._entriesByItemUuid.clear(); const recipes = await Promise.all(Object.keys(this._sourceData) @@ -138,7 +136,7 @@ export class RecipeDictionary implements Dictionary { This can occur if a recipe is loaded before it is saved or an invalid ID is passed.`); } const itemUuid = sourceRecord.itemUuid; - const itemData = itemDataCache.has(itemUuid) ? itemDataCache.get(itemUuid) : await this._documentManager.getDocumentByUuid(itemUuid); + const itemData = itemDataCache.has(itemUuid) ? itemDataCache.get(itemUuid) : await this._documentManager.loadItemDataByDocumentUuid(itemUuid); await this.loadDependencies(); return this.buildRecipe(id, sourceRecord, itemData); } @@ -148,13 +146,13 @@ export class RecipeDictionary implements Dictionary { id, itemData, disabled: sourceRecord.disabled, - ingredientOptions: this.buildIngredientOptions(sourceRecord.ingredientOptions, this._componentDictionary.getAll()), + requirementOptions: this.buildIngredientOptions(sourceRecord.requirementOptions, this._componentDictionary.getAll()), resultOptions: this.buildResultOptions(sourceRecord.resultOptions, this._componentDictionary.getAll()), - essences: combinationFromRecord(sourceRecord.essences, this._essenceDictionary.getAll()) + essences: Combination.fromRecord(sourceRecord.essences, this._essenceDictionary.getAll()) }); } - private buildIngredientOptions(ingredientOptionsJson: Record, allComponents: Map): SelectableOptions { + private buildIngredientOptions(ingredientOptionsJson: Record, allComponents: Map): SelectableOptions { const options = Object.keys(ingredientOptionsJson) .map(name => this.buildIngredientOption(name, ingredientOptionsJson[name], allComponents)); return new SelectableOptions({ @@ -162,7 +160,7 @@ export class RecipeDictionary implements Dictionary { }); } - private buildResultOptions(resultOptionsJson: Record, allComponents: Map): SelectableOptions { + private buildResultOptions(resultOptionsJson: Record, allComponents: Map): SelectableOptions { const options = Object.keys(resultOptionsJson) .map(name => this.buildResultOption(name, resultOptionsJson[name], allComponents)); return new SelectableOptions({ @@ -170,18 +168,18 @@ export class RecipeDictionary implements Dictionary { }); } - private buildIngredientOption(name: string, ingredientOptionJson: RequirementOptionJson, allComponents: Map): RequirementOption { + private buildIngredientOption(name: string, ingredientOptionJson: RequirementOptionJson, allComponents: Map): RequirementOption { return new RequirementOption({ name, - catalysts: combinationFromRecord(ingredientOptionJson.catalysts, allComponents), - ingredients: combinationFromRecord(ingredientOptionJson.ingredients, allComponents) + catalysts: Combination.fromRecord(ingredientOptionJson.catalysts, allComponents), + ingredients: Combination.fromRecord(ingredientOptionJson.ingredients, allComponents) }); } - private buildResultOption(name: string, resultOptionJson: ResultOptionJson, allComponents: Map): ResultOption { + private buildResultOption(name: string, resultOptionJson: ResultOptionJson, allComponents: Map): ResultOption { return new ResultOption({ name, - results: combinationFromRecord(resultOptionJson, allComponents) + results: Combination.fromRecord(resultOptionJson, allComponents) }); } @@ -209,7 +207,7 @@ export class RecipeDictionary implements Dictionary { return this._entriesById.size === 0; } - dropComponentReferences(componentToDelete: CraftingComponent) { + dropComponentReferences(componentToDelete: Component) { Array.from(this._entriesById.values()) .forEach(recipe => { recipe.ingredientOptions = recipe.ingredientOptions @@ -248,7 +246,7 @@ export class RecipeDictionary implements Dictionary { } async create(recipeJson: RecipeJson): Promise { - const itemData = await this._documentManager.getDocumentByUuid(recipeJson.itemUuid); + const itemData = await this._documentManager.loadItemDataByDocumentUuid(recipeJson.itemUuid); if (itemData.hasErrors) { throw new Error(`Could not load document with UUID "${recipeJson.itemUuid}". Errors ${itemData.errors.join(", ")} `); } @@ -269,7 +267,7 @@ export class RecipeDictionary implements Dictionary { throw new Error(`Unable to mutate recipe with ID ${id}. It doesn't exist.`); } const target = this._entriesById.get(id); - const itemData = target.itemUuid === mutation.itemUuid ? target.itemData : await this._documentManager.getDocumentByUuid(mutation.itemUuid); + const itemData = target.itemUuid === mutation.itemUuid ? target.itemData : await this._documentManager.loadItemDataByDocumentUuid(mutation.itemUuid); if (itemData.hasErrors) { throw new Error(`Could not load document with UUID "${mutation.itemUuid}". Errors ${itemData.errors.join(", ")} `); } diff --git a/src/scripts/system/bundled/AlchemistsSuppliesV16.ts b/src/scripts/system/bundled/AlchemistsSuppliesV16.ts deleted file mode 100644 index 22a77703..00000000 --- a/src/scripts/system/bundled/AlchemistsSuppliesV16.ts +++ /dev/null @@ -1,497 +0,0 @@ -import {CraftingSystemJson} from "../CraftingSystem"; - -const SYSTEM_DEFINITION: CraftingSystemJson = { - "id": "alchemists-supplies-v1.6", - "locked": true, - "details": { - "name": "Alchemist's Supplies v1.6", - "description": "Alchemy is the skill of exploiting unique properties of certain plants, minerals, and creature parts, combining them to produce fantastic substances. This allows even non-spellcasters to mimic minor magical effects, although the creations themselves are non-magical.", - "summary": "A crafting system for 5th Edition by u/calculusChild", - "author": "u/calculusChild", - }, - "enabled": true, - "parts": { - "essences": { - "water": { - "name": "Water", - "description": "Elemental water, one of the fundamental forces of nature", - "iconCode": "fa-solid fa-droplet", - "tooltip": "Elemental water", - "activeEffectSourceItemUuid": null - }, - "earth": { - "name": "Earth", - "description": "Elemental earth, one of the fundamental forces of nature", - "iconCode": "fa-solid fa-mountain", - "tooltip": "Elemental earth", - "activeEffectSourceItemUuid": null - }, - "air": { - "name": "Air", - "description": "Elemental air, one of the fundamental forces of nature", - "iconCode": "fa-solid fa-wind", - "tooltip": "Elemental air", - "activeEffectSourceItemUuid": null - }, - "fire": { - "name": "Fire", - "description": "Elemental fire, one of the fundamental forces of nature", - "iconCode": "fa-solid fa-fire", - "tooltip": "Elemental fire", - "activeEffectSourceItemUuid": null - }, - "negative-energy": { - "name": "Negative Energy", - "description": "Negative Energy - The essence of death and destruction", - "iconCode": "fa-solid fa-moon", - "tooltip": "Negative energy", - "activeEffectSourceItemUuid": null - }, - "positive-energy": { - "name": "Positive Energy", - "description": "Positive Energy - The essence of life and creation", - "iconCode": "fa-solid fa-sun", - "tooltip": "Positive energy", - "activeEffectSourceItemUuid": null - } - }, - "recipes": { - "Compendium.fabricate.alchemists-supplies-v16.3N7TGYWJRrUJ9thy": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.3N7TGYWJRrUJ9thy", - "essences": { - "earth": 2, - "water": 2, - "negative-energy": 1 - }, - "ingredientOptions": {}, - "resultOptions": { - "Option 1": { - "Compendium.fabricate.alchemists-supplies-v16.zogQbVwUggRUWK2l": 1 - } - }, - disabled: false - }, - "Compendium.fabricate.alchemists-supplies-v16.6mKiAWQIzDZRPNOP": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.6mKiAWQIzDZRPNOP", - "essences": { - "air": 2, - "negative-energy": 1 - }, - "ingredientOptions": {}, - "resultOptions": { - "Option 1": { - "Compendium.fabricate.alchemists-supplies-v16.m51mvhPcubqfPbWB": 1 - } - }, - disabled: false - }, - "Compendium.fabricate.alchemists-supplies-v16.8M0vwnOuCEIEP9ww": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.8M0vwnOuCEIEP9ww", - "essences": { - "fire": 2, - "positive-energy": 1 - }, - "ingredientOptions": {}, - "resultOptions": { - "Option 1": { - "Compendium.fabricate.alchemists-supplies-v16.HyicGjYWESWIdEhc": 1 - } - }, - disabled: false - }, - "Compendium.fabricate.alchemists-supplies-v16.EQfnAlIaDLh57ETN": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.EQfnAlIaDLh57ETN", - "essences": { - "earth": 2, - "positive-energy": 1 - }, - "ingredientOptions": {}, - "resultOptions": { - "Option 1": { - "Compendium.fabricate.alchemists-supplies-v16.fZsZ5EYMs9ioZ6Kk": 1 - } - }, - disabled: false - }, - "Compendium.fabricate.alchemists-supplies-v16.F2dhRws4mZppZVIa": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.F2dhRws4mZppZVIa", - "essences": { - "water": 2, - "positive-energy": 1 - }, - "ingredientOptions": {}, - "resultOptions": { - "Option 1": { - "Compendium.fabricate.alchemists-supplies-v16.Ffv7X37ZT7LRzDvP": 1 - } - }, - disabled: false - }, - "Compendium.fabricate.alchemists-supplies-v16.IVqzRv8v1MrfpKSa": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.IVqzRv8v1MrfpKSa", - "essences": { - "earth": 3, - "negative-energy": 2 - }, - "ingredientOptions": {}, - "resultOptions": { - "Option 1": { - "Compendium.fabricate.alchemists-supplies-v16.8AdG3048UmUOo5Qx": 1 - } - }, - disabled: false - }, - "Compendium.fabricate.alchemists-supplies-v16.JEewjPpD6JA2ifE3": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.JEewjPpD6JA2ifE3", - "essences": { - "fire": 1, - "negative-energy": 1 - }, - "ingredientOptions": {}, - "resultOptions": { - "Option 1": { - "Compendium.fabricate.alchemists-supplies-v16.QXN2n98HnjFzSsNJ": 1 - } - }, - disabled: false - }, - "Compendium.fabricate.alchemists-supplies-v16.O0FPlz7ARBLuLY1E": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.O0FPlz7ARBLuLY1E", - "essences": { - "earth": 2, - "positive-energy": 1 - }, - "ingredientOptions": {}, - "resultOptions": { - "Option 1": { - "Compendium.fabricate.alchemists-supplies-v16.zgDyoqjRxwhD9cNR": 1 - } - }, - disabled: false - }, - "Compendium.fabricate.alchemists-supplies-v16.Pl65b1VSVhUJ2li9": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.Pl65b1VSVhUJ2li9", - "essences": { - "fire": 2, - "earth": 1, - "water": 1, - "positive-energy": 1 - }, - - "ingredientOptions": {}, - "resultOptions": { - "Option 1": { - "Compendium.fabricate.alchemists-supplies-v16.Kv4752l95eP4IeBM": 1 - } - }, - disabled: false - }, - "Compendium.fabricate.alchemists-supplies-v16.WseDmjhOWaT1XeJf": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.WseDmjhOWaT1XeJf", - "essences": { - "earth": 1, - "fire": 2 - }, - "ingredientOptions": {}, - "resultOptions": { - "Option 1": { - "Compendium.fabricate.alchemists-supplies-v16.EDGSJpxtSuvq7XWW": 1 - } - }, - disabled: false - }, - "Compendium.fabricate.alchemists-supplies-v16.hJFEOjoiLdTOOoBn": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.hJFEOjoiLdTOOoBn", - "essences": { - "earth": 2, - "water": 2 - }, - "ingredientOptions": {}, - "resultOptions": { - "Option 1": { - "Compendium.fabricate.alchemists-supplies-v16.sB64MUbYpMqiWCkS": 1 - } - }, - disabled: false - }, - "Compendium.fabricate.alchemists-supplies-v16.kI3lqJmjWai0aX5L": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.kI3lqJmjWai0aX5L", - "essences": { - "air": 3 - }, - "ingredientOptions": {}, - "resultOptions": { - "Option 1": { - "Compendium.fabricate.alchemists-supplies-v16.4U429BrJKdMKDZaR": 1 - } - }, - disabled: false - }, - "Compendium.fabricate.alchemists-supplies-v16.x4MkisNL6yBoTZ94": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.x4MkisNL6yBoTZ94", - "essences": { - "air": 2, - "fire": 1 - }, - "ingredientOptions": {}, - "resultOptions": { - "Option 1": { - "Compendium.fabricate.alchemists-supplies-v16.bNOIJaY21OSsdybw": 1 - } - }, - disabled: false - }, - "Compendium.fabricate.alchemists-supplies-v16.ylQuglLVjQFuH9w1": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.ylQuglLVjQFuH9w1", - "essences": { - "water": 1, - "fire": 1, - "negative-energy": 1 - }, - "ingredientOptions": {}, - "resultOptions": { - "Option 1": { - "Compendium.fabricate.alchemists-supplies-v16.1lx7oTedScyEgTOm": 1 - } - }, - disabled: false - }, - "Compendium.fabricate.alchemists-supplies-v16.yqKIdplKhpa5Ir7A": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.yqKIdplKhpa5Ir7A", - "essences": { - "air": 2, - "fire": 2, - "negative-energy": 1 - }, - "ingredientOptions": {}, - "resultOptions": { - "Option 1": { - "Compendium.fabricate.alchemists-supplies-v16.VHUXcExXZLCDfqmM": 1 - } - }, - disabled: false - }, - }, - "components": { - "Compendium.fabricate.alchemists-supplies-v16.1lx7oTedScyEgTOm": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.1lx7oTedScyEgTOm", - disabled: false, - "salvageOptions": {}, - "essences": {} - }, - "Compendium.fabricate.alchemists-supplies-v16.0FiywpRZf3cOYs4U": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.0FiywpRZf3cOYs4U", - disabled: false, - "salvageOptions": {}, - "essences": { - "water": 2 - } - }, - "Compendium.fabricate.alchemists-supplies-v16.zogQbVwUggRUWK2l": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.zogQbVwUggRUWK2l", - disabled: false, - "salvageOptions": {}, - "essences": {} - }, - "Compendium.fabricate.alchemists-supplies-v16.4U429BrJKdMKDZaR": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.4U429BrJKdMKDZaR", - disabled: false, - "salvageOptions": {}, - "essences": {} - }, - "Compendium.fabricate.alchemists-supplies-v16.8AdG3048UmUOo5Qx": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.8AdG3048UmUOo5Qx", - disabled: false, - "salvageOptions": {}, - "essences": {} - }, - "Compendium.fabricate.alchemists-supplies-v16.90z9nOwmGnP4aUUk": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.90z9nOwmGnP4aUUk", - disabled: false, - "salvageOptions": {}, - "essences": {} - }, - "Compendium.fabricate.alchemists-supplies-v16.Act4cJsuz2HhID55": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.Act4cJsuz2HhID55", - disabled: false, - "salvageOptions": {}, - "essences": { - "fire": 1, - "earth": 1 - } - }, - "Compendium.fabricate.alchemists-supplies-v16.BPiO2qqMvJbdlxJf": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.BPiO2qqMvJbdlxJf", - disabled: false, - "salvageOptions": {}, - "essences": { - "fire": 1 - } - }, - "Compendium.fabricate.alchemists-supplies-v16.BjAyhAmkbFJdVVQW": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.BjAyhAmkbFJdVVQW", - disabled: false, - "salvageOptions": {}, - "essences": { - "air": 1, - "fire": 1 - } - }, - "Compendium.fabricate.alchemists-supplies-v16.BzC8HmlN3bNY4D8Z": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.BzC8HmlN3bNY4D8Z", - disabled: false, - "salvageOptions": {}, - "essences": { - "positive-energy": 1 - } - }, - "Compendium.fabricate.alchemists-supplies-v16.EDGSJpxtSuvq7XWW": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.EDGSJpxtSuvq7XWW", - disabled: false, - "salvageOptions": {}, - "essences": {} - }, - "Compendium.fabricate.alchemists-supplies-v16.Ffv7X37ZT7LRzDvP": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.Ffv7X37ZT7LRzDvP", - disabled: false, - "salvageOptions": {}, - "essences": {} - }, - "Compendium.fabricate.alchemists-supplies-v16.HyicGjYWESWIdEhc": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.HyicGjYWESWIdEhc", - disabled: false, - "salvageOptions": {}, - "essences": {} - }, - "Compendium.fabricate.alchemists-supplies-v16.JuAZVO3QioTf7ACf": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.JuAZVO3QioTf7ACf", - disabled: false, - "salvageOptions": {}, - "essences": { - "negative-energy": 1 - } - }, - "Compendium.fabricate.alchemists-supplies-v16.Kv4752l95eP4IeBM": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.Kv4752l95eP4IeBM", - disabled: false, - "salvageOptions": {}, - "essences": {} - }, - "Compendium.fabricate.alchemists-supplies-v16.LnFU0qkUkKz3iV1Q": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.LnFU0qkUkKz3iV1Q", - disabled: false, - "salvageOptions": {}, - "essences": { - "water": 1, - "air": 1 - } - }, - "Compendium.fabricate.alchemists-supplies-v16.PwEY1nRmDzoFN5TW": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.PwEY1nRmDzoFN5TW", - disabled: false, - "salvageOptions": {}, - "essences": { - "fire": 2 - } - }, - "Compendium.fabricate.alchemists-supplies-v16.QXN2n98HnjFzSsNJ": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.QXN2n98HnjFzSsNJ", - disabled: false, - "salvageOptions": {}, - "essences": {} - }, - "Compendium.fabricate.alchemists-supplies-v16.R50XdiWxV5awA6HL": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.R50XdiWxV5awA6HL", - disabled: false, - "salvageOptions": {}, - "essences": { - "earth": 2 - } - }, - "Compendium.fabricate.alchemists-supplies-v16.SmE4zjl9xLBOSo4A": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.SmE4zjl9xLBOSo4A", - disabled: false, - "salvageOptions": {}, - "essences": { - "water": 1 - } - }, - "Compendium.fabricate.alchemists-supplies-v16.TAdDF3reNmXc00ST": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.TAdDF3reNmXc00ST", - disabled: false, - "salvageOptions": {}, - "essences": { - "earth": 1 - } - }, - "Compendium.fabricate.alchemists-supplies-v16.VHUXcExXZLCDfqmM": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.VHUXcExXZLCDfqmM", - disabled: false, - "salvageOptions": {}, - "essences": {} - }, - "Compendium.fabricate.alchemists-supplies-v16.bNOIJaY21OSsdybw": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.bNOIJaY21OSsdybw", - disabled: false, - "salvageOptions": {}, - "essences": {} - }, - "Compendium.fabricate.alchemists-supplies-v16.dVgM7j75KoBDKRep": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.dVgM7j75KoBDKRep", - disabled: false, - "salvageOptions": {}, - "essences": { - "air": 1 - } - }, - "Compendium.fabricate.alchemists-supplies-v16.dvrdsBAt0B2ag8Q8": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.dvrdsBAt0B2ag8Q8", - disabled: false, - "salvageOptions": {}, - "essences": { - "air": 2 - } - }, - "Compendium.fabricate.alchemists-supplies-v16.fZsZ5EYMs9ioZ6Kk": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.fZsZ5EYMs9ioZ6Kk", - disabled: false, - "salvageOptions": {}, - "essences": {} - }, - "Compendium.fabricate.alchemists-supplies-v16.m51mvhPcubqfPbWB": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.m51mvhPcubqfPbWB", - disabled: false, - "salvageOptions": {}, - "essences": {} - }, - "Compendium.fabricate.alchemists-supplies-v16.sB64MUbYpMqiWCkS": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.sB64MUbYpMqiWCkS", - disabled: false, - "salvageOptions": {}, - "essences": {} - }, - "Compendium.fabricate.alchemists-supplies-v16.xtrSxjCohTVr1bZy": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.xtrSxjCohTVr1bZy", - disabled: false, - "salvageOptions": {}, - "essences": { - "earth": 1, - "water": 1 - } - }, - "Compendium.fabricate.alchemists-supplies-v16.zgDyoqjRxwhD9cNR": { - disabled: false, - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.zgDyoqjRxwhD9cNR", - "salvageOptions": {}, - "essences": {} - } - } - } -} - -const ALCHEMISTS_SUPPLIES_SYSTEM_DATA = { - definition: SYSTEM_DEFINITION, - gameSystem: "dnd5e" -} - -export { ALCHEMISTS_SUPPLIES_SYSTEM_DATA } \ No newline at end of file diff --git a/src/scripts/system/setting_versions/V1Json.ts b/src/scripts/system/setting_versions/V1Json.ts deleted file mode 100644 index ce1617b8..00000000 --- a/src/scripts/system/setting_versions/V1Json.ts +++ /dev/null @@ -1,40 +0,0 @@ -interface V1ComponentJson { - itemUuid: string; - essences: Record; - salvage: Record; -} - -interface V1RecipeJson { - itemUuid: string; - essences: Record; - catalysts: Record; - resultGroups: Record[]; - ingredientGroups: Record[]; -} - -interface V1EssenceJson { - id: string; - name: string; - tooltip: string; - iconCode: string; - description: string; -} - -interface V1SystemJson { - id: string; - details: { - name: string; - summary: string; - description: string; - author: string - }; - enabled: boolean; - locked: boolean; - parts: { - components: Record; - recipes: Record; - essences: Record; - } -} - -export { V1SystemJson, V1EssenceJson, V1ComponentJson, V1RecipeJson }; \ No newline at end of file diff --git a/src/styles/module.less b/src/styles/module.less index d099fdf1..926ba588 100644 --- a/src/styles/module.less +++ b/src/styles/module.less @@ -255,6 +255,7 @@ BEGIN FABRICATE THEME STYLES } .fab-component-name, .fab-recipe-name, .fab-component-requirements { background: var(--color-background-dark-5); + position: relative; i.fa-circle-exclamation { color: var(--color-error-bg); position: absolute; @@ -262,6 +263,7 @@ BEGIN FABRICATE THEME STYLES font-size: var(--font-size-20); right: -9px; top: -9px; + z-index: 10; &::after { border-radius: 50%; content: ""; @@ -1077,8 +1079,11 @@ END COMMON STRUCTURAL STYLES grid-template-columns: repeat(2, 1fr); column-gap: 1em; margin-bottom: 12px; + .fab-salvage-option-actual { + margin-bottom: 10px; + } } - .fab-no-salvage-opts { + .fab-no-salvage-opts, .fab-no-salvage-results, .fab-no-salvage-catalysts { display: flex; justify-content: center; align-items: center; @@ -1211,7 +1216,7 @@ END COMMON STRUCTURAL STYLES .fab-component-salvage-app-body { height: 100%; } - .fab-salvage-hint { + .fab-salvage-hint, .fab-catalyst-hint { margin: 1em; } } diff --git a/svelte.config.js b/svelte.config.js index 1a90ad4f..b5475f31 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -1,7 +1,9 @@ -import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' +import { vitePreprocess } from "@sveltejs/vite-plugin-svelte" -export default { +const config = { // Consult https://svelte.dev/docs#compile-time-svelte-preprocess // for more information about preprocessors - preprocess: vitePreprocess() + preprocess: [ vitePreprocess() ], } + +export default config; \ No newline at end of file diff --git a/test/AlchemistsSuppliesIntegration.test.ts b/test/AlchemistsSuppliesIntegration.test.ts deleted file mode 100644 index f541c717..00000000 --- a/test/AlchemistsSuppliesIntegration.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import {beforeEach, describe, expect, jest, test} from '@jest/globals'; - -import {CraftingSystemFactory} from "../src/scripts/system/CraftingSystemFactory"; -import {CraftingSystem} from "../src/scripts/system/CraftingSystem"; -import * as Sinon from "sinon"; -import {ALCHEMISTS_SUPPLIES_SYSTEM_DATA as AlchemistsSupplies} from "../src/scripts/system/bundled/AlchemistsSuppliesV16" -import {StubDocumentManager} from "./stubs/StubDocumentManager"; -import {Combination} from "../src/scripts/common/Combination"; - -const Sandbox: Sinon.SinonSandbox = Sinon.createSandbox(); - -beforeEach(() => { - jest.resetAllMocks(); - Sandbox.reset(); -}); - -describe('A Crafting System Factory', () => { - - test('should create a new Crafting System from a valid specification', async () => { - - const systemSpec = AlchemistsSupplies.definition; - - const craftingSystemFactory: CraftingSystemFactory = new CraftingSystemFactory({ - documentManager: StubDocumentManager.forPartDefinitions({ - craftingComponentsJson: Array.from(Object.values(systemSpec.parts.components)), - recipesJson: Array.from(Object.values(systemSpec.parts.recipes)) - }) - }); - - const result: CraftingSystem = await craftingSystemFactory.make(systemSpec); - - expect(result).not.toBeNull(); - await result.loadPartDictionary(); - - expect(result.id).toEqual("alchemists-supplies-v1.6"); - expect(result.enabled).toEqual(true); - - const essences = await result.getEssences(); - const essenceIds = Object.keys(systemSpec.parts.essences); - expect(essences.length).toEqual(essenceIds.length); - expect(essences.map(essence => essence.id)).toEqual(expect.arrayContaining(essenceIds)); - - const components = await result.getComponents(); - const componentIds = Object.keys(systemSpec.parts.components); - expect(components.length).toEqual(componentIds.length); - expect(components.map(component => component.id)).toEqual(expect.arrayContaining(componentIds)); - components.map(component => component.essences) - .reduce((left, right) => left.combineWith(right), Combination.EMPTY()) - .members - .forEach(essence => expect(essences).toContain(essence)); - - const recipes = await result.getRecipes(); - const recipeIds = Object.keys(systemSpec.parts.recipes); - expect(recipes.length).toEqual(recipeIds.length); - expect(recipes.map(recipe => recipe.id)).toEqual(expect.arrayContaining(recipeIds)); - recipes.flatMap(recipe => recipe.ingredientOptions.map(option => option.ingredients.combineWith(option.catalysts).members)) - .forEach(component => expect(components).toContain(component)); - recipes.flatMap(recipe => recipe.resultOptions.flatMap(option => option.results.members)) - .forEach(component => expect(components).toContain(component)); - recipes.map(recipe => recipe.essences) - .reduce((left, right) => left.combineWith(right), Combination.EMPTY()) - .members - .forEach(essence => expect(essences).toContain(essence)); - - }); - -}); diff --git a/test/Combination.test.ts b/test/Combination.test.ts index 2d2bc154..32633922 100644 --- a/test/Combination.test.ts +++ b/test/Combination.test.ts @@ -1,51 +1,56 @@ import {expect, jest, test, beforeEach} from "@jest/globals"; -import {Combination, Unit} from "../src/scripts/common/Combination"; -import {CraftingComponent, SalvageOption, SalvageOptionJson} from "../src/scripts/common/CraftingComponent"; +import {Combination} from "../src/scripts/common/Combination"; +import {Component} from "../src/scripts/crafting/component/Component"; import {testComponentFive, testComponentFour, testComponentOne, testComponentThree, testComponentTwo} from "./test_data/TestCraftingComponents"; import {NoFabricateItemData} from "../src/scripts/foundry/DocumentManager"; -import {SelectableOptions} from "../src/scripts/common/SelectableOptions"; +import {SelectableOptions} from "../src/scripts/crafting/selection/SelectableOptions"; +import {Unit} from "../src/scripts/common/Unit"; +import {SalvageOption, SalvageOptionJson} from "../src/scripts/crafting/component/SalvageOption"; beforeEach(() => { jest.resetAllMocks(); }); test('Should create an empty Combination',() => { - const underTest: Combination = Combination.EMPTY(); + const underTest: Combination = Combination.EMPTY(); expect(underTest.size).toBe(0); expect(underTest.isEmpty()).toBe(true); expect(underTest.has(testComponentOne)).toBe(false); - expect(underTest.has(new CraftingComponent({ + expect(underTest.has(new Component({ id: 'XYZ345', + craftingSystemId: "ABC123", salvageOptions: new SelectableOptions({}), essences: Combination.EMPTY(), disabled: true, itemData: NoFabricateItemData.INSTANCE() }))).toBe(false); - const underTestAsUnits: Unit[] = underTest.units; + const underTestAsUnits: Unit[] = underTest.units; expect(underTestAsUnits.length).toBe(0); }); test('Should create a Combination from a single Unit',() => { - const underTest: Combination = Combination.ofUnit(new Unit(testComponentOne, 1)); + const underTest: Combination = Combination.ofUnit(new Unit(testComponentOne, 1)); expect(underTest.size).toBe(1); expect(underTest.isEmpty()).toBe(false); expect(underTest.has(testComponentOne)).toBe(true); - let equivalentComponent = new CraftingComponent({ + let equivalentComponent = new Component({ id: testComponentOne.id, - salvageOptions: new SelectableOptions({options: testComponentOne.salvageOptions}), + craftingSystemId: testComponentOne.craftingSystemId, + salvageOptions: new SelectableOptions({ options: testComponentOne.salvageOptions.all }), essences: testComponentOne.essences, disabled: testComponentOne.isDisabled, itemData: testComponentOne.itemData }); expect(underTest.has(equivalentComponent)) .toBe(true); - let nonEquivalentComponent = new CraftingComponent({ + let nonEquivalentComponent = new Component({ id: 'XYZ345', + craftingSystemId: "ABC123", salvageOptions: new SelectableOptions({}), essences: Combination.EMPTY(), disabled: true, @@ -58,7 +63,7 @@ test('Should create a Combination from a single Unit',() => { }); test('Should create a Combination from a several Units',() => { - const underTest: Combination = Combination.ofUnits([ + const underTest: Combination = Combination.ofUnits([ new Unit(testComponentOne, 1), new Unit(testComponentOne, 1), new Unit(testComponentTwo, 2), @@ -73,15 +78,17 @@ test('Should create a Combination from a several Units',() => { expect(underTest.has(testComponentOne)).toBe(true); expect(underTest.has(testComponentTwo)).toBe(true); expect(underTest.has(testComponentThree)).toBe(true); - expect(underTest.has(new CraftingComponent({ + expect(underTest.has(new Component({ id: testComponentOne.id, + craftingSystemId: testComponentOne.craftingSystemId, itemData: testComponentOne.itemData, disabled: testComponentOne.isDisabled, - salvageOptions: new SelectableOptions({options: testComponentOne.salvageOptions}), + salvageOptions: new SelectableOptions({ options: testComponentOne.salvageOptions.all }), essences: testComponentOne.essences }))).toBe(true); - expect(underTest.has(new CraftingComponent({ + expect(underTest.has(new Component({ id: 'XYZ345', + craftingSystemId: "ABC123", salvageOptions: new SelectableOptions({}), essences: Combination.EMPTY(), disabled: true, @@ -91,31 +98,31 @@ test('Should create a Combination from a several Units',() => { }); test('Should convert a combination to Units',() => { - const underTest: Combination = Combination.ofUnits([ + const underTest: Combination = Combination.ofUnits([ new Unit(testComponentOne, 1), new Unit(testComponentOne, 1), new Unit(testComponentTwo, 2), new Unit(testComponentThree, 3) ]); - const underTestAsUnits: Unit[] = underTest.units; + const underTestAsUnits: Unit[] = underTest.units; expect(underTestAsUnits.length).toBe(3); }); test('Should create a Combination from combining existing Combinations',() => { - const sourceA: Combination = Combination.ofUnits([ + const sourceA: Combination = Combination.ofUnits([ new Unit(testComponentOne, 1), new Unit(testComponentTwo, 2), new Unit(testComponentThree, 3) ]); - const sourceB: Combination = Combination.ofUnits([ + const sourceB: Combination = Combination.ofUnits([ new Unit(testComponentFour, 4), new Unit(testComponentFive, 5) ]); - const testResultOne: Combination = sourceA.combineWith(sourceB); + const testResultOne: Combination = sourceA.combineWith(sourceB); expect(testResultOne.size).toBe(15); expect(testResultOne.isEmpty()).toBe(false); expect(testResultOne.has(testComponentOne)).toBe(true); @@ -125,12 +132,12 @@ test('Should create a Combination from combining existing Combinations',() => { expect(testResultOne.has(testComponentFive)).toBe(true); expect(testResultOne.members).toEqual(expect.arrayContaining([testComponentOne, testComponentTwo, testComponentThree, testComponentFour, testComponentFive])); - const sourceC: Combination = Combination.ofUnits([ + const sourceC: Combination = Combination.ofUnits([ new Unit(testComponentFour, 1), new Unit(testComponentFive, 1) ]); - const testResultTwo: Combination = testResultOne.combineWith(sourceC); + const testResultTwo: Combination = testResultOne.combineWith(sourceC); expect(testResultTwo.size).toBe(17); expect(testResultTwo.isEmpty()).toBe(false); expect(testResultTwo.has(testComponentOne)).toBe(true); @@ -142,13 +149,13 @@ test('Should create a Combination from combining existing Combinations',() => { }); test('Should add one Combination to another', () => { - const source: Combination = Combination.ofUnits([ + const source: Combination = Combination.ofUnits([ new Unit(testComponentOne, 17), new Unit(testComponentTwo, 21), new Unit(testComponentThree, 36) ]); - const underTest: Combination = source.add(new Unit(testComponentOne, 10)); + const underTest: Combination = source.addUnit(new Unit(testComponentOne, 10)); expect(underTest.size).toBe(84); expect(underTest.isEmpty()).toBe(false); expect(underTest.has(testComponentOne)).toBe(true); @@ -163,7 +170,7 @@ test('Should add one Combination to another', () => { }); test('Should determine when wne Combination contains another', () => { - const superset: Combination = Combination.ofUnits([ + const superset: Combination = Combination.ofUnits([ new Unit(testComponentOne, 1), new Unit(testComponentTwo, 2), new Unit(testComponentThree, 3), @@ -171,7 +178,7 @@ test('Should determine when wne Combination contains another', () => { ]); const fourComponentFours = new Unit(testComponentFour, 4); - const subset: Combination = Combination.ofUnits([ + const subset: Combination = Combination.ofUnits([ fourComponentFours ]); @@ -181,7 +188,7 @@ test('Should determine when wne Combination contains another', () => { expect(superset.isIn(subset)).toBe(false); const fiveComponentFours = new Unit(testComponentFour, 5); - const excludedSubset: Combination = Combination.ofUnits([ + const excludedSubset: Combination = Combination.ofUnits([ fiveComponentFours ]); expect(excludedSubset.isIn(superset)).toBe(false); @@ -189,19 +196,19 @@ test('Should determine when wne Combination contains another', () => { }) test('Should subtract one Combination from another', () => { - const largeCombination: Combination = Combination.ofUnits([ + const largeCombination: Combination = Combination.ofUnits([ new Unit(testComponentOne, 10), new Unit(testComponentTwo, 10), new Unit(testComponentThree, 10) ]); - const smallCombination: Combination = Combination.ofUnits([ + const smallCombination: Combination = Combination.ofUnits([ new Unit(testComponentOne, 2), new Unit(testComponentTwo, 7), new Unit(testComponentThree, 5) ]); - const testResultOne: Combination = largeCombination.subtract(smallCombination); + const testResultOne: Combination = largeCombination.subtract(smallCombination); expect(testResultOne.size).toBe(16); expect(testResultOne.isEmpty()).toBe(false); expect(testResultOne.has(testComponentOne)).toBe(true); @@ -211,18 +218,18 @@ test('Should subtract one Combination from another', () => { expect(testResultOne.amountFor(testComponentTwo.id)).toBe(3); expect(testResultOne.amountFor(testComponentThree.id)).toBe(5); - const largestCombination: Combination = Combination.ofUnits([ + const largestCombination: Combination = Combination.ofUnits([ new Unit(testComponentOne, 20), new Unit(testComponentTwo, 25), new Unit(testComponentThree, 50), new Unit(testComponentFour, 5) ]); - const testResultTwo: Combination = largeCombination.subtract(largestCombination); + const testResultTwo: Combination = largeCombination.subtract(largestCombination); expect(testResultTwo.size).toBe(0); expect(testResultTwo.isEmpty()).toBe(true); - const testResultThree: Combination = smallCombination.subtract(Combination.EMPTY()); + const testResultThree: Combination = smallCombination.subtract(Combination.EMPTY()); expect(testResultThree.size).toBe(14); expect(testResultThree.isEmpty()).toBe(false); expect(testResultThree.has(testComponentOne)).toBe(true); @@ -234,13 +241,13 @@ test('Should subtract one Combination from another', () => { }); test('Should multiply a Combination by a factor', () => { - const sourceCombination: Combination = Combination.ofUnits([ + const sourceCombination: Combination = Combination.ofUnits([ new Unit(testComponentOne, 10), new Unit(testComponentTwo, 8), new Unit(testComponentThree, 1) ]); - const underTest: Combination = sourceCombination.multiply(3); + const underTest: Combination = sourceCombination.multiply(3); expect(underTest.size).toBe(57); expect(underTest.isEmpty()).toBe(false); expect(underTest.has(testComponentOne)).toBe(true); @@ -252,13 +259,13 @@ test('Should multiply a Combination by a factor', () => { }); test('Should determine when two Combinations intersect', () => { - const sourceCombinationA: Combination = Combination.ofUnits([ + const sourceCombinationA: Combination = Combination.ofUnits([ new Unit(testComponentOne, 10), new Unit(testComponentTwo, 8), new Unit(testComponentThree, 1) ]); - const sourceCombinationB: Combination = Combination.ofUnits([ + const sourceCombinationB: Combination = Combination.ofUnits([ new Unit(testComponentThree, 3), new Unit(testComponentFour, 80), new Unit(testComponentFive, 17) diff --git a/test/ComponentAPI.test.ts b/test/ComponentAPI.test.ts new file mode 100644 index 00000000..8ad19a7e --- /dev/null +++ b/test/ComponentAPI.test.ts @@ -0,0 +1,162 @@ +import {beforeEach, describe, expect, test} from "@jest/globals"; +import {StubCraftingSystemAPI} from "./stubs/api/StubCraftingSystemAPI"; +import {StubLocalizationService} from "./stubs/foundry/StubLocalizationService"; +import {StubNotificationService} from "./stubs/foundry/StubNotificationService"; +import {StubDocumentManager} from "./stubs/StubDocumentManager"; +import {StubIdentityFactory} from "./stubs/foundry/StubIdentityFactory"; +import {StubSettingManager} from "./stubs/foundry/StubSettingManager"; +import { + testRecipeFive, + testRecipeFour, + testRecipeOne, + testRecipeSeven, + testRecipeSix, + testRecipeThree, + testRecipeTwo +} from "./test_data/TestRecipes"; +import { + testComponentFive, + testComponentFour, + testComponentOne, + testComponentSeven, + testComponentSix, + testComponentThree, + testComponentTwo +} from "./test_data/TestCraftingComponents"; +import {elementalAir, elementalEarth, elementalFire, elementalWater} from "./test_data/TestEssences"; +import {testCraftingSystemOne} from "./test_data/TestCrafingSystem"; +import {DefaultComponentValidator} from "../src/scripts/crafting/component/ComponentValidator"; +import {EntityDataStore, SerialisedEntityData} from "../src/scripts/repository/EntityDataStore"; +import {ComponentJson} from "../src/scripts/crafting/component/Component"; +import Properties from "../src/scripts/Properties"; +import {DefaultComponentAPI} from "../src/scripts/api/ComponentAPI"; +import {Component} from "../src/scripts/crafting/component/Component"; +import {ComponentFactory} from "../src/scripts/crafting/component/ComponentFactory"; +import {ComponentCollectionManager} from "../src/scripts/repository/CollectionManager"; +import {StubEssenceAPI} from "./stubs/api/StubEssenceAPI"; + +const identityFactory = new StubIdentityFactory(); +const localizationService = new StubLocalizationService(); +const notificationService = new StubNotificationService(); +const craftingSystemAPI = new StubCraftingSystemAPI({ + valuesById: new Map([[testCraftingSystemOne.id, testCraftingSystemOne]]) +}); +const essenceAPI = new StubEssenceAPI({ + valuesById: new Map([ + [elementalEarth.id, elementalEarth], + [elementalFire.id, elementalFire], + [elementalWater.id, elementalWater], + [elementalAir.id, elementalAir], + ]) +}); +const documentManager = new StubDocumentManager({ + itemDataByUuid: new Map([ + [testComponentOne.itemUuid, testComponentOne.itemData], + [testComponentTwo.itemUuid, testComponentTwo.itemData], + [testComponentThree.itemUuid, testComponentThree.itemData], + [testComponentFour.itemUuid, testComponentFour.itemData], + [testComponentFive.itemUuid, testComponentFive.itemData], + [testComponentSix.itemUuid, testComponentSix.itemData], + [testComponentSeven.itemUuid, testComponentSeven.itemData], + [testRecipeOne.itemUuid, testRecipeOne.itemData], + [testRecipeTwo.itemUuid, testRecipeTwo.itemData], + [testRecipeThree.itemUuid, testRecipeThree.itemData], + [testRecipeFour.itemUuid, testRecipeFour.itemData], + [testRecipeFive.itemUuid, testRecipeFive.itemData], + [testRecipeSix.itemUuid, testRecipeSix.itemData], + [testRecipeSeven.itemUuid, testRecipeSeven.itemData] + ]) +}); +const componentValidator = new DefaultComponentValidator({ + craftingSystemAPI, + essenceAPI +}); +const defaultSettingValue: () => SerialisedEntityData = () => { + return { + entities: { + [ testComponentOne.id ]: testComponentOne.toJson(), + [ testComponentTwo.id ]: testComponentTwo.toJson(), + [ testComponentThree.id ]: testComponentThree.toJson(), + [ testComponentFour.id ]: testComponentFour.toJson(), + [ testComponentFive.id ]: testComponentFive.toJson(), + [ testComponentSix.id ]: testComponentSix.toJson(), + [ testComponentSeven.id ]: testComponentSeven.toJson() + }, + collections: { + [ `${Properties.settings.collectionNames.item}.${testComponentOne.itemUuid}` ]: [ testComponentOne.id ], + [ `${Properties.settings.collectionNames.item}.${testComponentTwo.itemUuid}` ]: [ testComponentTwo.id ], + [ `${Properties.settings.collectionNames.item}.${testComponentThree.itemUuid}` ]: [ testComponentThree.id ], + [ `${Properties.settings.collectionNames.item}.${testComponentFour.itemUuid}` ]: [ testComponentFour.id ], + [ `${Properties.settings.collectionNames.item}.${testComponentFive.itemUuid}` ]: [ testComponentFive.id ], + [ `${Properties.settings.collectionNames.item}.${testComponentSix.itemUuid}` ]: [ testComponentSix.id ], + [ `${Properties.settings.collectionNames.item}.${testComponentSeven.itemUuid}` ]: [ testComponentSeven.id ], + [ `${Properties.settings.collectionNames.craftingSystem}.${testCraftingSystemOne.id}` ]: [ + testComponentOne.id, + testComponentTwo.id, + testComponentThree.id, + testComponentFour.id, + testComponentFive.id, + testComponentSix.id, + testComponentSeven.id + ] + } + }; +}; +const settingManager = new StubSettingManager>(defaultSettingValue()); + +beforeEach(() => { + settingManager.reset(defaultSettingValue()); + documentManager.reset(); +}); + +describe("Create", () => { + + test("Create a new component", async () => { + + const componentStore = new EntityDataStore({ + entityName: "Component", + settingManager, + collectionManager: new ComponentCollectionManager(), + entityFactory: new ComponentFactory({ documentManager }), + }); + documentManager.setAllowUnknownIds(true); + + const underTest = new DefaultComponentAPI({ + identityFactory, + localizationService, + notificationService, + componentStore, + componentValidator + }); + + const result = await underTest.create({ + essences: { + [elementalEarth.id]: 1 + }, + craftingSystemId: testCraftingSystemOne.id, + itemUuid: "test-item-uuid" + }); + + expect(result).not.toBeUndefined(); + + }); + +}); + +describe("Read", () => { + + + +}); + +describe("Update", () => { + + + +}); + +describe("Delete", () => { + + + +}); \ No newline at end of file diff --git a/test/CraftingAPI.test.ts b/test/CraftingAPI.test.ts new file mode 100644 index 00000000..4adc877e --- /dev/null +++ b/test/CraftingAPI.test.ts @@ -0,0 +1,105 @@ +import {describe, test, expect} from "@jest/globals"; +import {CraftingAPI, DefaultCraftingAPI} from "../src/scripts/api/CraftingAPI"; +import {StubLocalizationService} from "./stubs/foundry/StubLocalizationService"; +import {StubNotificationService} from "./stubs/foundry/StubNotificationService"; +import {DefaultInventoryFactory} from "../src/scripts/actor/InventoryFactory"; +import {StubObjectUtility} from "./stubs/StubObjectUtility"; +import {StubGameProvider} from "./stubs/foundry/StubGameProvider"; +import {StubCraftingSystemAPI} from "./stubs/api/StubCraftingSystemAPI"; +import {StubEssenceAPI} from "./stubs/api/StubEssenceAPI"; +import {StubComponentAPI} from "./stubs/api/StubComponentAPI"; +import {StubRecipeAPI} from "./stubs/api/StubRecipeAPI"; +import {allTestRecipes} from "./test_data/TestRecipes"; +import {allTestComponents, testComponentFour, testComponentThree} from "./test_data/TestCraftingComponents"; +import {allTestEssences} from "./test_data/TestEssences"; +import {testCraftingSystemOne} from "./test_data/TestCrafingSystem"; +import {BaseActor} from "@league-of-foundry-developers/foundry-vtt-types/src/foundry/common/documents.mjs"; +import {StubActorFactory} from "./stubs/StubActorFactory"; +import {Combination} from "../src/scripts/common/Combination"; +import {ConservativeEssenceSourcingComponentSelectionStrategy} from "../src/scripts/crafting/selection/ComponentSelectionStrategy"; + + +describe("Crafting API", () => { + + describe("Salvaging a Component", () => { + + test("should salvage a component with one salvage option", async () => { + + const stubActor = new StubActorFactory().make(Combination.of(testComponentFour)); + + const underTest = make(new Map([ [stubActor.id, stubActor] ])); + const result = await underTest.salvageComponent({ + componentId: testComponentFour.id, + sourceActorId: stubActor.id, + }); + + expect(result.produced.size).toEqual(2); + expect(result.produced.amountFor(testComponentThree)).toEqual(2); + expect(result.component.id).toEqual(testComponentFour.id); + expect(result.consumed.id).toEqual(testComponentFour.id); + expect(result.isSuccessful).toBe(true); + expect(result.sourceActorId).toEqual(stubActor.id); + expect(result.targetActorId).toEqual(stubActor.id); + + const ownedSalvageSourceAmount = await underTest.countOwnedComponentsOfType(stubActor.id, testComponentFour.id); + expect(ownedSalvageSourceAmount).toEqual(0); + const ownedSalvageResultAmount = await underTest.countOwnedComponentsOfType(stubActor.id, testComponentThree.id); + expect(ownedSalvageResultAmount).toEqual(2); + + }); + + test("should fail to salvage when the actor does not own the specified component", async () => { + + const stubActor = new StubActorFactory().make(); + + const underTest = make(new Map([ [stubActor.id, stubActor] ])); + const result = await underTest.salvageComponent({ + componentId: testComponentFour.id, + sourceActorId: stubActor.id, + }); + + expect(result.isSuccessful).toEqual(false); + expect(result.produced.isEmpty()).toBe(true); + expect(result.component.id).toEqual(testComponentFour.id); + expect(result.consumed).toBeUndefined(); + expect(result.sourceActorId).toEqual(stubActor.id); + expect(result.targetActorId).toEqual(stubActor.id); + + const ownedSalvageResultAmount = await underTest.countOwnedComponentsOfType(stubActor.id, testComponentThree.id); + expect(ownedSalvageResultAmount).toEqual(0); + + }); + + }); + +}); + +function make(stubActors: Map = new Map()): CraftingAPI { + const stubLocalizationService = new StubLocalizationService(); + return new DefaultCraftingAPI({ + recipeAPI: new StubRecipeAPI({ + valuesById: allTestRecipes + }), + componentAPI: new StubComponentAPI({ + valuesById: allTestComponents + }), + essenceAPI: new StubEssenceAPI({ + valuesById: allTestEssences + }), + craftingSystemAPI: new StubCraftingSystemAPI({ + valuesById: new Map([ + [ testCraftingSystemOne.id, testCraftingSystemOne ] + ]) + }), + localizationService: stubLocalizationService, + notificationService: new StubNotificationService(), + inventoryFactory: new DefaultInventoryFactory({ + objectUtility: new StubObjectUtility(), + localizationService: stubLocalizationService + }), + gameProvider: new StubGameProvider({ + stubActors, + }), + componentSelectionStrategy: new ConservativeEssenceSourcingComponentSelectionStrategy() + }); +} \ No newline at end of file diff --git a/test/CraftingAttemptFactory.test.ts b/test/CraftingAttemptFactory.test.ts deleted file mode 100644 index 8e6dbefe..00000000 --- a/test/CraftingAttemptFactory.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import {describe, expect, test} from "@jest/globals"; -import {RecipeCraftingPrepFactory} from "../src/scripts/crafting/attempt/RecipeCraftingPrepFactory"; -import {DefaultComponentSelectionStrategy} from "../src/scripts/crafting/selection/ComponentSelectionStrategy"; -import {Combination, Unit} from "../src/scripts/common/Combination"; -import {testRecipeFive, testRecipeFour} from "./test_data/TestRecipes"; -import {CraftingComponent} from "../src/scripts/common/CraftingComponent"; -import {TrackedUnit} from "../src/scripts/common/TrackedCombination"; -import {elementalEarth, elementalFire, elementalWater} from "./test_data/TestEssences"; -import { - testComponentFour, - testComponentOne, - testComponentSix, testComponentThree, - testComponentTwo -} from "./test_data/TestCraftingComponents"; - -describe("Create a Crafting Attempt", () => { - - const selectionStrategy = new DefaultComponentSelectionStrategy(); - - describe("for a Recipe with one ingredient option", () => { - - test("that cannot be crafted", () => { - - const underTest = new RecipeCraftingPrepFactory({ selectionStrategy }); - - const availableComponents = Combination.EMPTY(); - - const result = underTest.make(testRecipeFive, availableComponents); - - expect(result.isSingleton).toEqual(true); - - const craftingAttemptResult = result.getSingletonCraftingAttempt(); - - expect(craftingAttemptResult.requiresEssences).toEqual(true); - expect(craftingAttemptResult.essenceAmounts.isSufficient).toEqual(false); - expect(craftingAttemptResult.essenceAmounts.deficit).toEqual(2); - expect(craftingAttemptResult.essenceAmounts.units) - .toEqual(expect.arrayContaining( - [ - new TrackedUnit({ - target: new Unit(elementalFire, 1), - actual: new Unit(elementalFire, 0) - }), - new TrackedUnit({ - target: new Unit(elementalWater, 1), - actual: new Unit(elementalWater, 0) - }) - ] - ) - ); - expect(craftingAttemptResult.essenceSources.isEmpty()).toEqual(true); - - expect(craftingAttemptResult.requiresIngredients).toEqual(false); - expect(craftingAttemptResult.ingredientAmounts.isSufficient).toEqual(true); - expect(craftingAttemptResult.ingredientAmounts.isEmpty).toEqual(true); - - expect(craftingAttemptResult.requiresCatalysts).toEqual(true); - expect(craftingAttemptResult.catalystAmounts.isSufficient).toEqual(false); - expect(craftingAttemptResult.catalystAmounts.units) - .toEqual(expect.arrayContaining( - [ - new TrackedUnit({ - target: new Unit(testComponentFour, 1), - actual: new Unit(testComponentFour, 0) - }) - ] - ) - ); - - }); - - test("that can be crafted", () => { - - const underTest = new RecipeCraftingPrepFactory({ selectionStrategy }); - - const availableComponents = testRecipeFour.getSelectedIngredients().ingredients - .combineWith(testRecipeFour.getSelectedIngredients().catalysts) - .combineWith(Combination.ofUnits([ - new Unit(testComponentOne, 1), - new Unit(testComponentSix, 2) - ])); - - const result = underTest.make(testRecipeFour, availableComponents); - - expect(result.isSingleton).toEqual(true); - - const craftingAttemptResult = result.getSingletonCraftingAttempt(); - - expect(craftingAttemptResult.requiresEssences).toEqual(true); - expect(craftingAttemptResult.essenceAmounts.isSufficient).toEqual(true); - expect(craftingAttemptResult.essenceAmounts.isEmpty).toEqual(false); - expect(craftingAttemptResult.essenceAmounts.deficit).toEqual(0); - expect(craftingAttemptResult.essenceAmounts.units) - .toEqual(expect.arrayContaining( - [ - new TrackedUnit({ - target: new Unit(elementalEarth, 1), - actual: new Unit(elementalEarth, 2) - }), - new TrackedUnit({ - target: new Unit(elementalWater, 2), - actual: new Unit(elementalWater, 2) - }) - ] - ) - ); - expect(craftingAttemptResult.essenceSources.units) - .toEqual(expect.arrayContaining( - [ - new Unit(testComponentOne, 1), - new Unit(testComponentSix, 2), - ] - ) - ); - - expect(craftingAttemptResult.requiresIngredients).toEqual(true); - expect(craftingAttemptResult.ingredientAmounts.isSufficient).toEqual(true); - expect(craftingAttemptResult.ingredientAmounts.isEmpty).toEqual(false); - expect(craftingAttemptResult.ingredientAmounts.deficit).toEqual(0); - expect(craftingAttemptResult.ingredientAmounts.units) - .toEqual(expect.arrayContaining( - [ - new TrackedUnit({ - target: new Unit(testComponentTwo, 3), - actual: new Unit(testComponentTwo, 3) - }) - ] - ) - ); - - expect(craftingAttemptResult.requiresCatalysts).toEqual(true); - expect(craftingAttemptResult.catalystAmounts.isSufficient).toEqual(true); - expect(craftingAttemptResult.catalystAmounts.isEmpty).toEqual(false); - expect(craftingAttemptResult.catalystAmounts.deficit).toEqual(0); - expect(craftingAttemptResult.catalystAmounts.units) - .toEqual(expect.arrayContaining( - [ - new TrackedUnit({ - target: new Unit(testComponentThree, 1), - actual: new Unit(testComponentThree, 1) - }) - ] - ) - ); - - }); - - }); - -}); \ No newline at end of file diff --git a/test/CraftingInventory.test.ts b/test/CraftingInventory.test.ts index 6e28fffb..154fc91c 100644 --- a/test/CraftingInventory.test.ts +++ b/test/CraftingInventory.test.ts @@ -1,269 +1,218 @@ import {describe, expect, test} from '@jest/globals'; -import {CraftingInventory} from "../src/scripts/actor/Inventory"; -import {StubDocumentManager} from "./stubs/StubDocumentManager"; -import { - AlwaysOneItemQuantityReader, - DnD5EItemQuantityReader, - DnD5EItemQuantityWriter, NoItemQuantityWriter -} from "../src/scripts/actor/ItemQuantity"; -import {StubGameProvider} from "./stubs/StubGameProvider"; +import {Combination} from "../src/scripts/common/Combination"; +import {DefaultInventoryFactory} from "../src/scripts/actor/InventoryFactory"; +import {StubLocalizationService} from "./stubs/foundry/StubLocalizationService"; import {StubObjectUtility} from "./stubs/StubObjectUtility"; +import {StubActorFactory} from "./stubs/StubActorFactory"; import { + allTestComponents, testComponentFive, testComponentFour, - testComponentOne, - testComponentSeven, - testComponentSix, testComponentThree, testComponentTwo } from "./test_data/TestCraftingComponents"; -import {StubActorFactory} from "./stubs/StubActorFactory"; -import {Combination, Unit} from "../src/scripts/common/Combination"; -import {CraftingComponent} from "../src/scripts/common/CraftingComponent"; -import {StubItem} from "./stubs/StubItem"; -import {SuccessfulSalvageResult} from "../src/scripts/crafting/result/SalvageResult"; +import {Unit} from "../src/scripts/common/Unit"; +import {SimpleInventoryAction} from "../src/scripts/actor/InventoryAction"; +import {Component} from "../src/scripts/crafting/component/Component"; describe("Crafting Inventory", () => { - const stubGameObject = {}; - const knownComponentsByItemUuid = new Map([ - [testComponentOne.itemUuid, testComponentOne], - [testComponentTwo.itemUuid, testComponentTwo], - [testComponentThree.itemUuid, testComponentThree], - [testComponentFour.itemUuid, testComponentFour], - [testComponentFive.itemUuid, testComponentFive], - [testComponentSix.itemUuid, testComponentSix], - [testComponentSeven.itemUuid, testComponentSeven] - ]); - const fabricateItemDataByUuid = new Map(Array.from(knownComponentsByItemUuid.values()) - .map(component => [component.itemUuid, component.itemData])); - const documentManager = new StubDocumentManager(fabricateItemDataByUuid); + const dnd5eInventoryFactory = new DefaultInventoryFactory({ + localizationService: new StubLocalizationService(), + objectUtility: new StubObjectUtility(), + }); describe("indexing", () => { - test("should index actor's inventory with no fabricate items", async () => { + test("should index actor's inventory without fabricate items and without known components", () => { - const ownedItems = generateInventory(Combination.EMPTY(), 40); - const actorFactory = new StubActorFactory({ownedItems}); - const actor = actorFactory.make(); + const stubActor = new StubActorFactory().make(); + const underTest = dnd5eInventoryFactory.make("dnd5e", stubActor, new Map()); + const contents = underTest.getContents(); + expect(contents).not.toBeNull(); + expect(contents.size).toBe(0); - const underTest = new CraftingInventory({ - actor, - documentManager, - itemQuantityWriter: new DnD5EItemQuantityWriter(), - itemQuantityReader: new DnD5EItemQuantityReader(), - gameProvider: new StubGameProvider(stubGameObject), - objectUtils: new StubObjectUtility(), - knownComponentsByItemUuid - }); + }); - await underTest.index(); + test("should index actor's inventory without fabricate items with known components", () => { - expect(underTest.size).toEqual(0); - expect(underTest.amountFor(testComponentOne)).toEqual(0); - expect(underTest.amountFor(testComponentTwo)).toEqual(0); - expect(underTest.amountFor(testComponentThree)).toEqual(0); - expect(underTest.amountFor(testComponentFour)).toEqual(0); - expect(underTest.amountFor(testComponentFive)).toEqual(0); - expect(underTest.amountFor(testComponentSix)).toEqual(0); - expect(underTest.amountFor(testComponentSeven)).toEqual(0); + const stubActor = new StubActorFactory().make(); + const underTest = dnd5eInventoryFactory.make("dnd5e", stubActor,allTestComponentsByItemUuid()); + const contents = underTest.getContents(); + expect(contents).not.toBeNull(); + expect(contents.size).toBe(0); }); - test("should index actor's inventory with some fabricate items", async () => { - - const componentOneQuantity = 7; - const componentFiveQuantity = 4; - const componentSevenQuantity = 1; - const ownedItems = generateInventory(Combination.ofUnits([ - new Unit(testComponentOne, componentOneQuantity), - new Unit(testComponentFive, componentFiveQuantity), - new Unit(testComponentSeven, componentSevenQuantity) - ]), 34); - const actorFactory = new StubActorFactory({ownedItems}); - const actor = actorFactory.make(); - - const underTest = new CraftingInventory({ - actor, - documentManager, - itemQuantityWriter: new DnD5EItemQuantityWriter(), - itemQuantityReader: new DnD5EItemQuantityReader(), - gameProvider: new StubGameProvider(stubGameObject), - objectUtils: new StubObjectUtility(), - knownComponentsByItemUuid - }); - - await underTest.index(); - - expect(underTest.size).toEqual(12); - expect(underTest.amountFor(testComponentOne)).toEqual(componentOneQuantity); - expect(underTest.amountFor(testComponentTwo)).toEqual(0); - expect(underTest.amountFor(testComponentThree)).toEqual(0); - expect(underTest.amountFor(testComponentFour)).toEqual(0); - expect(underTest.amountFor(testComponentFive)).toEqual(componentFiveQuantity); - expect(underTest.amountFor(testComponentSix)).toEqual(0); - expect(underTest.amountFor(testComponentSeven)).toEqual(componentSevenQuantity); + test("should index actor's inventory with some fabricate items", () => { + + const stubActor = new StubActorFactory() + .make( + Combination.ofUnits([ + new Unit(testComponentTwo, 3), + new Unit(testComponentFive, 2), + ]) + ); + const underTest = dnd5eInventoryFactory.make("dnd5e", stubActor,allTestComponentsByItemUuid()); + const contents = underTest.getContents(); + expect(contents).not.toBeNull(); + expect(contents.size).toBe(5); + expect(contents.amountFor(testComponentTwo)).toBe(3); + expect(contents.amountFor(testComponentFive)).toBe(2); }); - test("should index actor's inventory without counting item quantity if reader not specified", async () => { - - const componentOneQuantity = 7; - const componentFiveQuantity = 4; - const componentSevenQuantity = 1; - const ownedItems = generateInventory(Combination.ofUnits([ - new Unit(testComponentOne, componentOneQuantity), - new Unit(testComponentFive, componentFiveQuantity), - new Unit(testComponentSeven, componentSevenQuantity) - ]), 34); - generateInventory(Combination.of(testComponentOne, 1), 0) - .forEach(value => ownedItems.set(value.id, value)); - const actorFactory = new StubActorFactory({ownedItems}); - const actor = actorFactory.make(); - - const underTest = new CraftingInventory({ - actor, - documentManager, - itemQuantityWriter: new NoItemQuantityWriter(), - itemQuantityReader: new AlwaysOneItemQuantityReader(), - gameProvider: new StubGameProvider(stubGameObject), - objectUtils: new StubObjectUtility(), - knownComponentsByItemUuid - }); + test("should index actor's inventory without counting item quantity if no item quantity property path is known", () => { + + const stubActor = new StubActorFactory() + .make( + Combination.ofUnits([ + new Unit(testComponentTwo, 3), + new Unit(testComponentFive, 2), + ]) + ); + const underTest = dnd5eInventoryFactory.make("notDnd5e", stubActor,allTestComponentsByItemUuid()); + const contents = underTest.getContents(); + expect(contents).not.toBeNull(); + expect(contents.size).toBe(2); + expect(contents.amountFor(testComponentTwo)).toBe(1); + expect(contents.amountFor(testComponentFive)).toBe(1); - await underTest.index(); + }); - expect(underTest.size).toEqual(4); + test("should register item quantity property path", () => { - const justOne = 1; - const two = 2; + const inventoryFactory = new DefaultInventoryFactory({ + localizationService: new StubLocalizationService(), + objectUtility: new StubObjectUtility(), + }); - expect(underTest.amountFor(testComponentOne)).toEqual(two); - expect(underTest.amountFor(testComponentTwo)).toEqual(0); - expect(underTest.amountFor(testComponentThree)).toEqual(0); - expect(underTest.amountFor(testComponentFour)).toEqual(0); - expect(underTest.amountFor(testComponentFive)).toEqual(justOne); - expect(underTest.amountFor(testComponentSix)).toEqual(0); - expect(underTest.amountFor(testComponentSeven)).toEqual(justOne); + inventoryFactory.registerGameSystemItemQuantityPropertyPath("notDnd5e", "system.quantity"); + + const stubActor = new StubActorFactory() + .make( + Combination.ofUnits([ + new Unit(testComponentTwo, 3), + new Unit(testComponentFive, 2), + ]) + ); + const underTest = inventoryFactory.make("dnd5e", stubActor,allTestComponentsByItemUuid()); + const contents = underTest.getContents(); + expect(contents).not.toBeNull(); + expect(contents.size).toBe(5); + expect(contents.amountFor(testComponentTwo)).toBe(3); + expect(contents.amountFor(testComponentFive)).toBe(2); }); }); - describe("accepting results", () => { - - test("should salvage a component when the results are not owned", async () => { + describe("performing actions", () => { - const ownedItems = generateInventory(Combination.of(testComponentThree, 1)); - const actorFactory = new StubActorFactory({ ownedItems }); - const actor = actorFactory.make(); + test("should add items without removing if no removals are specified", async () => { - const underTest = new CraftingInventory({ - actor, - documentManager, - itemQuantityWriter: new DnD5EItemQuantityWriter(), - itemQuantityReader: new DnD5EItemQuantityReader(), - gameProvider: new StubGameProvider(stubGameObject), - objectUtils: new StubObjectUtility(), - knownComponentsByItemUuid + const stubActor = new StubActorFactory() + .make( + Combination.ofUnits([ + new Unit(testComponentTwo, 3), + new Unit(testComponentFive, 2), + ]) + ); + const underTest = dnd5eInventoryFactory.make("dnd5e", stubActor,allTestComponentsByItemUuid()); + const addOneTestComponentThreeOnly = new SimpleInventoryAction({ + additions: Combination.ofUnits([new Unit(testComponentThree, 1)]) }); - await underTest.index(); + const result = await underTest.perform(addOneTestComponentThreeOnly); + expect(result).not.toBeNull(); + expect(result.size).toBe(6); + expect(result.amountFor(testComponentTwo)).toBe(3); + expect(result.amountFor(testComponentFive)).toBe(2); + expect(result.amountFor(testComponentThree)).toBe(1); - expect(underTest.size).toEqual(1); + }); - const componentSevenQuantity = 2; - const componentOneQuantity = 1; - const salvageResult = new SuccessfulSalvageResult({ - consumed: Combination.of(testComponentThree, 1), - created: Combination.ofUnits([ - new Unit(testComponentSeven, componentSevenQuantity), - new Unit(testComponentOne, componentOneQuantity) - ]) + test("should remove items without adding if no additions are specified", async () => { + + const stubActor = new StubActorFactory() + .make( + Combination.ofUnits([ + new Unit(testComponentTwo, 3), + new Unit(testComponentFive, 2), + ]) + ); + const underTest = dnd5eInventoryFactory.make("dnd5e", stubActor,allTestComponentsByItemUuid()); + const removeTwoTestComponentTwoOnly = new SimpleInventoryAction({ + removals: Combination.ofUnits([new Unit(testComponentTwo, 2)]) }); - await underTest.acceptSalvageResult(salvageResult); - - expect(underTest.size).toEqual(3); - expect(underTest.amountFor(testComponentThree)).toEqual(0); - expect(underTest.amountFor(testComponentSeven)).toEqual(componentSevenQuantity); - expect(underTest.amountFor(testComponentOne)).toEqual(componentOneQuantity); + const result = await underTest.perform(removeTwoTestComponentTwoOnly); + expect(result).not.toBeNull(); + expect(result.size).toBe(3); + expect(result.amountFor(testComponentTwo)).toBe(1); + expect(result.amountFor(testComponentFive)).toBe(2); }); - test("should salvage a component when the results are owned", async () => { - const ownedItems = generateInventory(Combination.ofUnits([ - new Unit(testComponentThree, 1), - new Unit(testComponentSeven, 1), - new Unit(testComponentOne, 1) - ])); - const actorFactory = new StubActorFactory({ ownedItems }); - const actor = actorFactory.make(); - - const underTest = new CraftingInventory({ - actor, - documentManager, - itemQuantityWriter: new DnD5EItemQuantityWriter(), - itemQuantityReader: new DnD5EItemQuantityReader(), - gameProvider: new StubGameProvider(stubGameObject), - objectUtils: new StubObjectUtility(), - knownComponentsByItemUuid + test("should add and remove items if both are specified", async () => { + + const stubActor = new StubActorFactory() + .make( + Combination.ofUnits([ + new Unit(testComponentTwo, 3), + new Unit(testComponentFive, 2), + ]) + ); + const underTest = dnd5eInventoryFactory.make("dnd5e", stubActor,allTestComponentsByItemUuid()); + const removeTwoTestComponentTwoAndTestComponentFiveAndAddTwoTestComponentThree = new SimpleInventoryAction({ + removals: Combination.ofUnits([ + new Unit(testComponentTwo, 2), + new Unit(testComponentFive, 2), + ]), + additions: Combination.ofUnits([ + new Unit(testComponentThree, 2), + ]), }); - await underTest.index(); + const result = await underTest.perform(removeTwoTestComponentTwoAndTestComponentFiveAndAddTwoTestComponentThree); + expect(result).not.toBeNull(); + expect(result.size).toBe(3); + expect(result.amountFor(testComponentTwo)).toBe(1); + expect(result.amountFor(testComponentThree)).toBe(2); - expect(underTest.size).toEqual(3); - const initialItemCollectionSize = actor.items.size; + }); - const componentSevenQuantity = 2; - const componentOneQuantity = 1; - const salvageResult = new SuccessfulSalvageResult({ - consumed: Combination.of(testComponentThree, 1), - created: Combination.ofUnits([ - new Unit(testComponentSeven, componentSevenQuantity), - new Unit(testComponentOne, componentOneQuantity) - ]) + test("should rationalise complete overlapping additions and removals to no action", async () => { + + const stubActor = new StubActorFactory() + .make( + Combination.ofUnits([ + new Unit(testComponentTwo, 3), + new Unit(testComponentFive, 2), + ]) + ); + const underTest = dnd5eInventoryFactory.make("dnd5e", stubActor, allTestComponentsByItemUuid()); + const removeTwoTestComponentTFourAndAddTwoTestComponentFour = new SimpleInventoryAction({ + removals: Combination.ofUnits([new Unit(testComponentFour, 2)]), + additions: Combination.ofUnits([new Unit(testComponentFour, 2)]), }); - await underTest.acceptSalvageResult(salvageResult); - - expect(underTest.size).toEqual(5); - expect(underTest.amountFor(testComponentThree)).toEqual(0); - expect(underTest.amountFor(testComponentSeven)).toEqual(componentSevenQuantity + 1); - expect(underTest.amountFor(testComponentOne)).toEqual(componentOneQuantity + 1); + const result = await underTest.perform(removeTwoTestComponentTFourAndAddTwoTestComponentFour); + expect(result).not.toBeNull(); + expect(result.size).toBe(5); + expect(result.amountFor(testComponentTwo)).toBe(3); + expect(result.amountFor(testComponentFive)).toBe(2); - expect(actor.items.size).toEqual(initialItemCollectionSize - 1); }); }); }); -function generateInventory(ownedComponents: Combination = Combination.EMPTY(), additionalItemCount = 10): Map { - const result: Map = new Map(); - for (let i = 0; i < additionalItemCount; i++) { - const id = randomIdentifier(); - result.set(id, new StubItem({ id })); - } - ownedComponents.units.map(unit => { - const id = randomIdentifier(); - result.set(id, new StubItem({ - id: id, - flags: { - core: { - sourceId: unit.part.itemUuid - } - }, - system: { - quantity: unit.quantity - } - })); - }); - return result; -} - -function randomIdentifier(): string { - return (Math.random() + 1) - .toString(36) - .substring(2); +function allTestComponentsByItemUuid() { + return Array.from(allTestComponents.values()) + .reduce((map, component) => { + map.set(component.itemUuid, component); + return map; + }, new Map()); } \ No newline at end of file diff --git a/test/CraftingSystem.test.ts b/test/CraftingSystem.test.ts index 1c5c6184..7a870297 100644 --- a/test/CraftingSystem.test.ts +++ b/test/CraftingSystem.test.ts @@ -1,7 +1,6 @@ import {beforeEach, describe, expect, jest, test} from "@jest/globals"; import {CraftingSystem} from "../src/scripts/system/CraftingSystem"; import * as Sinon from "sinon"; -import {testPartDictionary} from "./test_data/TestPartDictionary"; import {CraftingSystemDetails} from "../src/scripts/system/CraftingSystemDetails"; const Sandbox: Sinon.SinonSandbox = Sinon.createSandbox(); @@ -18,21 +17,20 @@ describe('Create and configure', () => { const testSystemId = `fabricate-test-system`; const underTest = new CraftingSystem({ - details: new CraftingSystemDetails({ + craftingSystemDetails: new CraftingSystemDetails({ name: "Test System", author: "", summary: "", description: "" }), id: testSystemId, - enabled: true, - locked: false, - partDictionary: testPartDictionary + disabled: true, + embedded: false }); expect(underTest).not.toBeNull(); expect(underTest.id).toEqual(testSystemId); - expect(underTest.enabled).toEqual(true); + expect(underTest.isDisabled).toEqual(true); }); @@ -40,21 +38,20 @@ describe('Create and configure', () => { const testSystemId = `fabricate-test-system`; const underTest = new CraftingSystem({ - details: new CraftingSystemDetails({ + craftingSystemDetails: new CraftingSystemDetails({ name: "Test System", author: "", summary: "", description: "" }), id: testSystemId, - enabled: true, - locked: false, - partDictionary: testPartDictionary + disabled: true, + embedded: false }); expect(underTest).not.toBeNull(); expect(underTest.id).toEqual(testSystemId); - expect(underTest.enabled).toEqual(true); + expect(underTest.isDisabled).toEqual(true); }); diff --git a/test/CraftingSystemAPI.test.ts b/test/CraftingSystemAPI.test.ts new file mode 100644 index 00000000..e0033c7e --- /dev/null +++ b/test/CraftingSystemAPI.test.ts @@ -0,0 +1,429 @@ +import {beforeEach, describe, expect, test} from "@jest/globals"; +import {DefaultCraftingSystemAPI} from "../src/scripts/api/CraftingSystemAPI"; +import {StubIdentityFactory} from "./stubs/foundry/StubIdentityFactory"; +import {StubLocalizationService} from "./stubs/foundry/StubLocalizationService"; +import {StubNotificationService} from "./stubs/foundry/StubNotificationService"; +import Properties from "../src/scripts/Properties"; +import {CraftingSystemDetails} from "../src/scripts/system/CraftingSystemDetails"; +import {EntityDataStore, SerialisedEntityData} from "../src/scripts/repository/EntityDataStore"; +import {CraftingSystem, CraftingSystemJson} from "../src/scripts/system/CraftingSystem"; +import {StubSettingManager} from "./stubs/foundry/StubSettingManager"; +import {testCraftingSystemOne, testCraftingSystemTwo} from "./test_data/TestCrafingSystem"; +import {StubEntityFactory} from "./stubs/StubEntityFactory"; +import {CraftingSystemCollectionManager} from "../src/scripts/repository/CollectionManager"; +import {CraftingSystemValidator} from "../src/scripts/system/CraftingSystemValidator"; + + +const defaultSettingValue = (): SerialisedEntityData => { + return { + entities: { + [ testCraftingSystemOne.id ]: testCraftingSystemOne.toJson(), + [ testCraftingSystemTwo.id ]: testCraftingSystemTwo.toJson() + }, + collections: {} + } +} + +const settingManager = new StubSettingManager>(defaultSettingValue()); + +const craftingSystemValidator = new CraftingSystemValidator(); +const localizationService = new StubLocalizationService(); +const notificationService = new StubNotificationService(); +const stubCraftingSystemFactory = new StubEntityFactory( + { + valuesById: new Map([ + [testCraftingSystemOne.id, testCraftingSystemOne], + [testCraftingSystemTwo.id, testCraftingSystemTwo] + ]) + }); + +beforeEach(() => { + settingManager.reset(defaultSettingValue()); + notificationService.reset(); +}); + +describe("Create", () => { + + test("should create a new crafting system with details", async () => { + + const craftingSystemStore = new EntityDataStore({ + entityName: "CraftingSystem", + settingManager, + entityFactory: stubCraftingSystemFactory, + collectionManager: new CraftingSystemCollectionManager() + }); + + const expectedIdentity = "expected-identity"; + const expectedName = "Expected name"; + const expectedSummary = "Expected summary"; + const expectedDescription = "Expected description"; + const expectedAuthor = "Expected author"; + + const underTest = new DefaultCraftingSystemAPI({ + notificationService, + localizationService, + craftingSystemValidator, + user: "Game Master", + craftingSystemStore, + identityFactory: new StubIdentityFactory(expectedIdentity) + }); + + const result = await underTest.create({ + name: expectedName, + summary: expectedSummary, + description: expectedDescription, + author: expectedAuthor + }); + + expect(result).not.toBeUndefined(); + expect(result.id).toEqual(expectedIdentity); + expect(result.details.name).toEqual(expectedName); + expect(result.details.summary).toEqual(expectedSummary); + expect(result.details.description).toEqual(expectedDescription); + expect(result.details.author).toEqual(expectedAuthor); + + }); + + test("should create a new crafting system with default details", async () => { + + const craftingSystemStore = new EntityDataStore({ + entityName: "CraftingSystem", + settingManager, + entityFactory: stubCraftingSystemFactory, + collectionManager: new CraftingSystemCollectionManager() + }); + + const expectedAuthor = "User"; + const underTest = new DefaultCraftingSystemAPI({ + notificationService, + localizationService, + craftingSystemValidator, + user: expectedAuthor, + craftingSystemStore, + identityFactory: new StubIdentityFactory() + }); + + const result = await underTest.create(); + + expect(result).not.toBeUndefined(); + expect(typeof result.id).toEqual("string"); + expect(result.details.name).toEqual(Properties.ui.defaults.craftingSystem.name); + expect(result.details.summary).toEqual(Properties.ui.defaults.craftingSystem.summary); + expect(result.details.description).toEqual(Properties.ui.defaults.craftingSystem.description); + expect(result.details.author).toEqual(expectedAuthor); + + }); + +}); + +describe("Read", () => { + + test("should return crafting system if exists", async () => { + + const craftingSystemStore = new EntityDataStore({ + entityName: "CraftingSystem", + settingManager, + entityFactory: stubCraftingSystemFactory, + collectionManager: new CraftingSystemCollectionManager() + }); + + const underTest = new DefaultCraftingSystemAPI({ + notificationService, + localizationService, + craftingSystemValidator, + user: "User", + craftingSystemStore, + identityFactory: new StubIdentityFactory() + }); + + const result = await underTest.getById(testCraftingSystemOne.id); + + expect(result).not.toBeUndefined(); + expect(result.id).toEqual(testCraftingSystemOne.id); + expect(result.details.name).toEqual(testCraftingSystemOne.details.name); + expect(result.details.summary).toEqual(testCraftingSystemOne.details.summary); + expect(result.details.description).toEqual(testCraftingSystemOne.details.description); + expect(result.details.author).toEqual(testCraftingSystemOne.details.author); + expect(result.isEmbedded).toEqual(testCraftingSystemOne.isEmbedded); + expect(result.isDisabled).toEqual(testCraftingSystemOne.isDisabled); + + }); + + test("should return embedded crafting system if exists", async () => { + + const craftingSystemStore = new EntityDataStore({ + entityName: "CraftingSystem", + settingManager, + entityFactory: stubCraftingSystemFactory, + collectionManager: new CraftingSystemCollectionManager() + }); + + const underTest = new DefaultCraftingSystemAPI({ + notificationService, + localizationService, + craftingSystemValidator, + user: "User", + craftingSystemStore, + identityFactory: new StubIdentityFactory() + }); + + const result = await underTest.getById(testCraftingSystemTwo.id); + + expect(result).not.toBeUndefined(); + expect(result.id).toEqual(testCraftingSystemTwo.id); + expect(result.details.name).toEqual(testCraftingSystemTwo.details.name); + expect(result.details.summary).toEqual(testCraftingSystemTwo.details.summary); + expect(result.details.description).toEqual(testCraftingSystemTwo.details.description); + expect(result.details.author).toEqual(testCraftingSystemTwo.details.author); + expect(result.isEmbedded).toEqual(testCraftingSystemTwo.isEmbedded); + expect(result.isDisabled).toEqual(testCraftingSystemTwo.isDisabled); + + await expect(craftingSystemStore.size()).resolves.toEqual(2); + + }); + + test("should return undefined if system does not exist", async () => { + + const craftingSystemStore = new EntityDataStore({ + entityName: "CraftingSystem", + settingManager, + entityFactory: stubCraftingSystemFactory, + collectionManager: new CraftingSystemCollectionManager() + }); + + const underTest = new DefaultCraftingSystemAPI({ + notificationService, + localizationService, + craftingSystemValidator, + user: "User", + craftingSystemStore, + identityFactory: new StubIdentityFactory() + }); + + await expect(underTest.getById("non-existent")).resolves.toBeUndefined(); + + }); + +}); + +describe("Update", () => { + + test("should fail to save a new crafting system with the same ID as an embedded system", async () => { + + const craftingSystemStore = new EntityDataStore({ + entityName: "CraftingSystem", + settingManager, + entityFactory: stubCraftingSystemFactory, + collectionManager: new CraftingSystemCollectionManager() + }); + + const underTest = new DefaultCraftingSystemAPI({ + notificationService, + localizationService, + craftingSystemValidator, + user: "User", + craftingSystemStore, + identityFactory: new StubIdentityFactory() + }); + + await expect(underTest.save( + new CraftingSystem({ + id: testCraftingSystemTwo.id, + craftingSystemDetails: new CraftingSystemDetails({ + name: "Any name", + summary: "Any summary", + description: "Any description", + author: "Any author" + }), + embedded: false + }) + )) + .rejects + .toThrowError(); + + await expect(craftingSystemStore.size()).resolves.toEqual(2); + const allEntities = await craftingSystemStore.getAllEntities(); + expect(allEntities.length).toEqual(2); + + expect(notificationService.invocations.length).toEqual(1); + expect(notificationService.invocations[0].level).toEqual("error"); + }); + + test("should allow an embedded system with `isDisabled` toggled as the only change to be saved", async () => { + + const craftingSystemStore = new EntityDataStore({ + entityName: "CraftingSystem", + settingManager, + entityFactory: stubCraftingSystemFactory, + collectionManager: new CraftingSystemCollectionManager() + }); + + const underTest = new DefaultCraftingSystemAPI({ + notificationService, + localizationService, + craftingSystemValidator, + user: "User", + craftingSystemStore, + identityFactory: new StubIdentityFactory() + }); + + const testCraftingSystemTwoClone = testCraftingSystemTwo.clone({ id: testCraftingSystemTwo.id, embedded: true }); + testCraftingSystemTwoClone.isDisabled = !testCraftingSystemTwoClone.isDisabled; + const saved = await underTest.save(testCraftingSystemTwoClone); + + expect(saved.equals(testCraftingSystemTwo, true)).toBe(true); + await expect(craftingSystemStore.size()).resolves.toEqual(2); + const allEntities = await craftingSystemStore.getAllEntities(); + expect(allEntities.length).toEqual(2); + + expect(notificationService.invocations.length).toEqual(1); + expect(notificationService.invocations[0].level).toEqual("info"); + }); + + test("should update system if exists and changes valid", async () => { + + const craftingSystemStore = new EntityDataStore({ + entityName: "CraftingSystem", + settingManager, + entityFactory: stubCraftingSystemFactory, + collectionManager: new CraftingSystemCollectionManager() + }); + + const underTest = new DefaultCraftingSystemAPI({ + notificationService, + localizationService, + craftingSystemValidator, + user: "User", + craftingSystemStore, + identityFactory: new StubIdentityFactory() + }); + + const expectedName = "New name"; + const update = testCraftingSystemOne.clone({ + id: testCraftingSystemOne.id, + name: expectedName, + embedded: false + }); + + const updated = await underTest.save(update); + expect(updated).not.toBeUndefined(); + expect(updated.id).toEqual(testCraftingSystemOne.id); + expect(updated.details.name).toEqual(expectedName); + expect(updated.details.summary).toEqual(testCraftingSystemOne.details.summary); + expect(updated.details.description).toEqual(testCraftingSystemOne.details.description); + expect(updated.details.author).toEqual(testCraftingSystemOne.details.author); + + }); + + test("should reject update if system changes are not valid", async () => { + + const craftingSystemStore = new EntityDataStore({ + entityName: "CraftingSystem", + settingManager, + entityFactory: stubCraftingSystemFactory, + collectionManager: new CraftingSystemCollectionManager() + }); + + const underTest = new DefaultCraftingSystemAPI({ + notificationService, + localizationService, + craftingSystemValidator, + user: "User", + craftingSystemStore, + identityFactory: new StubIdentityFactory() + }); + + const expectedName = ""; + const update = testCraftingSystemOne.clone({ + id: testCraftingSystemOne.id, + name: expectedName, + embedded: false + }); + + await expect(underTest.save(update)) + .rejects + .toThrowError(); + + expect(notificationService.invocations.length).toEqual(1); + expect(notificationService.invocations[0].level).toEqual("error"); + + }); + +}); + +describe("Delete", () => { + + test("should delete system if exists", async () => { + + const craftingSystemStore = new EntityDataStore({ + entityName: "CraftingSystem", + settingManager, + entityFactory: stubCraftingSystemFactory, + collectionManager: new CraftingSystemCollectionManager() + }); + + const underTest = new DefaultCraftingSystemAPI({ + notificationService, + localizationService, + craftingSystemValidator, + user: "User", + craftingSystemStore, + identityFactory: new StubIdentityFactory() + }); + + const deleted = await underTest.deleteById(testCraftingSystemOne.id); + expect(deleted).not.toBeUndefined(); + expect(deleted.id).toEqual(testCraftingSystemOne.id); + + }); + + test("should return undefined if system does not exist", async () => { + + const craftingSystemStore = new EntityDataStore({ + entityName: "CraftingSystem", + settingManager, + entityFactory: stubCraftingSystemFactory, + collectionManager: new CraftingSystemCollectionManager() + }); + + const underTest = new DefaultCraftingSystemAPI({ + notificationService, + localizationService, + craftingSystemValidator, + user: "User", + craftingSystemStore, + identityFactory: new StubIdentityFactory() + }); + + await expect(underTest.deleteById("non-existent")).resolves.toBeUndefined(); + + }); + + test("should fail to delete an embedded crafting system", async () => { + + const craftingSystemStore = new EntityDataStore({ + entityName: "CraftingSystem", + settingManager, + entityFactory: stubCraftingSystemFactory, + collectionManager: new CraftingSystemCollectionManager() + }); + + const underTest = new DefaultCraftingSystemAPI({ + notificationService, + localizationService, + craftingSystemValidator, + user: "User", + craftingSystemStore, + identityFactory: new StubIdentityFactory() + }); + + await expect(underTest.deleteById(testCraftingSystemTwo.id)) + .rejects + .toThrowError(); + + expect(notificationService.invocations.length).toEqual(1); + expect(notificationService.invocations[0].level).toEqual("error"); + + }); + +}); \ No newline at end of file diff --git a/test/CraftingSystemSettings.test.ts b/test/CraftingSystemSettings.test.ts deleted file mode 100644 index ac5b1d30..00000000 --- a/test/CraftingSystemSettings.test.ts +++ /dev/null @@ -1,226 +0,0 @@ -import {expect, test, describe, beforeEach} from "@jest/globals"; -import { - DefaultSettingManager, - FabricateSetting, - FabricateSettingMigrator, - SettingState -} from "../src/scripts/settings/FabricateSetting"; -import * as Sinon from "sinon"; -import {GameProvider} from "../src/scripts/foundry/GameProvider"; -import {CraftingSystemJson} from "../src/scripts/system/CraftingSystem"; - -const Sandbox: Sinon.SinonSandbox = Sinon.createSandbox(); - -const stubGameObject: Game = { - settings: { - set: () => {}, - get: () => {} - } -}; -const stubGetSettingsMethod = Sandbox.stub(stubGameObject.settings, "get"); -const stubSetSettingsMethod = Sandbox.stub(stubGameObject.settings, "set"); - -const stubGameProvider: GameProvider = { - get: () => {} -}; -const stubGetGameMethod = Sandbox.stub(stubGameProvider, "get"); - -beforeEach(() => { - Sandbox.reset(); - - stubGetGameMethod.returns(stubGameObject); - stubSetSettingsMethod.resolves(); -}); - -describe("Settings Manager", () => { - - const targetVersion = "2"; - const craftingSystemsKey = "craftingSystems"; - - test("Should load current version without mapping", async () => { - const underTest = new DefaultSettingManager>({ - gameProvider: stubGameProvider, - targetVersion: targetVersion, - settingKey: craftingSystemsKey - }); - const testSystemId = "abc123"; - const name = "Name"; - const summary = "Summary"; - const description = "Description"; - const author = "Author"; - stubGetSettingsMethod.withArgs("fabricate", craftingSystemsKey) - .returns({ - version: targetVersion, - value: { - [testSystemId]: { - id: testSystemId, - details: { - name: name, - summary: summary, - description: description, - author: author - }, - parts: { - essences: {}, - recipes: {}, - components: {} - }, - enabled: true, - locked: false - } - } - }) - const result: Record = await underTest.read(); - const resultValue = result[testSystemId]; - expect(resultValue.details.name).toEqual(name); - expect(resultValue.details.summary).toEqual(summary); - expect(resultValue.details.description).toEqual(description); - expect(resultValue.details.author).toEqual(author); - }); - - interface OldVersion { - id: string, - name: string, - description: string; - author: string; - summary: string; - essences: {}, - recipes: {}, - components: {} - config: { - enabled: boolean; - locked: boolean; - }; - } - - test("Should migrate to new version", async () => { - const stubSettingMigrator: FabricateSettingMigrator, Record> = { - fromVersion: "1", - toVersion: "2", - perform: from => { - const mappedEntries = Object.values(from) - .map(oldVersion => { - const mapped = { - id: oldVersion.id, - details: { - name: oldVersion.name, - description: oldVersion.description, - author: oldVersion.author, - summary: oldVersion.summary - }, - parts: { - components: oldVersion.components, - recipes: oldVersion.recipes, - essences: oldVersion.essences - }, - enabled: oldVersion.config.enabled, - locked: oldVersion.config.locked - } - return [mapped.id, mapped]; - }); - return Object.fromEntries(mappedEntries); - } - }; - const noOpSettingsMigrator: FabricateSettingMigrator = { - fromVersion: "0", - toVersion: "1", - perform: (input) => input - } - const underTest = new DefaultSettingManager>({ - gameProvider: stubGameProvider, - targetVersion: targetVersion, - settingKey: craftingSystemsKey, - settingsMigrators: [stubSettingMigrator, noOpSettingsMigrator] - }); - const testSystemId = "abc123"; - const name = "Name"; - const summary = "Summary"; - const description = "Description"; - const author = "Author"; - stubGetSettingsMethod.withArgs("fabricate", craftingSystemsKey) - .returns({ - version: "0", - value: { - [testSystemId]: { - id: testSystemId, - name: name, - summary: summary, - description: description, - author: author, - essences: {}, - recipes: {}, - components: {}, - config: { - enabled: true, - locked: false - } - } - } - }) - - const checkResult = underTest.check(); - expect(checkResult.validationCheck.isValid).toEqual(true); - expect(checkResult.state).toEqual(SettingState.OUTDATED); - expect(checkResult.migrationCheck.requiresMigration).toEqual(true); - expect(checkResult.migrationCheck.currentVersion).toEqual("0"); - expect(checkResult.migrationCheck.targetVersion).toEqual("2"); - - const migrationResult = await underTest.migrate(); - expect(migrationResult.isSuccessful).toEqual(true); - expect(migrationResult.steps).toEqual(2); - expect(migrationResult.initialVersion).toEqual("0"); - expect(migrationResult.finalVersion).toEqual("2"); - - const savedSetting = stubSetSettingsMethod.getCall(0).args[2] as FabricateSetting>; - - expect(savedSetting).not.toBeNull(); - expect(savedSetting.version).toEqual("2"); - const resultValue = savedSetting.value; - expect(resultValue[testSystemId]).not.toBeNull(); - const migratedValue = resultValue[testSystemId]; - expect(migratedValue.details.name).toEqual(name); - expect(migratedValue.details.summary).toEqual(summary); - expect(migratedValue.details.description).toEqual(description); - expect(migratedValue.details.author).toEqual(author); - }); - - test("Should throw error when setting is null", async () => { - const underTest = new DefaultSettingManager({ - gameProvider: stubGameProvider, - targetVersion: "1", - settingKey: craftingSystemsKey - }); - stubGetSettingsMethod.withArgs("fabricate", `craftingSystems`) - .returns(null); - await expect(() => underTest.read()).toThrow("Unable to read setting value for key craftingSystems. "); - }); - - test("Should throw error when settings version is null", async () => { - const underTest = new DefaultSettingManager({ - gameProvider: stubGameProvider, - targetVersion: "1", - settingKey: craftingSystemsKey - }); - stubGetSettingsMethod.withArgs("fabricate", `craftingSystems`) - .returns({ - version: null, - value: {} - }); - await expect(() => underTest.read()).toThrow("Unable to read setting value for key craftingSystems. "); - }); - - test("Should throw error when setting value is null", async () => { - const underTest = new DefaultSettingManager({ - gameProvider: stubGameProvider, - targetVersion: "1", - settingKey: craftingSystemsKey - }); - stubGetSettingsMethod.withArgs("fabricate", `craftingSystems`) - .returns({ - version: "1", - value: null - }); - await expect(() => underTest.read()).toThrow("Unable to read setting value for key craftingSystems. "); - }); - -}); \ No newline at end of file diff --git a/test/DefaultCraftingCheck.test.ts b/test/DefaultCraftingCheck.test.ts deleted file mode 100644 index 5acd7478..00000000 --- a/test/DefaultCraftingCheck.test.ts +++ /dev/null @@ -1,238 +0,0 @@ -import {beforeEach, describe, expect, jest, test} from "@jest/globals"; -import * as Sinon from "sinon"; - -import {DiceRoller, RollResult} from "../src/scripts/foundry/DiceRoller"; -import {Combination, Unit} from "../src/scripts/common/Combination"; -import {DefaultCraftingCheck} from "../src/scripts/crafting/check/CraftingCheck"; -import {DefaultThresholdCalculator, ThresholdType} from "../src/scripts/crafting/check/Threshold"; -import {ContributionCounterFactory} from "../src/scripts/crafting/check/ContributionCounter"; -import {GameSystemRollModifierProvider} from "../src/scripts/crafting/check/GameSystemRollModifierProvider"; -import {CraftingComponent} from "../src/scripts/common/CraftingComponent"; -import {testComponentFour, testComponentTwo} from "./test_data/TestCraftingComponents"; - -const Sandbox: Sinon.SinonSandbox = Sinon.createSandbox(); - -const stubDiceRoller: DiceRoller = { - evaluate: () => {}, - createUnmodifiedRoll: () => {} -}; -const stubEvaluateMethod = Sandbox.stub(stubDiceRoller, "evaluate"); - -const stubRollModifierProvider: GameSystemRollModifierProvider = >{ - getForActor: () => {} -}; - -const stubActor: Actor = {} - -beforeEach(() => { - jest.resetAllMocks(); - Sandbox.reset(); -}); - -describe("Create", () => { - - test("Should create a Crafting Check 5e",() => { - - const underTest: DefaultCraftingCheck = new DefaultCraftingCheck({ - thresholdCalculator: new DefaultThresholdCalculator({ - thresholdType: ThresholdType.MEET, - contributionCounter: new ContributionCounterFactory({ - essenceContribution: 0, - ingredientContribution: 0 - }).make(), - baseValue: 10 - }), - gameSystemRollModifierProvider: stubRollModifierProvider, - diceRoller: stubDiceRoller - }); - - expect(underTest).not.toBeNull(); - - }); - -}); - -describe("Perform", () => { - - describe("should fail with", () => { - - test("empty combination and roll does not meet threshold", () => { - - const underTest: DefaultCraftingCheck = new DefaultCraftingCheck({ - thresholdCalculator: new DefaultThresholdCalculator({ - thresholdType: ThresholdType.MEET, - contributionCounter: new ContributionCounterFactory({ - essenceContribution: 0, - ingredientContribution: 0 - }).make(), - baseValue: 10 - }), - gameSystemRollModifierProvider: stubRollModifierProvider, - diceRoller: stubDiceRoller - }); - - stubEvaluateMethod.returns(new RollResult(9, "1d20")); - - const result = underTest.perform(stubActor, Combination.EMPTY()); - - expect(result.result).toEqual(9); - expect(result.expression).toEqual("1d20"); - expect(result.successThreshold).toEqual(10); - expect(result.isSuccessful).toEqual(false); - - }); - - test("empty combination and roll does not exceed threshold", () => { - - const underTest: DefaultCraftingCheck = new DefaultCraftingCheck({ - thresholdCalculator: new DefaultThresholdCalculator({ - thresholdType: ThresholdType.EXCEED, - contributionCounter: new ContributionCounterFactory({ - essenceContribution: 0, - ingredientContribution: 0 - }).make(), - baseValue: 10 - }), - gameSystemRollModifierProvider: stubRollModifierProvider, - diceRoller: stubDiceRoller - }); - - stubEvaluateMethod.returns(new RollResult(10, "1d20")); - - const result = underTest.perform(stubActor, Combination.EMPTY()); - - expect(result.result).toEqual(10); - expect(result.expression).toEqual("1d20"); - expect(result.successThreshold).toEqual(11); - expect(result.isSuccessful).toEqual(false); - - }); - - }); - - describe("should succeed with", () => { - - test("empty combination and roll meets threshold", () => { - - const underTest: DefaultCraftingCheck = new DefaultCraftingCheck({ - thresholdCalculator: new DefaultThresholdCalculator({ - thresholdType: ThresholdType.MEET, - contributionCounter: new ContributionCounterFactory({ - essenceContribution: 0, - ingredientContribution: 0 - }).make(), - baseValue: 10 - }), - gameSystemRollModifierProvider: stubRollModifierProvider, - diceRoller: stubDiceRoller - }); - - stubEvaluateMethod.returns(new RollResult(10, "1d20")); - - const result = underTest.perform(stubActor, Combination.EMPTY()); - - expect(result.result).toEqual(10); - expect(result.expression).toEqual("1d20"); - expect(result.successThreshold).toEqual(10); - expect(result.isSuccessful).toEqual(true); - - }); - - test("empty combination and roll exceeds threshold", () => { - - const underTest: DefaultCraftingCheck = new DefaultCraftingCheck({ - thresholdCalculator: new DefaultThresholdCalculator({ - thresholdType: ThresholdType.EXCEED, - contributionCounter: new ContributionCounterFactory({ - essenceContribution: 0, - ingredientContribution: 0 - }).make(), - baseValue: 10 - }), - gameSystemRollModifierProvider: stubRollModifierProvider, - diceRoller: stubDiceRoller - }); - - stubEvaluateMethod.returns(new RollResult(11, "1d20")); - - const result = underTest.perform(stubActor, Combination.EMPTY()); - - expect(result.result).toEqual(11); - expect(result.expression).toEqual("1d20"); - expect(result.successThreshold).toEqual(11); - expect(result.isSuccessful).toEqual(true); - - }); - - }); - - describe("should calculate threshold with", () => { - - test("ingredients add 2 and essences add 1 to be 7 greater than the base threshold when meeting", () => { - - const baseValue = 10; - const underTest: DefaultCraftingCheck = new DefaultCraftingCheck({ - thresholdCalculator: new DefaultThresholdCalculator({ - thresholdType: ThresholdType.MEET, - contributionCounter: new ContributionCounterFactory({ - essenceContribution: 1, - ingredientContribution: 2 - }).make(), - baseValue: baseValue - }), - gameSystemRollModifierProvider: stubRollModifierProvider, - diceRoller: stubDiceRoller - }); - - stubEvaluateMethod.returns(new RollResult(11, "1d20")); - - const ingredients = Combination.ofUnits([ - new Unit(testComponentFour, 1), - new Unit(testComponentTwo, 1) - ]) - - const result = underTest.perform(stubActor, ingredients); - - expect(result.result).toEqual(11); - expect(result.expression).toEqual("1d20"); - expect(result.successThreshold).toEqual(baseValue + 8); - expect(result.isSuccessful).toEqual(false); - - }); - - test("ingredients add 2 and essences add 1 to be 8 greater than the base threshold when exceeding", () => { - - const baseValue = 10; - const underTest: DefaultCraftingCheck = new DefaultCraftingCheck({ - thresholdCalculator: new DefaultThresholdCalculator({ - thresholdType: ThresholdType.EXCEED, - contributionCounter: new ContributionCounterFactory({ - essenceContribution: 1, - ingredientContribution: 2 - }).make(), - baseValue: baseValue - }), - gameSystemRollModifierProvider: stubRollModifierProvider, - diceRoller: stubDiceRoller - }); - - stubEvaluateMethod.returns(new RollResult(11, "1d20")); - - const ingredients = Combination.ofUnits([ - new Unit(testComponentFour, 1), - new Unit(testComponentTwo, 1) - ]) - - const result = underTest.perform(stubActor, ingredients); - - expect(result.result).toEqual(11); - expect(result.expression).toEqual("1d20"); - expect(result.successThreshold).toEqual(baseValue + 9); - expect(result.isSuccessful).toEqual(false); - - }); - - }); - -}); - diff --git a/test/EntityDataStore.test.ts b/test/EntityDataStore.test.ts new file mode 100644 index 00000000..6ac828b8 --- /dev/null +++ b/test/EntityDataStore.test.ts @@ -0,0 +1,315 @@ +import { describe, test, expect } from "@jest/globals"; +import {EntityDataStore, SerialisedEntityData} from "../src/scripts/repository/EntityDataStore"; +import {allTestRecipes, testRecipeFour, testRecipeOne, testRecipeThree, testRecipeTwo} from "./test_data/TestRecipes"; +import {testCraftingSystemOne} from "./test_data/TestCrafingSystem"; +import {Recipe, RecipeJson} from "../src/scripts/crafting/recipe/Recipe"; +import {RecipeCollectionManager} from "../src/scripts/repository/CollectionManager"; +import {StubSettingManager} from "./stubs/foundry/StubSettingManager"; +import Properties from "../src/scripts/Properties"; +import {StubEntityFactory} from "./stubs/StubEntityFactory"; + +const recipeFactory = new StubEntityFactory({ valuesById: allTestRecipes }); + +describe('Reading', () => { + + test("should fail to read from invalid setting data without entities or collections", async () => { + + const underTest = new EntityDataStore({ + entityName: "Recipe", + entityFactory: recipeFactory, + collectionManager: new RecipeCollectionManager(), + // @ts-ignore + settingManager: new StubSettingManager>({}) + }); + + await expect(underTest.getById("anyId")).rejects + .toThrow("The settings value at \"stub.setting.path\" for the Recipe data store is missing both of the required \"entities\" and \"collections\" properties"); + await expect(underTest.getCollection("anyCollection", "anyPrefix")).rejects + .toThrow("The settings value at \"stub.setting.path\" for the Recipe data store is missing both of the required \"entities\" and \"collections\" properties"); + + }); + + test("should fail to read from invalid setting data without entities", async () => { + + const underTest = new EntityDataStore({ + entityName: "Recipe", + entityFactory: recipeFactory, + collectionManager: new RecipeCollectionManager(), + // @ts-ignore + settingManager: new StubSettingManager>({ collections: {} }) + }); + + await expect(underTest.getById("anyId")).rejects + .toThrow("The settings value at \"stub.setting.path\" for the Recipe data store is missing the required \"entities\" property"); + await expect(underTest.getCollection("anyCollection", "anyPrefix")).rejects + .toThrow("The settings value at \"stub.setting.path\" for the Recipe data store is missing the required \"entities\" property"); + + }); + + test("should fail to read from invalid setting data without collections", async () => { + + const underTest = new EntityDataStore({ + entityName: "Recipe", + entityFactory: recipeFactory, + collectionManager: new RecipeCollectionManager(), + // @ts-ignore + settingManager: new StubSettingManager>({ entities: {} }) + }); + + await expect(underTest.getById("anyId")).rejects + .toThrow("The settings value at \"stub.setting.path\" for the Recipe data store is missing the required \"collections\" property"); + await expect(underTest.getCollection("anyCollection", "anyPrefix")).rejects + .toThrow("The settings value at \"stub.setting.path\" for the Recipe data store is missing the required \"collections\" property"); + + }); + + + test("should fail to get entities when the entity factory returns undefined", async () => { + + // Prepare the serialized data + const serializedData: SerialisedEntityData = { + entities: { + [testRecipeOne.id]: testRecipeOne.toJson(), + [testRecipeTwo.id]: testRecipeTwo.toJson() + }, + collections: { + [ `${Properties.settings.collectionNames.craftingSystem}.${testCraftingSystemOne.id}` ]: [ testRecipeOne.id, testRecipeTwo.id ], + [ `${Properties.settings.collectionNames.item}.${testRecipeOne.itemUuid}` ]: [ testRecipeOne.id ], + [ `${Properties.settings.collectionNames.item}.${testRecipeTwo.itemUuid}` ]: [ testRecipeTwo.id ] + } + }; + + const underTest = new EntityDataStore({ + entityName: "Recipe", + entityFactory: { make: () => undefined }, + collectionManager: new RecipeCollectionManager(), + settingManager: new StubSettingManager>(serializedData) + }); + + await expect(() => underTest.getById(testRecipeOne.id)).rejects.toThrow("Failed to create Recipe from JSON:"); + await expect(underTest.getCollection(testCraftingSystemOne.id, Properties.settings.collectionNames.craftingSystem)).rejects.toThrow("Failed to create Recipe from JSON:"); + + }); + + test('should successfully get entities from collection data without collection name prefixes', async () => { + + // Prepare the serialized data + const serializedData: SerialisedEntityData = { + entities: { + [testRecipeOne.id]: testRecipeOne.toJson(), + [testRecipeTwo.id]: testRecipeTwo.toJson() + }, + collections: { + [ testCraftingSystemOne.id ]: [ testRecipeOne.id, testRecipeTwo.id ], + [ testRecipeOne.itemUuid ]: [ testRecipeOne.id ], + [ testRecipeTwo.itemUuid ]: [ testRecipeTwo.id ] + } + }; + + const underTest = new EntityDataStore({ + entityName: "Recipe", + entityFactory: recipeFactory, + collectionManager: { + listCollectionMemberships() { + return [] + } + }, + settingManager: new StubSettingManager>(serializedData) + }); + + // Verify the entities are added correctly + const allEntities = await underTest.getAllEntities(); + expect(allEntities.length).toEqual(2); + expect(recipeInArray(testRecipeOne, allEntities)).toEqual(true); + expect(recipeInArray(testRecipeTwo, allEntities)).toEqual(true); + + // Verify the crafting system collection is added correctly + const craftingSystemCollection = await underTest.getCollection(testCraftingSystemOne.id); + expect(craftingSystemCollection.length).toEqual(2); + expect(recipeInArray(testRecipeOne, craftingSystemCollection)).toEqual(true); + expect(recipeInArray(testRecipeTwo, craftingSystemCollection)).toEqual(true); + + // Verify the item UUID collections are added correctly + const itemOneCollection = await underTest.getCollection(testRecipeOne.itemUuid); + expect(itemOneCollection.length).toEqual(1); + expect(recipeInArray(testRecipeOne, itemOneCollection)).toEqual(true); + const itemTwoCollection = await underTest.getCollection(testRecipeTwo.itemUuid); + expect(itemTwoCollection.length).toEqual(1); + expect(recipeInArray(testRecipeTwo, itemTwoCollection)).toEqual(true); + + }); + +}); + +describe("Addition", () => { + + test("should add entities", async () => { + + const underTest = new EntityDataStore({ + entityName: "Recipe", + entityFactory: recipeFactory, + collectionManager: { + listCollectionMemberships() { + return [] + } + }, + settingManager: new StubSettingManager>({ entities: {}, collections: {} }) + }); + + await underTest.insert(testRecipeOne); + await underTest.insert(testRecipeTwo); + await underTest.insert(testRecipeThree); + await underTest.insert(testRecipeFour); + + const allAfter = await underTest.getAllEntities(); + + await expect(underTest.size()).resolves.toEqual(4); + expect(allAfter.length).toEqual(4); + await expect(underTest.collectionCount()).resolves.toEqual(0); + + }); + + test("should add entities to collections", async () => { + + const underTest = new EntityDataStore({ + entityName: "Recipe", + entityFactory: recipeFactory, + collectionManager: new RecipeCollectionManager(), + settingManager: new StubSettingManager>({ entities: {}, collections: {} }) + }); + + await underTest.insert(testRecipeOne); + await underTest.insert(testRecipeTwo); + await underTest.insert(testRecipeThree); + + const collectionAfter = await underTest.getCollection(testCraftingSystemOne.id, Properties.settings.collectionNames.craftingSystem); + + expect(collectionAfter).not.toBeNull(); + expect(collectionAfter.length).toEqual(3); + expect(recipeInArray(testRecipeOne, collectionAfter)).toEqual(true); + expect(recipeInArray(testRecipeTwo, collectionAfter)).toEqual(true); + expect(recipeInArray(testRecipeThree, collectionAfter)).toEqual(true); + + }); + +}); + +describe("Deletion", () => { + + test("should delete and clean up member entities when deleting collections", async () => { + + const serializedData: SerialisedEntityData = { + entities: { + [testRecipeOne.id]: testRecipeOne.toJson(), + [testRecipeTwo.id]: testRecipeTwo.toJson() + }, + collections: { + [ `${Properties.settings.collectionNames.craftingSystem}.${testCraftingSystemOne.id}` ]: [ testRecipeOne.id, testRecipeTwo.id ], + [ `${Properties.settings.collectionNames.item}.${testRecipeOne.itemUuid}` ]: [ testRecipeOne.id ], + [ `${Properties.settings.collectionNames.item}.${testRecipeTwo.itemUuid}` ]: [ testRecipeTwo.id ] + } + }; + + const underTest = new EntityDataStore({ + entityName: "Recipe", + entityFactory: recipeFactory, + collectionManager: new RecipeCollectionManager(), + settingManager: new StubSettingManager>(serializedData) + }); + + const itemOneCollectionBefore = await underTest.getCollection(testRecipeOne.itemUuid, Properties.settings.collectionNames.item); + expect(itemOneCollectionBefore).not.toBeNull(); + expect(itemOneCollectionBefore.length).toEqual(1); + expect(recipeInArray(testRecipeOne, itemOneCollectionBefore)).toEqual(true); + + const deletedItemOneCollection = await underTest.deleteCollection(testRecipeOne.itemUuid, Properties.settings.collectionNames.item); + + expect(deletedItemOneCollection).toEqual(true); + + const itemOneCollectionAfter = await underTest.getCollection(testRecipeOne.itemUuid, Properties.settings.collectionNames.item); + + expect(itemOneCollectionAfter).not.toBeNull(); + expect(itemOneCollectionAfter.length).toEqual(0); + + const allAfter = await underTest.getAllEntities(); + + expect(allAfter).not.toBeNull(); + expect(allAfter.length).toEqual(1); + + const craftingSystemCollectionAfter = await underTest.getCollection(testCraftingSystemOne.id, Properties.settings.collectionNames.craftingSystem); + expect(craftingSystemCollectionAfter).not.toBeNull(); + expect(craftingSystemCollectionAfter.length).toEqual(1); + expect(recipeInArray(testRecipeTwo, craftingSystemCollectionAfter)).toEqual(true); + + const itemTwoCollection = await underTest.getCollection(testRecipeTwo.itemUuid, Properties.settings.collectionNames.item); + expect(itemTwoCollection).not.toBeNull(); + expect(itemTwoCollection.length).toEqual(1); + expect(recipeInArray(testRecipeTwo, itemTwoCollection)).toEqual(true); + + }); + + test("should remove entities from collections when they are deleted", async () => { + + const serializedData: SerialisedEntityData = { + entities: { + [testRecipeOne.id]: testRecipeOne.toJson(), + [testRecipeTwo.id]: testRecipeTwo.toJson() + }, + collections: { + [ `${Properties.settings.collectionNames.craftingSystem}.${testCraftingSystemOne.id}` ]: [ testRecipeOne.id, testRecipeTwo.id ], + [ `${Properties.settings.collectionNames.item}.${testRecipeOne.itemUuid}` ]: [ testRecipeOne.id ], + [ `${Properties.settings.collectionNames.item}.${testRecipeTwo.itemUuid}` ]: [ testRecipeTwo.id ] + } + }; + + const underTest = new EntityDataStore({ + entityName: "Recipe", + entityFactory: recipeFactory, + collectionManager: new RecipeCollectionManager(), + settingManager: new StubSettingManager>(serializedData) + }); + + const allBefore = await underTest.getAllEntities(); + expect(allBefore.length).toEqual(2); + expect(recipeInArray(testRecipeOne, allBefore)).toEqual(true); + expect(recipeInArray(testRecipeTwo, allBefore)).toEqual(true); + + const craftingSystemCollectionBefore = await underTest.getCollection(testCraftingSystemOne.id, Properties.settings.collectionNames.craftingSystem); + expect(craftingSystemCollectionBefore.length).toEqual(2); + expect(recipeInArray(testRecipeOne, craftingSystemCollectionBefore)).toEqual(true); + expect(recipeInArray(testRecipeTwo, craftingSystemCollectionBefore)).toEqual(true); + + const itemOneCollectionBefore = await underTest.getCollection(testRecipeOne.itemUuid, Properties.settings.collectionNames.item); + expect(recipeInArray(testRecipeOne, itemOneCollectionBefore)).toEqual(true); + + const itemTwoCollectionBefore = await underTest.getCollection(testRecipeTwo.itemUuid, Properties.settings.collectionNames.item); + expect(recipeInArray(testRecipeTwo, itemTwoCollectionBefore)).toEqual(true); + + const deleted = await underTest.deleteById(testRecipeOne.id); + expect(deleted).toEqual(true); + + const allAfter = await underTest.getAllEntities(); + expect(allAfter.length).toEqual(1); + expect(recipeInArray(testRecipeTwo, allAfter)).toEqual(true); + + const craftingSystemCollectionAfter = await underTest.getCollection(testCraftingSystemOne.id, Properties.settings.collectionNames.craftingSystem); + expect(craftingSystemCollectionAfter.length).toEqual(1); + expect(recipeInArray(testRecipeTwo, craftingSystemCollectionAfter)).toEqual(true); + + const itemOneCollectionAfter = await underTest.getCollection(testRecipeOne.itemUuid, Properties.settings.collectionNames.item); + expect(itemOneCollectionAfter.length).toEqual(0); + + const itemTwoCollectionAfter = await underTest.getCollection(testRecipeTwo.itemUuid, Properties.settings.collectionNames.item); + expect(itemTwoCollectionAfter.length).toEqual(1); + expect(recipeInArray(testRecipeTwo, itemTwoCollectionAfter)).toEqual(true); + + }); + +}); + +function recipeInArray(expected: Recipe, known: Recipe[]): boolean { + if (!known || known.length === 0) { + return false; + } + return !!known.find(candidate => expected.equalsNotLoaded(candidate)); +} diff --git a/test/EssenceSelection.test.ts b/test/EssenceSelection.test.ts index bf5ad05b..fe2f9dd2 100644 --- a/test/EssenceSelection.test.ts +++ b/test/EssenceSelection.test.ts @@ -1,6 +1,6 @@ import {describe, expect, test} from "@jest/globals"; import {EssenceSelection} from "../src/scripts/actor/EssenceSelection"; -import {Combination, Unit} from "../src/scripts/common/Combination"; +import {Combination} from "../src/scripts/common/Combination"; import {elementalAir, elementalEarth, elementalFire, elementalWater} from "./test_data/TestEssences"; import { testComponentFive, @@ -8,14 +8,15 @@ import { testComponentSix, testComponentTwo } from "./test_data/TestCraftingComponents"; +import {Unit} from "../src/scripts/common/Unit"; describe("Essence selection", () => { test("Should produce closest incomplete selection", () => { const required = Combination.ofUnits([ - new Unit(elementalWater, 2), - new Unit(elementalEarth, 4) + new Unit(elementalWater.toReference(), 2), + new Unit(elementalEarth.toReference(), 4) ]); const underTest = new EssenceSelection(required); @@ -45,8 +46,8 @@ describe("Essence selection", () => { test("Should produce smallest complete selection", () => { const required = Combination.ofUnits([ - new Unit(elementalFire, 3), - new Unit(elementalAir, 1) + new Unit(elementalFire.toReference(), 3), + new Unit(elementalAir.toReference(), 1) ]); const underTest = new EssenceSelection(required); diff --git a/test/FabricateAPI.test.ts b/test/FabricateAPI.test.ts new file mode 100644 index 00000000..ef19db6e --- /dev/null +++ b/test/FabricateAPI.test.ts @@ -0,0 +1,633 @@ +import {describe, expect, test} from "@jest/globals"; +import {StubDocumentManager} from "./stubs/StubDocumentManager"; +import {StubGameProvider} from "./stubs/foundry/StubGameProvider"; +import {GameProvider} from "../src/scripts/foundry/GameProvider"; +import {StubClientSettingsFactory} from "./stubs/foundry/StubClientSettings"; +import Properties from "../src/scripts/Properties"; +import {StubIdentityFactory} from "./stubs/foundry/StubIdentityFactory"; +import {StubUIProvider} from "./stubs/foundry/StubUIProvider"; +import {FabricateItemData, LoadedFabricateItemData} from "../src/scripts/foundry/DocumentManager"; +import {DefaultFabricateAPIFactory} from "../src/scripts/api/FabricateAPIFactory"; +import {SettingVersion} from "../src/scripts/repository/migration/SettingVersion"; +import {SettingMigrationStatus} from "../src/scripts/repository/migration/SettingMigrationStatus"; +import { + buildV2SettingsValue, + buildDefaultV3SettingsValue, + buildAlchemistsSuppliesOnlyV3SettingsValue +} from "./test_data/TestSettingMigrationData"; +import {FabricateStatistics} from "../src/scripts/api/FabricateAPI"; +import { + AlchemistsSuppliesV16SystemDefinition +} from "../src/scripts/repository/embedded_systems/AlchemistsSuppliesV16SystemDefinition"; +import {Combination} from "../src/scripts/common/Combination"; +import {EssenceReference} from "../src/scripts/crafting/essence/EssenceReference"; +import {ComponentReference} from "../src/scripts/crafting/component/ComponentReference"; + +describe("Crafting System integration", () => { + + test("Create Crafting System from scratch", async () => { + + // Prepare the test item data that would be provided by Foundry + + const testItemOneId = "testItemOneId"; + const testItemTwoId = "testItemTwoId"; + const testItemThreeId = "testItemThreeId"; + const testItemFourId = "testItemFourId"; + const testItemFiveId = "testItemFiveId"; + const testItemOneName = "Test Item One"; + const testItemOneImageUrl = "path/to/image/webp"; + const testItemFourName = "Test Item Four"; + const testItemFourImageUrl = "path/to/image/webp"; + const itemDataByUuid = new Map([ + [ testItemOneId, new LoadedFabricateItemData({ + name: testItemOneName, + imageUrl: testItemOneImageUrl, + itemUuid: testItemOneId, + sourceDocument: {} + }) ], + [ testItemTwoId, new LoadedFabricateItemData({ + name: "Test Item Two", + imageUrl: "path/to/image/webp", + itemUuid: testItemTwoId, + sourceDocument: {} + }) ], + [ testItemThreeId, new LoadedFabricateItemData({ + name: "Test Item Three", + imageUrl: "path/to/image/webp", + itemUuid: testItemThreeId, + sourceDocument: {} + }) ], + [ testItemFourId, new LoadedFabricateItemData({ + name: testItemFourName, + imageUrl: testItemFourImageUrl, + itemUuid: testItemFourId, + sourceDocument: {} + } )], + [ testItemFiveId, new LoadedFabricateItemData({ + name: "Test Item Five", + imageUrl: "path/to/image/webp", + itemUuid: testItemFiveId, + sourceDocument: {} + }) ], + ]); + const documentManager = new StubDocumentManager({ itemDataByUuid }); + + // Build the API factory's dependencies + + const gameProvider: GameProvider = new StubGameProvider(); + const uiProvider = new StubUIProvider(); + const defaultSettingsValue = buildDefaultV3SettingsValue(); + const clientSettings: ClientSettings = new StubClientSettingsFactory().make(defaultSettingsValue); + const identityFactory = new StubIdentityFactory(); + + const user = "Game Master"; + const gameSystem = "dnd5e"; + const fabricateAPIFactory = new DefaultFabricateAPIFactory({ + user, + gameSystem, + documentManager, + clientSettings, + gameProvider, + identityFactory, + uiProvider + }); + + // Create the test Fabricate API instance + + const underTest = fabricateAPIFactory.make(); + + // Create the crafting system + + const craftingSystemOptions = { + name: "Test Crafting System", + summary: "Test Summary", + description: "Test Description" + }; + const craftingSystem = await underTest.systems.create(craftingSystemOptions); + + expect(craftingSystem).not.toBeUndefined(); + expect(craftingSystem.details.name).toEqual(craftingSystemOptions.name); + expect(craftingSystem.details.summary).toEqual(craftingSystemOptions.summary); + expect(craftingSystem.details.description).toEqual(craftingSystemOptions.description); + expect(craftingSystem.details.author).toEqual(user); + + // Create essences + + const essenceOne = await underTest.essences.create({ + name: "Essence One", + craftingSystemId: craftingSystem.id + }); + + expect(essenceOne).not.toBeUndefined(); + + const essenceTwo = await underTest.essences.create({ + name: "Essence Two", + craftingSystemId: craftingSystem.id + }); + + expect(essenceTwo).not.toBeUndefined(); + + const essenceThree = await underTest.essences.create({ + name: "Essence Three", + craftingSystemId: craftingSystem.id + }); + + expect(essenceThree).not.toBeUndefined(); + + // Create the first component + + const componentOne = await underTest.components.create({ + itemUuid: testItemOneId, + craftingSystemId: craftingSystem.id, + }); + + expect(componentOne).not.toBeUndefined(); + expect(componentOne.itemUuid).toEqual(testItemOneId); + + await componentOne.load(); + + expect(componentOne.craftingSystemId).toEqual(craftingSystem.id); + expect(componentOne.name).toEqual(testItemOneName); + expect(componentOne.imageUrl).toEqual(testItemOneImageUrl); + + // Create the second component + + const componentTwo = await underTest.components.create({ + itemUuid: testItemTwoId, + craftingSystemId: craftingSystem.id, + essences: { + [ essenceTwo.id ]: 1 + } + }); + + // Create the third component + + const componentThree = await underTest.components.create({ + itemUuid: testItemThreeId, + craftingSystemId: craftingSystem.id, + essences: { + [ essenceThree.id ]: 1 + } + }); + + // Add salvage options to the first component + + expect(componentOne.isSalvageable).toEqual(false); + expect(componentOne.hasEssences).toEqual(false); + + componentOne.setSalvageOption({ + name: "salvageOptionOne", + results: { + [ componentTwo.id ]: 1 + }, + catalysts: { + [ componentThree.id ]: 1 + } + }); + + componentOne.addEssence(essenceOne.id, 1); + + componentOne.salvageOptions.all.find(option => option.name === "salvageOptionOne").addResult(componentTwo.id, 1); + + componentOne.setSalvageOption({ + name: "salvageOptionTwo", + results: { + [ componentTwo.id ]: 1 + }, + catalysts: {} + }); + + // Save the first component + + const updatedComponent = await underTest.components.save(componentOne); + + expect(updatedComponent.hasEssences).toEqual(true); + expect(updatedComponent.isSalvageable).toEqual(true); + expect(updatedComponent.salvageOptions.all.length).toEqual(2); + const updatedSalvageOption = updatedComponent.salvageOptions.all.find(option => option.name === "salvageOptionOne"); + expect(updatedSalvageOption.catalysts.size).toEqual(1); + expect(updatedSalvageOption.catalysts.amountFor(componentThree.id)).toEqual(1); + expect(updatedSalvageOption.results.size).toEqual(2); + expect(updatedSalvageOption.results.amountFor(componentTwo.id)).toEqual(2); + + // Create the first recipe + + const recipeOne = await underTest.recipes.create({ + itemUuid: testItemFourId, + craftingSystemId: craftingSystem.id + }); + + expect(recipeOne).not.toBeUndefined(); + expect(recipeOne.itemUuid).toEqual(testItemFourId); + expect(recipeOne.craftingSystemId).toEqual(craftingSystem.id); + expect(recipeOne.hasResults).toEqual(false); + expect(recipeOne.hasRequirements).toEqual(false); + + await recipeOne.load(); + + expect(recipeOne.name).toEqual(testItemFourName); + expect(recipeOne.imageUrl).toEqual(testItemFourImageUrl); + + // Add a requirement to the recipe + + recipeOne.setRequirementOption({ + name: "requirementOptionOne", + essences: {}, + ingredients: { + [ componentOne.id ]: 1 + }, + catalysts: { + [ componentTwo.id ]: 1 + } + }); + + recipeOne.requirementOptions.all.find(option => option.name === "requirementOptionOne").addIngredient(componentOne.id, 1); + + recipeOne.setResultOption({ + name: "resultOptionOne", + results: { + [ componentThree.id ]: 2 + } + }); + + recipeOne.resultOptions.all.find(option => option.name === "resultOptionOne").subtract(componentThree.id, 1); + + // Save the recipe + + const updatedRecipeOne = await underTest.recipes.save(recipeOne); + + expect(updatedRecipeOne.hasResults).toEqual(true); + expect(updatedRecipeOne.hasRequirements).toEqual(true); + expect(updatedRecipeOne.requirementOptions.size).toEqual(1); + const recipeOneRequirementOptionOne = updatedRecipeOne.requirementOptions.all.find(option => option.name === "requirementOptionOne"); + expect(recipeOneRequirementOptionOne.ingredients.size).toEqual(2); + expect(recipeOneRequirementOptionOne.ingredients.amountFor(componentOne.id)).toEqual(2); + expect(recipeOneRequirementOptionOne.catalysts.size).toEqual(1); + expect(recipeOneRequirementOptionOne.catalysts.amountFor(componentTwo.id)).toEqual(1); + expect(updatedRecipeOne.resultOptions.size).toEqual(1); + const recipeOneResultOptionOne = updatedRecipeOne.resultOptions.all.find(option => option.name === "resultOptionOne"); + expect(recipeOneResultOptionOne.results.size).toEqual(1); + expect(recipeOneResultOptionOne.results.amountFor(componentThree.id)).toEqual(1); + + // Create the second recipe + + const recipeTwo = await underTest.recipes.create({ + itemUuid: testItemFiveId, + craftingSystemId: craftingSystem.id + }); + + expect(recipeTwo).not.toBeUndefined(); + + recipeTwo.setRequirementOption({ + name: "requirementOptionOne", + essences: { + [ essenceOne.id ]: 1 + } + }); + + recipeTwo.requirementOptions.all.find(option => option.name === "requirementOptionOne").addEssence(essenceTwo.id, 2); + + recipeTwo.setResultOption({ + name: "resultOptionOne", + results: { + [ componentOne.id ]: 1 + } + }); + + const updatedRecipeTwo = await underTest.recipes.save(recipeTwo); + + expect(updatedRecipeTwo.hasResults).toEqual(true); + expect(updatedRecipeTwo.hasRequirements).toEqual(true); + expect(updatedRecipeTwo.resultOptions.size).toEqual(1); + const recipeTwoResultOptionOne = updatedRecipeTwo.resultOptions.all.find(option => option.name === "resultOptionOne"); + expect(recipeTwoResultOptionOne.results.size).toEqual(1); + expect(recipeTwoResultOptionOne.results.amountFor(componentOne.id)).toEqual(1); + + }); + +}); + +describe("Data migration", () => { + + test("should use 'V2' when no global setting version is set", async () => { + + const documentManager = new StubDocumentManager(); + const gameProvider: GameProvider = new StubGameProvider(); + const uiProvider = new StubUIProvider(); + const defaultSettingsValue = buildV2SettingsValue(); + const clientSettings: ClientSettings = new StubClientSettingsFactory().make(defaultSettingsValue); + const identityFactory = new StubIdentityFactory(); + + const user = "Game Master"; + const gameSystem = "dnd5e"; + const fabricateAPIFactory = new DefaultFabricateAPIFactory({ + user, + gameSystem, + documentManager, + clientSettings, + gameProvider, + identityFactory, + uiProvider + }); + + const underTest = fabricateAPIFactory.make(); + + const result = await underTest.migration.getCurrentVersion(); + + expect(result).toBe(SettingVersion.V2); + + }); + + test("should use 'V3' when global setting version is set to 'V3'", async () => { + + const documentManager = new StubDocumentManager(); + const gameProvider: GameProvider = new StubGameProvider(); + const uiProvider = new StubUIProvider(); + const defaultSettingsValue = buildDefaultV3SettingsValue(); + const clientSettings: ClientSettings = new StubClientSettingsFactory().make(defaultSettingsValue); + const identityFactory = new StubIdentityFactory(); + + const user = "Game Master"; + const gameSystem = "dnd5e"; + const fabricateAPIFactory = new DefaultFabricateAPIFactory({ + user, + gameSystem, + documentManager, + clientSettings, + gameProvider, + identityFactory, + uiProvider + }); + + const underTest = fabricateAPIFactory.make(); + + const result = await underTest.migration.getCurrentVersion(); + + expect(result).toBe(SettingVersion.V3); + + }); + + test("should not migrate when target version and current version match", async () => { + + const documentManager = new StubDocumentManager(); + const gameProvider: GameProvider = new StubGameProvider(); + const uiProvider = new StubUIProvider(); + const defaultSettingsValue = buildDefaultV3SettingsValue(); + const clientSettings: ClientSettings = new StubClientSettingsFactory().make(defaultSettingsValue); + const identityFactory = new StubIdentityFactory(); + + const user = "Game Master"; + const gameSystem = "dnd5e"; + const fabricateAPIFactory = new DefaultFabricateAPIFactory({ + user, + gameSystem, + documentManager, + clientSettings, + gameProvider, + identityFactory, + uiProvider + }); + + const underTest = fabricateAPIFactory.make(); + + const migrationNeeded = await underTest.migration.isMigrationNeeded(); + + expect(migrationNeeded).toBe(false); + + const result = await underTest.migration.migrateAll(); + + expect(result.status).toBe(SettingMigrationStatus.NOT_NEEDED); + expect(result.from).toBe(SettingVersion.V3); + expect(result.to).toBe(SettingVersion.V3); + + }); + + test("should migrate from V2 to V3", async () => { + + const documentManager = new StubDocumentManager(); + const gameProvider: GameProvider = new StubGameProvider(); + const uiProvider = new StubUIProvider(); + const defaultSettingsValue = buildV2SettingsValue(); + const clientSettings: ClientSettings = new StubClientSettingsFactory().make(defaultSettingsValue); + const identityFactory = new StubIdentityFactory(); + + const craftingSystemsBefore = defaultSettingsValue.get(Properties.module.id).get(Properties.settings.craftingSystems.key).value; + const craftingSystemIds = Object.keys(craftingSystemsBefore); + const fabricateStatisticsBefore = craftingSystemIds + .map(craftingSystemId => { + const craftingSystem = craftingSystemsBefore[craftingSystemId]; + const essences = craftingSystem.parts.essences; + const components = craftingSystem.parts.components; + const recipes = craftingSystem.parts.recipes; + return { + craftingSystemId, + essences, + components, + recipes + } + }) + .reduce((statistics, system) => { + statistics.craftingSystems.count++; + statistics.craftingSystems.ids.push(system.craftingSystemId); + statistics.essences.count += Object.keys(system.essences).length; + statistics.essences.ids.push(...Object.keys(system.essences)); + statistics.components.count += Object.keys(system.components).length; + statistics.components.ids.push(...Object.keys(system.components)); + statistics.recipes.count += Object.keys(system.recipes).length; + statistics.recipes.ids.push(...Object.keys(system.recipes)); + return statistics; + }, { + craftingSystems: { count: 0, ids: [] }, + essences: { count: 0, ids: [], byCraftingSystem: {} }, + components: { count: 0, ids: [], byCraftingSystem: {} }, + recipes: { count: 0, ids: [], byCraftingSystem: {} } + }); + + const user = "Game Master"; + const gameSystem = "dnd5e"; + const fabricateAPIFactory = new DefaultFabricateAPIFactory({ + user, + gameSystem, + documentManager, + clientSettings, + gameProvider, + identityFactory, + uiProvider + }); + + const underTest = fabricateAPIFactory.make(); + + const result = await underTest.migration.migrateAll(); + + expect(result.status).toBe(SettingMigrationStatus.SUCCESS); + expect(result.from).toBe(SettingVersion.V2); + expect(result.to).toBe(SettingVersion.V3); + + const fabricateStatisticsAfter = await underTest.getStatistics(); + + const alchemistsSuppliesV16SystemDefinition = new AlchemistsSuppliesV16SystemDefinition(); + const alchemistsSuppliesUpdatedComponentIds = alchemistsSuppliesV16SystemDefinition.components.map(component => component.id); + const alchemistsSuppliesUpdatedRecipeIds = alchemistsSuppliesV16SystemDefinition.recipes.map(recipe => recipe.id); + const otherSystemPartIds = Object.keys(craftingSystemsBefore) + .filter(craftingSystemId => craftingSystemId !== alchemistsSuppliesV16SystemDefinition.craftingSystem.id) + .map(craftingSystemId => craftingSystemsBefore[craftingSystemId]) + .map(craftingSystem => { + const componentIds = Object.keys(craftingSystem.parts.components); + const recipeIds = Object.keys(craftingSystem.parts.recipes); + return { + componentIds, + recipeIds + } + }) + .reduce((allIds, systemIds) => { + allIds.componentIds.push(...systemIds.componentIds); + allIds.recipeIds.push(...systemIds.recipeIds); + return allIds; + }, { recipeIds: [], componentIds: [] }); + + const expectedComponentIds = [ + ...alchemistsSuppliesUpdatedComponentIds, + ...otherSystemPartIds.componentIds + ]; + const expectedRecipeIds = [ + ...alchemistsSuppliesUpdatedRecipeIds, + ...otherSystemPartIds.recipeIds + ]; + + expect(fabricateStatisticsAfter.craftingSystems.count).toBe(fabricateStatisticsBefore.craftingSystems.count); + expect(fabricateStatisticsAfter.craftingSystems.ids).toEqual(fabricateStatisticsBefore.craftingSystems.ids); + expect(fabricateStatisticsAfter.essences.count).toEqual(fabricateStatisticsBefore.essences.count); + expect(fabricateStatisticsAfter.essences.ids).toEqual(expect.arrayContaining(fabricateStatisticsBefore.essences.ids)); + expect(fabricateStatisticsAfter.components.count).toEqual(fabricateStatisticsBefore.components.count); + expect(fabricateStatisticsAfter.components.ids).toEqual(expect.arrayContaining(expectedComponentIds)); + expect(fabricateStatisticsAfter.recipes.count).toEqual(fabricateStatisticsBefore.recipes.count); + expect(fabricateStatisticsAfter.recipes.ids).toEqual(expect.arrayContaining(expectedRecipeIds)); + + }); + +}); + +describe("Export and import data", () => { + + test("Import V1 export data", async () => {}); + + test("Import V2 export data", async () => {}); + + test("Export Crafting System to V2 export data", async () => { + + // Build the API factory's dependencies + + const gameProvider: GameProvider = new StubGameProvider(); + const uiProvider = new StubUIProvider(); + const documentManager = new StubDocumentManager({ allowUnknownIds: true }); + const defaultSettingsValue = buildAlchemistsSuppliesOnlyV3SettingsValue(); + const clientSettings: ClientSettings = new StubClientSettingsFactory().make(defaultSettingsValue); + const identityFactory = new StubIdentityFactory(); + + const user = "Game Master"; + const gameSystem = "dnd5e"; + const fabricateAPIFactory = new DefaultFabricateAPIFactory({ + user, + gameSystem, + documentManager, + clientSettings, + gameProvider, + identityFactory, + uiProvider + }); + + // Create the test Fabricate API instance + + const underTest = fabricateAPIFactory.make(); + + const alchemistsSupplies = new AlchemistsSuppliesV16SystemDefinition(); + const exportData = await underTest.export(alchemistsSupplies.craftingSystem.id); + + expect(exportData).not.toBeNull(); + expect(exportData.version).toBe("V2"); + + expect(exportData.craftingSystem).not.toBeNull(); + expect(exportData.craftingSystem.id).toBe(alchemistsSupplies.craftingSystem.id); + expect(exportData.craftingSystem.details.name).toBe(alchemistsSupplies.craftingSystem.details.name); + expect(exportData.craftingSystem.details.description).toBe(alchemistsSupplies.craftingSystem.details.description); + expect(exportData.craftingSystem.details.author).toBe(alchemistsSupplies.craftingSystem.details.author); + expect(exportData.craftingSystem.details.summary).toBe(alchemistsSupplies.craftingSystem.details.summary); + + expect(exportData.essences).not.toBeNull(); + expect(exportData.essences.length).toBe(alchemistsSupplies.essences.length); + alchemistsSupplies.essences.forEach(essence => { + const exportedEssence = exportData.essences.find(exportedEssence => exportedEssence.id === essence.id); + expect(exportedEssence).not.toBeNull(); + expect(exportedEssence.id).toEqual(essence.id); + expect(exportedEssence.craftingSystemId).toEqual(essence.craftingSystemId); + expect(exportedEssence.name).toEqual(essence.name); + expect(exportedEssence.description).toEqual(essence.description); + expect(exportedEssence.tooltip).toEqual(essence.tooltip); + expect(exportedEssence.iconCode).toEqual(essence.iconCode); + expect(exportedEssence.disabled).toEqual(essence.disabled); + }); + + expect(exportData.components).not.toBeNull(); + expect(exportData.components.length).toEqual(alchemistsSupplies.components.length); + alchemistsSupplies.components.forEach(component => { + const exportedComponent = exportData.components.find(exportedComponent => exportedComponent.id === component.id); + expect(exportedComponent).not.toBeNull(); + expect(exportedComponent.id).toEqual(component.id); + expect(exportedComponent.craftingSystemId).toEqual(component.craftingSystemId); + expect(exportedComponent.itemUuid).toEqual(component.itemUuid); + expect(exportedComponent.disabled).toEqual(component.isDisabled); + + const exportedComponentEssences = Combination.fromRecord(exportedComponent.essences, id => new EssenceReference(id)); + expect(exportedComponentEssences.equals(component.essences)).toBe(true); + + expect(exportedComponent.salvageOptions.length).toEqual(component.salvageOptions.size); + exportedComponent.salvageOptions.forEach(exportedSalvageOption => { + const componentSalvageOption = component.salvageOptions.all.find(componentSalvageOption => componentSalvageOption.id === exportedSalvageOption.id); + expect(componentSalvageOption).not.toBeNull(); + expect(exportedSalvageOption.id).toEqual(componentSalvageOption.id); + expect(exportedSalvageOption.name).toEqual(componentSalvageOption.name); + const exportedSalvageOptionCatalysts = Combination.fromRecord(exportedSalvageOption.catalysts, id => new ComponentReference(id)); + expect(exportedSalvageOptionCatalysts.equals(componentSalvageOption.catalysts)).toBe(true); + const exportedSalvageOptionResults = Combination.fromRecord(exportedSalvageOption.results, id => new ComponentReference(id)); + expect(exportedSalvageOptionResults.equals(componentSalvageOption.results)).toBe(true); + }); + }); + + expect(exportData.recipes).not.toBeNull(); + expect(exportData.recipes.length).toEqual(alchemistsSupplies.recipes.length); + alchemistsSupplies.recipes.forEach(recipe => { + const exportedRecipe = exportData.recipes.find(exportedRecipe => exportedRecipe.id === recipe.id); + expect(exportedRecipe).not.toBeNull(); + expect(exportedRecipe.id).toEqual(recipe.id); + expect(exportedRecipe.craftingSystemId).toEqual(recipe.craftingSystemId); + expect(exportedRecipe.itemUuid).toEqual(recipe.itemUuid); + expect(exportedRecipe.disabled).toEqual(recipe.isDisabled); + + expect(exportedRecipe.resultOptions.length).toEqual(recipe.resultOptions.size); + exportedRecipe.resultOptions.forEach(exportedResultOption => { + const recipeResultOption = recipe.resultOptions.byId.get(exportedResultOption.id); + expect(recipeResultOption).not.toBeNull(); + expect(exportedResultOption.id).toEqual(recipeResultOption.id); + expect(exportedResultOption.name).toEqual(recipeResultOption.name); + const exportedResultOptionResults = Combination.fromRecord(exportedResultOption.results, id => new ComponentReference(id)); + expect(exportedResultOptionResults.equals(recipeResultOption.results)).toBe(true); + }); + + expect(exportedRecipe.requirementOptions.length).toEqual(recipe.requirementOptions.size); + exportedRecipe.requirementOptions.forEach(exportedRequirementOption => { + const recipeRequirementOption = recipe.requirementOptions.byId.get(exportedRequirementOption.id); + expect(recipeRequirementOption).not.toBeNull(); + expect(exportedRequirementOption.id).toEqual(recipeRequirementOption.id); + expect(exportedRequirementOption.name).toEqual(recipeRequirementOption.name); + const exportedRequirementOptionCatalysts = Combination.fromRecord(exportedRequirementOption.catalysts, id => new ComponentReference(id)); + expect(exportedRequirementOptionCatalysts.equals(recipeRequirementOption.catalysts)).toBe(true); + const exportedRequirementOptionIngredients = Combination.fromRecord(exportedRequirementOption.ingredients, id => new ComponentReference(id)); + expect(exportedRequirementOptionIngredients.equals(recipeRequirementOption.ingredients)).toBe(true); + const exportedRequirementOptionEssences = Combination.fromRecord(exportedRequirementOption.essences, id => new EssenceReference(id)); + expect(exportedRequirementOptionEssences.equals(recipeRequirementOption.essences)).toBe(true); + }); + }); + + }); + +}); \ No newline at end of file diff --git a/test/PartDictionary.test.ts b/test/PartDictionary.test.ts deleted file mode 100644 index edd5e765..00000000 --- a/test/PartDictionary.test.ts +++ /dev/null @@ -1,291 +0,0 @@ -import {describe, expect, test} from "@jest/globals"; - -import {PartDictionary, PartDictionaryFactory} from "../src/scripts/system/PartDictionary"; -import { - testComponentFive, - testComponentFour, - testComponentOne, - testComponentThree, - testComponentTwo -} from "./test_data/TestCraftingComponents"; -import { - testRecipeFive, - testRecipeFour, - testRecipeOne, - testRecipeSeven, - testRecipeSix, - testRecipeThree, - testRecipeTwo -} from "./test_data/TestRecipes"; -import {elementalAir, elementalEarth, elementalFire, elementalWater} from "./test_data/TestEssences"; -import {EssenceJson} from "../src/scripts/common/Essence"; -import {RecipeJson} from "../src/scripts/common/Recipe"; -import {CraftingComponentJson} from "../src/scripts/common/CraftingComponent"; -import {StubDocumentManager} from "./stubs/StubDocumentManager"; -import {LoadedFabricateItemData} from "../src/scripts/foundry/DocumentManager"; - -describe("Create", () => { - - test("Should construct empty Part Dictionary", async () => { - const underTest: PartDictionary = new PartDictionaryFactory({}) - .make({ - essences: {}, - recipes: {}, - components: {} - }); - - expect(underTest).not.toBeNull(); - expect(underTest.size).toBe(0); - const id = "NOT_A_VALID_ID"; - expect(underTest.hasEssence(id)).toBe(false); - expect(underTest.hasComponent(id)).toBe(false); - expect(underTest.hasRecipe(id)).toBe(false); - - await underTest.loadAll(); - const asJson = underTest.toJson(); - expect(Object.keys(asJson.essences).length).toEqual(0); - expect(Object.keys(asJson.recipes).length).toEqual(0); - expect(Object.keys(asJson.components).length).toEqual(0); - }); - -}); - -describe("Index and Retrieve", () => { - - function rawEssenceData() { - const essences: Record = {}; - essences[elementalFire.id] = elementalFire.toJson(); - essences[elementalEarth.id] = elementalEarth.toJson(); - essences[elementalWater.id] = elementalWater.toJson(); - essences[elementalAir.id] = elementalAir.toJson(); - return essences; - } - - function rawRecipeData() { - const recipes: Record = {}; - recipes[testRecipeOne.id] = testRecipeOne.toJson(); - recipes[testRecipeTwo.id] = testRecipeTwo.toJson(); - recipes[testRecipeThree.id] = testRecipeThree.toJson(); - recipes[testRecipeFour.id] = testRecipeFour.toJson(); - recipes[testRecipeFive.id] = testRecipeFive.toJson(); - recipes[testRecipeSix.id] = testRecipeSix.toJson(); - recipes[testRecipeSeven.id] = testRecipeSeven.toJson(); - return recipes; - } - - function rawComponentData() { - const components: Record = {}; - components[testComponentOne.id] = testComponentOne.toJson(); - components[testComponentTwo.id] = testComponentTwo.toJson(); - components[testComponentThree.id] = testComponentThree.toJson(); - components[testComponentFour.id] = testComponentFour.toJson(); - components[testComponentFive.id] = testComponentFive.toJson(); - return components; - } - - const stubItemName = "Item name"; - const stubItemImageUrl = "/img/url.ext"; - const stubItemSource = {}; - const recipeUuids = Object.values(rawRecipeData()).map(recipeData => recipeData.itemUuid); - const componentUuids = Object.values(rawComponentData()).map(componentData => componentData.itemUuid); - const allItemUuids = componentUuids.concat(recipeUuids); - const itemData = new Map( - allItemUuids - .map(id => [id, new LoadedFabricateItemData({ - name: stubItemName, - imageUrl: stubItemImageUrl, - itemUuid: id, - sourceDocument: stubItemSource - })]) - ); - const stubDocumentManager = new StubDocumentManager(itemData); - - test("Should load a populated Part Dictionary from source data", async () => { - - const essences = rawEssenceData(); - const underTest: PartDictionary = new PartDictionaryFactory({documentManager: stubDocumentManager}) - .make({ - essences: essences, - recipes: rawRecipeData(), - components: rawComponentData() - }); - - expect(underTest).not.toBeNull(); - await underTest.loadAll(); - - expect(underTest.size).toBe(16); - - Object.keys(essences).forEach(id => expect(underTest.hasEssence(id)).toEqual(true)); - - const loadedComponents = underTest.getComponents(); - loadedComponents.forEach(component => { - expect(underTest.hasComponent(component.id)).toEqual(true); - expect(component.name).toEqual(stubItemName); - expect(component.imageUrl).toEqual(stubItemImageUrl); - expect(componentUuids).toEqual(expect.arrayContaining([component.itemUuid])); - }); - - const loadedRecipes = await underTest.getRecipes(); - loadedRecipes.forEach(recipe => { - expect(underTest.hasRecipe(recipe.id)).toEqual(true); - expect(recipe.name).toEqual(stubItemName); - expect(recipe.imageUrl).toEqual(stubItemImageUrl); - expect(recipeUuids).toEqual(expect.arrayContaining([recipe.itemUuid])); - }) - }); - - test("Should delete and dereference components from recipes and salvage", async () => { - - const underTest: PartDictionary = new PartDictionaryFactory({documentManager: stubDocumentManager}) - .make({ - essences: rawEssenceData(), - recipes: rawRecipeData(), - components: rawComponentData() - }); - - expect(underTest).not.toBeNull(); - - await underTest.loadAll(); - expect(underTest.size).toBe(16); - - const componentToDelete = testComponentThree; - const componentIdToDelete = componentToDelete.id; - - underTest.deleteComponentById(componentIdToDelete); - - expect(underTest.size).toBe(15); - - const recipes = await underTest.getRecipes(); - - recipes.forEach(recipe => { - recipe.ingredientOptions.forEach(option => { - expect(option.catalysts.has(componentToDelete)).toEqual(false); - expect(option.ingredients.has(componentToDelete)).toEqual(false); - }); - recipe.resultOptions.forEach(option => { - expect(option.results.has(componentToDelete)).toEqual(false); - }); - }); - - const components = underTest.getComponents(); - expect(components.find(component => component.id === componentIdToDelete)).toBeUndefined(); - components.forEach(component => { - expect(component.salvageOptions.find(option => option.salvage.has(componentToDelete))).toBeUndefined(); - }); - - const asJson = underTest.toJson(); - expect(Object.keys(asJson.components).length).toEqual(4); - expect(asJson.components[componentIdToDelete]).toBeUndefined(); - - }); - - test("Should delete and dereference essences from recipes and components", async () => { - - const underTest: PartDictionary = new PartDictionaryFactory({documentManager: stubDocumentManager}) - .make({ - essences: rawEssenceData(), - recipes: rawRecipeData(), - components: rawComponentData() - }); - - expect(underTest).not.toBeNull(); - - await underTest.loadAll(); - expect(underTest.size).toBe(16); - - const essenceToDelete = elementalFire; - const essenceIdToDelete = essenceToDelete.id; - - await underTest.deleteEssenceById(essenceIdToDelete); - - expect(underTest.size).toBe(15); - - const recipes = await underTest.getRecipes(); - - recipes.forEach(recipe => { - expect(recipe.essences.has(essenceToDelete)).toEqual(false); - }); - - const components = await underTest.getComponents(); - components.forEach(component => { - expect(component.essences.has(essenceToDelete)).toEqual(false); - }); - - const essences = await underTest.getEssences(); - expect(new Map(essences.map(essence => [essence.id, essence])).has(essenceIdToDelete)).toEqual(false); - - const asJson = underTest.toJson(); - expect(Object.keys(asJson.essences).length).toEqual(3); - expect(asJson.essences[essenceIdToDelete]).toBeUndefined(); - - }); - - test("Should add and Get Recipes and Components", async () => { - const underTest: PartDictionary = new PartDictionaryFactory({}) - .make({ - essences: {}, - recipes: {}, - components: {} - }); - - await underTest.insertEssence(elementalFire); - await underTest.insertEssence(elementalEarth); - await underTest.insertEssence(elementalAir); - await underTest.insertEssence(elementalWater); - - await underTest.insertComponent(testComponentOne); - await underTest.insertComponent(testComponentTwo); - await underTest.insertComponent(testComponentThree); - await underTest.insertComponent(testComponentFour); - await underTest.insertComponent(testComponentFive); - - await underTest.insertRecipe(testRecipeOne); - await underTest.insertRecipe(testRecipeTwo); - await underTest.insertRecipe(testRecipeThree); - await underTest.insertRecipe(testRecipeFour); - - expect(underTest).not.toBeNull(); - expect(underTest.size).toBe(13); - - await expect(underTest.getEssence(elementalFire.id)).toEqual(elementalFire); - await expect(underTest.getEssence(elementalEarth.id)).toEqual(elementalEarth); - await expect(underTest.getEssence(elementalAir.id)).toEqual(elementalAir); - await expect(underTest.getEssence(elementalWater.id)).toEqual(elementalWater); - - await expect(underTest.getComponent(testComponentOne.id)).toEqual(testComponentOne); - await expect(underTest.getComponent(testComponentTwo.id)).toEqual(testComponentTwo); - await expect(underTest.getComponent(testComponentThree.id)).toEqual(testComponentThree); - await expect(underTest.getComponent(testComponentFour.id)).toEqual(testComponentFour); - await expect(underTest.getComponent(testComponentFive.id)).toEqual(testComponentFive); - - await expect(underTest.getRecipe(testRecipeOne.id)).toEqual(testRecipeOne); - await expect(underTest.getRecipe(testRecipeTwo.id)).toEqual(testRecipeTwo); - await expect(underTest.getRecipe(testRecipeThree.id)).toEqual(testRecipeThree); - await expect(underTest.getRecipe(testRecipeFour.id)).toEqual(testRecipeFour); - }); - - test("Should throw errors when parts are not found", async () => { - - const underTest: PartDictionary = new PartDictionaryFactory({}) - .make({ - essences: {}, - recipes: {}, - components: {} - }); - - await underTest.insertComponent(testComponentOne); - await underTest.insertComponent(testComponentThree); - await underTest.insertComponent(testComponentFour); - - await underTest.insertRecipe(testRecipeOne); - - expect(underTest).not.toBeNull(); - expect(underTest.size).toBe(4); - - const id: string = "notAValidId"; - await expect(() => underTest.getComponent(id)).toThrow(new Error(`No Component data was found for the id "${id}". Known Component IDs for this system are: iyeUGBbSts0ij92X, tdyV4AWuTMkXbepw, Ra2Z1ujre76weR0i`)); - await expect(() => underTest.getRecipe(id)).toThrow(new Error(`No Recipe data was found for the id "${id}". Known Recipe IDs for this system are: z2ixo2m312l`)); - - }); - -}); \ No newline at end of file diff --git a/test/Recipe.test.ts b/test/Recipe.test.ts index e1b0e958..89d7389b 100644 --- a/test/Recipe.test.ts +++ b/test/Recipe.test.ts @@ -1,9 +1,7 @@ import {expect, jest, test, beforeEach, describe} from "@jest/globals"; import { - RequirementOption, - RequirementOptionJson, - Recipe, ResultOption, ResultOptionJson -} from "../src/scripts/common/Recipe"; + Recipe +} from "../src/scripts/crafting/recipe/Recipe"; import { testComponentOne, @@ -12,16 +10,19 @@ import { testComponentFour, testComponentFive } from "./test_data/TestCraftingComponents"; -import {Combination, Unit} from "../src/scripts/common/Combination"; +import {Combination} from "../src/scripts/common/Combination"; import {elementalEarth, elementalFire, elementalWater} from "./test_data/TestEssences"; import { - testRecipeFive, + testRecipeFive, testRecipeFour, testRecipeOne, testRecipeSeven, testRecipeSix, testRecipeThree, testRecipeTwo } from "./test_data/TestRecipes"; import {NoFabricateItemData} from "../src/scripts/foundry/DocumentManager"; -import {SelectableOptions} from "../src/scripts/common/SelectableOptions"; +import {SelectableOptions} from "../src/scripts/crafting/selection/SelectableOptions"; +import {Unit} from "../src/scripts/common/Unit"; +import {RequirementOption, RequirementOptionJson} from "../src/scripts/crafting/recipe/RequirementOption"; +import {ResultOption, ResultOptionJson} from "../src/scripts/crafting/recipe/ResultOption"; beforeEach(() => { jest.resetAllMocks(); @@ -32,38 +33,35 @@ describe("When creating a recipe", () => { test("should correctly assess requirements for a recipe with essences only", () => { const underTest = testRecipeThree; - expect(underTest.essences.size).toEqual(4); - expect(underTest.essences.amountFor(elementalFire.id)).toEqual(1); - expect(underTest.essences.amountFor(elementalEarth.id)).toEqual(3); - expect(underTest.hasOptions).toEqual(false); expect(underTest.ready()).toEqual(true); - expect(underTest.requiresEssences).toEqual(true); - expect(underTest.hasIngredients).toEqual(false); - expect(underTest.hasIngredientOptions).toEqual(false); + expect(underTest.hasRequirements).toEqual(true); + expect(underTest.requirementOptions.all[0].essences.size).toEqual(4); + expect(underTest.requirementOptions.all[0].essences.amountFor(elementalFire.id)).toEqual(1); + expect(underTest.requirementOptions.all[0].essences.amountFor(elementalEarth.id)).toEqual(3); + expect(underTest.hasRequirementChoices).toEqual(false); expect(underTest.hasResults).toEqual(true); - expect(underTest.hasResultOptions).toEqual(false); + expect(underTest.hasResultChoices).toEqual(false); }); test("should correctly assess requirements for a recipe with essences and catalysts", () => { const underTest = testRecipeFive; - expect(underTest.essences.size).toEqual(2); - expect(underTest.essences.amountFor(elementalFire.id)).toEqual(1); - expect(underTest.essences.amountFor(elementalWater.id)).toEqual(1); - expect(underTest.hasOptions).toEqual(false); expect(underTest.ready()).toEqual(true); - expect(underTest.requiresEssences).toEqual(true); - expect(underTest.hasIngredients).toEqual(true); - expect(underTest.hasIngredientOptions).toEqual(false); - expect(underTest.ingredientOptions.length).toEqual(1); - expect(underTest.ingredientOptions[0].requiresCatalysts).toEqual(true); - expect(underTest.ingredientOptions[0].requiresIngredients).toEqual(false); - expect(underTest.ingredientOptions[0].catalysts.size).toEqual(1); - expect(underTest.ingredientOptions[0].catalysts.amountFor(testComponentFour.id)).toEqual(1); + expect(underTest.hasRequirements).toEqual(true); + expect(underTest.hasRequirementChoices).toEqual(false); + expect(underTest.requirementOptions.all.length).toEqual(1); + expect(underTest.requirementOptions.all[0].requiresCatalysts).toEqual(true); + expect(underTest.requirementOptions.all[0].requiresIngredients).toEqual(false); + expect(underTest.requirementOptions.all[0].requiresEssences).toEqual(true); + expect(underTest.requirementOptions.all[0].essences.size).toEqual(2); + expect(underTest.requirementOptions.all[0].essences.amountFor(elementalFire.id)).toEqual(1); + expect(underTest.requirementOptions.all[0].essences.amountFor(elementalWater.id)).toEqual(1); + expect(underTest.requirementOptions.all[0].catalysts.size).toEqual(1); + expect(underTest.requirementOptions.all[0].catalysts.amountFor(testComponentFour.id)).toEqual(1); expect(underTest.hasResults).toEqual(true); - expect(underTest.hasResultOptions).toEqual(false); + expect(underTest.hasResultChoices).toEqual(false); }); test("should correctly assess requirements for a recipe with named ingredients and catalysts", () => { @@ -71,18 +69,17 @@ describe("When creating a recipe", () => { expect(underTest.hasOptions).toEqual(false); expect(underTest.ready()).toEqual(true); - expect(underTest.requiresEssences).toEqual(false); - expect(underTest.hasIngredients).toEqual(true); - expect(underTest.hasIngredientOptions).toEqual(false); - expect(underTest.ingredientOptions.length).toEqual(1); - expect(underTest.ingredientOptions[0].requiresCatalysts).toEqual(true); - expect(underTest.ingredientOptions[0].requiresIngredients).toEqual(true); - expect(underTest.ingredientOptions[0].catalysts.size).toEqual(1); - expect(underTest.ingredientOptions[0].catalysts.amountFor(testComponentFive.id)).toEqual(1); - expect(underTest.ingredientOptions[0].ingredients.size).toEqual(1); - expect(underTest.ingredientOptions[0].ingredients.amountFor(testComponentFour.id)).toEqual(1); + expect(underTest.hasRequirements).toEqual(true); + expect(underTest.hasRequirementChoices).toEqual(false); + expect(underTest.requirementOptions.all.length).toEqual(1); + expect(underTest.requirementOptions.all[0].requiresCatalysts).toEqual(true); + expect(underTest.requirementOptions.all[0].requiresIngredients).toEqual(true); + expect(underTest.requirementOptions.all[0].catalysts.size).toEqual(1); + expect(underTest.requirementOptions.all[0].catalysts.amountFor(testComponentFive.id)).toEqual(1); + expect(underTest.requirementOptions.all[0].ingredients.size).toEqual(1); + expect(underTest.requirementOptions.all[0].ingredients.amountFor(testComponentFour.id)).toEqual(1); expect(underTest.hasResults).toEqual(true); - expect(underTest.hasResultOptions).toEqual(false); + expect(underTest.hasResultChoices).toEqual(false); }); @@ -90,14 +87,12 @@ describe("When creating a recipe", () => { const underTest = testRecipeSix; expect(underTest.hasOptions).toEqual(true); - expect(underTest.ready()).toEqual(false); - expect(underTest.requiresEssences).toEqual(true); - expect(underTest.hasIngredients).toEqual(true); - expect(underTest.hasIngredientOptions).toEqual(true); + expect(underTest.hasRequirements).toEqual(true); + expect(underTest.hasRequirementChoices).toEqual(true); expect(underTest.hasResults).toEqual(true); - expect(underTest.hasResultOptions).toEqual(true); - expect(underTest.ingredientOptions.length).toEqual(2); - expect(underTest.resultOptions.length).toEqual(2); + expect(underTest.hasResultChoices).toEqual(true); + expect(underTest.requirementOptions.all.length).toEqual(2); + expect(underTest.resultOptions.all.length).toEqual(2); }); @@ -107,28 +102,31 @@ describe("When selecting ingredients", () => { const id = "hq4F67hS"; - test("should require choices from ingredient groups with options", () => { + test("should default to first choice from ingredient groups with options", () => { const combinationOne = Combination.ofUnits([ - new Unit(testComponentOne, 1), - new Unit(testComponentTwo, 1) + new Unit(testComponentOne.toReference(), 1), + new Unit(testComponentTwo.toReference(), 1) ]); const combinationTwo = Combination.ofUnits([ - new Unit(testComponentThree, 2) + new Unit(testComponentThree.toReference(), 2) ]); const underTest = new Recipe({ id, + craftingSystemId: "acb123", disabled: false, itemData: NoFabricateItemData.INSTANCE(), - ingredientOptions: new SelectableOptions({ + requirementOptions: new SelectableOptions({ options: [ new RequirementOption({ + id: `${id}-requirement-1`, name: "Option 1", ingredients: combinationOne }), new RequirementOption({ + id: `${id}-requirement-2`, name: "Option 2", ingredients: combinationTwo }), @@ -136,7 +134,8 @@ describe("When selecting ingredients", () => { }) }); - expect(underTest.ready()).toEqual(false); + expect(underTest.ready()).toEqual(true); + expect(underTest.selectedRequirementOption.name).toEqual("Option 1"); expect(underTest.getSelectedIngredients).toThrow(Error); @@ -145,50 +144,54 @@ describe("When selecting ingredients", () => { test("should produce the correct selected ingredients when components in an ingredient group are re-selected", () => { const combinationOne = Combination.ofUnits([ - new Unit(testComponentOne, 1), - new Unit(testComponentTwo, 1) + new Unit(testComponentOne.toReference(), 1), + new Unit(testComponentTwo.toReference(), 1) ]); const optionOneId = "1"; + const optionOneName = "Option 1"; const optionOne = new RequirementOption({ - name: optionOneId, + id: optionOneId, + name: optionOneName, ingredients: combinationOne }); const optionTwoId = "2"; + const optionTwoName = "Option 2"; const combinationTwo = Combination.ofUnits([ - new Unit(testComponentThree, 2) + new Unit(testComponentThree.toReference(), 2) ]); const optionTwo = new RequirementOption({ - name: optionTwoId, + id: optionTwoId, + name: optionTwoName, ingredients: combinationTwo }); const underTest = new Recipe({ id, + craftingSystemId: "acb123", itemData: NoFabricateItemData.INSTANCE(), - ingredientOptions: new SelectableOptions({ + requirementOptions: new SelectableOptions({ options: [optionOne, optionTwo] }) }); - expect(underTest.ingredientOptions.length).toEqual(2); - expect(underTest.ready()).toEqual(false); + expect(underTest.requirementOptions.all.length).toEqual(2); - underTest.selectIngredientOption(optionOneId); + underTest.selectRequirementOption(optionOneId); expect(underTest.ready()).toEqual(true); let selectedIngredients = underTest.getSelectedIngredients(); expect(selectedIngredients.ingredients.equals(combinationOne)).toEqual(true); - underTest.selectIngredientOption(optionTwoId); + underTest.selectRequirementOption(optionTwoId); expect(underTest.ready()).toEqual(true); selectedIngredients = underTest.getSelectedIngredients(); expect(selectedIngredients.ingredients.equals(combinationTwo)).toEqual(true); - underTest.deselectIngredients(); + underTest.deselectRequirements(); expect(underTest.ready()).toEqual(false); }); @@ -201,28 +204,31 @@ describe("When selecting results", () => { test("should not require choice when there is only one option", () => { const singletonResult = Combination.ofUnits([ - new Unit(testComponentFive, 3), - new Unit(testComponentOne, 1) + new Unit(testComponentFive.toReference(), 3), + new Unit(testComponentOne.toReference(), 1) ]); const singletonIngredient = Combination.ofUnits([ - new Unit(testComponentFour, 2) + new Unit(testComponentFour.toReference(), 2) ]); const underTest = new Recipe({ id, + craftingSystemId: "acb123", itemData: NoFabricateItemData.INSTANCE(), resultOptions: new SelectableOptions({ options: [ new ResultOption({ + id: `${id}-result-1`, name: "Option 1", results: singletonResult }) ] }), - ingredientOptions: new SelectableOptions({ + requirementOptions: new SelectableOptions({ options: [ new RequirementOption({ + id: `${id}-requirement-1`, name: "Option 1", ingredients: singletonIngredient }) @@ -241,26 +247,29 @@ describe("When selecting results", () => { }); - test("should require choices from result groups with options", () => { + test("should use first option by default from result groups with options", () => { const resultCombinationOne = Combination.ofUnits([ - new Unit(testComponentThree, 2) + new Unit(testComponentThree.toReference(), 2) ]); const resultCombinationTwo = Combination.ofUnits([ - new Unit(testComponentFour, 2) + new Unit(testComponentFour.toReference(), 2) ]); const underTest = new Recipe({ id, + craftingSystemId: "acb123", itemData: NoFabricateItemData.INSTANCE(), resultOptions: new SelectableOptions({ options: [ new ResultOption({ + id: `${id}-result-1`, name: "Option 1", results: resultCombinationOne }), new ResultOption({ + id: `${id}-result-2`, name: "Option 2", results: resultCombinationTwo }) @@ -268,7 +277,8 @@ describe("When selecting results", () => { }) }); - expect(underTest.ready()).toEqual(false); + expect(underTest.ready()).toEqual(true); + expect(underTest.selectedResultOption.name).toEqual("Option 1"); expect(underTest.getSelectedResults).toThrow(Error); @@ -277,35 +287,36 @@ describe("When selecting results", () => { test("should produce the correct selected results when components in a result group are re-selected", () => { const resultCombinationOne = Combination.ofUnits([ - new Unit(testComponentOne, 1), - new Unit(testComponentTwo, 1) + new Unit(testComponentOne.toReference(), 1), + new Unit(testComponentTwo.toReference(), 1) ]); const resultCombinationTwo = Combination.ofUnits([ - new Unit(testComponentThree, 2) + new Unit(testComponentThree.toReference(), 2) ]); - const optionOneId = "1"; - const optionTwoId = "2"; + const optionOneId = `${id}-result-1`; + const optionTwoId = `${id}-result-2`; const underTest = new Recipe({ id, + craftingSystemId: "acb123", itemData: NoFabricateItemData.INSTANCE(), resultOptions: new SelectableOptions({ options: [ new ResultOption({ - name: optionOneId, + id: optionOneId, + name: "Option 1", results: resultCombinationOne }), new ResultOption({ - name: optionTwoId, + id: optionTwoId, + name: "Option 2", results: resultCombinationTwo }) ] }) }); - expect(underTest.ready()).toEqual(false); - underTest.selectResultOption(optionOneId); expect(underTest.ready()).toEqual(true); let selectedResults = underTest.getSelectedResults().results; @@ -322,3 +333,62 @@ describe("When selecting results", () => { }); }); + +describe("when describing a recipe", () => { + + test("should correctly list the unique referenced essences for a recipe with no options with essences", () => { + + const result = testRecipeOne.getUniqueReferencedEssences(); + expect(result).not.toBeNull(); + expect(result.length).toEqual(0); + + }); + + test("should correctly list the unique referenced essences for a recipe with one option with essences", () => { + + const result = testRecipeFour.getUniqueReferencedEssences(); + expect(result).not.toBeNull(); + expect(result.length).toEqual(2); + expect(result).toEqual(expect.arrayContaining([elementalWater.toReference(), elementalEarth.toReference()])); + + }); + + test("should correctly list the unique referenced essences for a recipe with two options with essences", () => { + + const result = testRecipeSix.getUniqueReferencedEssences(); + expect(result).not.toBeNull(); + expect(result.length).toEqual(2); + expect(result).toEqual(expect.arrayContaining([elementalWater.toReference(), elementalEarth.toReference()])); + + }); + + test("should correctly list the unique referenced components for a recipe with no options with components", () => { + + const result = testRecipeThree.getUniqueReferencedComponents(); + expect(result).not.toBeNull(); + expect(result.length).toEqual(1); + expect(result).toEqual(expect.arrayContaining([testComponentOne.toReference()])); + + }); + + test("should correctly list the unique referenced components for a recipe with one option with catalysts", () => { + + const result = testRecipeFive.getUniqueReferencedComponents(); + expect(result).not.toBeNull(); + expect(result.length).toEqual(2); + expect(result).toEqual(expect.arrayContaining([testComponentFour.toReference(), testComponentFive.toReference()])); + + + }); + + test("should correctly list the unique referenced components for a recipe with one option with ingredients", () => { + + const result = testRecipeSeven.getUniqueReferencedComponents(); + expect(result).not.toBeNull(); + expect(result.length).toEqual(2); + expect(result).toEqual(expect.arrayContaining([testComponentFour.toReference(), testComponentTwo.toReference()])); + + + }); + +}); diff --git a/test/RecipeAPI.test.ts b/test/RecipeAPI.test.ts new file mode 100644 index 00000000..8a405736 --- /dev/null +++ b/test/RecipeAPI.test.ts @@ -0,0 +1,869 @@ +import {beforeEach, describe, expect, test} from "@jest/globals"; +import {DefaultRecipeAPI} from "../src/scripts/api/RecipeAPI" +import {StubCraftingSystemAPI} from "./stubs/api/StubCraftingSystemAPI"; +import {StubEssenceAPI} from "./stubs/api/StubEssenceAPI"; +import {StubComponentAPI} from "./stubs/api/StubComponentAPI"; +import {StubLocalizationService} from "./stubs/foundry/StubLocalizationService"; +import {StubNotificationService} from "./stubs/foundry/StubNotificationService"; +import {StubDocumentManager} from "./stubs/StubDocumentManager"; +import {StubIdentityFactory} from "./stubs/foundry/StubIdentityFactory"; +import {DefaultRecipeValidator} from "../src/scripts/crafting/recipe/RecipeValidator"; +import {StubSettingManager} from "./stubs/foundry/StubSettingManager"; +import { + allTestRecipes, + resetAllTestRecipes, + testRecipeFive, + testRecipeFour, + testRecipeOne, + testRecipeSeven, + testRecipeSix, + testRecipeThree, + testRecipeTwo +} from "./test_data/TestRecipes"; +import { + testComponentFive, + testComponentFour, + testComponentOne, + testComponentSeven, + testComponentSix, + testComponentThree, + testComponentTwo +} from "./test_data/TestCraftingComponents"; +import {elementalAir, elementalEarth, elementalFire, elementalWater} from "./test_data/TestEssences"; +import {testCraftingSystemOne} from "./test_data/TestCrafingSystem"; +import {Recipe, RecipeJson} from "../src/scripts/crafting/recipe/Recipe"; +import {EntityDataStore, SerialisedEntityData} from "../src/scripts/repository/EntityDataStore"; +import {RecipeCollectionManager} from "../src/scripts/repository/CollectionManager"; +import {StubEntityFactory} from "./stubs/StubEntityFactory"; +import { + LoadedFabricateItemData, + PendingFabricateItemData +} from "../src/scripts/foundry/DocumentManager"; +import Properties from "../src/scripts/Properties"; +import {StubRecipeValidator} from "./stubs/StubRecipeValidator"; + +const identityFactory = new StubIdentityFactory(); +const localizationService = new StubLocalizationService(); +const notificationService = new StubNotificationService(); +const craftingSystemAPI = new StubCraftingSystemAPI({ + valuesById: new Map([[testCraftingSystemOne.id, testCraftingSystemOne]]) +}); +const componentAPI = new StubComponentAPI({ + valuesById: new Map([ + [testComponentOne.id, testComponentOne], + [testComponentTwo.id, testComponentTwo], + [testComponentThree.id, testComponentThree], + [testComponentFour.id, testComponentFour], + [testComponentFive.id, testComponentFive], + [testComponentSix.id, testComponentSix], + [testComponentSeven.id, testComponentSeven] + ]) +}); +const essenceAPI = new StubEssenceAPI({ + valuesById: new Map([ + [elementalEarth.id, elementalEarth], + [elementalFire.id, elementalFire], + [elementalWater.id, elementalWater], + [elementalAir.id, elementalAir], + ]) +}); +const documentManager = new StubDocumentManager({ + itemDataByUuid: new Map([ + [testComponentOne.itemUuid, testComponentOne.itemData], + [testComponentTwo.itemUuid, testComponentTwo.itemData], + [testComponentThree.itemUuid, testComponentThree.itemData], + [testComponentFour.itemUuid, testComponentFour.itemData], + [testComponentFive.itemUuid, testComponentFive.itemData], + [testComponentSix.itemUuid, testComponentSix.itemData], + [testComponentSeven.itemUuid, testComponentSeven.itemData], + [testRecipeOne.itemUuid, testRecipeOne.itemData], + [testRecipeTwo.itemUuid, testRecipeTwo.itemData], + [testRecipeThree.itemUuid, testRecipeThree.itemData], + [testRecipeFour.itemUuid, testRecipeFour.itemData], + [testRecipeFive.itemUuid, testRecipeFive.itemData], + [testRecipeSix.itemUuid, testRecipeSix.itemData], + [testRecipeSeven.itemUuid, testRecipeSeven.itemData] + ]) +}); +const recipeValidator = new DefaultRecipeValidator({ + craftingSystemAPI, + componentAPI, + essenceAPI +}); +const defaultSettingValue = () => { + return { + entities: { + [ testRecipeOne.id ]: testRecipeOne.toJson(), + [ testRecipeTwo.id ]: testRecipeTwo.toJson(), + [ testRecipeThree.id ]: testRecipeThree.toJson(), + [ testRecipeFour.id ]: testRecipeFour.toJson(), + [ testRecipeFive.id ]: testRecipeFive.toJson(), + [ testRecipeSix.id ]: testRecipeSix.toJson(), + [ testRecipeSeven.id ]: testRecipeSeven.toJson() + }, + collections: { + [ `${Properties.settings.collectionNames.item}.${testRecipeOne.itemUuid}` ]: [ testRecipeOne.id ], + [ `${Properties.settings.collectionNames.item}.${testRecipeTwo.itemUuid}` ]: [ testRecipeOne.id ], + [ `${Properties.settings.collectionNames.item}.${testRecipeThree.itemUuid}` ]: [ testRecipeOne.id ], + [ `${Properties.settings.collectionNames.item}.${testRecipeFour.itemUuid}` ]: [ testRecipeOne.id ], + [ `${Properties.settings.collectionNames.item}.${testRecipeFive.itemUuid}` ]: [ testRecipeOne.id ], + [ `${Properties.settings.collectionNames.item}.${testRecipeSix.itemUuid}` ]: [ testRecipeOne.id ], + [ `${Properties.settings.collectionNames.item}.${testRecipeSeven.itemUuid}` ]: [ testRecipeOne.id ], + [ `${Properties.settings.collectionNames.craftingSystem}.${testCraftingSystemOne.id}` ]: + [ + testRecipeOne.id, + testRecipeTwo.id, + testRecipeThree.id, + testRecipeFour.id, + testRecipeFive.id, + testRecipeSix.id, + testRecipeSeven.id + ] + } + }; +}; + +beforeEach(() => { + documentManager.reset(); + resetAllTestRecipes(); +}); + +describe("Create", () => { + + + test("should create a new recipe for valid item UUID and crafting system ID", async () => { + + const itemUuid = "12345abcd"; + const craftingSystemId = testCraftingSystemOne.id; + const loadedFabricateItemData = new LoadedFabricateItemData({ + itemUuid, + sourceDocument: {}, + errors: [], + imageUrl: "path/to/image", + name: "Test Item", + }); + const createdRecipe = new Recipe({ + id: "3456abcd", + itemData: new PendingFabricateItemData(itemUuid, () => Promise.resolve(loadedFabricateItemData)), + craftingSystemId: craftingSystemId + }); + documentManager.setAllowUnknownIds(true); + + const recipeDataStore = new EntityDataStore({ + entityName: "recipe", + settingManager: new StubSettingManager>(defaultSettingValue()), + entityFactory: new StubEntityFactory({ + valuesById: new Map([ + [ "3456abcd", createdRecipe ], + [ testRecipeOne.id, testRecipeOne ], + [ testRecipeTwo.id, testRecipeTwo ], + [ testRecipeThree.id, testRecipeThree ], + [ testRecipeFour.id, testRecipeFour ], + [ testRecipeFive.id, testRecipeFive ], + [ testRecipeSix.id, testRecipeSix ], + [ testRecipeSeven.id, testRecipeSeven ] + ]) + }), + collectionManager: new RecipeCollectionManager() + }); + + const underTest = new DefaultRecipeAPI({ + notificationService, + localizationService, + recipeValidator, + recipeStore: recipeDataStore, + identityFactory: new StubIdentityFactory("3456abcd") + }); + + const result = await underTest.create({ itemUuid, craftingSystemId }); + + expect(result.id).toEqual("3456abcd"); + expect(result.craftingSystemId).toEqual(craftingSystemId); + expect(result.itemUuid).toEqual(itemUuid); + + }); + + test("should not create a recipe when the crafting system does not exist", async () => { + + const recipeDataStore = new EntityDataStore({ + entityName: "recipe", + settingManager: new StubSettingManager>(defaultSettingValue()), + entityFactory: new StubEntityFactory({ valuesById: allTestRecipes }), + collectionManager: new RecipeCollectionManager() + }); + + const underTest = new DefaultRecipeAPI({ + notificationService, + localizationService, + recipeValidator, + recipeStore: recipeDataStore, + identityFactory: new StubIdentityFactory() + }); + + const itemUuid = "1234abcd"; + documentManager.setAllowUnknownIds(true); + const craftingSystemId = "2345abcd"; + + expect.assertions(1); + await expect(underTest.create({itemUuid, craftingSystemId})).rejects.toThrow(); + + }); + + test("should not create a recipe when the item does not exist", async () => { + const craftingSystemId = testCraftingSystemOne.id; + + const recipeDataStore = new EntityDataStore({ + entityName: "recipe", + settingManager: new StubSettingManager>(defaultSettingValue()), + entityFactory: new StubEntityFactory({ valuesById: allTestRecipes }), + collectionManager: new RecipeCollectionManager() + }); + + const underTest = new DefaultRecipeAPI({ + notificationService, + localizationService, + recipeValidator, + recipeStore: recipeDataStore, + identityFactory: new StubIdentityFactory() + }); + + const itemUuid = "1234abcd"; + + expect.assertions(1); + await expect(underTest.create({itemUuid, craftingSystemId})).rejects.toThrow(); + + }); + + test("should save a valid recipe when the crafting system and item document exist", async () => { + + const recipeDataStore = new EntityDataStore({ + entityName: "recipe", + settingManager: new StubSettingManager>(defaultSettingValue()), + entityFactory: new StubEntityFactory({ valuesById: allTestRecipes }), + collectionManager: new RecipeCollectionManager() + }); + + const underTest = new DefaultRecipeAPI({ + notificationService, + localizationService, + recipeValidator, + recipeStore: recipeDataStore, + identityFactory: new StubIdentityFactory() + }); + + const result = await underTest.save(testRecipeOne); + + expect(result).not.toBeUndefined(); + + }); + + test("should not save a recipe when the crafting system doesn't exist", async () => { + + const recipeDataStore = new EntityDataStore({ + entityName: "recipe", + settingManager: new StubSettingManager>(defaultSettingValue()), + entityFactory: new StubEntityFactory({ valuesById: allTestRecipes }), + collectionManager: new RecipeCollectionManager() + }); + + const underTest = new DefaultRecipeAPI({ + notificationService, + localizationService, + recipeValidator, + recipeStore: recipeDataStore, + identityFactory: new StubIdentityFactory() + }); + + const modified = new Recipe({ + id: identityFactory.make(), + craftingSystemId: "notAValidCraftingSystemId", + itemData: testRecipeOne.itemData, + resultOptions: testRecipeOne.resultOptions.clone(), + requirementOptions: testRecipeOne.requirementOptions.clone(), + disabled: testRecipeOne.isDisabled + }); + + expect.assertions(1); + await expect(underTest.save(modified)).rejects.toThrow(); + + }); + + test("should not save a recipe when the recipe is invalid", async () => { + + const recipeDataStore = new EntityDataStore({ + entityName: "recipe", + settingManager: new StubSettingManager>(defaultSettingValue()), + entityFactory: new StubEntityFactory({ valuesById: allTestRecipes }), + collectionManager: new RecipeCollectionManager() + }); + + const underTest = new DefaultRecipeAPI({ + notificationService, + localizationService, + recipeValidator: new StubRecipeValidator(false), + recipeStore: recipeDataStore, + identityFactory: new StubIdentityFactory() + }); + + expect.assertions(1); + await expect(underTest.save(testRecipeOne)).rejects.toThrow(); + + }); + +}); + +describe("Access", () => { + + test("Should return undefined for recipe that does not exist", async () => { + + const recipeDataStore = new EntityDataStore({ + entityName: "recipe", + settingManager: new StubSettingManager>(defaultSettingValue()), + entityFactory: new StubEntityFactory({ valuesById: allTestRecipes }), + collectionManager: new RecipeCollectionManager() + }); + + const underTest = new DefaultRecipeAPI({ + notificationService, + localizationService, + recipeValidator, + recipeStore: recipeDataStore, + identityFactory: new StubIdentityFactory() + }); + + const result = await underTest.getById("notAValidId"); + + expect(result).toBeUndefined(); + + }); + + test("Should get a recipe by ID with pending item data ready to load", async () => { + + const recipeDataStore = new EntityDataStore({ + entityName: "recipe", + settingManager: new StubSettingManager>(defaultSettingValue()), + entityFactory: new StubEntityFactory({ valuesById: allTestRecipes }), + collectionManager: new RecipeCollectionManager() + }); + + const underTest = new DefaultRecipeAPI({ + notificationService, + localizationService, + recipeValidator, + recipeStore: recipeDataStore, + identityFactory: new StubIdentityFactory() + }); + + const result = await underTest.getById(testRecipeOne.id); + + expect(result).not.toBeUndefined(); + expect(result.loaded).toEqual(false); + expect(result.itemUuid).toEqual(testRecipeOne.itemUuid); + expect(() => result.name).toThrow(); + expect(() => result.imageUrl).toThrow(); + expect(() => result.itemData.sourceDocument).toThrow(); + + await result.load(); + + expect(result.loaded).toEqual(true); + expect(result.itemUuid).toEqual(testRecipeOne.itemUuid); + expect(result.name).toEqual(testRecipeOne.name); + expect(result.imageUrl).toEqual(testRecipeOne.imageUrl); + expect(result.itemData.sourceDocument).toEqual(testRecipeOne.itemData.sourceDocument); + + }); + + test("Should get many recipes by ID with pending item data ready to load and undefined for missing values", async () => { + + const recipeDataStore = new EntityDataStore({ + entityName: "recipe", + settingManager: new StubSettingManager>(defaultSettingValue()), + entityFactory: new StubEntityFactory({ valuesById: allTestRecipes }), + collectionManager: new RecipeCollectionManager() + }); + + const underTest = new DefaultRecipeAPI({ + notificationService, + localizationService, + recipeValidator, + recipeStore: recipeDataStore, + identityFactory: new StubIdentityFactory() + }); + + const result = await underTest.getAllById([ testRecipeOne.id, testRecipeTwo.id, testRecipeThree.id, "notAValidId" ]); + + expect(result).not.toBeUndefined(); + expect(result.size).toEqual(4); + const recipes = Array.from(result.values()).filter(recipe => typeof recipe !== 'undefined'); + expect(recipes.length).toEqual(3); + expect(recipes.filter(recipe => recipe.loaded).length).toEqual(0); + + const loaded = await Promise.all(recipes.map(async recipe => { + await recipe.load(); + return recipe; + })); + + expect(loaded.filter(recipe => recipe.loaded).length).toEqual(3); + + }); + + test("Should get all recipes without loading", async () => { + + const recipeDataStore = new EntityDataStore({ + entityName: "recipe", + settingManager: new StubSettingManager>(defaultSettingValue()), + entityFactory: new StubEntityFactory({ valuesById: allTestRecipes }), + collectionManager: new RecipeCollectionManager() + }); + + const underTest = new DefaultRecipeAPI({ + notificationService, + localizationService, + recipeValidator, + recipeStore: recipeDataStore, + identityFactory: new StubIdentityFactory() + }); + + const result = await underTest.getAll(); + + expect(result).not.toBeUndefined(); + expect(result.size).toEqual(7); + expect(result.has(testRecipeOne.id)).toEqual(true); + expect(result.has(testRecipeTwo.id)).toEqual(true); + expect(result.has(testRecipeThree.id)).toEqual(true); + expect(result.has(testRecipeFour.id)).toEqual(true); + expect(result.has(testRecipeFive.id)).toEqual(true); + expect(result.has(testRecipeSix.id)).toEqual(true); + expect(result.has(testRecipeSeven.id)).toEqual(true); + expect(Array.from(result.values()).filter(recipe => recipe.loaded).length).toEqual(0); + + }); + + test("Should get all recipes by crafting system ID without loading", async () => { + + const recipeDataStore = new EntityDataStore({ + entityName: "recipe", + settingManager: new StubSettingManager>(defaultSettingValue()), + entityFactory: new StubEntityFactory({ valuesById: allTestRecipes }), + collectionManager: new RecipeCollectionManager() + }); + + const underTest = new DefaultRecipeAPI({ + notificationService, + localizationService, + recipeValidator, + recipeStore: recipeDataStore, + identityFactory: new StubIdentityFactory() + }); + + const result = await underTest.getAllByCraftingSystemId(testCraftingSystemOne.id); + + expect(result).not.toBeUndefined(); + expect(result.size).toEqual(7); + expect(result.has(testRecipeOne.id)).toEqual(true); + expect(result.has(testRecipeTwo.id)).toEqual(true); + expect(result.has(testRecipeThree.id)).toEqual(true); + expect(result.has(testRecipeFour.id)).toEqual(true); + expect(result.has(testRecipeFive.id)).toEqual(true); + expect(result.has(testRecipeSix.id)).toEqual(true); + expect(result.has(testRecipeSeven.id)).toEqual(true); + expect(Array.from(result.values()).filter(recipe => recipe.loaded).length).toEqual(0); + + }); + + test("Should get all recipes by item UUID without loading", async () => { + + const recipeDataStore = new EntityDataStore({ + entityName: "recipe", + settingManager: new StubSettingManager>(defaultSettingValue()), + entityFactory: new StubEntityFactory({ valuesById: allTestRecipes }), + collectionManager: new RecipeCollectionManager() + }); + + const underTest = new DefaultRecipeAPI({ + notificationService, + localizationService, + recipeValidator, + recipeStore: recipeDataStore, + identityFactory: new StubIdentityFactory() + }); + + const result = await underTest.getAllByItemUuid(testRecipeOne.itemUuid); + + expect(result).not.toBeUndefined(); + expect(result.size).toEqual(1); + expect(result.has(testRecipeOne.id)).toEqual(true); + expect(result.get(testRecipeOne.id).loaded).toEqual(false); + + }); + +}); + +describe("Edit", () => { + + test("should clone a recipe by ID", async () => { + + const factoryFunction = async (recipeJson: RecipeJson) => { + return Recipe.fromJson(recipeJson); + }; + const recipeDataStore = new EntityDataStore({ + entityName: "recipe", + settingManager: new StubSettingManager>(defaultSettingValue()), + entityFactory: new StubEntityFactory({ valuesById: allTestRecipes, factoryFunction }), + collectionManager: new RecipeCollectionManager() + }); + + const underTest = new DefaultRecipeAPI({ + notificationService, + localizationService, + recipeValidator, + recipeStore: recipeDataStore, + identityFactory: new StubIdentityFactory() + }); + + const result = await underTest.cloneById(testRecipeOne.id); + + expect(result).not.toBeNull(); + expect(result.id.length).toBeGreaterThan(1); + expect(result.id).not.toEqual(testRecipeOne.id); + expect(result.requirementOptions.equals(testRecipeOne.requirementOptions)).toBe(true); + expect(result.resultOptions.equals(testRecipeOne.resultOptions)).toBe(true); + + }); + + test("should modify a recipe", async () => { + + const recipeDataStore = new EntityDataStore({ + entityName: "recipe", + settingManager: new StubSettingManager>(defaultSettingValue()), + entityFactory: new StubEntityFactory({ valuesById: allTestRecipes }), + collectionManager: new RecipeCollectionManager() + }); + + const underTest = new DefaultRecipeAPI({ + notificationService, + localizationService, + recipeValidator, + recipeStore: recipeDataStore, + identityFactory: new StubIdentityFactory() + }); + + const recipeToEdit = await underTest.getById(testRecipeOne.id); + + const essencesBefore = { + size: recipeToEdit.requirementOptions.all.map(option => option.essences.size).reduce((left, right) => left + right, 0), + fireCount: recipeToEdit.requirementOptions.all.map(option => option.essences.amountFor(elementalFire.id)).reduce((left, right) => left + right, 0) + }; + const requirementOptionCount = recipeToEdit.requirementOptions.size; + recipeToEdit.requirementOptions.all.forEach(option => { + option.essences = option.essences.increment(elementalFire.toReference()); + }); + + await underTest.save(recipeToEdit); + + const editedRecipe = await underTest.getById(testRecipeOne.id); + const essencesAfter = { + size: editedRecipe.requirementOptions.all.map(option => option.essences.size).reduce((left, right) => left + right, 0), + fireCount: editedRecipe.requirementOptions.all.map(option => option.essences.amountFor(elementalFire.id)).reduce((left, right) => left + right, 0) + }; + + expect(essencesAfter.size).toEqual(essencesBefore.size + requirementOptionCount); + expect(essencesAfter.fireCount).toEqual(essencesBefore.fireCount + requirementOptionCount); + + }); + +}); + +describe("Delete", () => { + + test("should delete a recipe by ID", async () => { + + const recipeDataStore = new EntityDataStore({ + entityName: "recipe", + settingManager: new StubSettingManager>(defaultSettingValue()), + entityFactory: new StubEntityFactory({ valuesById: allTestRecipes }), + collectionManager: new RecipeCollectionManager() + }); + + const underTest = new DefaultRecipeAPI({ + notificationService, + localizationService, + recipeValidator, + recipeStore: recipeDataStore, + identityFactory: new StubIdentityFactory() + }); + + const before = await underTest.getById(testRecipeOne.id); + const allBefore = await underTest.getAll(); + + await underTest.deleteById(testRecipeOne.id); + + const after = await underTest.getById(testRecipeOne.id); + const allAfter = await underTest.getAll(); + + expect(before).not.toBeUndefined(); + expect(allBefore.size).toEqual(7); + expect(after).toBeUndefined(); + expect(allAfter.size).toEqual(6); + + }); + + test("should delete a recipe by ID even if the crafting system does not exist", async () => { + + const emptyCraftingSystemApi = new StubCraftingSystemAPI(); + const recipeDataStore = new EntityDataStore({ + entityName: "recipe", + settingManager: new StubSettingManager>(defaultSettingValue()), + entityFactory: new StubEntityFactory({ valuesById: allTestRecipes }), + collectionManager: new RecipeCollectionManager() + }); + + const underTest = new DefaultRecipeAPI({ + notificationService, + localizationService, + recipeValidator: new DefaultRecipeValidator({ + craftingSystemAPI: emptyCraftingSystemApi, + componentAPI: componentAPI, + essenceAPI: essenceAPI + }), + recipeStore: recipeDataStore, + identityFactory: new StubIdentityFactory() + }); + + const before = await underTest.getById(testRecipeOne.id); + const allBefore = await underTest.getAll(); + + await underTest.deleteById(testRecipeOne.id); + + const after = await underTest.getById(testRecipeOne.id); + const allAfter = await underTest.getAll(); + + expect(before).not.toBeUndefined(); + expect(allBefore.size).toEqual(7); + expect(after).toBeUndefined(); + expect(allAfter.size).toEqual(6); + + }); + + test("should delete recipes by crafting system ID", async () => { + + const recipeDataStore = new EntityDataStore({ + entityName: "recipe", + settingManager: new StubSettingManager>(defaultSettingValue()), + entityFactory: new StubEntityFactory({ valuesById: allTestRecipes }), + collectionManager: new RecipeCollectionManager() + }); + + const underTest = new DefaultRecipeAPI({ + notificationService, + localizationService, + recipeValidator, + recipeStore: recipeDataStore, + identityFactory: new StubIdentityFactory() + }); + + const before = await underTest.getById(testRecipeOne.id); + const allBefore = await underTest.getAll(); + + await underTest.deleteByCraftingSystemId(testCraftingSystemOne.id); + + const after = await underTest.getById(testRecipeOne.id); + const allAfter = await underTest.getAll(); + + expect(before).not.toBeUndefined(); + expect(allBefore.size).toEqual(7); + expect(after).toBeUndefined(); + expect(allAfter.size).toEqual(0); + + }); + + test("should delete recipes by crafting system ID even if the crafting system does not exist", async () => { + + const emptyCraftingSystemApi = new StubCraftingSystemAPI(); + + const recipeDataStore = new EntityDataStore({ + entityName: "recipe", + settingManager: new StubSettingManager>(defaultSettingValue()), + entityFactory: new StubEntityFactory({ valuesById: allTestRecipes }), + collectionManager: new RecipeCollectionManager() + }); + + const underTest = new DefaultRecipeAPI({ + notificationService, + localizationService, + recipeValidator: new DefaultRecipeValidator({ + craftingSystemAPI: emptyCraftingSystemApi, + componentAPI: componentAPI, + essenceAPI: essenceAPI + }), + recipeStore: recipeDataStore, + identityFactory: new StubIdentityFactory() + }); + + const before = await underTest.getById(testRecipeOne.id); + const allBefore = await underTest.getAll(); + + await underTest.deleteByCraftingSystemId(testCraftingSystemOne.id); + + const after = await underTest.getById(testRecipeOne.id); + const allAfter = await underTest.getAll(); + + expect(before).not.toBeUndefined(); + expect(allBefore.size).toEqual(7); + expect(after).toBeUndefined(); + expect(allAfter.size).toEqual(0); + + }); + + test("should delete recipes by item UUID", async () => { + + const recipeDataStore = new EntityDataStore({ + entityName: "recipe", + settingManager: new StubSettingManager>(defaultSettingValue()), + entityFactory: new StubEntityFactory({ valuesById: allTestRecipes }), + collectionManager: new RecipeCollectionManager() + }); + + const underTest = new DefaultRecipeAPI({ + notificationService, + localizationService, + recipeValidator, + recipeStore: recipeDataStore, + identityFactory: new StubIdentityFactory() + }); + + const before = await underTest.getById(testRecipeOne.id); + const allBefore = await underTest.getAll(); + + await underTest.deleteByItemUuid(testRecipeOne.itemUuid); + + const after = await underTest.getById(testRecipeOne.id); + const allAfter = await underTest.getAll(); + + expect(before).not.toBeUndefined(); + expect(allBefore.size).toEqual(7); + expect(after).toBeUndefined(); + expect(allAfter.size).toEqual(6); + + }); + + test("should remove all component references from all recipes", async () => { + + const recipeDataStore = new EntityDataStore({ + entityName: "recipe", + settingManager: new StubSettingManager>(defaultSettingValue()), + entityFactory: new StubEntityFactory({ valuesById: allTestRecipes }), + collectionManager: new RecipeCollectionManager() + }); + + const underTest = new DefaultRecipeAPI({ + notificationService, + localizationService, + recipeValidator, + recipeStore: recipeDataStore, + identityFactory: new StubIdentityFactory() + }); + + const componentIdToDelete = testComponentThree.id; + const craftingSystemId = testCraftingSystemOne.id; + const allBefore = await underTest.getAllByCraftingSystemId(craftingSystemId); + const countBefore = countComponentReferences(Array.from(allBefore.values()), componentIdToDelete); + expect(countBefore.matchingRecipes.length).toBeGreaterThan(0); + expect(countBefore.amount).toBeGreaterThan(0); + + const modified = await underTest.removeComponentReferences(componentIdToDelete, craftingSystemId); + expect(modified.length).toEqual(countBefore.matchingRecipes.length); + modified.forEach(recipe => expect(countBefore.matchingRecipes.includes(recipe.id)).toEqual(true)); + + const allAfter = await underTest.getAllByCraftingSystemId(craftingSystemId); + expect(allAfter.size).toEqual(allBefore.size); + const countAfter = countComponentReferences(Array.from(allAfter.values()), componentIdToDelete); + expect(countAfter.matchingRecipes.length).toEqual(0); + expect(countAfter.amount).toEqual(0); + + }); + + test("should remove all essence references from all recipes", async () => { + + const recipeDataStore = new EntityDataStore({ + entityName: "recipe", + settingManager: new StubSettingManager>(defaultSettingValue()), + entityFactory: new StubEntityFactory({ valuesById: allTestRecipes }), + collectionManager: new RecipeCollectionManager() + }); + + const underTest = new DefaultRecipeAPI({ + notificationService, + localizationService, + recipeValidator, + recipeStore: recipeDataStore, + identityFactory: new StubIdentityFactory() + }); + + const essenceIdToDelete = elementalFire.id; + const craftingSystemId = testCraftingSystemOne.id; + const allBefore = await underTest.getAllByCraftingSystemId(craftingSystemId); + const countBefore = countEssenceReferences(Array.from(allBefore.values()), essenceIdToDelete); + expect(countBefore.matchingRecipes.length).toBeGreaterThan(0); + expect(countBefore.amount).toBeGreaterThan(0); + + const modified = await underTest.removeEssenceReferences(essenceIdToDelete, craftingSystemId); + expect(modified.length).toEqual(countBefore.matchingRecipes.length); + modified.forEach(recipe => expect(countBefore.matchingRecipes.includes(recipe.id)).toEqual(true)); + + const allAfter = await underTest.getAllByCraftingSystemId(craftingSystemId); + const countAfter = countEssenceReferences(Array.from(allAfter.values()), essenceIdToDelete); + expect(countAfter.matchingRecipes.length).toEqual(0); + expect(countAfter.amount).toEqual(0); + + }); + +}); + +function countComponentReferences(recipes: Recipe[], componentId: string) { + return recipes + .map(recipe => { + const amountInIngredients = recipe.requirementOptions + .all + .map(requirementOption => requirementOption.ingredients.amountFor(componentId)) + .reduce((previousValue, currentValue) => previousValue + currentValue, 0); + const amountInCatalysts = recipe.requirementOptions + .all + .map(requirementOption => requirementOption.catalysts.amountFor(componentId)) + .reduce((previousValue, currentValue) => previousValue + currentValue, 0); + const amountInResults = recipe.resultOptions + .all + .map(resultOption => resultOption.results.amountFor(componentId)) + .reduce((previousValue, currentValue) => previousValue + currentValue, 0); + const amount = amountInIngredients + amountInCatalysts + amountInResults; + return { + amount, + matchingRecipes: amount > 0 ? [recipe.id] : [] + }; + }) + .reduce((previousValue, currentValue) => { + return { + amount: previousValue.amount + currentValue.amount, + matchingRecipes: previousValue.matchingRecipes.concat(currentValue.matchingRecipes) + } + }, { amount: 0, matchingRecipes: [] }); +} + +function countEssenceReferences(recipes: Recipe[], essenceId: string): { amount: number, matchingRecipes: string[] } { + return recipes + .flatMap(recipe => { + return recipe.requirementOptions.all.map(requirementOption => { + return { + requirementOption, + recipeId: recipe.id + } + }); + }) + .filter(candidate => candidate.requirementOption.essences.has(essenceId)) + .reduce((summary, recipeRequirementOption) => { + return { + amount: summary.amount + recipeRequirementOption.requirementOption.essences.amountFor(essenceId), + matchingRecipes: summary.matchingRecipes.includes(recipeRequirementOption.recipeId) ? summary.matchingRecipes : summary.matchingRecipes.concat(recipeRequirementOption.recipeId) + } + }, { amount: 0, matchingRecipes: [] }); +} \ No newline at end of file diff --git a/test/SystemRegistry.test.ts b/test/SystemRegistry.test.ts deleted file mode 100644 index 736befa1..00000000 --- a/test/SystemRegistry.test.ts +++ /dev/null @@ -1,336 +0,0 @@ -import {beforeEach, describe, expect, test} from "@jest/globals"; -import * as Sinon from "sinon"; -import {DefaultSettingManager} from "../src/scripts/settings/FabricateSetting"; -import {GameProvider} from "../src/scripts/foundry/GameProvider"; -import {CraftingComponentJson} from "../src/scripts/common/CraftingComponent"; -import {DefaultSystemRegistry} from "../src/scripts/registries/SystemRegistry"; -import {CraftingSystem, CraftingSystemJson} from "../src/scripts/system/CraftingSystem"; -import {CraftingSystemFactory} from "../src/scripts/system/CraftingSystemFactory"; -import {RecipeJson} from "../src/scripts/common/Recipe"; -import {StubDocumentManager} from "./stubs/StubDocumentManager"; -import {FabricateItemData, LoadedFabricateItemData} from "../src/scripts/foundry/DocumentManager"; - -const Sandbox: Sinon.SinonSandbox = Sinon.createSandbox(); - -const stubGameObject = { - settings: { - get: () => {}, - set: () => {} - } -} -const stubGetSettingsMethod = Sandbox.stub(stubGameObject.settings, "get"); -const stubGameProvider: GameProvider = { - get: () => stubGameObject -} - -beforeEach(() => { - Sandbox.reset(); -}); - -function randomIdentifier(): string { - return (Math.random() + 1) - .toString(36) - .substring(2); -} - -describe("integration test", () => { - - const systemOneId = randomIdentifier(); - const componentOneId = randomIdentifier(); - const componentOneItemUuid = randomIdentifier(); - const componentTwoItemUuid = randomIdentifier(); - const componentTwoId = randomIdentifier(); - const componentThreeItemUuid = randomIdentifier(); - const componentThreeId = randomIdentifier(); - const essenceOneId = randomIdentifier(); - const essenceTwoId = randomIdentifier(); - const essenceThreeId = randomIdentifier(); - const optionOneId = "Option One" - const optionTwoId = "Option Two" - - const componentOne: CraftingComponentJson = { - itemUuid: componentOneItemUuid, - essences: { - [essenceOneId]: 2, - [essenceTwoId]: 1, - }, - salvageOptions: { - [optionOneId]: { - [componentTwoId]: 2 - } - }, - disabled: false - }; - const componentTwo: CraftingComponentJson = { - itemUuid: componentTwoItemUuid, - essences: { - [essenceThreeId]: 1 - }, - salvageOptions: {}, - disabled: false - }; - const componentThree: CraftingComponentJson = { - itemUuid: componentThreeItemUuid, - essences: {}, - salvageOptions: { - [optionOneId]: { - [componentOneId]: 2, - [componentTwoId]: 1 - } - }, - disabled: true - }; - - const recipeOneItemUuid = randomIdentifier(); - const recipeOneId = randomIdentifier(); - const recipeTwoItemUuid = randomIdentifier(); - const recipeTwoId = randomIdentifier(); - const recipeThreeItemUuid = randomIdentifier(); - const recipeThreeId = randomIdentifier(); - - const recipeOne: RecipeJson = { - itemUuid: recipeOneItemUuid, - essences: { - [essenceOneId]: 1, - [essenceTwoId]: 2 - }, - ingredientOptions: {}, - resultOptions: { - [optionOneId]: { - [componentOneId]: 1 - } - }, - disabled: true - }; - const recipeTwo: RecipeJson = { - itemUuid: recipeTwoItemUuid, - essences: {}, - ingredientOptions: { - [optionOneId]: { - catalysts: { - [componentThreeId]: 1 - }, - ingredients: { - [componentOneId]: 1 - } - } - }, - resultOptions: { - [optionOneId]: { - [componentTwoId]: 1 - } - }, - disabled: false - }; - const recipeThree: RecipeJson = { - itemUuid: recipeThreeItemUuid, - essences: {}, - ingredientOptions: { - [optionOneId]: { - ingredients: { - [componentOneId]: 1, - [componentTwoId]: 1 - }, - catalysts: {} - }, - [optionTwoId]: { - ingredients: { - [componentTwoId]: 2 - }, - catalysts: {} - } - }, - resultOptions: { - [optionOneId]: { - [componentThreeId]: 1 - } - }, - disabled: false - }; - - const systemOne: CraftingSystemJson = { - id: systemOneId, - parts: { - components: { - [componentOneId]: componentOne, - [componentTwoId]: componentTwo, - [componentThreeId]: componentThree - }, - recipes: { - [recipeOneId]: recipeOne, - [recipeTwoId]: recipeTwo, - [recipeThreeId]: recipeThree - }, - essences: { - [essenceOneId]: { - name: "Essence Name", - iconCode: "fa-solid circle", - tooltip: "Tooltip text", - description: "Essence description", - activeEffectSourceItemUuid: componentOneItemUuid - }, - [essenceTwoId]: { - name: "Essence Name", - iconCode: "fa-solid circle", - tooltip: "Tooltip text", - description: "Essence description", - activeEffectSourceItemUuid: componentTwoItemUuid - }, - [essenceThreeId]: { - name: "Essence Name", - iconCode: "fa-solid circle", - tooltip: "Tooltip text", - description: "Essence description", - activeEffectSourceItemUuid: undefined - } - } - }, - details: { - name: "System 1", - author: "Test User", - description: "Crafting system 1", - summary: "The first crafting system" - }, - locked: true, - enabled: true - } - - const systemTwoId = randomIdentifier(); - const systemTwo: CraftingSystemJson = { - id: systemTwoId, - details: { - name: "System 2", - author: "Test User", - description: "Crafting system 2", - summary: "The second crafting system" - }, - locked: false, - enabled: true, - parts: { - recipes: {}, - components: {}, - essences: {} - } - } - - const storedSettingsValue = { - version: "1", - value: { - [systemOneId]: systemOne, - [systemTwoId]: systemTwo - } - }; - - const itemData: Map = new Map([ - [ - componentOneItemUuid, - new LoadedFabricateItemData({ - name: "Component One", imageUrl: "path/to/img.webp", itemUuid: componentOneItemUuid, sourceDocument: componentOne - }) - ], - [ - componentTwoItemUuid, - new LoadedFabricateItemData({ - name: "Component Two", imageUrl: "path/to/img.webp", itemUuid: componentTwoItemUuid, sourceDocument: componentTwo - }) - ], - [ - componentThreeItemUuid, - new LoadedFabricateItemData({ - name: "Component Three", imageUrl: "path/to/img.webp", itemUuid: componentThreeItemUuid, sourceDocument: componentThree - }) - ], - [ - recipeOneItemUuid, - new LoadedFabricateItemData({ - name: "Recipe One", imageUrl: "path/to/img.webp", itemUuid: recipeOneItemUuid, sourceDocument: recipeOne - }) - ], - [ - recipeTwoItemUuid, - new LoadedFabricateItemData({ - name: "Recipe Two", imageUrl: "path/to/img.webp", itemUuid: recipeTwoItemUuid, sourceDocument: recipeTwo - }) - ], - [ - recipeThreeItemUuid, - new LoadedFabricateItemData({ - name: "Recipe Three", imageUrl: "path/to/img.webp", itemUuid: recipeThreeItemUuid, sourceDocument: recipeThree - }) - ], - ]); - - test('Should read all settings values and build crafting system correctly', async () => { - - const fabricateSettingsManager = new DefaultSettingManager>({ - gameProvider: stubGameProvider, - targetVersion: "1", - settingKey: "ANY_KEY" - }); - stubGetSettingsMethod.returns(storedSettingsValue); - - const stubDocumentManager = new StubDocumentManager(itemData); - const craftingSystemFactory = new CraftingSystemFactory({ - documentManager: stubDocumentManager - }); - const underTest = new DefaultSystemRegistry({ - settingManager: fabricateSettingsManager, - craftingSystemFactory, - gameSystem: "dnd5e" - }); - - const result: Map = await underTest.getAllCraftingSystems(); - - expect(result).not.toBeUndefined(); - const craftingSystemOne = result.get(systemOneId); - - expect(craftingSystemOne).not.toBeUndefined(); - await craftingSystemOne.loadPartDictionary(); - - const essences = craftingSystemOne.getEssences(); - expect(essences.length).toEqual(3); - expect(craftingSystemOne.hasEssence(essenceOneId)).toEqual(true); - expect(craftingSystemOne.hasEssence(essenceTwoId)).toEqual(true); - expect(craftingSystemOne.hasEssence(essenceThreeId)).toEqual(true); - - const components = craftingSystemOne.getComponents(); - expect(components.length).toEqual(3); - expect(craftingSystemOne.hasPart(componentOneId)).toEqual(true); - expect(craftingSystemOne.hasPart(componentTwoId)).toEqual(true); - expect(craftingSystemOne.hasPart(componentThreeId)).toEqual(true); - - const componentOneResult = craftingSystemOne.getComponentById(componentOneId); - expect(componentOneResult.id).toEqual(componentOneId); - expect(componentOneResult.name).toEqual(itemData.get(componentOneItemUuid).name); - expect(componentOneResult.imageUrl).toEqual(itemData.get(componentOneItemUuid).imageUrl); - expect(componentOneResult.salvageOptions.length).toEqual(1); - expect(componentOneResult.salvageOptions[0].salvage.size).toEqual(2); - expect(componentOneResult.essences.size).toEqual(3); - expect(componentOneResult.essences.amountFor(essenceOneId)).toEqual(2); - expect(componentOneResult.essences.amountFor(essenceTwoId)).toEqual(1); - - const recipes = craftingSystemOne.getRecipes(); - expect(recipes.length).toEqual(3); - expect(craftingSystemOne.hasPart(recipeOneId)).toEqual(true); - expect(craftingSystemOne.hasPart(recipeTwoId)).toEqual(true); - expect(craftingSystemOne.hasPart(recipeThreeId)).toEqual(true); - - const recipeOneResult = await craftingSystemOne.getRecipeById(recipeOneId); - expect(recipeOneResult.id).toEqual(recipeOneId); - expect(recipeOneResult.name).toEqual(itemData.get(recipeOneItemUuid).name); - expect(recipeOneResult.imageUrl).toEqual(itemData.get(recipeOneItemUuid).imageUrl); - expect(recipeOneResult.essences.amountFor(essenceOneId)).toEqual(1); - expect(recipeOneResult.essences.amountFor(essenceTwoId)).toEqual(2); - expect(recipeOneResult.ingredientOptions.length).toEqual(0); - expect(recipeOneResult.hasIngredients).toEqual(false); - expect(recipeOneResult.resultOptions.length).toEqual(1); - expect(recipeOneResult.hasResults).toEqual(true); - - const serialized = craftingSystemOne.toJson(); - expect(serialized).toEqual(storedSettingsValue.value[craftingSystemOne.id]) - - }); - -}); - - diff --git a/test/V2SettingMigratorTest.test.ts b/test/V2SettingMigratorTest.test.ts deleted file mode 100644 index 209d4364..00000000 --- a/test/V2SettingMigratorTest.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import {describe, expect, test} from "@jest/globals"; -import {V1ComponentJson, V1EssenceJson, V1RecipeJson} from "../src/scripts/system/setting_versions/V1Json"; -import {EssenceJson} from "../src/scripts/common/Essence"; -import V1_CHILDS_PLAY_SYSTEM_DEFINITION from "./resources/V1ChildsPlaySystemSpec"; -import {V2CraftingSystemSettingMigrator} from "../src/scripts/settings/migrators/V2CraftingSystemSettingMigrator"; -import {CraftingComponentJson} from "../src/scripts/common/CraftingComponent"; -import {RecipeJson} from "../src/scripts/common/Recipe"; -import { V1_ALCHEMISTS_SUPPLIES_SYSTEM_DEFINITION } from "./resources/V1AlchemistsSuppliesSystemSpec"; -import {ALCHEMISTS_SUPPLIES_SYSTEM_DATA} from "../src/scripts/system/bundled/AlchemistsSuppliesV16"; - -function expectEssenceMigrationSuccess(before: V1EssenceJson, allAfter: Record) { - expect(allAfter[before.id]).not.toBeNull(); - const after = allAfter[before.id]; - expect(after.name).toEqual(before.name); - expect(after.description).toEqual(before.description); - expect(after.iconCode).toEqual(before.iconCode); - expect(after.tooltip).toEqual(before.tooltip); - expect(after.activeEffectSourceItemUuid).toBeNull(); -} - -function expectComponentMigrationSuccess(before: V1ComponentJson, allAfter: Record) { - expect(allAfter[before.itemUuid]).not.toBeNull(); - const after = allAfter[before.itemUuid]; - expect(after.itemUuid).toEqual(before.itemUuid); - expect(after.disabled).toEqual(false); - - const essenceIdsAfter = Object.keys(after.essences); - expect(essenceIdsAfter.length).toEqual(Object.keys(before.essences).length); - essenceIdsAfter.forEach(essenceId => { - expect(after.essences[essenceId]).toEqual(before.essences[essenceId]); - }); - - const salvageOptionIdsAfter = Object.keys(after.salvageOptions); - const salvageComponentIdsBefore = Object.keys(before.salvage); - if (salvageOptionIdsAfter.length === 0) { - expect(salvageComponentIdsBefore.length).toEqual(0); - } else { - expect(salvageOptionIdsAfter.length).toEqual(1); - const salvageAfter = after.salvageOptions[salvageOptionIdsAfter[0]]; - const salvageComponentIdsAfter = Object.keys(salvageAfter); - expect(salvageComponentIdsAfter.length).toEqual(salvageComponentIdsBefore.length); - salvageComponentIdsAfter.forEach(componentId => expect(salvageAfter[componentId]).toEqual(before.salvage[componentId])); - } -} - -function expectRecipeMigrationSuccess(before: V1RecipeJson, allAfter: Record) { - expect(allAfter[before.itemUuid]).not.toBeNull(); - const after = allAfter[before.itemUuid]; - expect(after.itemUuid).toEqual(before.itemUuid); - expect(after.disabled).toEqual(false); - - const essenceIdsAfter = Object.keys(after.essences); - expect(essenceIdsAfter.length).toEqual(Object.keys(before.essences).length); - essenceIdsAfter.forEach(essenceId => { - expect(after.essences[essenceId]).toEqual(before.essences[essenceId]); - }); - - const ingredientOptionIdsAfter = Object.keys(after.ingredientOptions); - const ingredientOptionsAfter = ingredientOptionIdsAfter.map(ingredientOptionId => after.ingredientOptions[ingredientOptionId]); - expect(ingredientOptionsAfter.length).toEqual(before.ingredientGroups.length); - before.ingredientGroups.forEach(ingredientGroupBefore => { - const ingredientsFound = ingredientOptionsAfter.find(ingredientOptionAfter => { - const catalystIdsAfter = Object.keys(ingredientOptionAfter.catalysts); - if (catalystIdsAfter.length !== Object.keys(before.catalysts).length) { - return false; - } - const catalystsCopied = catalystIdsAfter.map(catalystId => before.catalysts[catalystId] === ingredientOptionAfter.catalysts[catalystId]) - .reduce((previousValue, currentValue) => previousValue && currentValue, true); - if (!catalystsCopied) { - return false; - } - const ingredientIdsAfter = Object.keys(ingredientOptionAfter.ingredients); - if (ingredientIdsAfter.length !== Object.keys(ingredientGroupBefore).length) { - return false; - } - const ingredientsMatch = ingredientIdsAfter.map(ingredientId => ingredientGroupBefore[ingredientId] === ingredientOptionAfter.ingredients[ingredientId]) - .reduce((previousValue, currentValue) => previousValue && currentValue, true); - return ingredientsMatch; - - }); - expect(ingredientsFound).not.toBeUndefined(); - }); - - const resultOptionIdsAfter = Object.keys(after.resultOptions); - const resultOptionsAfter = resultOptionIdsAfter.map(resultOptionId => after.resultOptions[resultOptionId]); - expect(resultOptionsAfter.length).toEqual(before.resultGroups.length); - before.resultGroups.forEach(resultGroupBefore => { - const resultsFound = resultOptionsAfter.find(resultOptionAfter => { - const resultIdsAfter = Object.keys(resultOptionAfter); - if (resultIdsAfter.length !== Object.keys(resultGroupBefore).length) { - return false; - } - const resultsMatch = resultIdsAfter.map(resultId => resultGroupBefore[resultId] === resultOptionAfter[resultId]) - .reduce((previousValue, currentValue) => previousValue && currentValue, true); - return resultsMatch; - - }); - expect(resultsFound).not.toBeUndefined(); - }); - -} - -describe("Migrating from V1 to V2", () => { - - test("should migrate Child's Play", () => { - - const childsPlay = V1_CHILDS_PLAY_SYSTEM_DEFINITION; - const underTest = new V2CraftingSystemSettingMigrator(); - - const resultSystems = underTest.perform({[childsPlay.id]: childsPlay}); - expect(resultSystems).not.toBeNull(); - const result = resultSystems[childsPlay.id]; - expect(result).not.toBeNull(); - - expect(result.id).toEqual(childsPlay.id); - expect(result.enabled).toEqual(childsPlay.enabled); - expect(result.locked).toEqual(childsPlay.locked); - - expect(result.details.name).toEqual(childsPlay.details.name); - expect(result.details.author).toEqual(childsPlay.details.author); - expect(result.details.summary).toEqual(childsPlay.details.summary); - expect(result.details.description).toEqual(childsPlay.details.description); - - expect(Object.keys(result.parts.essences).length).toEqual(Object.keys(childsPlay.parts.essences).length); - expect(Object.keys(result.parts.components).length).toEqual(Object.keys(childsPlay.parts.components).length); - expect(Object.keys(result.parts.recipes).length).toEqual(Object.keys(childsPlay.parts.recipes).length); - - Object.keys(childsPlay.parts.essences) - .map(essenceId => { - const before = childsPlay.parts.essences[essenceId]; - expectEssenceMigrationSuccess(before, result.parts.essences); - }); - - Object.keys(childsPlay.parts.components) - .map(componentId => { - const before = childsPlay.parts.components[componentId]; - expectComponentMigrationSuccess(before, result.parts.components); - }); - - Object.keys(childsPlay.parts.recipes) - .map(recipeId => { - const before = childsPlay.parts.recipes[recipeId]; - expectRecipeMigrationSuccess(before, result.parts.recipes); - }); - }); - - test("Should migrate Alchemist's Supplies v1.6", () => { - - const alchemistsSupplies = V1_ALCHEMISTS_SUPPLIES_SYSTEM_DEFINITION; - const underTest = new V2CraftingSystemSettingMigrator(); - - const resultSystems = underTest.perform({[alchemistsSupplies.id]: alchemistsSupplies}); - expect(resultSystems).not.toBeNull(); - const result = resultSystems[alchemistsSupplies.id]; - expect(result).not.toBeNull(); - - expect(result).toEqual(ALCHEMISTS_SUPPLIES_SYSTEM_DATA.definition); - - }); - -}); \ No newline at end of file diff --git a/test/V2ToV3SettingMigrationStep.test.ts b/test/V2ToV3SettingMigrationStep.test.ts new file mode 100644 index 00000000..d9926826 --- /dev/null +++ b/test/V2ToV3SettingMigrationStep.test.ts @@ -0,0 +1,299 @@ +import {describe, test, expect} from "@jest/globals"; +import {V2ToV3SettingMigrationStep} from "../src/scripts/repository/migration/V2ToV3SettingMigrationStep"; +import {StubIdentityFactory} from "./stubs/foundry/StubIdentityFactory"; +import {SettingManager} from "../src/scripts/repository/SettingManager"; +import Properties from "../src/scripts/Properties"; +import {StubSettingManager} from "./stubs/foundry/StubSettingManager"; +import { + getAlchemistsSuppliesV16InitialSettingValue, + getBlacksmithingInitialSettingValue +} from "./test_data/TestSettingMigrationData"; + +const alchmistsSupliesSystemId = "alchemists-supplies-v1.6"; +const blacksmithingSystemId = "PeZF10C7FMN4dhfa"; +const alchemistsSuppliesV16InitialSettingValue: any = getAlchemistsSuppliesV16InitialSettingValue(); +const blacksmithingInitialSettingValue: any = getBlacksmithingInitialSettingValue(); +const v2CraftingSystemSettingsValue = { + version: "2", + value: { + [alchmistsSupliesSystemId]: alchemistsSuppliesV16InitialSettingValue, + [blacksmithingSystemId]: blacksmithingInitialSettingValue + } +}; + +describe("V2 to V3 Settings Migration Step", () => { + + test("Should migrate crafting systems correctly", async () => { + + const identityFactory = new StubIdentityFactory(); + const craftingSystemSettingManager = new StubSettingManager(v2CraftingSystemSettingsValue); + + const settingManagersBySettingPath = new Map>([ + [ Properties.settings.craftingSystems.key, craftingSystemSettingManager ], + [ Properties.settings.essences.key, new StubSettingManager() ], + [ Properties.settings.components.key, new StubSettingManager() ], + [ Properties.settings.recipes.key, new StubSettingManager() ], + [ Properties.settings.modelVersion.key, new StubSettingManager() ], + ]); + + const underTest = new V2ToV3SettingMigrationStep({ + identityFactory, + embeddedCraftingSystemsIds: [alchmistsSupliesSystemId], + settingManagersBySettingPath, + }); + + await underTest.perform(); + + const migratedCraftingSystemsSettings: any = await craftingSystemSettingManager.read(); + + expect(migratedCraftingSystemsSettings).not.toBeNull(); + expect(migratedCraftingSystemsSettings.collections).toEqual({}); + const migratedCraftingSystemIds = Object.keys(migratedCraftingSystemsSettings.entities); + expect(migratedCraftingSystemIds.length).toEqual(1); + expect(migratedCraftingSystemIds).toEqual(expect.arrayContaining([blacksmithingSystemId])); + + const migratedBlacksmithingSystem = migratedCraftingSystemsSettings.entities[blacksmithingSystemId]; + expect(migratedBlacksmithingSystem.details.name).toEqual(blacksmithingInitialSettingValue.details.name); + expect(migratedBlacksmithingSystem.details.description).toEqual(blacksmithingInitialSettingValue.details.description); + expect(migratedBlacksmithingSystem.details.author).toEqual(blacksmithingInitialSettingValue.details.author); + expect(migratedBlacksmithingSystem.details.summary).toEqual(blacksmithingInitialSettingValue.details.summary); + expect(migratedBlacksmithingSystem.embedded).toBe(false); + expect(migratedBlacksmithingSystem.disabled).toBe(!!blacksmithingInitialSettingValue.enabled); + + }); + + test("Should migrate recipes correctly", async () => { + + const identityFactory = new StubIdentityFactory(); + const craftingSystemSettingManager = new StubSettingManager(v2CraftingSystemSettingsValue); + const recipeSettingsManager = new StubSettingManager(); + + const settingManagersBySettingPath = new Map>([ + [ Properties.settings.craftingSystems.key, craftingSystemSettingManager ], + [ Properties.settings.essences.key, new StubSettingManager() ], + [ Properties.settings.components.key, new StubSettingManager() ], + [ Properties.settings.recipes.key, recipeSettingsManager ], + [ Properties.settings.modelVersion.key, new StubSettingManager() ], + ]); + + const underTest = new V2ToV3SettingMigrationStep({ + identityFactory, + embeddedCraftingSystemsIds: [alchmistsSupliesSystemId], + settingManagersBySettingPath, + }); + + await underTest.perform(); + + const migratedRecipeSettings: any = await recipeSettingsManager.read(); + + expect(migratedRecipeSettings).not.toBeNull(); + const blacksmithingRecipeIds = Object.keys(blacksmithingInitialSettingValue.parts.recipes); + // recipes should have collection references for their crafting system and the item + expect(migratedRecipeSettings.collections[`${Properties.settings.collectionNames.craftingSystem}.${blacksmithingSystemId}`]).toEqual(expect.arrayContaining(blacksmithingRecipeIds)); + blacksmithingRecipeIds.forEach(recipeId => { + const originalRecipe = blacksmithingInitialSettingValue.parts.recipes[recipeId]; + expect(migratedRecipeSettings.collections[`${Properties.settings.collectionNames.item}.${originalRecipe.itemUuid}`]).toEqual(expect.arrayContaining([recipeId])); + }); + + blacksmithingRecipeIds.forEach(recipeId => { + const migratedRecipe = migratedRecipeSettings.entities[recipeId]; + const originalRecipe = blacksmithingInitialSettingValue.parts.recipes[recipeId]; + expect(migratedRecipe.id).toEqual(recipeId); + // itemUuid is migrated as-is + expect(migratedRecipe.itemUuid).toEqual(originalRecipe.itemUuid); + // disabled and embedded are new fields,which default to false for user-defined crafting systems + expect(migratedRecipe.disabled).toEqual(false); + expect(migratedRecipe.embedded).toEqual(false); + // craftingSystemId is now included in the recipe + expect(migratedRecipe.craftingSystemId).toEqual(blacksmithingSystemId); + + // compare the recipe's requirement options (formerly ingredient options) + const ingredientOptionNames = Object.keys(originalRecipe.ingredientOptions); + const hadRequirements = ingredientOptionNames.length > 0; + const essenceNames = Object.keys(originalRecipe.essences); + const hadEssences = essenceNames.length > 0; + // if the recipe had requirements or essences, the migrated recipe should have requirement options + // essences are migrated as requirement options,either added to all existing or added as a new option + if (hadRequirements || hadEssences) { + ingredientOptionNames.forEach(ingredientOptionName => { + // options should have generated IDs in addition to names + // they should take different values, so we have to search for the option by name + const originalIngredientOption = originalRecipe.ingredientOptions[ingredientOptionName]; + const migratedRequirementOption: any = Object.values(migratedRecipe.requirementOptions) + .find((migratedRequirementOption: any) => migratedRequirementOption.name === ingredientOptionName); + expect(migratedRequirementOption).not.toBeNull(); + // test the migrated option settings + expect(migratedRequirementOption.catalysts).toMatchObject(originalIngredientOption.catalysts); + expect(migratedRequirementOption.ingredients).toMatchObject(originalIngredientOption.ingredients); + expect(migratedRequirementOption.essences).toMatchObject(originalRecipe.essences); + expect(migratedRequirementOption.name).toEqual(ingredientOptionName); + expect(typeof migratedRequirementOption.id).toEqual("string"); + expect(migratedRequirementOption.id.length).toBeGreaterThan(0); + expect(migratedRequirementOption.id).not.toEqual(ingredientOptionName); + }); + } else { + expect(migratedRecipe.requirementOptions).toEqual({}); + } + + // compare the recipe's result options + const resultOptionNames = Object.keys(originalRecipe.resultOptions); + const hadResults = resultOptionNames.length > 0; + if (hadResults) { + resultOptionNames.forEach(resultOptionName => { + // options should have generated IDs in addition to names + // they should take different values, so we have to search for the option by name + const originalResultOption = originalRecipe.resultOptions[resultOptionName]; + const migratedResultOption: any = Object.values(migratedRecipe.resultOptions) + .find((migratedResultOption: any) => migratedResultOption.name === resultOptionName); + expect(migratedResultOption).not.toBeNull(); + // test the migrated option settings + // result options now contain a results property, to which the original result option is migrated + expect(migratedResultOption.results).toMatchObject(originalResultOption); + expect(migratedResultOption.name).toEqual(resultOptionName); + expect(typeof migratedResultOption.id).toEqual("string"); + expect(migratedResultOption.id.length).toBeGreaterThan(0); + expect(migratedResultOption.id).not.toEqual(resultOptionName); + }); + } else { + expect(migratedRecipe.resultOptions).toEqual({}); + } + + }); + + + }); + + test("Should migrate components correctly", async () => { + + const identityFactory = new StubIdentityFactory(); + const craftingSystemSettingManager = new StubSettingManager(v2CraftingSystemSettingsValue); + + const componentsSettingManager = new StubSettingManager(); + const settingManagersBySettingPath = new Map>([ + [ Properties.settings.craftingSystems.key, craftingSystemSettingManager ], + [ Properties.settings.essences.key, new StubSettingManager() ], + [ Properties.settings.components.key, componentsSettingManager ], + [ Properties.settings.recipes.key, new StubSettingManager() ], + [ Properties.settings.modelVersion.key, new StubSettingManager() ], + ]); + + const underTest = new V2ToV3SettingMigrationStep({ + identityFactory, + embeddedCraftingSystemsIds: [alchmistsSupliesSystemId], + settingManagersBySettingPath, + }); + + await underTest.perform(); + + const migratedComponentSettings: any = await componentsSettingManager.read(); + + expect(migratedComponentSettings).not.toBeNull(); + const blacksmithingComponentIds = Object.keys(blacksmithingInitialSettingValue.parts.components); + // components should have collection sentries for the crafting system and the item + expect(migratedComponentSettings.collections[`${Properties.settings.collectionNames.craftingSystem}.${blacksmithingSystemId}`]).toEqual(expect.arrayContaining(blacksmithingComponentIds)); + blacksmithingComponentIds.forEach(componentId => { + const originalComponent = blacksmithingInitialSettingValue.parts.components[componentId]; + expect(migratedComponentSettings.collections[`${Properties.settings.collectionNames.item}.${originalComponent.itemUuid}`]).toEqual(expect.arrayContaining([componentId])); + }); + + blacksmithingComponentIds.forEach(componentId => { + const migratedComponent = migratedComponentSettings.entities[componentId]; + const originalComponent = blacksmithingInitialSettingValue.parts.components[componentId]; + expect(migratedComponent.id).toEqual(componentId); + // itemUuid is migrated as-is + expect(migratedComponent.itemUuid).toEqual(originalComponent.itemUuid); + expect(migratedComponent.disabled).toEqual(migratedComponent.disabled); + // embedded is a new field, which defaults to false for user-defined crafting systems + expect(migratedComponent.embedded).toEqual(false); + // craftingSystemId is now included in the component + expect(migratedComponent.craftingSystemId).toEqual(blacksmithingSystemId); + expect(migratedComponent.essences).toMatchObject(originalComponent.essences); + + // compare the component's salvage options + const salvageOptionNames = Object.keys(originalComponent.salvageOptions); + const hadSalvage = salvageOptionNames.length > 0; + if (hadSalvage) { + // options should have generated IDs in addition to names + // they should take different values, so we have to search for the option by name + salvageOptionNames.forEach(salvageOptionName => { + const originalSalvageOption = originalComponent.salvageOptions[salvageOptionName]; + const migratedSalvageOption: any = Object.values(migratedComponent.salvageOptions) + .find((migratedSalvageOption: any) => migratedSalvageOption.name === salvageOptionName); + expect(migratedSalvageOption).not.toBeNull(); + // test the migrated option settings + expect(migratedSalvageOption.results).toMatchObject(originalSalvageOption); + // catalysts is a new property that defaults to an empty object + expect(migratedSalvageOption.catalysts).toMatchObject({}); + expect(migratedSalvageOption.name).toEqual(salvageOptionName); + expect(typeof migratedSalvageOption.id).toEqual("string"); + expect(migratedSalvageOption.id.length).toBeGreaterThan(0); + expect(migratedSalvageOption.id).not.toEqual(salvageOptionName); + }); + } else { + expect(migratedComponent.salvageOptions).toEqual({}); + } + }); + + }); + + test("Should migrate essences correctly", async () => { + + const identityFactory = new StubIdentityFactory(); + const craftingSystemSettingManager = new StubSettingManager(v2CraftingSystemSettingsValue); + + const essenceSettingManager = new StubSettingManager(); + const settingManagersBySettingPath = new Map>([ + [ Properties.settings.craftingSystems.key, craftingSystemSettingManager ], + [ Properties.settings.essences.key, essenceSettingManager ], + [ Properties.settings.components.key, new StubSettingManager() ], + [ Properties.settings.recipes.key, new StubSettingManager() ], + [ Properties.settings.modelVersion.key, new StubSettingManager() ], + ]); + + const underTest = new V2ToV3SettingMigrationStep({ + identityFactory, + embeddedCraftingSystemsIds: [alchmistsSupliesSystemId], + settingManagersBySettingPath, + }); + + await underTest.perform(); + + const migratedEssenceSettings: any = await essenceSettingManager.read(); + + expect(migratedEssenceSettings).not.toBeNull(); + const blacksmithingEssenceIds = Object.keys(blacksmithingInitialSettingValue.parts.essences); + // essences should have collection entries for the crafting system and the item *if* they have an active effect source + expect(migratedEssenceSettings.collections[`${Properties.settings.collectionNames.craftingSystem}.${blacksmithingSystemId}`]).toEqual(expect.arrayContaining(blacksmithingEssenceIds)); + blacksmithingEssenceIds + .map(essenceId => { + return { + id: essenceId, + value: blacksmithingInitialSettingValue.parts.essences[essenceId], + } + }) + .filter(essence => essence.value.activeEffectSourceItemUuid) + .forEach(essence => { + const originalEssence = essence.value; + expect(migratedEssenceSettings.collections[`${Properties.settings.collectionNames.item}.${originalEssence.activeEffectSourceItemUuid}`]).toEqual(expect.arrayContaining([essence.id])); + }); + + blacksmithingEssenceIds.forEach(essenceId => { + const migratedEssence = migratedEssenceSettings.entities[essenceId]; + const originalEssence = blacksmithingInitialSettingValue.parts.essences[essenceId]; + expect(migratedEssence.id).toEqual(essenceId); + expect(migratedEssence.name).toEqual(originalEssence.name); + expect(migratedEssence.tooltip).toEqual(originalEssence.tooltip); + expect(migratedEssence.activeEffectSourceItemUuid).toEqual(originalEssence.activeEffectSourceItemUuid); + expect(migratedEssence.iconCode).toEqual(originalEssence.iconCode); + expect(migratedEssence.description).toEqual(originalEssence.description); + // disabled and embedded are new fields, which default to false for user-defined crafting systems + expect(migratedEssence.disabled).toEqual(false); + expect(migratedEssence.embedded).toEqual(false); + // craftingSystemId is now included in the essence + expect(migratedEssence.craftingSystemId).toEqual(blacksmithingSystemId); + }); + + + }); + +}); \ No newline at end of file diff --git a/test/resources/V1AlchemistsSuppliesSystemSpec.ts b/test/resources/V1AlchemistsSuppliesSystemSpec.ts deleted file mode 100644 index d389e0ff..00000000 --- a/test/resources/V1AlchemistsSuppliesSystemSpec.ts +++ /dev/null @@ -1,461 +0,0 @@ -import {V1SystemJson} from "../../src/scripts/system/setting_versions/V1Json"; - -const V1_ALCHEMISTS_SUPPLIES_SYSTEM_DEFINITION: V1SystemJson = { - "id": "alchemists-supplies-v1.6", - "details": { - "name": "Alchemist's Supplies v1.6", - "summary": "A crafting system for 5th Edition by u/calculusChild", - "description": "Alchemy is the skill of exploiting unique properties of certain plants, minerals, and creature parts, combining them to produce fantastic substances. This allows even non-spellcasters to mimic minor magical effects, although the creations themselves are non-magical.", - "author": "u/calculusChild" - }, - "enabled": true, - "locked": true, - "parts": { - "components": { - "Compendium.fabricate.alchemists-supplies-v16.1lx7oTedScyEgTOm": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.1lx7oTedScyEgTOm", - "salvage": {}, - "essences": {} - }, - "Compendium.fabricate.alchemists-supplies-v16.0FiywpRZf3cOYs4U": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.0FiywpRZf3cOYs4U", - "salvage": {}, - "essences": { - "water": 2 - } - }, - "Compendium.fabricate.alchemists-supplies-v16.zogQbVwUggRUWK2l": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.zogQbVwUggRUWK2l", - "salvage": {}, - "essences": {} - }, - "Compendium.fabricate.alchemists-supplies-v16.4U429BrJKdMKDZaR": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.4U429BrJKdMKDZaR", - "salvage": {}, - "essences": {} - }, - "Compendium.fabricate.alchemists-supplies-v16.8AdG3048UmUOo5Qx": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.8AdG3048UmUOo5Qx", - "salvage": {}, - "essences": {} - }, - "Compendium.fabricate.alchemists-supplies-v16.90z9nOwmGnP4aUUk": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.90z9nOwmGnP4aUUk", - "salvage": {}, - "essences": {} - }, - "Compendium.fabricate.alchemists-supplies-v16.Act4cJsuz2HhID55": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.Act4cJsuz2HhID55", - "salvage": {}, - "essences": { - "fire": 1, - "earth": 1 - } - }, - "Compendium.fabricate.alchemists-supplies-v16.BPiO2qqMvJbdlxJf": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.BPiO2qqMvJbdlxJf", - "salvage": {}, - "essences": { - "fire": 1 - } - }, - "Compendium.fabricate.alchemists-supplies-v16.BjAyhAmkbFJdVVQW": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.BjAyhAmkbFJdVVQW", - "salvage": {}, - "essences": { - "air": 1, - "fire": 1 - } - }, - "Compendium.fabricate.alchemists-supplies-v16.BzC8HmlN3bNY4D8Z": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.BzC8HmlN3bNY4D8Z", - "salvage": {}, - "essences": { - "positive-energy": 1 - } - }, - "Compendium.fabricate.alchemists-supplies-v16.EDGSJpxtSuvq7XWW": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.EDGSJpxtSuvq7XWW", - "salvage": {}, - "essences": {} - }, - "Compendium.fabricate.alchemists-supplies-v16.Ffv7X37ZT7LRzDvP": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.Ffv7X37ZT7LRzDvP", - "salvage": {}, - "essences": {} - }, - "Compendium.fabricate.alchemists-supplies-v16.HyicGjYWESWIdEhc": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.HyicGjYWESWIdEhc", - "salvage": {}, - "essences": {} - }, - "Compendium.fabricate.alchemists-supplies-v16.JuAZVO3QioTf7ACf": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.JuAZVO3QioTf7ACf", - "salvage": {}, - "essences": { - "negative-energy": 1 - } - }, - "Compendium.fabricate.alchemists-supplies-v16.Kv4752l95eP4IeBM": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.Kv4752l95eP4IeBM", - "salvage": {}, - "essences": {} - }, - "Compendium.fabricate.alchemists-supplies-v16.LnFU0qkUkKz3iV1Q": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.LnFU0qkUkKz3iV1Q", - "salvage": {}, - "essences": { - "water": 1, - "air": 1 - } - }, - "Compendium.fabricate.alchemists-supplies-v16.PwEY1nRmDzoFN5TW": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.PwEY1nRmDzoFN5TW", - "salvage": {}, - "essences": { - "fire": 2 - } - }, - "Compendium.fabricate.alchemists-supplies-v16.QXN2n98HnjFzSsNJ": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.QXN2n98HnjFzSsNJ", - "salvage": {}, - "essences": {} - }, - "Compendium.fabricate.alchemists-supplies-v16.R50XdiWxV5awA6HL": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.R50XdiWxV5awA6HL", - "salvage": {}, - "essences": { - "earth": 2 - } - }, - "Compendium.fabricate.alchemists-supplies-v16.SmE4zjl9xLBOSo4A": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.SmE4zjl9xLBOSo4A", - "salvage": {}, - "essences": { - "water": 1 - } - }, - "Compendium.fabricate.alchemists-supplies-v16.TAdDF3reNmXc00ST": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.TAdDF3reNmXc00ST", - "salvage": {}, - "essences": { - "earth": 1 - } - }, - "Compendium.fabricate.alchemists-supplies-v16.VHUXcExXZLCDfqmM": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.VHUXcExXZLCDfqmM", - "salvage": {}, - "essences": {} - }, - "Compendium.fabricate.alchemists-supplies-v16.bNOIJaY21OSsdybw": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.bNOIJaY21OSsdybw", - "salvage": {}, - "essences": {} - }, - "Compendium.fabricate.alchemists-supplies-v16.dVgM7j75KoBDKRep": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.dVgM7j75KoBDKRep", - "salvage": {}, - "essences": { - "air": 1 - } - }, - "Compendium.fabricate.alchemists-supplies-v16.dvrdsBAt0B2ag8Q8": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.dvrdsBAt0B2ag8Q8", - "salvage": {}, - "essences": { - "air": 2 - } - }, - "Compendium.fabricate.alchemists-supplies-v16.fZsZ5EYMs9ioZ6Kk": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.fZsZ5EYMs9ioZ6Kk", - "salvage": {}, - "essences": {} - }, - "Compendium.fabricate.alchemists-supplies-v16.m51mvhPcubqfPbWB": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.m51mvhPcubqfPbWB", - "salvage": {}, - "essences": {} - }, - "Compendium.fabricate.alchemists-supplies-v16.sB64MUbYpMqiWCkS": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.sB64MUbYpMqiWCkS", - "salvage": {}, - "essences": {} - }, - "Compendium.fabricate.alchemists-supplies-v16.xtrSxjCohTVr1bZy": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.xtrSxjCohTVr1bZy", - "salvage": {}, - "essences": { - "earth": 1, - "water": 1 - } - }, - "Compendium.fabricate.alchemists-supplies-v16.zgDyoqjRxwhD9cNR": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.zgDyoqjRxwhD9cNR", - "salvage": {}, - "essences": {} - } - }, - "recipes": { - "Compendium.fabricate.alchemists-supplies-v16.3N7TGYWJRrUJ9thy": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.3N7TGYWJRrUJ9thy", - "essences": { - "earth": 2, - "water": 2, - "negative-energy": 1 - }, - "catalysts": {}, - "ingredientGroups": [], - "resultGroups": [ - { - "Compendium.fabricate.alchemists-supplies-v16.zogQbVwUggRUWK2l": 1 - } - ] - }, - "Compendium.fabricate.alchemists-supplies-v16.6mKiAWQIzDZRPNOP": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.6mKiAWQIzDZRPNOP", - "essences": { - "air": 2, - "negative-energy": 1 - }, - "catalysts": {}, - "ingredientGroups": [], - "resultGroups": [ - { - "Compendium.fabricate.alchemists-supplies-v16.m51mvhPcubqfPbWB": 1 - } - ] - }, - "Compendium.fabricate.alchemists-supplies-v16.8M0vwnOuCEIEP9ww": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.8M0vwnOuCEIEP9ww", - "essences": { - "fire": 2, - "positive-energy": 1 - }, - "catalysts": {}, - "ingredientGroups": [], - "resultGroups": [ - { - "Compendium.fabricate.alchemists-supplies-v16.HyicGjYWESWIdEhc": 1 - } - ] - }, - "Compendium.fabricate.alchemists-supplies-v16.EQfnAlIaDLh57ETN": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.EQfnAlIaDLh57ETN", - "essences": { - "earth": 2, - "positive-energy": 1 - }, - "catalysts": {}, - "ingredientGroups": [], - "resultGroups": [ - { - "Compendium.fabricate.alchemists-supplies-v16.fZsZ5EYMs9ioZ6Kk": 1 - } - ] - }, - "Compendium.fabricate.alchemists-supplies-v16.F2dhRws4mZppZVIa": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.F2dhRws4mZppZVIa", - "essences": { - "water": 2, - "positive-energy": 1 - }, - "catalysts": {}, - "ingredientGroups": [], - "resultGroups": [ - { - "Compendium.fabricate.alchemists-supplies-v16.Ffv7X37ZT7LRzDvP": 1 - } - ] - }, - "Compendium.fabricate.alchemists-supplies-v16.IVqzRv8v1MrfpKSa": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.IVqzRv8v1MrfpKSa", - "essences": { - "earth": 3, - "negative-energy": 2 - }, - "catalysts": {}, - "ingredientGroups": [], - "resultGroups": [ - { - "Compendium.fabricate.alchemists-supplies-v16.8AdG3048UmUOo5Qx": 1 - } - ] - }, - "Compendium.fabricate.alchemists-supplies-v16.JEewjPpD6JA2ifE3": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.JEewjPpD6JA2ifE3", - "essences": { - "fire": 1, - "negative-energy": 1 - }, - "catalysts": {}, - "ingredientGroups": [], - "resultGroups": [ - { - "Compendium.fabricate.alchemists-supplies-v16.QXN2n98HnjFzSsNJ": 1 - } - ] - }, - "Compendium.fabricate.alchemists-supplies-v16.O0FPlz7ARBLuLY1E": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.O0FPlz7ARBLuLY1E", - "essences": { - "earth": 2, - "positive-energy": 1 - }, - "catalysts": {}, - "ingredientGroups": [], - "resultGroups": [ - { - "Compendium.fabricate.alchemists-supplies-v16.zgDyoqjRxwhD9cNR": 1 - } - ] - }, - "Compendium.fabricate.alchemists-supplies-v16.Pl65b1VSVhUJ2li9": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.Pl65b1VSVhUJ2li9", - "essences": { - "fire": 2, - "earth": 1, - "water": 1, - "positive-energy": 1 - }, - "catalysts": {}, - "ingredientGroups": [], - "resultGroups": [ - { - "Compendium.fabricate.alchemists-supplies-v16.Kv4752l95eP4IeBM": 1 - } - ] - }, - "Compendium.fabricate.alchemists-supplies-v16.WseDmjhOWaT1XeJf": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.WseDmjhOWaT1XeJf", - "essences": { - "earth": 1, - "fire": 2 - }, - "catalysts": {}, - "ingredientGroups": [], - "resultGroups": [ - { - "Compendium.fabricate.alchemists-supplies-v16.EDGSJpxtSuvq7XWW": 1 - } - ] - }, - "Compendium.fabricate.alchemists-supplies-v16.hJFEOjoiLdTOOoBn": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.hJFEOjoiLdTOOoBn", - "essences": { - "earth": 2, - "water": 2 - }, - "catalysts": {}, - "ingredientGroups": [], - "resultGroups": [ - { - "Compendium.fabricate.alchemists-supplies-v16.sB64MUbYpMqiWCkS": 1 - } - ] - }, - "Compendium.fabricate.alchemists-supplies-v16.kI3lqJmjWai0aX5L": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.kI3lqJmjWai0aX5L", - "essences": { - "air": 3 - }, - "catalysts": {}, - "ingredientGroups": [], - "resultGroups": [ - { - "Compendium.fabricate.alchemists-supplies-v16.4U429BrJKdMKDZaR": 1 - } - ] - }, - "Compendium.fabricate.alchemists-supplies-v16.x4MkisNL6yBoTZ94": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.x4MkisNL6yBoTZ94", - "essences": { - "air": 2, - "fire": 1 - }, - "catalysts": {}, - "ingredientGroups": [], - "resultGroups": [ - { - "Compendium.fabricate.alchemists-supplies-v16.bNOIJaY21OSsdybw": 1 - } - ] - }, - "Compendium.fabricate.alchemists-supplies-v16.ylQuglLVjQFuH9w1": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.ylQuglLVjQFuH9w1", - "essences": { - "water": 1, - "fire": 1, - "negative-energy": 1 - }, - "catalysts": {}, - "ingredientGroups": [], - "resultGroups": [ - { - "Compendium.fabricate.alchemists-supplies-v16.1lx7oTedScyEgTOm": 1 - } - ] - }, - "Compendium.fabricate.alchemists-supplies-v16.yqKIdplKhpa5Ir7A": { - "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.yqKIdplKhpa5Ir7A", - "essences": { - "air": 2, - "fire": 2, - "negative-energy": 1 - }, - "catalysts": {}, - "ingredientGroups": [], - "resultGroups": [ - { - "Compendium.fabricate.alchemists-supplies-v16.VHUXcExXZLCDfqmM": 1 - } - ] - } - }, - "essences": { - "water": { - "name": "Water", - "id": "water", - "description": "Elemental water, one of the fundamental forces of nature", - "iconCode": "fa-solid fa-droplet", - "tooltip": "Elemental water" - }, - "earth": { - "name": "Earth", - "id": "fa-solid fa-mountain", - "description": "Elemental earth, one of the fundamental forces of nature", - "iconCode": "fa-solid fa-mountain", - "tooltip": "Elemental earth" - }, - "air": { - "name": "Air", - "id": "air", - "description": "Elemental air, one of the fundamental forces of nature", - "iconCode": "fa-solid fa-wind", - "tooltip": "Elemental air" - }, - "fire": { - "name": "Fire", - "id": "fire", - "description": "Elemental fire, one of the fundamental forces of nature", - "iconCode": "fa-solid fa-fire", - "tooltip": "Elemental fire" - }, - "negative-energy": { - "name": "Negative Energy", - "id": "negative-energy", - "description": "Negative Energy - The essence of death and destruction", - "iconCode": "fa-solid fa-moon", - "tooltip": "Negative energy" - }, - "positive-energy": { - "name": "Positive Energy", - "id": "positive-energy", - "description": "Positive Energy - The essence of life and creation", - "iconCode": "fa-solid fa-sun", - "tooltip": "Positive energy" - } - } - } -} - -export { V1_ALCHEMISTS_SUPPLIES_SYSTEM_DEFINITION } \ No newline at end of file diff --git a/test/resources/V1ChildsPlaySystemSpec.ts b/test/resources/V1ChildsPlaySystemSpec.ts deleted file mode 100644 index 9b71d371..00000000 --- a/test/resources/V1ChildsPlaySystemSpec.ts +++ /dev/null @@ -1,123 +0,0 @@ -import {V1SystemJson} from "../../src/scripts/system/setting_versions/V1Json"; - -const V1_CHILDS_PLAY_SYSTEM_DEFINITION: V1SystemJson = { - "id": "VMjcCEagWtutgKi3", - "details": { - "name": "Child's Play", - "summary": "A demo system", - "description": "", - "author": "Gamemaster" - }, - "enabled": true, - "locked": false, - "parts": { - "components": { - "Item.B3zDhh2OsXhfBrpA": { - "itemUuid": "Item.B3zDhh2OsXhfBrpA", - "essences": { - "iSS9Vy6mLUTZ4K2i": 1, - "JMRPrw4231ryQO45": 1 - }, - "salvage": {} - }, - "Item.I7SjtltffaXBZ7jl": { - "itemUuid": "Item.I7SjtltffaXBZ7jl", - "essences": { - "iSS9Vy6mLUTZ4K2i": 1 - }, - "salvage": {} - }, - "Item.qefRHSJYoNK7m7wE": { - "itemUuid": "Item.qefRHSJYoNK7m7wE", - "essences": { - "JMRPrw4231ryQO45": 1 - }, - "salvage": {} - }, - "Item.NxAfHbjx9oou5gZj": { - "itemUuid": "Item.NxAfHbjx9oou5gZj", - "essences": {}, - "salvage": {} - }, - "Item.lCVwi68NxCV6wQGL": { - "itemUuid": "Item.lCVwi68NxCV6wQGL", - "essences": {}, - "salvage": {} - } - }, - "recipes": { - "Item.DnpnEdjUa5N6crx4": { - "itemUuid": "Item.DnpnEdjUa5N6crx4", - "essences": {}, - "catalysts": {}, - "resultGroups": [ - { - "Item.NxAfHbjx9oou5gZj": 1 - } - ], - "ingredientGroups": [ - { - "Item.B3zDhh2OsXhfBrpA": 1, - "Item.I7SjtltffaXBZ7jl": 1, - "Item.qefRHSJYoNK7m7wE": 1 - } - ] - }, - "Item.8eSOM7wzoEYMvh42": { - "itemUuid": "Item.8eSOM7wzoEYMvh42", - "essences": {}, - "catalysts": {}, - "resultGroups": [ - { - "Item.lCVwi68NxCV6wQGL": 1 - } - ], - "ingredientGroups": [ - { - "Item.B3zDhh2OsXhfBrpA": 3, - "Item.I7SjtltffaXBZ7jl": 2, - "Item.qefRHSJYoNK7m7wE": 2 - }, - { - "Item.NxAfHbjx9oou5gZj": 1, - "Item.B3zDhh2OsXhfBrpA": 1, - "Item.I7SjtltffaXBZ7jl": 1, - "Item.qefRHSJYoNK7m7wE": 1 - } - ] - } - }, - "essences": { - "iSS9Vy6mLUTZ4K2i": { - "id": "iSS9Vy6mLUTZ4K2i", - "name": "Water", - "tooltip": "Water", - "iconCode": "fa-solid fa-droplet", - "description": "" - }, - "JMRPrw4231ryQO45": { - "id": "JMRPrw4231ryQO45", - "name": "Earth", - "tooltip": "Earth", - "iconCode": "fa-solid fa-mountain", - "description": "" - }, - "FNJYisoUcSyTANi7": { - "id": "FNJYisoUcSyTANi7", - "name": "Fire", - "tooltip": "Fire", - "iconCode": "fa-solid fa-fire", - "description": "" - }, - "BMmOAtAArVTjhfOR": { - "id": "BMmOAtAArVTjhfOR", - "name": "Wind", - "tooltip": "Wind", - "iconCode": "fa-solid fa-wind", - "description": "" - } - } - } -} - -export default V1_CHILDS_PLAY_SYSTEM_DEFINITION; \ No newline at end of file diff --git a/test/stubs/StubActorFactory.ts b/test/stubs/StubActorFactory.ts index 5e2f0624..7f3b937e 100644 --- a/test/stubs/StubActorFactory.ts +++ b/test/stubs/StubActorFactory.ts @@ -1,17 +1,17 @@ import {StubItem} from "./StubItem"; import {BaseActor} from "@league-of-foundry-developers/foundry-vtt-types/src/foundry/common/documents.mjs"; +import {Combination} from "../../src/scripts/common/Combination"; +import {Component} from "../../src/scripts/crafting/component/Component"; class StubActorFactory { - private readonly _ownedItems: Map ; + constructor() {} - constructor({ ownedItems = new Map() }: { ownedItems?: Map }) { - this._ownedItems = ownedItems; - } - - public make(): BaseActor { - const items = new Map(this._ownedItems); - const result: BaseActor = { + public make(ownedComponents: Combination = Combination.EMPTY(), additionalItemCount = 10): BaseActor { + const items = this.generateInventory(ownedComponents, additionalItemCount); + return { + id: randomIdentifier(), + name: "Stub Actor", items, deleteEmbeddedDocuments: (type: string, ids: string[]) => { if (type !== "Item") { @@ -61,6 +61,28 @@ class StubActorFactory { return itemData; } }; + } + + private generateInventory(ownedComponents: Combination = Combination.EMPTY(), additionalItemCount = 10): Map { + const result: Map = new Map(); + for (let i = 0; i < additionalItemCount; i++) { + const id = randomIdentifier(); + result.set(id, new StubItem({ id })); + } + ownedComponents.units.map(unit => { + const id = randomIdentifier(); + result.set(id, new StubItem({ + id: id, + flags: { + core: { + sourceId: unit.element.itemUuid + } + }, + system: { + quantity: unit.quantity + } + })); + }); return result; } diff --git a/test/stubs/StubDocumentManager.ts b/test/stubs/StubDocumentManager.ts index 6a38c713..9ca9c028 100644 --- a/test/stubs/StubDocumentManager.ts +++ b/test/stubs/StubDocumentManager.ts @@ -2,125 +2,159 @@ import { DocumentManager, FabricateItemData, ItemNotFoundError, - LoadedFabricateItemData + LoadedFabricateItemData, PendingFabricateItemData } from "../../src/scripts/foundry/DocumentManager"; -import {CraftingComponent, CraftingComponentJson} from "../../src/scripts/common/CraftingComponent"; -import {Recipe, RecipeJson} from "../../src/scripts/common/Recipe"; +import {Component, ComponentJson} from "../../src/scripts/crafting/component/Component"; +import {Recipe, RecipeJson} from "../../src/scripts/crafting/recipe/Recipe"; class StubDocumentManager implements DocumentManager { - private readonly _permissive: boolean; - private static readonly _defaultItemData: FabricateItemData = new LoadedFabricateItemData({ - name: "Item name", - imageUrl: "path/to/image/webp", - itemUuid: "NOT_A_UUID", - sourceDocument: { - effects: [] - } - }); + private static DEFAULT_ITEM_DATA(uuid: string): FabricateItemData { + return new LoadedFabricateItemData({ + name: "Item name", + imageUrl: "path/to/image/webp", + itemUuid: uuid, + sourceDocument: { + effects: [] + } + }); + } - private readonly _itemDataByUuid: Map; - private readonly _poisonIds: string[] = []; + private itemDataByUuid: Map; + private poisonIds: string[] = []; + private allowUnknownIds: boolean; + private cachedState: { + itemDataByUuid: Map; + poisonIds: string[]; + allowUnknownIds: boolean; + }; - constructor(itemDataByUuid: Map, permissive = true) { - this._itemDataByUuid = itemDataByUuid; - this._permissive = permissive; + constructor({ + itemDataByUuid = new Map(), + allowUnknownIds = false, + poisonIds = [] + }: { + itemDataByUuid?: Map; + allowUnknownIds?: boolean; + poisonIds?: string[]; + } = {}) { + this.itemDataByUuid = itemDataByUuid; + this.allowUnknownIds = allowUnknownIds; + this.poisonIds = poisonIds; + this.cachedState = { + itemDataByUuid: new Map(itemDataByUuid), + poisonIds: Array.from(poisonIds), + allowUnknownIds + }; } public static forParts({ - craftingComponents = [], + components = [], recipes = [] - }: { - craftingComponents?: CraftingComponent[]; + }: { + components?: Component[]; recipes?: Recipe[] }): StubDocumentManager { - const itemData = new Map(); - this.ingest((component: CraftingComponent) => { + const result = new StubDocumentManager(); + result.ingest((component: Component) => { return new LoadedFabricateItemData({ itemUuid: component.itemUuid, name: component.name, imageUrl: component.imageUrl, sourceDocument: component }); - }, craftingComponents, itemData); - this.ingest((recipe: Recipe) => { + }, components); + result.ingest((recipe: Recipe) => { return new LoadedFabricateItemData({ itemUuid: recipe.itemUuid, name: recipe.name, imageUrl: recipe.imageUrl, sourceDocument: recipe }); - }, recipes, itemData); - return new StubDocumentManager(itemData); + }, recipes); + return result; } public static forPartDefinitions({ - craftingComponentsJson = [], + componentsJson = [], recipesJson = [] }: { - craftingComponentsJson?: CraftingComponentJson[]; + componentsJson?: ComponentJson[]; recipesJson?: RecipeJson[] }): StubDocumentManager { - const itemData = new Map(); + const result = new StubDocumentManager(); const notLoaded = "NOT_LOADED"; - this.ingest((componentJson: CraftingComponentJson) => { + result.ingest((componentJson: ComponentJson) => { return new LoadedFabricateItemData({ itemUuid: componentJson.itemUuid, name: notLoaded, imageUrl: notLoaded, sourceDocument: componentJson }); - }, craftingComponentsJson, itemData); - this.ingest((recipeJson: RecipeJson) => { + }, componentsJson); + result.ingest((recipeJson: RecipeJson) => { return new LoadedFabricateItemData({ itemUuid: recipeJson.itemUuid, name: notLoaded, imageUrl: notLoaded, sourceDocument: recipeJson }) - }, recipesJson, itemData); - return new StubDocumentManager(itemData); + }, recipesJson); + return result; } - private static ingest(mappingFunction: (part: T) => FabricateItemData, parts: T[], target: Map): void { + private ingest(mappingFunction: (part: T) => FabricateItemData, parts: T[]): void { parts.map(part => mappingFunction(part)) - .forEach(itemData => target.set(itemData.uuid, itemData)); + .forEach(itemData => this.itemDataByUuid.set(itemData.uuid, itemData)); } - public async getDocumentByUuid(id: string): Promise { - if (this._poisonIds.includes(id)) { - throw new ItemNotFoundError(id); + public async loadItemDataByDocumentUuid(uuid: string): Promise { + if (this.poisonIds.includes(uuid)) { + throw new ItemNotFoundError(uuid); } - const result = this._itemDataByUuid.get(id); - if (!result && this._permissive) { - return StubDocumentManager._defaultItemData; + const result = this.itemDataByUuid.get(uuid); + if (!result && this.allowUnknownIds) { + return StubDocumentManager.DEFAULT_ITEM_DATA(uuid); } else if (!result) { - throw new ItemNotFoundError(id); + throw new ItemNotFoundError(uuid); } else { return result; } } - public async getDocumentsByUuid(ids: string[]): Promise> { - const results = new Map(); - for (const id of ids) { - if (this._poisonIds.includes(id)) { - throw new ItemNotFoundError(id); - } - const result = this._itemDataByUuid.get(id); - if (!result && this._permissive) { - results.set(id, StubDocumentManager._defaultItemData); - } else if (!result) { - throw new ItemNotFoundError(id); - } else { - results.set(id, this._itemDataByUuid.get(id)); - } + public async loadItemDataForDocumentsByUuid(uuids: string[]): Promise> { + const itemData = await Promise.all(uuids.map(uuid => this.loadItemDataByDocumentUuid(uuid))); + return new Map(itemData.map(data => [data.uuid, data])); + } + + poison(poisonId: string): void { + this.poisonIds.push(poisonId); + } + + cure(cureId: string): void { + this.poisonIds = this.poisonIds.filter(poisonId => poisonId !== cureId); + } + + public setAllowUnknownIds(value: boolean): void { + this.allowUnknownIds = value; + } + + public save(): void { + this.cachedState = { + itemDataByUuid: this.itemDataByUuid, + poisonIds: this.poisonIds, + allowUnknownIds: this.allowUnknownIds } - return results; } - poison(id: string) { - this._poisonIds.push(id); + public reset(): void { + this.itemDataByUuid = this.cachedState.itemDataByUuid; + this.poisonIds = this.cachedState.poisonIds; + this.allowUnknownIds = this.cachedState.allowUnknownIds; + } + + prepareItemDataByDocumentUuid(uuid: string): FabricateItemData { + return new PendingFabricateItemData(uuid, () => this.loadItemDataByDocumentUuid(uuid)) } } diff --git a/test/stubs/StubEntityFactory.ts b/test/stubs/StubEntityFactory.ts new file mode 100644 index 00000000..c840741e --- /dev/null +++ b/test/stubs/StubEntityFactory.ts @@ -0,0 +1,30 @@ +import {EntityFactory} from "../../src/scripts/repository/EntityFactory"; +import {Identifiable} from "../../src/scripts/common/Identifiable"; +import {Serializable} from "../../src/scripts/common/Serializable"; + +class StubEntityFactory> implements EntityFactory { + + private readonly valuesById: Map; + private readonly factoryFunction: (entityJson: J) => Promise; + + constructor({ + valuesById = new Map(), + factoryFunction = () => Promise.resolve(undefined) + }: { + valuesById?: Map; + factoryFunction?: (entityJson: J) => Promise; + } = {}) { + this.valuesById = valuesById; + this.factoryFunction = factoryFunction; + } + + async make(entityJson: J): Promise { + if (this.valuesById.has(entityJson.id)) { + return this.valuesById.get(entityJson.id); + } + return this.factoryFunction(entityJson); + } + +} + +export { StubEntityFactory } \ No newline at end of file diff --git a/test/stubs/StubGameObject.ts b/test/stubs/StubGameObject.ts deleted file mode 100644 index 021f6901..00000000 --- a/test/stubs/StubGameObject.ts +++ /dev/null @@ -1,9 +0,0 @@ -class StubGameObject extends Game { - - constructor() { - super(null, null, null, null); - } - -} - -export { StubGameObject } \ No newline at end of file diff --git a/test/stubs/StubGameProvider.ts b/test/stubs/StubGameProvider.ts deleted file mode 100644 index 5ea6a026..00000000 --- a/test/stubs/StubGameProvider.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {GameProvider} from "../../src/scripts/foundry/GameProvider"; - -class StubGameProvider implements GameProvider { - - private readonly _stubGameObject: Game; - - constructor(stubGameObject: Game) { - this._stubGameObject = stubGameObject; - } - - get(): Game { - return this._stubGameObject; - } - -} - -export { StubGameProvider } \ No newline at end of file diff --git a/test/stubs/StubInventory.ts b/test/stubs/StubInventory.ts index 3ff914bf..d3dbd84e 100644 --- a/test/stubs/StubInventory.ts +++ b/test/stubs/StubInventory.ts @@ -1,6 +1,6 @@ import {Inventory} from "../../src/scripts/actor/Inventory"; import {Combination} from "../../src/scripts/common/Combination"; -import {CraftingComponent} from "../../src/scripts/common/CraftingComponent"; +import {Component} from "../../src/scripts/crafting/component/Component"; import {AlchemyResult} from "../../src/scripts/crafting/alchemy/AlchemyResult"; import {CraftingResult} from "../../src/scripts/crafting/result/CraftingResult"; import {SalvageResult} from "../../src/scripts/crafting/result/SalvageResult"; @@ -8,14 +8,14 @@ import {SalvageResult} from "../../src/scripts/crafting/result/SalvageResult"; class StubInventory implements Inventory { actor: any; - ownedComponents: Combination; + ownedComponents: Combination; constructor({ actor, ownedComponents = Combination.EMPTY() }: { actor: any, - ownedComponents?: Combination + ownedComponents?: Combination }) { this.actor = actor; this.ownedComponents = ownedComponents; @@ -29,7 +29,7 @@ class StubInventory implements Inventory { return []; } - contains(craftingComponent: CraftingComponent, quantity: number = 1): boolean { + contains(craftingComponent: Component, quantity: number = 1): boolean { return this.ownedComponents.amountFor(craftingComponent.id) >= quantity; } @@ -43,7 +43,7 @@ class StubInventory implements Inventory { return Promise.resolve([]); } - amountFor(craftingComponent: CraftingComponent): number { + amountFor(craftingComponent: Component): number { return this.ownedComponents.amountFor(craftingComponent); } diff --git a/test/stubs/StubItem.ts b/test/stubs/StubItem.ts index 0884c03b..b6d18747 100644 --- a/test/stubs/StubItem.ts +++ b/test/stubs/StubItem.ts @@ -1,9 +1,9 @@ export class StubItem { - private readonly _id; - private readonly _flags: Record>; - private readonly _system: { quantity: number }; - private readonly _effects: any[]; + public readonly id; + public flags: Record>; + public system: { quantity: number }; + public effects: any[]; constructor({ id, @@ -18,33 +18,21 @@ export class StubItem { }; effects?: any[]; }) { - this._id = id; - this._flags = flags; - this._system = system; - this._effects = effects; - } - - get id() { - return this._id; - } - - get flags(): Record> { - return this._flags; - } - - get system(): { quantity: number } { - return this._system; + this.id = id; + this.flags = flags; + this.system = system; + this.effects = effects; } getFlag(scope: string, key: string) { - if (scope in this._flags) { - return this._flags[scope][key]; + if (scope in this.flags) { + return this.flags[scope][key]; } return undefined; } - get effects(): any[] { - return this._effects; + get _id() { + return this.id; } } \ No newline at end of file diff --git a/test/stubs/StubObjectUtility.ts b/test/stubs/StubObjectUtility.ts index c2236644..808da173 100644 --- a/test/stubs/StubObjectUtility.ts +++ b/test/stubs/StubObjectUtility.ts @@ -11,6 +11,20 @@ class StubObjectUtility implements ObjectUtility { return _.merge(target, source); } + getPropertyValue(propertyPath: string, object: object): T { + if (!_.has(object, propertyPath)) { + throw new Error(`Property path ${propertyPath} not found on object`); + } + return _.get(object, propertyPath); + } + + setPropertyValue(propertyPath: string, object: object, value: T): void { + if (!_.has(object, propertyPath)) { + throw new Error(`Property path ${propertyPath} not found on object`); + } + _.set(object, propertyPath, value); + } + } export { StubObjectUtility } \ No newline at end of file diff --git a/test/stubs/StubRecipeValidator.ts b/test/stubs/StubRecipeValidator.ts new file mode 100644 index 00000000..4e2b94ba --- /dev/null +++ b/test/stubs/StubRecipeValidator.ts @@ -0,0 +1,26 @@ +import {Recipe, RecipeJson} from "../../src/scripts/crafting/recipe/Recipe"; +import { + DefaultEntityValidationResult, + EntityValidationResult, + EntityValidator +} from "../../src/scripts/api/EntityValidator"; + +class StubRecipeValidator implements EntityValidator { + + private readonly result: boolean; + + constructor(result: boolean) { + this.result = result; + } + + async validate(candidate: Recipe): Promise> { + return new DefaultEntityValidationResult({ entity: candidate, errors: ["test-error"], isSuccessful: this.result } ); + } + + async validateJson(candidate: RecipeJson): Promise> { + return new DefaultEntityValidationResult({ entity: candidate, errors: ["test-error"], isSuccessful: this.result } ); + } + +} + +export { StubRecipeValidator } \ No newline at end of file diff --git a/test/stubs/api/StubComponentAPI.ts b/test/stubs/api/StubComponentAPI.ts new file mode 100644 index 00000000..442dccff --- /dev/null +++ b/test/stubs/api/StubComponentAPI.ts @@ -0,0 +1,129 @@ +import {Component} from "../../../src/scripts/crafting/component/Component"; +import {ComponentAPI, ComponentCreationOptions} from "../../../src/scripts/api/ComponentAPI"; +import {NotificationService} from "../../../src/scripts/foundry/NotificationService"; +import {ComponentExportModel} from "../../../src/scripts/repository/import/FabricateExportModel"; + +class StubComponentAPI implements ComponentAPI { + + private static readonly _USE_REAL_INSTEAD_MESSAGE = "Complex operations with real behaviour are not implemented by stubs. Should you be using the real thing instead?"; + + private readonly _valuesById: Map; + + constructor({ + valuesById = new Map(), + }: { + valuesById?: Map; + } = {}) { + this._valuesById = valuesById; + } + + get notifications(): NotificationService { + throw new Error("This is a stub. Stubs do not provide user interface notifications. "); + } + + cloneAll( + _sourceComponents: Component[], + _targetCraftingSystemId?: string, + _substituteEssenceIds?: Map + ): Promise<{ + components: Component[]; + idLinks: Map; + }> { + throw new Error(StubComponentAPI._USE_REAL_INSTEAD_MESSAGE); + } + + insert(_componentData: ComponentExportModel): Promise { + throw new Error(StubComponentAPI._USE_REAL_INSTEAD_MESSAGE); + } + + insertMany(_componentData: ComponentExportModel[]): Promise { + throw new Error(StubComponentAPI._USE_REAL_INSTEAD_MESSAGE); + } + + async saveAll(components: Component[]): Promise { + components.forEach(component => this._valuesById.set(component.id, component)); + return components; + } + + async getAllByCraftingSystemId(craftingSystemId: string): Promise> { + return new Map(Array.from(this._valuesById.values()) + .filter(component => component.craftingSystemId === craftingSystemId) + .map(component => [component.id, component])); + } + + async getById(id: string): Promise { + return this._valuesById.get(id); + } + + cloneById(_componentId: string): Promise { + throw new Error(StubComponentAPI._USE_REAL_INSTEAD_MESSAGE); + } + + async create(componentOptions: ComponentCreationOptions): Promise { + const result = Array.from(this._valuesById.values()) + .find(component => + component.craftingSystemId === componentOptions.craftingSystemId + && component.itemUuid === componentOptions.itemUuid + ); + if (result) { + return result; + } + throw new Error(`No component with crafting system id ${componentOptions.craftingSystemId} and item uuid ${componentOptions.itemUuid} was configured for this stub. Make sure to provide all expected components in the constructor.`); + } + + async deleteByCraftingSystemId(craftingSystemId: string): Promise { + const componentsToDelete = Array.from(this._valuesById.values()) + .filter(component => component.craftingSystemId === craftingSystemId) + componentsToDelete.forEach(component => this._valuesById.delete(component.id)); + return componentsToDelete; + } + + async deleteById(componentId: string): Promise { + const result = this._valuesById.get(componentId); + this._valuesById.delete(componentId); + return result; + } + + async deleteByItemUuid(componentId: string): Promise { + const componentsToDelete = Array.from(this._valuesById.values()) + .filter(component => component.itemUuid === componentId) + componentsToDelete.forEach(component => this._valuesById.delete(component.id)); + return componentsToDelete; + } + + async getAll(): Promise> { + return new Map(this._valuesById); + } + + async getAllById(componentIds: string[]): Promise> { + return componentIds.reduce((result, componentId) => { + result.set(componentId, this._valuesById.get(componentId)); + return result; + }, new Map()); + } + + async getAllByItemUuid(itemUuid: string): Promise> { + return Array.from(this._valuesById.values()) + .filter(component => component.itemUuid === itemUuid) + .reduce((result, component) => { + result.set(component.id, component); + return result; + }, new Map()); + } + + async removeEssenceReferences(_essenceId: string, _craftingSystemId: string): Promise { + throw new Error(StubComponentAPI._USE_REAL_INSTEAD_MESSAGE); + } + + async save(component: Component): Promise { + this._valuesById.set(component.id, component); + return component; + } + + async removeSalvageReferences(_componentId: string, _craftingSystemId: string): Promise { + throw new Error(StubComponentAPI._USE_REAL_INSTEAD_MESSAGE); + } + +} + +export { StubComponentAPI } \ No newline at end of file diff --git a/test/stubs/api/StubCraftingSystemAPI.ts b/test/stubs/api/StubCraftingSystemAPI.ts new file mode 100644 index 00000000..6f13753d --- /dev/null +++ b/test/stubs/api/StubCraftingSystemAPI.ts @@ -0,0 +1,66 @@ +import {CraftingSystemAPI, CraftingSystemImportData} from "../../../src/scripts/api/CraftingSystemAPI"; +import {CraftingSystem} from "../../../src/scripts/system/CraftingSystem"; +import {NotificationService} from "../../../src/scripts/foundry/NotificationService"; + +class StubCraftingSystemAPI implements CraftingSystemAPI { + + private static readonly _USE_REAL_INSTEAD_MESSAGE = "Complex operations with real behaviour are not implemented by stubs. Should you be using the real thing instead?"; + + private readonly _valuesById: Map; + + constructor({ + valuesById = new Map() + }: { + valuesById?: Map; + } = {}) { + this._valuesById = valuesById; + } + + cloneById(_craftingSystemId: string): Promise { + throw new Error(StubCraftingSystemAPI._USE_REAL_INSTEAD_MESSAGE) + } + + insert(_craftingSystemData: CraftingSystemImportData): Promise { + throw new Error(StubCraftingSystemAPI._USE_REAL_INSTEAD_MESSAGE) + } + + async create(craftingSystemConfig: { name?: string, summary?: string, description?: string, author?: string }): Promise { + const result = Array.from(this._valuesById.values()) + .find(craftingSystem => craftingSystem.details.name === craftingSystemConfig.name + && craftingSystem.details.author === craftingSystemConfig.author); + if (result) { + return result; + } + throw new Error(`No crafting system with name ${craftingSystemConfig.name} and author ${craftingSystemConfig.author} was configured for this stub. Make sure to provide all expected crafting systems in the constructor.`); + } + + get notifications(): NotificationService { + throw new Error("This is a stub. Stubs do not provide user interface notifications. "); + } + + async deleteById(id: string): Promise { + const value = await this.getById(id); + this._valuesById.delete(id); + return value; + } + + async getAllForGameSystem(): Promise> { + return new Map(this._valuesById); + } + + async getById(id: string): Promise { + return this._valuesById.get(id); + } + + async getAll(): Promise> { + return new Map(this._valuesById); + } + + async save(craftingSystem: CraftingSystem): Promise { + this._valuesById.set(craftingSystem.id, craftingSystem); + return craftingSystem; + } + +} + +export { StubCraftingSystemAPI } \ No newline at end of file diff --git a/test/stubs/api/StubEssenceAPI.ts b/test/stubs/api/StubEssenceAPI.ts new file mode 100644 index 00000000..7173bc3d --- /dev/null +++ b/test/stubs/api/StubEssenceAPI.ts @@ -0,0 +1,110 @@ +import {EssenceAPI, EssenceCreationOptions} from "../../../src/scripts/api/EssenceAPI"; +import {Essence} from "../../../src/scripts/crafting/essence/Essence"; +import {NotificationService} from "../../../src/scripts/foundry/NotificationService"; +import {EssenceExportModel} from "../../../src/scripts/repository/import/FabricateExportModel"; + +class StubEssenceAPI implements EssenceAPI { + + private static readonly _USE_REAL_INSTEAD_MESSAGE = "Complex operations with real behaviour are not implemented by stubs. Should you be using the real thing instead?"; + + private readonly _valuesById: Map; + + constructor({ + valuesById = new Map() + }: { + valuesById?: Map + } = {}) { + this._valuesById = valuesById; + } + + get notifications(): NotificationService { + throw new Error("This is a stub. Stubs do not provide user interface notifications. "); + } + + cloneAll( + _sourceEssences: Essence[], + _targetCraftingSystemId?: string + ): Promise<{ + essences: Essence[]; + idLinks: Map + }> { + throw new Error(StubEssenceAPI._USE_REAL_INSTEAD_MESSAGE); + } + + insert(_essenceData: EssenceExportModel): Promise { + throw new Error(StubEssenceAPI._USE_REAL_INSTEAD_MESSAGE); + } + + insertMany(_essenceData: EssenceExportModel[]): Promise { + throw new Error(StubEssenceAPI._USE_REAL_INSTEAD_MESSAGE); + } + + saveAll(essences: Essence[]): Promise { + essences.forEach(essence => this._valuesById.set(essence.id, essence)); + return Promise.resolve(essences); + } + + async getAllByCraftingSystemId(craftingSystemId: string): Promise> { + return Array.from(this._valuesById.values()) + .filter(essence => essence.craftingSystemId === craftingSystemId) + .reduce((map, essence) => { + map.set(essence.id, essence); + return map; + }, new Map()); + } + + async getById(id: string): Promise { + return this._valuesById.get(id); + } + + async getAll(): Promise> { + return new Map(this._valuesById); + } + + async create(essenceCreationOptions: EssenceCreationOptions): Promise { + const result = Array.from(this._valuesById.values()) + .find(essence => + essence.craftingSystemId === essenceCreationOptions.craftingSystemId + && essence.name === essenceCreationOptions.name + ); + if (result) { + return result; + } + throw new Error(`No essence with crafting system id ${essenceCreationOptions.craftingSystemId} and name ${essenceCreationOptions.name} was configured for this stub. Make sure to provide all expected essences in the constructor.`); + } + + async deleteById(id: string): Promise { + const deleted = this._valuesById.get(id); + this._valuesById.delete(id); + return deleted; + } + + async deleteByItemUuid(itemUuid: string): Promise { + const essencesToDelete = Array.from(this._valuesById.values()) + .filter(essence => essence.activeEffectSource.uuid === itemUuid) + essencesToDelete.forEach(essence => this._valuesById.delete(essence.id)); + return essencesToDelete; + } + + async getAllById(essenceIds: string[]): Promise> { + return essenceIds.reduce((result, essenceId) => { + result.set(essenceId, this._valuesById.get(essenceId)); + return result; + }, new Map()); + } + + async save(essence: Essence): Promise { + this._valuesById.set(essence.id, essence); + return essence; + } + + async deleteByCraftingSystemId(craftingSystemId: string): Promise { + const essencesToDelete = Array.from(this._valuesById.values()) + .filter(essence => essence.craftingSystemId === craftingSystemId) + essencesToDelete.forEach(essence => this._valuesById.delete(essence.id)); + return essencesToDelete; + } + +} + +export { StubEssenceAPI } \ No newline at end of file diff --git a/test/stubs/api/StubRecipeAPI.ts b/test/stubs/api/StubRecipeAPI.ts new file mode 100644 index 00000000..ded5b94a --- /dev/null +++ b/test/stubs/api/StubRecipeAPI.ts @@ -0,0 +1,127 @@ +import {RecipeAPI, RecipeOptions} from "../../../src/scripts/api/RecipeAPI"; +import {NotificationService} from "../../../src/scripts/foundry/NotificationService"; +import {Recipe} from "../../../src/scripts/crafting/recipe/Recipe"; +import {RecipeExportModel} from "../../../src/scripts/repository/import/FabricateExportModel"; + +class StubRecipeAPI implements RecipeAPI { + + private static readonly _USE_REAL_INSTEAD_MESSAGE = "Complex operations with real behaviour are not implemented by stubs. Should you be using the real thing instead?"; + + private readonly _valuesById: Map; + + constructor({ + valuesById = new Map(), + }: { + valuesById?: Map; + } = {}) { + this._valuesById = valuesById; + } + + get notifications(): NotificationService { + throw new Error("This is a stub. Stubs do not provide user interface notifications. "); + } + + cloneAll(_recipes: Recipe[], + _targetCraftingSystemId?: string, + _substituteEssenceIds?: Map, + _substituteComponentIds?: Map + ): Promise<{ recipes: Recipe[]; idLinks: Map }> { + throw new Error(StubRecipeAPI._USE_REAL_INSTEAD_MESSAGE); + } + + async cloneById(recipeId: string): Promise { + if (this._valuesById.has(recipeId)) { + return this._valuesById.get(recipeId); + } + throw new Error(`No recipe with id ${recipeId}`); + } + + async create(recipeOptions: RecipeOptions): Promise { + const result = Array.from(this._valuesById.values()) + .find(recipe => + recipe.craftingSystemId === recipeOptions.craftingSystemId + && recipe.itemUuid === recipeOptions.itemUuid + ); + if (result) { + return result; + } + throw new Error(`No recipe with crafting system id ${recipeOptions.craftingSystemId} and item uuid ${recipeOptions.itemUuid} was configured for this stub. Make sure to provide all expected recipes in the constructor.`); + } + + async deleteByCraftingSystemId(craftingSystemId: string): Promise { + const recipesToDelete = Array.from(this._valuesById.values()) + .filter(recipe => recipe.craftingSystemId === craftingSystemId) + recipesToDelete.forEach(recipe => this._valuesById.delete(recipe.id)); + return recipesToDelete; + } + + async deleteById(recipeId: string): Promise { + const result = this._valuesById.get(recipeId); + this._valuesById.delete(recipeId); + return result; + } + + async deleteByItemUuid(recipeId: string): Promise { + const recipesToDelete = Array.from(this._valuesById.values()) + .filter(recipe => recipe.itemUuid === recipeId) + recipesToDelete.forEach(recipe => this._valuesById.delete(recipe.id)); + return recipesToDelete; + } + + async getAll(): Promise> { + return new Map(this._valuesById); + } + + async getAllByCraftingSystemId(craftingSystemId: string): Promise> { + return Array.from(this._valuesById.values()) + .filter(recipe => recipe.craftingSystemId === craftingSystemId) + .reduce((result, recipe) => { + result.set(recipe.id, recipe); + return result; + }, new Map()); + } + + async getAllById(recipeIds: string[]): Promise> { + return recipeIds.reduce((result, recipeId) => { + result.set(recipeId, this._valuesById.get(recipeId)); + return result; + }, new Map()); + } + + async getAllByItemUuid(itemUuid: string): Promise> { + return Array.from(this._valuesById.values()) + .filter(recipe => recipe.itemUuid === itemUuid) + .reduce((result, recipe) => { + result.set(recipe.id, recipe); + return result; + }, new Map()); + } + + async getById(recipeId: string): Promise { + return this._valuesById.get(recipeId); + } + + async insert(_recipeData: RecipeExportModel): Promise { + throw new Error(StubRecipeAPI._USE_REAL_INSTEAD_MESSAGE); + } + + async insertMany(_recipeData: RecipeExportModel[]): Promise { + throw new Error(StubRecipeAPI._USE_REAL_INSTEAD_MESSAGE); + } + + async removeComponentReferences(_craftingComponentId: string, _craftingSystemId: string): Promise { + throw new Error(StubRecipeAPI._USE_REAL_INSTEAD_MESSAGE); + } + + async removeEssenceReferences(_essenceId: string, _craftingSystemId: string): Promise { + throw new Error(StubRecipeAPI._USE_REAL_INSTEAD_MESSAGE); + } + + async save(recipe: Recipe): Promise { + this._valuesById.set(recipe.id, recipe); + return recipe; + } + +} + +export { StubRecipeAPI }; \ No newline at end of file diff --git a/test/stubs/foundry/StubClientSettings.ts b/test/stubs/foundry/StubClientSettings.ts new file mode 100644 index 00000000..fb333048 --- /dev/null +++ b/test/stubs/foundry/StubClientSettings.ts @@ -0,0 +1,32 @@ +class StubClientSettings { + + private readonly settings: Map>; + + constructor(settings: Map> = new Map()) { + this.settings = settings; + } + + get(scope: string, key: string): any { + return this.settings.get(scope)?.get(key); + } + + set(scope: string, key: string, value: any): void { + if (!this.settings.has(scope)) { + this.settings.set(scope, new Map()); + } + this.settings.get(scope).set(key, value); + } + +} + +export { StubClientSettings } + +class StubClientSettingsFactory { + + make(settings: Map> = new Map()): ClientSettings { + return new StubClientSettings(settings) as unknown as ClientSettings; + } + +} + +export { StubClientSettingsFactory } \ No newline at end of file diff --git a/test/stubs/foundry/StubGameProvider.ts b/test/stubs/foundry/StubGameProvider.ts new file mode 100644 index 00000000..96b94379 --- /dev/null +++ b/test/stubs/foundry/StubGameProvider.ts @@ -0,0 +1,51 @@ +import {GameProvider} from "../../../src/scripts/foundry/GameProvider"; +import {BaseActor} from "@league-of-foundry-developers/foundry-vtt-types/src/foundry/common/documents.mjs"; + +class StubGameObject { + + get i18n() { + return { + localize: (path: string) => path, + format: (path: string, _params: {}) => path + } + } + +} + +export { StubGameObject } + +class StubGameProvider implements GameProvider { + + private readonly gameSystemId: string; + private readonly stubGameObject: StubGameObject; + private readonly stubActors: Map; + + constructor({ + gameSystemId = "dnd5e", + stubGameObject = new StubGameObject(), + stubActors = new Map(), + }: { + gameSystemId?: string, + stubGameObject?: StubGameObject; + stubActors?: Map, + } = {}) { + this.gameSystemId = gameSystemId; + this.stubGameObject = stubGameObject; + this.stubActors = stubActors; + } + + get(): Game { + return this.stubGameObject as unknown as Game; + } + + getGameSystemId(): string { + return this.gameSystemId; + } + + async loadActor(actorId: string): Promise { + return this.stubActors.get(actorId); + } + +} + +export { StubGameProvider } \ No newline at end of file diff --git a/test/stubs/foundry/StubIdentityFactory.ts b/test/stubs/foundry/StubIdentityFactory.ts new file mode 100644 index 00000000..ac6b1531 --- /dev/null +++ b/test/stubs/foundry/StubIdentityFactory.ts @@ -0,0 +1,34 @@ +import {IdentityFactory} from "../../../src/scripts/foundry/IdentityFactory"; + +class StubIdentityFactory implements IdentityFactory { + + private readonly _queuedIdentities: string[]; + + constructor(...queuedIdentities: string[]) { + this._queuedIdentities = queuedIdentities.reverse(); + } + + make(excludedValues: string[] = []): string { + if (this._queuedIdentities.length > 0) { + const nextId = this._queuedIdentities.pop(); + if (!excludedValues.includes(nextId)) { + return nextId; + } + throw new Error(`The stub identity provider is misconfigured. The queued ID ${nextId} is in the excluded values: ${excludedValues.join(", ")}`); + } + return this.randomIdentifier(excludedValues); + } + + randomIdentifier(excludedValues: string[] = []): string { + const generated = (Math.random() + 1) + .toString(36) + .substring(2); + if (!excludedValues.includes(generated)) { + return generated; + } + return this.randomIdentifier(excludedValues); + } + +} + +export { StubIdentityFactory } \ No newline at end of file diff --git a/test/stubs/foundry/StubLocalizationService.ts b/test/stubs/foundry/StubLocalizationService.ts new file mode 100644 index 00000000..117c813b --- /dev/null +++ b/test/stubs/foundry/StubLocalizationService.ts @@ -0,0 +1,29 @@ +import {LocalizationService} from "../../../src/applications/common/LocalizationService"; + +class StubLocalizationService implements LocalizationService { + + private readonly _invocations: { path: string, params: {} }[] = []; + + format(path: string, params: {}): string { + this._invocations.push({ path, params }); + return `Stub localization: "${path}" with params ${JSON.stringify(params)}`; + } + + localize(path: string): string { + this._invocations.push({ path, params: {} }); + return `Stub localization: "${path}"`; + } + + localizeAll(basePath: string, childPaths: string[], lineBreak: boolean): string { + return childPaths.map(childPath => `${basePath}.${childPath}`) + .map(value => this.localize(value)) + .join(lineBreak ? "\n" : ", "); + } + + get invocations(): { path: string; params: {} }[] { + return Array.from(this._invocations); + } + +} + +export { StubLocalizationService } \ No newline at end of file diff --git a/test/stubs/foundry/StubNotificationService.ts b/test/stubs/foundry/StubNotificationService.ts new file mode 100644 index 00000000..4f77b3a0 --- /dev/null +++ b/test/stubs/foundry/StubNotificationService.ts @@ -0,0 +1,40 @@ +class StubNotificationService implements NotificationService { + + private _suppressed: boolean; + private _invocations: { level: string, message: string; }[] = []; + + constructor(suppressed: boolean = false) { + this._suppressed = suppressed; + } + + get suppressed(): boolean { + return this._suppressed; + } + + set suppressed(value: boolean) { + this._suppressed = value; + } + + error(message: string): void { + this._invocations.push({ level: "error", message }); + } + + info(message: string): void { + this._invocations.push({ level: "info", message }); + } + + warn(message: string): void { + this._invocations.push({ level: "warn", message }); + } + + get invocations(): { level: string; message: string }[] { + return Array.from(this._invocations); + } + + reset() { + this._invocations = []; + this._suppressed = false; + } +} + +export { StubNotificationService } \ No newline at end of file diff --git a/test/stubs/foundry/StubSettingManager.ts b/test/stubs/foundry/StubSettingManager.ts new file mode 100644 index 00000000..6c4842ea --- /dev/null +++ b/test/stubs/foundry/StubSettingManager.ts @@ -0,0 +1,42 @@ +import {SettingManager} from "../../../src/scripts/repository/SettingManager"; +import {cloneDeep} from "lodash"; + +class StubSettingManager implements SettingManager{ + + private readonly initialValue: T; + private value: T; + + constructor(value?: T, initialValue?: T) { + this.value = value; + this.initialValue = initialValue ?? cloneDeep(value); + } + + async delete(): Promise { + this.value = null; + return; + } + + async read(): Promise { + return this.value; + } + + async write(value: T): Promise { + this.value = value + return; + } + + reset(value?: T): void { + this.value = value ?? this.initialValue; + } + + get settingPath(): string { + return "stub.setting.path"; + } + + get settingKey(): string { + return "stubKey"; + } + +} + +export { StubSettingManager } \ No newline at end of file diff --git a/test/stubs/foundry/StubUIProvider.ts b/test/stubs/foundry/StubUIProvider.ts new file mode 100644 index 00000000..eab7af77 --- /dev/null +++ b/test/stubs/foundry/StubUIProvider.ts @@ -0,0 +1,41 @@ +import {UI, UIProvider} from "../../../src/scripts/foundry/UIProvider"; + +class StubUIObject implements UI { + + private readonly _notifications: { + error: () => void; + info: () => void; + warn: () => void; + }; + + constructor() { + this._notifications = { + error: () => {}, + info: () => {}, + warn: () => {} + } + } + + get notifications() { + return this._notifications; + } + +} + +export { StubUIObject } + +class StubUIProvider implements UIProvider { + + private readonly stubUIObject: StubUIObject; + + constructor() { + this.stubUIObject = new StubUIObject(); + } + + get(): UI { + return this.stubUIObject as unknown as UI; + } + +} + +export { StubUIProvider } \ No newline at end of file diff --git a/test/test_data/TestCrafingSystem.ts b/test/test_data/TestCrafingSystem.ts new file mode 100644 index 00000000..c0eaccf6 --- /dev/null +++ b/test/test_data/TestCrafingSystem.ts @@ -0,0 +1,30 @@ +import {CraftingSystem} from "../../src/scripts/system/CraftingSystem"; +import {CraftingSystemDetails} from "../../src/scripts/system/CraftingSystemDetails"; + +const testCraftingSystemOne = new CraftingSystem({ + id: "c3d50462684c5d960f3fba9f953a93a1", + disabled: false, + craftingSystemDetails: new CraftingSystemDetails({ + name: "Test system one", + summary: "Test system one is not an embedded system", + description: "Test system one is a user-defined system used for test purposes only", + author: "None" + }), + embedded: false, +}); + +export { testCraftingSystemOne } + +const testCraftingSystemTwo = new CraftingSystem({ + id: "c3d50462684c5d960f3fba9f953a93a2", + disabled: false, + craftingSystemDetails: new CraftingSystemDetails({ + name: "Test system two", + summary: "Test system two is an embedded system", + description: "Test system two is an embedded system used for test purposes only", + author: "None" + }), + embedded: true, +}); + +export { testCraftingSystemTwo } \ No newline at end of file diff --git a/test/test_data/TestCraftingComponents.ts b/test/test_data/TestCraftingComponents.ts index 75a4a203..2afb245c 100644 --- a/test/test_data/TestCraftingComponents.ts +++ b/test/test_data/TestCraftingComponents.ts @@ -1,111 +1,136 @@ -import {CraftingComponent, SalvageOption} from "../../src/scripts/common/CraftingComponent"; -import {Combination, Unit} from "../../src/scripts/common/Combination"; +import {Component} from "../../src/scripts/crafting/component/Component"; +import {Combination} from "../../src/scripts/common/Combination"; import {elementalAir, elementalEarth, elementalFire, elementalWater} from "./TestEssences"; import Properties from "../../src/scripts/Properties"; -import {SelectableOptions} from "../../src/scripts/common/SelectableOptions"; +import {SelectableOptions} from "../../src/scripts/crafting/selection/SelectableOptions"; import {LoadedFabricateItemData} from "../../src/scripts/foundry/DocumentManager"; +import {testCraftingSystemOne} from "./TestCrafingSystem"; +import {Unit} from "../../src/scripts/common/Unit"; +import {SalvageOption} from "../../src/scripts/crafting/component/SalvageOption"; -const testComponentOne: CraftingComponent = new CraftingComponent({ +const testComponentOne: Component = new Component({ id: "iyeUGBbSts0ij92X", + craftingSystemId: testCraftingSystemOne.id, itemData: new LoadedFabricateItemData({ name: "Test Component One", imageUrl: Properties.ui.defaults.itemImageUrl, itemUuid: "Compendium.module.compendium-name.iyeUGBbSts0ij92X", - sourceDocument: { effects: [], flags: {}, system: {} } + sourceDocument: { effects: [], flags: {}, system: { quantity: null } } }), - essences: Combination.ofUnits([new Unit(elementalEarth, 2)]), + essences: Combination.ofUnits([new Unit(elementalEarth.toReference(), 2)]), }); -const testComponentTwo: CraftingComponent = new CraftingComponent({ +const testComponentTwo: Component = new Component({ id: "Ie7NoXMja9wI6xya", + craftingSystemId: testCraftingSystemOne.id, itemData: new LoadedFabricateItemData({ name: "Test Component Two", itemUuid: "Compendium.module.compendium-name.Ie7NoXMja9wI6xya", imageUrl: Properties.ui.defaults.recipeImageUrl, - sourceDocument: { effects: [], flags: {}, system: {} } + sourceDocument: { effects: [], flags: {}, system: { quantity: null } } }), - essences: Combination.ofUnits([new Unit(elementalFire, 2)]), + essences: Combination.ofUnits([new Unit(elementalFire.toReference(), 2)]), }); -const testComponentThree: CraftingComponent = new CraftingComponent({ +const testComponentThree: Component = new Component({ id: "tdyV4AWuTMkXbepw", + craftingSystemId: testCraftingSystemOne.id, itemData: new LoadedFabricateItemData({ itemUuid: "Compendium.module.compendium-name.tdyV4AWuTMkXbepw", name: "Test Component Three", imageUrl: Properties.ui.defaults.recipeImageUrl, - sourceDocument: { effects: [], flags: {}, system: {} } + sourceDocument: { effects: [], flags: {}, system: { quantity: null } } }), - essences: Combination.ofUnits([new Unit(elementalWater, 2)]), + essences: Combination.ofUnits([new Unit(elementalWater.toReference(), 2)]), }); -const testComponentFour: CraftingComponent = new CraftingComponent({ - id: "Ra2Z1ujre76weR0i", +const testComponentFourId = "Ra2Z1ujre76weR0i"; +const testComponentFour: Component = new Component({ + id: testComponentFourId, + craftingSystemId: testCraftingSystemOne.id, itemData: new LoadedFabricateItemData({ name: "Test Component Four", itemUuid: "Compendium.module.compendium-name.Ra2Z1ujre76weR0i", imageUrl: Properties.ui.defaults.recipeImageUrl, - sourceDocument: { effects: [], flags: {}, system: {} } + sourceDocument: { effects: [], flags: {}, system: { quantity: null } } }), - essences: Combination.ofUnits([new Unit(elementalAir, 2)]), + essences: Combination.ofUnits([new Unit(elementalAir.toReference(), 2)]), salvageOptions: new SelectableOptions({ options: [ new SalvageOption({ + id: `${testComponentFourId}-salvage-1`, name: "Option 1", - salvage: Combination.of(testComponentThree, 2) + results: Combination.of(testComponentThree.toReference(), 2) }) ] }) }); -const testComponentFive: CraftingComponent = new CraftingComponent({ - id:"74K6TAuSg2xzd209", +const testComponentFiveId = "74K6TAuSg2xzd209"; +const testComponentFive: Component = new Component({ + id: testComponentFiveId, + craftingSystemId: testCraftingSystemOne.id, itemData: new LoadedFabricateItemData({ itemUuid: "Compendium.module.compendium-name.74K6TAuSg2xzd209", name: "Test Component Five", imageUrl: Properties.ui.defaults.recipeImageUrl, - sourceDocument: { effects: [], flags: {}, system: {} } + sourceDocument: { effects: [], flags: {}, system: { quantity: null } } }), essences: Combination.ofUnits([ - new Unit(elementalFire, 1), - new Unit(elementalEarth, 3) + new Unit(elementalFire.toReference(), 1), + new Unit(elementalEarth.toReference(), 3) ]), salvageOptions: new SelectableOptions({ options: [ new SalvageOption({ + id: `${testComponentFiveId}-salvage-1`, name: "Option 1", - salvage: Combination.ofUnits([ - new Unit(testComponentOne, 2), - new Unit(testComponentTwo, 1) + results: Combination.ofUnits([ + new Unit(testComponentOne.toReference(), 2), + new Unit(testComponentTwo.toReference(), 1) ]) }) ] }) }); -const testComponentSix: CraftingComponent = new CraftingComponent({ +const testComponentSix: Component = new Component({ id: "rgTv21iOSwjK1882", + craftingSystemId: testCraftingSystemOne.id, itemData: new LoadedFabricateItemData({ name: "Test Component Six", itemUuid: "Compendium.module.compendium-name.rgTv21iOSwjK1882", imageUrl: Properties.ui.defaults.recipeImageUrl, - sourceDocument: { effects: [], flags: {}, system: {} } + sourceDocument: { effects: [], flags: {}, system: { quantity: null } } }), - essences: Combination.ofUnits([new Unit(elementalWater, 1)]) + essences: Combination.ofUnits([new Unit(elementalWater.toReference(), 1)]) }); -const testComponentSeven: CraftingComponent = new CraftingComponent({ +const testComponentSeven: Component = new Component({ id: "u9jwSlvIUhlQiEe1", + craftingSystemId: testCraftingSystemOne.id, itemData: new LoadedFabricateItemData({ name: "Test Component Seven", itemUuid: "Compendium.module.compendium-name.u9jwSlvIUhlQiEe1", imageUrl: Properties.ui.defaults.recipeImageUrl, - sourceDocument: { effects: [], flags: {}, system: {} } + sourceDocument: { effects: [], flags: {}, system: { quantity: null } } }), - essences: Combination.ofUnits([new Unit(elementalAir, 1)]) + essences: Combination.ofUnits([new Unit(elementalAir.toReference(), 1)]) }); +const allTestComponents = new Map([ + [testComponentOne.id, testComponentOne], + [testComponentTwo.id, testComponentTwo], + [testComponentThree.id, testComponentThree], + [testComponentFour.id, testComponentFour], + [testComponentFive.id,testComponentFive], + [testComponentSix.id, testComponentSix], + [testComponentSeven.id, testComponentSeven] +]); + export { + allTestComponents, testComponentOne, testComponentTwo, testComponentThree, diff --git a/test/test_data/TestEssences.ts b/test/test_data/TestEssences.ts index ee3773e5..52a8a636 100644 --- a/test/test_data/TestEssences.ts +++ b/test/test_data/TestEssences.ts @@ -1,11 +1,13 @@ -import {Essence} from "../../src/scripts/common/Essence"; +import {Essence} from "../../src/scripts/crafting/essence/Essence"; +import {testCraftingSystemOne} from "./TestCrafingSystem"; const elementalEarth = new Essence({ description: "One of the four fundamental forces, Elemental Earth.", iconCode: "mountain", name: "Earth", tooltip: "Elemental Earth", - id: "earth" + id: "earth", + craftingSystemId: testCraftingSystemOne.id }); const elementalWater = new Essence({ @@ -13,7 +15,8 @@ const elementalWater = new Essence({ iconCode: "water", name: "Water", tooltip: "Elemental Water", - id: "water" + id: "water", + craftingSystemId: testCraftingSystemOne.id }); const elementalAir = new Essence({ @@ -21,7 +24,8 @@ const elementalAir = new Essence({ iconCode: "air", name: "Air", tooltip: "Elemental Air", - id: "air" + id: "air", + craftingSystemId: testCraftingSystemOne.id }); const elementalFire = new Essence({ @@ -29,7 +33,21 @@ const elementalFire = new Essence({ iconCode: "fire", name: "Fire", tooltip: "Elemental Fire", - id: "fire" + id: "fire", + craftingSystemId: testCraftingSystemOne.id }); -export { elementalEarth, elementalWater, elementalAir, elementalFire } +const allTestEssences = new Map([ + [elementalEarth.id, elementalEarth], + [elementalAir.id, elementalAir], + [elementalWater.id, elementalWater], + [elementalFire.id, elementalFire] +]); + +export { + allTestEssences, + elementalEarth, + elementalWater, + elementalAir, + elementalFire +} diff --git a/test/test_data/TestPartDictionary.ts b/test/test_data/TestPartDictionary.ts index 5850e26d..a28cc0ac 100644 --- a/test/test_data/TestPartDictionary.ts +++ b/test/test_data/TestPartDictionary.ts @@ -1,6 +1,6 @@ import {PartDictionary, PartDictionaryFactory} from "../../src/scripts/system/PartDictionary"; -import {CraftingComponentJson} from "../../src/scripts/common/CraftingComponent"; -import {RecipeJson} from "../../src/scripts/common/Recipe"; +import {ComponentJson} from "../../src/scripts/crafting/component/Component"; +import {RecipeJson} from "../../src/scripts/crafting/recipe/Recipe"; import { testComponentFive, testComponentFour, @@ -18,6 +18,7 @@ import { } from "./TestRecipes"; import {elementalAir, elementalEarth, elementalFire, elementalWater} from "./TestEssences"; import {StubDocumentManager} from "../stubs/StubDocumentManager"; +import {EssenceJson} from "../../src/scripts/crafting/essence/Essence"; const components = { [testComponentOne.id]: testComponentOne, @@ -28,7 +29,7 @@ const components = { }; const componentsJson = Array.from(Object.values(components)) .map(component => component.toJson()) - .reduce((left: Record, right) => { + .reduce((left: Record, right) => { left[right.itemUuid] = right; return left; }, {}); @@ -44,7 +45,7 @@ const recipes = { const recipesJson = Array.from(Object.values(recipes)) .map(recipe => recipe.toJson()) .reduce((left: Record, right) => { - left[right.itemUuid] = right; + left[right.id] = right; return left; }, {}); const essences = { @@ -53,16 +54,22 @@ const essences = { [elementalWater.id]: elementalWater, [elementalAir.id]: elementalAir }; +const essencesJson = Array.from(Object.values(essences)) + .map(essence => essence.toJson()) + .reduce((left: Record, right) => { + left[right.id] = right; + return left; + }, {}); const testPartDictionaryFactory = new PartDictionaryFactory({ documentManager: StubDocumentManager.forParts({ - craftingComponents: Array.from(Object.values(components)), + components: Array.from(Object.values(components)), recipes: Array.from(Object.values(recipes)) }), }); const testPartDictionary: PartDictionary = testPartDictionaryFactory.make({ - essences, + essences: essencesJson, components: componentsJson, recipes: recipesJson }); diff --git a/test/test_data/TestRecipes.ts b/test/test_data/TestRecipes.ts index 8c423bf3..5f1f1959 100644 --- a/test/test_data/TestRecipes.ts +++ b/test/test_data/TestRecipes.ts @@ -1,10 +1,24 @@ -import {RequirementOption, Recipe, ResultOption} from "../../src/scripts/common/Recipe"; -import {Combination, Unit} from "../../src/scripts/common/Combination"; +import {Recipe} from "../../src/scripts/crafting/recipe/Recipe"; +import {Combination} from "../../src/scripts/common/Combination"; import {testComponentFive, testComponentFour, testComponentOne, testComponentThree, testComponentTwo} from "./TestCraftingComponents"; import {elementalEarth, elementalFire, elementalWater} from "./TestEssences"; -import {SelectableOptions} from "../../src/scripts/common/SelectableOptions"; -import {LoadedFabricateItemData} from "../../src/scripts/foundry/DocumentManager"; +import {SelectableOptions} from "../../src/scripts/crafting/selection/SelectableOptions"; +import {LoadedFabricateItemData, PendingFabricateItemData} from "../../src/scripts/foundry/DocumentManager"; import Properties from "../../src/scripts/Properties"; +import {testCraftingSystemOne} from "./TestCrafingSystem"; +import {Unit} from "../../src/scripts/common/Unit"; +import {RequirementOption} from "../../src/scripts/crafting/recipe/RequirementOption"; +import {ResultOption} from "../../src/scripts/crafting/recipe/ResultOption"; + +function buildPendingItemData(name: string, itemUuid: string) { + const loadedFabricateItemData = new LoadedFabricateItemData({ + name, + itemUuid, + imageUrl: Properties.ui.defaults.recipeImageUrl, + sourceDocument: {} + }); + return new PendingFabricateItemData(itemUuid, () => Promise.resolve(loadedFabricateItemData)); +} /** * Essences: None @@ -14,29 +28,28 @@ import Properties from "../../src/scripts/Properties"; * */ const testRecipeOne: Recipe = new Recipe({ id: "z2ixo2m312l", - itemData: new LoadedFabricateItemData({ - name: "Test Recipe One", - itemUuid: "Compendium.module.compendium-name.z2ixo2m312l", - imageUrl: Properties.ui.defaults.recipeImageUrl, - sourceDocument: {} - }), - ingredientOptions: new SelectableOptions({ + craftingSystemId: testCraftingSystemOne.id, + itemData: buildPendingItemData("Test Recipe One", "Compendium.module.compendium-name.z2ixo2m312l"), + requirementOptions: new SelectableOptions({ options: [ new RequirementOption({ + id: "z2ixo2m312l-requirement-1", name: "Option 1", - ingredients: Combination.of(testComponentOne, 1) + ingredients: Combination.of(testComponentOne.toReference(), 1) }), new RequirementOption({ + id: "z2ixo2m312l-requirement-2", name: "Option 2", - ingredients: Combination.of(testComponentThree, 2) + ingredients: Combination.of(testComponentThree.toReference(), 2) }), ] }), resultOptions: new SelectableOptions({ options: [ new ResultOption({ + id: "z2ixo2m312l-result-1", name: "Option 1", - results: Combination.of(testComponentFive, 1) + results: Combination.of(testComponentFive.toReference(), 1) }) ] }) @@ -50,26 +63,24 @@ const testRecipeOne: Recipe = new Recipe({ * */ const testRecipeTwo: Recipe = new Recipe({ id: "fzv66f90sd", - itemData: new LoadedFabricateItemData({ - name: "Test Recipe Two", - itemUuid: "Compendium.module.compendium-name.fzv66f90sd", - imageUrl: Properties.ui.defaults.recipeImageUrl, - sourceDocument: {} - }), - ingredientOptions: new SelectableOptions({ + craftingSystemId: testCraftingSystemOne.id, + itemData: buildPendingItemData("Test Recipe Two", "Compendium.module.compendium-name.fzv66f90sd"), + requirementOptions: new SelectableOptions({ options: [ new RequirementOption({ + id: "fzv66f90sd-requirement-1", name: "Option 1", - ingredients: Combination.of(testComponentFour, 1), - catalysts: Combination.of(testComponentFive, 1) + ingredients: Combination.of(testComponentFour.toReference(), 1), + catalysts: Combination.of(testComponentFive.toReference(), 1) }) ] }), resultOptions: new SelectableOptions({ options: [ new ResultOption({ + id: "fzv66f90sd-result-1", name: "Option 1", - results: Combination.of(testComponentTwo, 2) + results: Combination.of(testComponentTwo.toReference(), 2) }) ] }) @@ -83,21 +94,26 @@ const testRecipeTwo: Recipe = new Recipe({ * */ const testRecipeThree: Recipe = new Recipe({ id: "5pux8ghlct", - itemData: new LoadedFabricateItemData({ - name: "Test Recipe Three", - itemUuid: "Compendium.module.compendium-name.5pux8ghlct", - imageUrl: Properties.ui.defaults.recipeImageUrl, - sourceDocument: {} + craftingSystemId: testCraftingSystemOne.id, + itemData: buildPendingItemData("Test Recipe Three", "Compendium.module.compendium-name.5pux8ghlct"), + requirementOptions: new SelectableOptions({ + options: [ + new RequirementOption({ + id: "5pux8ghlct-requirement-1", + name: "Option 1", + essences: Combination.ofUnits([ + new Unit(elementalEarth.toReference(), 3), + new Unit(elementalFire.toReference(), 1) + ]) + }) + ] }), - essences: Combination.ofUnits([ - new Unit(elementalEarth, 3), - new Unit(elementalFire, 1) - ]), resultOptions: new SelectableOptions({ options: [ new ResultOption({ + id: "5pux8ghlct-result-1", name: "Option 1", - results: Combination.of(testComponentOne, 3) + results: Combination.of(testComponentOne.toReference(), 3), }) ] }) @@ -111,30 +127,28 @@ const testRecipeThree: Recipe = new Recipe({ * */ const testRecipeFour: Recipe = new Recipe({ id: "3lieym2gjef", - itemData: new LoadedFabricateItemData({ - name: "Test Recipe Four", - itemUuid: "Compendium.module.compendium-name.3lieym2gjef", - imageUrl: Properties.ui.defaults.recipeImageUrl, - sourceDocument: {} - }), - essences: Combination.ofUnits([ - new Unit(elementalEarth, 1), - new Unit(elementalWater, 2) - ]), - ingredientOptions: new SelectableOptions({ + craftingSystemId: testCraftingSystemOne.id, + itemData: buildPendingItemData("Test Recipe Four", "Compendium.module.compendium-name.3lieym2gjef"), + requirementOptions: new SelectableOptions({ options: [ new RequirementOption({ + id: "3lieym2gjef-requirement-1", name: "Option 1", - ingredients: Combination.of(testComponentTwo, 3), - catalysts: Combination.of(testComponentThree, 1) + ingredients: Combination.of(testComponentTwo.toReference(), 3), + catalysts: Combination.of(testComponentThree.toReference(), 1), + essences: Combination.ofUnits([ + new Unit(elementalEarth.toReference(), 1), + new Unit(elementalWater.toReference(), 2) + ]) }) ] }), resultOptions: new SelectableOptions({ options: [ new ResultOption({ + id: "3lieym2gjef-result-1", name: "Option 1", - results: Combination.of(testComponentFive, 10) + results: Combination.of(testComponentFive.toReference(), 10) }) ] }) @@ -148,29 +162,27 @@ const testRecipeFour: Recipe = new Recipe({ * */ const testRecipeFive: Recipe = new Recipe({ id: "fequ5qvoqh", - itemData: new LoadedFabricateItemData({ - itemUuid: "Compendium.module.compendium-name.fequ5qvoqh", - name: "Test Recipe Five", - imageUrl: Properties.ui.defaults.recipeImageUrl, - sourceDocument: {} - }), - essences: Combination.ofUnits([ - new Unit(elementalFire, 1), - new Unit(elementalWater, 1) - ]), - ingredientOptions: new SelectableOptions({ + craftingSystemId: testCraftingSystemOne.id, + itemData: buildPendingItemData("Test Recipe Five", "Compendium.module.compendium-name.fequ5qvoqh"), + requirementOptions: new SelectableOptions({ options: [ new RequirementOption({ + id: "fequ5qvoqh-requirement-1", name: "Option 1", - catalysts: Combination.of(testComponentFour, 1) + catalysts: Combination.of(testComponentFour.toReference(), 1), + essences: Combination.ofUnits([ + new Unit(elementalFire.toReference(), 1), + new Unit(elementalWater.toReference(), 1) + ]) }) ] }), resultOptions: new SelectableOptions({ options: [ new ResultOption({ + id: "fequ5qvoqh-result-1", name: "Option 1", - results: Combination.of(testComponentFive, 10) + results: Combination.of(testComponentFive.toReference(), 10) }) ] }) @@ -184,40 +196,44 @@ const testRecipeFive: Recipe = new Recipe({ * */ const testRecipeSix: Recipe = new Recipe({ id: "bx8luu4cpd", - itemData: new LoadedFabricateItemData({ - name: "Test Recipe Six", - itemUuid: "Compendium.module.compendium-name.bx8luu4cpd", - imageUrl: Properties.ui.defaults.recipeImageUrl, - sourceDocument: {} - }), - essences: Combination.ofUnits([ - new Unit(elementalEarth, 3), - new Unit(elementalWater, 1) - ]), - ingredientOptions: new SelectableOptions({ + craftingSystemId: testCraftingSystemOne.id, + itemData: buildPendingItemData("Test Recipe Six", "Compendium.module.compendium-name.bx8luu4cpd"), + requirementOptions: new SelectableOptions({ options: [ new RequirementOption({ + id: "bx8luu4cpd-requirement-1", name: "Option 1", ingredients: Combination.ofUnits([ - new Unit(testComponentOne, 1), - new Unit(testComponentThree, 2) - ]) + new Unit(testComponentOne.toReference(), 1), + new Unit(testComponentThree.toReference(), 2) + ]), + essences: Combination.ofUnits([ + new Unit(elementalEarth.toReference(), 3), + new Unit(elementalWater.toReference(), 1) + ]), }), new RequirementOption({ + id: "bx8luu4cpd-requirement-2", name: "Option 2", - ingredients: Combination.of(testComponentTwo, 1) + ingredients: Combination.of(testComponentTwo.toReference(), 1), + essences: Combination.ofUnits([ + new Unit(elementalEarth.toReference(), 3), + new Unit(elementalWater.toReference(), 1) + ]), }) ] }), resultOptions: new SelectableOptions({ options: [ new ResultOption({ + id: "bx8luu4cpd-result-1", name: "Option 1", - results: Combination.of(testComponentThree, 2) + results: Combination.of(testComponentThree.toReference(), 2) }), new ResultOption({ + id: "bx8luu4cpd-result-2", name: "Option 2", - results: Combination.of(testComponentFive, 2) + results: Combination.of(testComponentFive.toReference(), 2) }) ] }) @@ -231,32 +247,40 @@ const testRecipeSix: Recipe = new Recipe({ * */ const testRecipeSeven: Recipe = new Recipe({ id: "8kimdf8z83", - itemData: new LoadedFabricateItemData({ - itemUuid: "Compendium.module.compendium-name.8kimdf8z83", - name: "Test Recipe Seven", - imageUrl: Properties.ui.defaults.recipeImageUrl, - sourceDocument: {} - }), - ingredientOptions: new SelectableOptions({ + craftingSystemId: testCraftingSystemOne.id, + itemData: buildPendingItemData("Test Recipe Seven", "Compendium.module.compendium-name.8kimdf8z83"), + requirementOptions: new SelectableOptions({ options: [ new RequirementOption({ + id: "8kimdf8z83-requirement-1", name: "Option 1", - ingredients: Combination.of(testComponentFour, 1) + ingredients: Combination.of(testComponentFour.toReference(), 1) }) ] }), resultOptions: new SelectableOptions({ options: [ new ResultOption({ + id: "8kimdf8z83-result-1", name: "Option 1", - results: Combination.of(testComponentTwo, 2) + results: Combination.of(testComponentTwo.toReference(), 2) }) ] }) }) +const allTestRecipes = new Map([ + [testRecipeOne.id, testRecipeOne], + [testRecipeTwo.id, testRecipeTwo], + [testRecipeThree.id, testRecipeThree], + [testRecipeFour.id, testRecipeFour], + [testRecipeFive.id, testRecipeFive], + [testRecipeSix.id, testRecipeSix], + [testRecipeSeven.id, testRecipeSeven] +]); export { + allTestRecipes, testRecipeOne, testRecipeTwo, testRecipeThree, @@ -265,3 +289,13 @@ export { testRecipeSix, testRecipeSeven } + +function resetAllTestRecipes() { + allTestRecipes.forEach(recipe => { + if (recipe.loaded) { + recipe.itemData = buildPendingItemData(recipe.itemData.name, recipe.itemData.uuid); + } + }); +} + +export { resetAllTestRecipes } \ No newline at end of file diff --git a/test/test_data/TestSettingMigrationData.ts b/test/test_data/TestSettingMigrationData.ts new file mode 100644 index 00000000..fc72dba3 --- /dev/null +++ b/test/test_data/TestSettingMigrationData.ts @@ -0,0 +1,835 @@ +import Properties from "../../src/scripts/Properties"; +import {SettingVersion} from "../../src/scripts/repository/migration/SettingVersion"; +import { + AlchemistsSuppliesV16SystemDefinition +} from "../../src/scripts/repository/embedded_systems/AlchemistsSuppliesV16SystemDefinition"; +import {CraftingSystemJson} from "../../src/scripts/system/CraftingSystem"; +import {EssenceJson} from "../../src/scripts/crafting/essence/Essence"; +import {ComponentJson} from "../../src/scripts/crafting/component/Component"; +import {RecipeJson} from "../../src/scripts/crafting/recipe/Recipe"; + +export function getAlchemistsSuppliesV16InitialSettingValue() { + return { + "id": "alchemists-supplies-v1.6", + "details": { + "name": "Alchemist's Supplies v1.6", + "summary": "A crafting system for 5th Edition by u/calculusChild", + "description": "Alchemy is the skill of exploiting unique properties of certain plants, minerals, and creature parts, combining them to produce fantastic substances. This allows even non-spellcasters to mimic minor magical effects, although the creations themselves are non-magical.", + "author": "u/calculusChild" + }, + "enabled": true, + "locked": true, + "parts": { + "components": { + "Compendium.fabricate.alchemists-supplies-v16.1lx7oTedScyEgTOm": { + "disabled": false, + "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.1lx7oTedScyEgTOm", + "essences": {}, + "salvageOptions": {} + }, + "Compendium.fabricate.alchemists-supplies-v16.0FiywpRZf3cOYs4U": { + "disabled": false, + "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.0FiywpRZf3cOYs4U", + "essences": { + "water": 2 + }, + "salvageOptions": {} + }, + "Compendium.fabricate.alchemists-supplies-v16.zogQbVwUggRUWK2l": { + "disabled": false, + "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.zogQbVwUggRUWK2l", + "essences": {}, + "salvageOptions": {} + }, + "Compendium.fabricate.alchemists-supplies-v16.4U429BrJKdMKDZaR": { + "disabled": false, + "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.4U429BrJKdMKDZaR", + "essences": {}, + "salvageOptions": {} + }, + "Compendium.fabricate.alchemists-supplies-v16.8AdG3048UmUOo5Qx": { + "disabled": false, + "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.8AdG3048UmUOo5Qx", + "essences": {}, + "salvageOptions": {} + }, + "Compendium.fabricate.alchemists-supplies-v16.90z9nOwmGnP4aUUk": { + "disabled": false, + "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.90z9nOwmGnP4aUUk", + "essences": {}, + "salvageOptions": {} + }, + "Compendium.fabricate.alchemists-supplies-v16.Act4cJsuz2HhID55": { + "disabled": false, + "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.Act4cJsuz2HhID55", + "essences": { + "fire": 1, + "earth": 1 + }, + "salvageOptions": {} + }, + "Compendium.fabricate.alchemists-supplies-v16.BPiO2qqMvJbdlxJf": { + "disabled": false, + "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.BPiO2qqMvJbdlxJf", + "essences": { + "fire": 1 + }, + "salvageOptions": {} + }, + "Compendium.fabricate.alchemists-supplies-v16.BjAyhAmkbFJdVVQW": { + "disabled": false, + "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.BjAyhAmkbFJdVVQW", + "essences": { + "air": 1, + "fire": 1 + }, + "salvageOptions": {} + }, + "Compendium.fabricate.alchemists-supplies-v16.BzC8HmlN3bNY4D8Z": { + "disabled": false, + "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.BzC8HmlN3bNY4D8Z", + "essences": { + "positive-energy": 1 + }, + "salvageOptions": {} + }, + "Compendium.fabricate.alchemists-supplies-v16.EDGSJpxtSuvq7XWW": { + "disabled": false, + "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.EDGSJpxtSuvq7XWW", + "essences": {}, + "salvageOptions": {} + }, + "Compendium.fabricate.alchemists-supplies-v16.Ffv7X37ZT7LRzDvP": { + "disabled": false, + "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.Ffv7X37ZT7LRzDvP", + "essences": {}, + "salvageOptions": {} + }, + "Compendium.fabricate.alchemists-supplies-v16.HyicGjYWESWIdEhc": { + "disabled": false, + "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.HyicGjYWESWIdEhc", + "essences": {}, + "salvageOptions": {} + }, + "Compendium.fabricate.alchemists-supplies-v16.JuAZVO3QioTf7ACf": { + "disabled": false, + "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.JuAZVO3QioTf7ACf", + "essences": { + "negative-energy": 1 + }, + "salvageOptions": {} + }, + "Compendium.fabricate.alchemists-supplies-v16.Kv4752l95eP4IeBM": { + "disabled": false, + "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.Kv4752l95eP4IeBM", + "essences": {}, + "salvageOptions": {} + }, + "Compendium.fabricate.alchemists-supplies-v16.LnFU0qkUkKz3iV1Q": { + "disabled": false, + "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.LnFU0qkUkKz3iV1Q", + "essences": { + "water": 1, + "air": 1 + }, + "salvageOptions": {} + }, + "Compendium.fabricate.alchemists-supplies-v16.PwEY1nRmDzoFN5TW": { + "disabled": false, + "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.PwEY1nRmDzoFN5TW", + "essences": { + "fire": 2 + }, + "salvageOptions": {} + }, + "Compendium.fabricate.alchemists-supplies-v16.QXN2n98HnjFzSsNJ": { + "disabled": false, + "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.QXN2n98HnjFzSsNJ", + "essences": {}, + "salvageOptions": {} + }, + "Compendium.fabricate.alchemists-supplies-v16.R50XdiWxV5awA6HL": { + "disabled": false, + "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.R50XdiWxV5awA6HL", + "essences": { + "earth": 2 + }, + "salvageOptions": {} + }, + "Compendium.fabricate.alchemists-supplies-v16.SmE4zjl9xLBOSo4A": { + "disabled": false, + "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.SmE4zjl9xLBOSo4A", + "essences": { + "water": 1 + }, + "salvageOptions": {} + }, + "Compendium.fabricate.alchemists-supplies-v16.TAdDF3reNmXc00ST": { + "disabled": false, + "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.TAdDF3reNmXc00ST", + "essences": { + "earth": 1 + }, + "salvageOptions": {} + }, + "Compendium.fabricate.alchemists-supplies-v16.VHUXcExXZLCDfqmM": { + "disabled": false, + "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.VHUXcExXZLCDfqmM", + "essences": {}, + "salvageOptions": {} + }, + "Compendium.fabricate.alchemists-supplies-v16.bNOIJaY21OSsdybw": { + "disabled": false, + "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.bNOIJaY21OSsdybw", + "essences": {}, + "salvageOptions": {} + }, + "Compendium.fabricate.alchemists-supplies-v16.dVgM7j75KoBDKRep": { + "disabled": false, + "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.dVgM7j75KoBDKRep", + "essences": { + "air": 1 + }, + "salvageOptions": {} + }, + "Compendium.fabricate.alchemists-supplies-v16.dvrdsBAt0B2ag8Q8": { + "disabled": false, + "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.dvrdsBAt0B2ag8Q8", + "essences": { + "air": 2 + }, + "salvageOptions": {} + }, + "Compendium.fabricate.alchemists-supplies-v16.fZsZ5EYMs9ioZ6Kk": { + "disabled": false, + "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.fZsZ5EYMs9ioZ6Kk", + "essences": {}, + "salvageOptions": {} + }, + "Compendium.fabricate.alchemists-supplies-v16.m51mvhPcubqfPbWB": { + "disabled": false, + "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.m51mvhPcubqfPbWB", + "essences": {}, + "salvageOptions": {} + }, + "Compendium.fabricate.alchemists-supplies-v16.sB64MUbYpMqiWCkS": { + "disabled": false, + "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.sB64MUbYpMqiWCkS", + "essences": {}, + "salvageOptions": {} + }, + "Compendium.fabricate.alchemists-supplies-v16.xtrSxjCohTVr1bZy": { + "disabled": false, + "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.xtrSxjCohTVr1bZy", + "essences": { + "earth": 1, + "water": 1 + }, + "salvageOptions": {} + }, + "Compendium.fabricate.alchemists-supplies-v16.zgDyoqjRxwhD9cNR": { + "disabled": false, + "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.zgDyoqjRxwhD9cNR", + "essences": {}, + "salvageOptions": {} + } + }, + "recipes": { + "Compendium.fabricate.alchemists-supplies-v16.3N7TGYWJRrUJ9thy": { + "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.3N7TGYWJRrUJ9thy", + "disabled": false, + "essences": { + "earth": 2, + "water": 2, + "negative-energy": 1 + }, + "resultOptions": { + "Option 1": { + "Compendium.fabricate.alchemists-supplies-v16.zogQbVwUggRUWK2l": 1 + } + }, + "ingredientOptions": {} + }, + "Compendium.fabricate.alchemists-supplies-v16.6mKiAWQIzDZRPNOP": { + "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.6mKiAWQIzDZRPNOP", + "disabled": false, + "essences": { + "air": 2, + "negative-energy": 1 + }, + "resultOptions": { + "Option 1": { + "Compendium.fabricate.alchemists-supplies-v16.m51mvhPcubqfPbWB": 1 + } + }, + "ingredientOptions": {} + }, + "Compendium.fabricate.alchemists-supplies-v16.8M0vwnOuCEIEP9ww": { + "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.8M0vwnOuCEIEP9ww", + "disabled": false, + "essences": { + "fire": 2, + "positive-energy": 1 + }, + "resultOptions": { + "Option 1": { + "Compendium.fabricate.alchemists-supplies-v16.HyicGjYWESWIdEhc": 1 + } + }, + "ingredientOptions": {} + }, + "Compendium.fabricate.alchemists-supplies-v16.EQfnAlIaDLh57ETN": { + "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.EQfnAlIaDLh57ETN", + "disabled": false, + "essences": { + "earth": 2, + "positive-energy": 1 + }, + "resultOptions": { + "Option 1": { + "Compendium.fabricate.alchemists-supplies-v16.fZsZ5EYMs9ioZ6Kk": 1 + } + }, + "ingredientOptions": {} + }, + "Compendium.fabricate.alchemists-supplies-v16.F2dhRws4mZppZVIa": { + "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.F2dhRws4mZppZVIa", + "disabled": false, + "essences": { + "water": 2, + "positive-energy": 1 + }, + "resultOptions": { + "Option 1": { + "Compendium.fabricate.alchemists-supplies-v16.Ffv7X37ZT7LRzDvP": 1 + } + }, + "ingredientOptions": {} + }, + "Compendium.fabricate.alchemists-supplies-v16.IVqzRv8v1MrfpKSa": { + "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.IVqzRv8v1MrfpKSa", + "disabled": false, + "essences": { + "earth": 3, + "negative-energy": 2 + }, + "resultOptions": { + "Option 1": { + "Compendium.fabricate.alchemists-supplies-v16.8AdG3048UmUOo5Qx": 1 + } + }, + "ingredientOptions": {} + }, + "Compendium.fabricate.alchemists-supplies-v16.JEewjPpD6JA2ifE3": { + "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.JEewjPpD6JA2ifE3", + "disabled": false, + "essences": { + "fire": 1, + "negative-energy": 1 + }, + "resultOptions": { + "Option 1": { + "Compendium.fabricate.alchemists-supplies-v16.QXN2n98HnjFzSsNJ": 1 + } + }, + "ingredientOptions": {} + }, + "Compendium.fabricate.alchemists-supplies-v16.O0FPlz7ARBLuLY1E": { + "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.O0FPlz7ARBLuLY1E", + "disabled": false, + "essences": { + "earth": 2, + "positive-energy": 1 + }, + "resultOptions": { + "Option 1": { + "Compendium.fabricate.alchemists-supplies-v16.zgDyoqjRxwhD9cNR": 1 + } + }, + "ingredientOptions": {} + }, + "Compendium.fabricate.alchemists-supplies-v16.Pl65b1VSVhUJ2li9": { + "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.Pl65b1VSVhUJ2li9", + "disabled": false, + "essences": { + "fire": 2, + "earth": 1, + "water": 1, + "positive-energy": 1 + }, + "resultOptions": { + "Option 1": { + "Compendium.fabricate.alchemists-supplies-v16.Kv4752l95eP4IeBM": 1 + } + }, + "ingredientOptions": {} + }, + "Compendium.fabricate.alchemists-supplies-v16.WseDmjhOWaT1XeJf": { + "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.WseDmjhOWaT1XeJf", + "disabled": false, + "essences": { + "earth": 1, + "fire": 2 + }, + "resultOptions": { + "Option 1": { + "Compendium.fabricate.alchemists-supplies-v16.EDGSJpxtSuvq7XWW": 1 + } + }, + "ingredientOptions": {} + }, + "Compendium.fabricate.alchemists-supplies-v16.hJFEOjoiLdTOOoBn": { + "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.hJFEOjoiLdTOOoBn", + "disabled": false, + "essences": { + "earth": 2, + "water": 2 + }, + "resultOptions": { + "Option 1": { + "Compendium.fabricate.alchemists-supplies-v16.sB64MUbYpMqiWCkS": 1 + } + }, + "ingredientOptions": {} + }, + "Compendium.fabricate.alchemists-supplies-v16.kI3lqJmjWai0aX5L": { + "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.kI3lqJmjWai0aX5L", + "disabled": false, + "essences": { + "air": 3 + }, + "resultOptions": { + "Option 1": { + "Compendium.fabricate.alchemists-supplies-v16.4U429BrJKdMKDZaR": 1 + } + }, + "ingredientOptions": {} + }, + "Compendium.fabricate.alchemists-supplies-v16.x4MkisNL6yBoTZ94": { + "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.x4MkisNL6yBoTZ94", + "disabled": false, + "essences": { + "air": 2, + "fire": 1 + }, + "resultOptions": { + "Option 1": { + "Compendium.fabricate.alchemists-supplies-v16.bNOIJaY21OSsdybw": 1 + } + }, + "ingredientOptions": {} + }, + "Compendium.fabricate.alchemists-supplies-v16.ylQuglLVjQFuH9w1": { + "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.ylQuglLVjQFuH9w1", + "disabled": false, + "essences": { + "water": 1, + "fire": 1, + "negative-energy": 1 + }, + "resultOptions": { + "Option 1": { + "Compendium.fabricate.alchemists-supplies-v16.1lx7oTedScyEgTOm": 1 + } + }, + "ingredientOptions": {} + }, + "Compendium.fabricate.alchemists-supplies-v16.yqKIdplKhpa5Ir7A": { + "itemUuid": "Compendium.fabricate.alchemists-supplies-v16.yqKIdplKhpa5Ir7A", + "disabled": false, + "essences": { + "air": 2, + "fire": 2, + "negative-energy": 1 + }, + "resultOptions": { + "Option 1": { + "Compendium.fabricate.alchemists-supplies-v16.VHUXcExXZLCDfqmM": 1 + } + }, + "ingredientOptions": {} + } + }, + "essences": { + "water": { + "name": "Water", + "tooltip": "Elemental water", + "iconCode": "tint", + "description": "Elemental water, one of the fundamental forces of nature" + }, + "earth": { + "name": "Earth", + "tooltip": "Elemental earth", + "iconCode": "mountain", + "description": "Elemental earth, one of the fundamental forces of nature" + }, + "air": { + "name": "Air", + "tooltip": "Elemental air", + "iconCode": "wind", + "description": "Elemental air, one of the fundamental forces of nature" + }, + "fire": { + "name": "Fire", + "tooltip": "Elemental fire", + "iconCode": "fire", + "description": "Elemental fire, one of the fundamental forces of nature" + }, + "negative-energy": { + "name": "Negative Energy", + "tooltip": "Negative energy", + "iconCode": "moon", + "description": "Negative Energy - The essence of death and destruction" + }, + "positive-energy": { + "name": "Positive Energy", + "tooltip": "Positive energy", + "iconCode": "sun", + "description": "Positive Energy - The essence of life and creation" + } + } + } + } +} + +export function getBlacksmithingInitialSettingValue() { + return { + "id": "PeZF10C7FMN4dhfa", + "details": { + "name": "Blacksmithing", + "summary": "A crafting system about smelting and forging!", + "description": "", + "author": "MisterPotts" + }, + "enabled": true, + "locked": false, + "parts": { + "components": { + "BM4mXqHsz6CBYMJY": { + "disabled": false, + "itemUuid": "Item.YVDyPdP4rv8jYUbq", + "essences": {}, + "salvageOptions": {} + }, + "xc25Gh2kWhkBx4AW": { + "disabled": false, + "itemUuid": "Item.6eELU2hU1gnZKdpo", + "essences": {}, + "salvageOptions": {} + }, + "qdAHJSiZD8EaokIW": { + "disabled": false, + "itemUuid": "Item.y2zPbfNQBy48Knzy", + "essences": {}, + "salvageOptions": {} + }, + "oBoVjDB6iTWEoCe2": { + "disabled": false, + "itemUuid": "Item.VewQgt0sRBGw52gH", + "essences": { + "xKBJYzDLgW600jk4": 1 + }, + "salvageOptions": {} + }, + "XRlT1jmuZa3GQPFT": { + "disabled": false, + "itemUuid": "Item.G5tUkGT5g81IvopK", + "essences": {}, + "salvageOptions": {} + }, + "LTzX7ZAy78UUJk6c": { + "disabled": false, + "itemUuid": "Item.2JiDCdQt9GXf5Sm4", + "essences": {}, + "salvageOptions": {} + }, + "JpEo7w1uqlTlPzMz": { + "disabled": false, + "itemUuid": "Item.jR7PSW77wQFTbfLT", + "essences": {}, + "salvageOptions": {} + }, + "CPUZsGkhsrBmYtIe": { + "disabled": false, + "itemUuid": "Item.gJKVOd1Xb1JKC3rc", + "essences": {}, + "salvageOptions": {} + }, + "JEN370kcXwh0960Y": { + "disabled": false, + "itemUuid": "Item.SjgcKWPv1vCVBLo2", + "essences": {}, + "salvageOptions": {} + }, + "GDjKCR002BIWeaXG": { + "disabled": false, + "itemUuid": "Item.Hb0yOaStIQ0ha1nu", + "essences": {}, + "salvageOptions": {} + }, + "rRkPPoLo4C7CZHoj": { + "disabled": false, + "itemUuid": "Item.ma6EulvXKSEJLBSp", + "essences": { + "xKBJYzDLgW600jk4": 2 + }, + "salvageOptions": { + "Option 1": { + "oBoVjDB6iTWEoCe2": 1 + } + } + }, + "GUZbwMP1EbKbE2rG": { + "disabled": false, + "itemUuid": "Item.gZVPP6spVyZDIrLj", + "essences": {}, + "salvageOptions": {} + }, + "iAKRIsaJiKNcsw4D": { + "disabled": false, + "itemUuid": "Item.BWfHuqOMrGGu0SmD", + "essences": {}, + "salvageOptions": {} + }, + "XBbIw8jp2OgVE1VE": { + "disabled": false, + "itemUuid": "Item.C8AgtVWs6jAWZHjX", + "essences": {}, + "salvageOptions": {} + }, + "3FWP5zrPCYM0mZX7": { + "disabled": false, + "itemUuid": "Item.EgUWySU1KXmvHZUE", + "essences": {}, + "salvageOptions": {} + }, + "7yzYKGN2b9GxCP0U": { + "disabled": false, + "itemUuid": "Item.yBeg6M0h1u8pdHcr", + "essences": {}, + "salvageOptions": {} + }, + "Ma7ltU1yhrl4gbD5": { + "disabled": false, + "itemUuid": "Item.MB0ZzvBxhZJGG7Gr", + "essences": {}, + "salvageOptions": {} + }, + "Q3bp1AQ89Zecz51P": { + "disabled": false, + "itemUuid": "Item.YqBLLPCm06x9wCNj", + "essences": {}, + "salvageOptions": { + "Option 1": { + "xc25Gh2kWhkBx4AW": 2, + "XBbIw8jp2OgVE1VE": 1 + }, + "Option 2": { + "xc25Gh2kWhkBx4AW": 1, + "XBbIw8jp2OgVE1VE": 2 + } + } + } + }, + "recipes": { + "lFjcjUg2jZsfqMwU": { + "itemUuid": "Item.fvC1HA7KGxILAzBU", + "essences": {}, + "resultOptions": { + "Option 1": { + "Q3bp1AQ89Zecz51P": 1 + } + }, + "ingredientOptions": { + "Option 1": { + "catalysts": { + "JpEo7w1uqlTlPzMz": 1, + "CPUZsGkhsrBmYtIe": 1 + }, + "ingredients": { + "xc25Gh2kWhkBx4AW": 4, + "XBbIw8jp2OgVE1VE": 2 + } + } + } + }, + "RQPr3tGrHYyNcis9": { + "itemUuid": "Item.bsD9Rtf4T0MT01cn", + "essences": { + "xKBJYzDLgW600jk4": 2 + }, + "resultOptions": {}, + "ingredientOptions": {} + }, + "tegu4v2pdxdhhfkR": { + "itemUuid": "Item.pYeRwYNAthPTyIQD", + "essences": { + "xKBJYzDLgW600jk4": 1, + "DLjJ02fNb41YVTlx": 2 + }, + "resultOptions": { + "Option 1": { + "BM4mXqHsz6CBYMJY": 1, + } + }, + "ingredientOptions": {} + }, + "cBT3w7YCBWlzNfEj": { + "itemUuid": "Item.PmDYrZRkvkThzrMx", + "essences": {}, + "resultOptions": { + "Option 2": { + "rRkPPoLo4C7CZHoj": 1 + } + }, + "ingredientOptions": { + "Option 2": { + "catalysts": { + "LTzX7ZAy78UUJk6c": 1, + "JpEo7w1uqlTlPzMz": 1 + }, + "ingredients": { + "oBoVjDB6iTWEoCe2": 2 + } + }, + "Option 1": { + "catalysts": { + "JpEo7w1uqlTlPzMz": 1, + "CPUZsGkhsrBmYtIe": 1 + }, + "ingredients": { + "oBoVjDB6iTWEoCe2": 3 + } + } + } + } + }, + "essences": { + "xKBJYzDLgW600jk4": { + "name": "Fire", + "tooltip": "My new Essence", + "iconCode": "fa-solid fa-fire-flame-simple", + "description": "A new Essence added to Blacksmithing" + }, + "DLjJ02fNb41YVTlx": { + "name": "Warpfire", + "tooltip": "My new Essence", + "iconCode": "fa-regular fa-fire-flame-curved", + "description": "A new Essence added to Blacksmithing", + "activeEffectSourceItemUuid": "XrYAtSQhioRclpOq" + } + } + } + } +} + +export function buildV2SettingsValue() { + const v2Settings = new Map(); + v2Settings.set(Properties.module.id, new Map()); + const fabricateSettingsNamespace = v2Settings.get(Properties.module.id); + const craftingSystemSettingsValue: Record = {}; + craftingSystemSettingsValue["alchemists-supplies-v1.6"] = getAlchemistsSuppliesV16InitialSettingValue(); + craftingSystemSettingsValue["PeZF10C7FMN4dhfa"] = getBlacksmithingInitialSettingValue(); + const craftingSystemVersionedSettings: Record = { + value: craftingSystemSettingsValue, + version: "2" + }; + fabricateSettingsNamespace.set(Properties.settings.craftingSystems.key, craftingSystemVersionedSettings); + return v2Settings; +} + +export function buildDefaultV3SettingsValue() { + const v3Settings = new Map(); + v3Settings.set(Properties.module.id, new Map()); + const fabricateSettingsNamespace = v3Settings.get(Properties.module.id); + fabricateSettingsNamespace.set(Properties.settings.modelVersion.key, SettingVersion.V3); + fabricateSettingsNamespace.set(Properties.settings.craftingSystems.key, { + entities: {}, + collections: {} + }); + fabricateSettingsNamespace.set(Properties.settings.essences.key, { + entities: {}, + collections: {} + }); + fabricateSettingsNamespace.set(Properties.settings.components.key, { + entities: {}, + collections: {} + }); + fabricateSettingsNamespace.set(Properties.settings.recipes.key, { + entities: {}, + collections: {} + }); + return v3Settings; +} + +export function buildAlchemistsSuppliesOnlyV3SettingsValue() { + const v3Settings = buildDefaultV3SettingsValue(); + v3Settings.get(Properties.module.id).set(Properties.settings.craftingSystems.key, buildCraftingSystemsV3SettingValue()); + v3Settings.get(Properties.module.id).set(Properties.settings.essences.key, buildEssencesV3SettingValue()); + v3Settings.get(Properties.module.id).set(Properties.settings.components.key, buildComponentsV3SettingValue()); + v3Settings.get(Properties.module.id).set(Properties.settings.recipes.key, buildRecipesV3SettingValue()); + return v3Settings; +} + +export function buildCraftingSystemsV3SettingValue() { + const craftingSystemsV3SettingValue: Record = { + entities: >{ + "alchemists-supplies-v1.6": new AlchemistsSuppliesV16SystemDefinition().craftingSystem.toJson(), + }, + collections: {} + }; + return craftingSystemsV3SettingValue; +} + +export function buildEssencesV3SettingValue() { + const alchemistsSupplies = new AlchemistsSuppliesV16SystemDefinition(); + const alchemistsSuppliesCraftingSystemCollection = `${Properties.settings.collectionNames.craftingSystem}.alchemists-supplies-v1.6`; + const essencesV3SettingValue: Record = { + entities: >{}, + collections: { + [alchemistsSuppliesCraftingSystemCollection]: [] + } + }; + alchemistsSupplies.essences.forEach(essence => { + essencesV3SettingValue.entities[essence.id] = essence.toJson(); + essencesV3SettingValue.collections[alchemistsSuppliesCraftingSystemCollection].push(essence.id); + if (essence.hasActiveEffectSource) { + essencesV3SettingValue.collections[`${Properties.settings.collectionNames.item}.${essence.activeEffectSource.uuid}`].push(essence.id); + } + }); + return essencesV3SettingValue; +} + +export function buildComponentsV3SettingValue() { + const alchemistsSupplies = new AlchemistsSuppliesV16SystemDefinition(); + const alchemistsSuppliesCraftingSystemCollection = `${Properties.settings.collectionNames.craftingSystem}.alchemists-supplies-v1.6`; + const componentsV3SettingValue: Record = { + entities: >{}, + collections: { + [alchemistsSuppliesCraftingSystemCollection]: [] + } + }; + alchemistsSupplies.components.forEach(component => { + componentsV3SettingValue.entities[component.id] = component.toJson(); + componentsV3SettingValue.collections[alchemistsSuppliesCraftingSystemCollection].push(component.id); + componentsV3SettingValue.collections[`${Properties.settings.collectionNames.item}.${component.itemUuid}`] = [component.id]; + }); + return componentsV3SettingValue; +} + +export function buildRecipesV3SettingValue() { + const alchemistsSupplies = new AlchemistsSuppliesV16SystemDefinition(); + const alchemistsSuppliesCraftingSystemCollection = `${Properties.settings.collectionNames.craftingSystem}.alchemists-supplies-v1.6`; + const recipesV3SettingValue: Record = { + entities: >{}, + collections: { + [alchemistsSuppliesCraftingSystemCollection]: [] + } + }; + alchemistsSupplies.recipes.forEach(recipe => { + recipesV3SettingValue.entities[recipe.id] = recipe.toJson(); + recipesV3SettingValue.collections[alchemistsSuppliesCraftingSystemCollection].push(recipe.id); + recipesV3SettingValue.collections[`${Properties.settings.collectionNames.item}.${recipe.itemUuid}`] = [recipe.id]; + }); + return recipesV3SettingValue; +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index f140552d..b377b1ac 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,8 +16,11 @@ "forceConsistentCasingInFileNames": true, "sourceMap": true, "noErrorTruncation": true, - "target": "ES6", - "lib": ["DOM", "ES6", "ES2017", "ES2018", "ES2019"], + "target": "ES2021", + "lib": [ + "ES2022", + "dom" + ], "types": [ "node", "sinon",